From 981372ab86843f64276785cce97dd465cc15fa45 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 13 Jun 2025 02:17:00 +0530 Subject: [PATCH] wip webhooks --- cmd/conversation.go | 21 +- cmd/handlers.go | 9 + cmd/init.go | 21 +- cmd/main.go | 9 +- cmd/messages.go | 3 - cmd/upgrade.go | 1 + cmd/webhooks.go | 180 +++++++++ config.sample.toml | 8 + docs/docs/translations.md | 2 +- docs/mkdocs.yml | 2 +- frontend/src/api/index.js | 172 +++++---- frontend/src/constants/navigation.js | 280 +++++++------- frontend/src/constants/permissions.js | 81 ++-- .../src/features/admin/roles/RoleForm.vue | 3 +- .../features/admin/webhooks/WebhookForm.vue | 176 +++++++++ .../admin/webhooks/dataTableColumns.js | 78 ++++ .../admin/webhooks/dataTableDropdown.vue | 144 +++++++ .../src/features/admin/webhooks/formSchema.js | 25 ++ frontend/src/router/index.js | 79 ++-- .../admin/webhooks/CreateEditWebhook.vue | 155 ++++++++ .../src/views/admin/webhooks/WebhookList.vue | 60 +++ .../src/views/admin/webhooks/Webhooks.vue | 24 ++ i18n/en.json | 6 +- internal/authz/models/models.go | 4 + internal/automation/automation.go | 50 +-- internal/conversation/conversation.go | 45 +++ internal/conversation/message.go | 56 ++- internal/migrations/v0.7.0.go | 69 ++++ internal/webhook/models/models.go | 47 +++ internal/webhook/queries.sql | 100 +++++ internal/webhook/webhook.go | 353 ++++++++++++++++++ schema.sql | 37 +- 32 files changed, 1961 insertions(+), 339 deletions(-) create mode 100644 cmd/webhooks.go create mode 100644 frontend/src/features/admin/webhooks/WebhookForm.vue create mode 100644 frontend/src/features/admin/webhooks/dataTableColumns.js create mode 100644 frontend/src/features/admin/webhooks/dataTableDropdown.vue create mode 100644 frontend/src/features/admin/webhooks/formSchema.js create mode 100644 frontend/src/views/admin/webhooks/CreateEditWebhook.vue create mode 100644 frontend/src/views/admin/webhooks/WebhookList.vue create mode 100644 frontend/src/views/admin/webhooks/Webhooks.vue create mode 100644 internal/migrations/v0.7.0.go create mode 100644 internal/webhook/models/models.go create mode 100644 internal/webhook/queries.sql create mode 100644 internal/webhook/webhook.go diff --git a/cmd/conversation.go b/cmd/conversation.go index 5d607e7..6077150 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -13,6 +13,7 @@ import ( medModels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" + wmodels "github.com/abhinavxd/libredesk/internal/webhook/models" "github.com/valyala/fasthttp" "github.com/volatiletech/null/v9" "github.com/zerodha/fastglue" @@ -326,9 +327,6 @@ func handleUpdateUserAssignee(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Evaluate automation rules. - app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned) - return r.SendEnvelope(true) } @@ -362,9 +360,6 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Evaluate automation rules on team assignment. - app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned) - return r.SendEnvelope(true) } @@ -392,9 +387,6 @@ func handleUpdateConversationPriority(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Evaluate automation rules. - app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange) - return r.SendEnvelope(true) } @@ -442,9 +434,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Evaluate automation rules. - app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange) - // If status is `Resolved`, send CSAT survey if enabled on inbox. if status == cmodels.StatusResolved { // Check if CSAT is enabled on the inbox and send CSAT survey message. @@ -720,7 +709,11 @@ func handleCreateConversation(r *fastglue.Request) error { app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) } - // Send the created conversation back to the client. - conversation, _ := app.conversation.GetConversation(conversationID, "") + // Trigger webhook event for conversation created. + conversation, err := app.conversation.GetConversation(conversationID, "") + if err == nil { + app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation) + } + return r.SendEnvelope(conversation) } diff --git a/cmd/handlers.go b/cmd/handlers.go index fd0a2d6..b86a89c 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -157,6 +157,15 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage")) g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage")) + // Webhooks. + g.GET("/api/v1/webhooks", perm(handleGetWebhooks, "webhooks:manage")) + g.GET("/api/v1/webhooks/{id}", perm(handleGetWebhook, "webhooks:manage")) + g.POST("/api/v1/webhooks", perm(handleCreateWebhook, "webhooks:manage")) + g.PUT("/api/v1/webhooks/{id}", perm(handleUpdateWebhook, "webhooks:manage")) + g.DELETE("/api/v1/webhooks/{id}", perm(handleDeleteWebhook, "webhooks:manage")) + g.PUT("/api/v1/webhooks/{id}/toggle", perm(handleToggleWebhook, "webhooks:manage")) + g.POST("/api/v1/webhooks/{id}/test", perm(handleTestWebhook, "webhooks:manage")) + // Reports. g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage")) g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage")) diff --git a/cmd/init.go b/cmd/init.go index 406aac6..c38a10f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -45,6 +45,7 @@ import ( tmpl "github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/view" + "github.com/abhinavxd/libredesk/internal/webhook" "github.com/abhinavxd/libredesk/internal/ws" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" @@ -220,8 +221,9 @@ func initConversations( csat *csat.Manager, automationEngine *automation.Engine, template *tmpl.Manager, + webhook *webhook.Manager, ) *conversation.Manager { - c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, conversation.Opts{ + c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, conversation.Opts{ DB: db, Lo: initLogger("conversation_manager"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), @@ -838,6 +840,23 @@ func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager { return m } +// initWebhook inits webhook manager. +func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager { + var lo = initLogger("webhook") + m, err := webhook.New(webhook.Opts{ + DB: db, + Lo: lo, + I18n: i18n, + Workers: ko.MustInt("webhook.workers"), + QueueSize: ko.MustInt("webhook.queue_size"), + Timeout: ko.MustDuration("webhook.timeout"), + }) + if err != nil { + log.Fatalf("error initializing webhook manager: %v", err) + } + return m +} + // initLogger initializes a logf logger. func initLogger(src string) *logf.Logger { lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env") diff --git a/cmd/main.go b/cmd/main.go index 888ee6b..1fd75fe 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -41,6 +41,7 @@ import ( "github.com/abhinavxd/libredesk/internal/team" "github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/user" + "github.com/abhinavxd/libredesk/internal/webhook" "github.com/knadh/go-i18n" "github.com/knadh/koanf/v2" "github.com/knadh/stuffbin" @@ -92,6 +93,7 @@ type App struct { notifier *notifier.Service customAttribute *customAttribute.Manager report *report.Manager + webhook *webhook.Manager // Global state that stores data on an available app update. update *AppUpdate @@ -191,12 +193,13 @@ func main() { inbox = initInbox(db, i18n) team = initTeam(db, i18n) businessHours = initBusinessHours(db, i18n) + webhook = initWebhook(db, i18n) user = initUser(i18n, db) wsHub = initWS(user) notifier = initNotifier() automation = initAutomationEngine(db, i18n) sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n) - conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template) + conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook) autoassigner = initAutoAssigner(team, user, conversation) ) automation.SetConversationStore(conversation) @@ -206,6 +209,7 @@ func main() { go autoassigner.Run(ctx, autoAssignInterval) go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval) go conversation.RunUnsnoozer(ctx, unsnoozeInterval) + go webhook.Run(ctx) go notifier.Run(ctx) go sla.Run(ctx, slaEvaluationInterval) go sla.SendNotifications(ctx) @@ -243,6 +247,7 @@ func main() { tag: initTag(db, i18n), macro: initMacro(db, i18n), ai: initAI(db, i18n), + webhook: webhook, } app.consts.Store(constants) @@ -286,6 +291,8 @@ func main() { autoassigner.Close() colorlog.Red("Shutting down notifier...") notifier.Close() + colorlog.Red("Shutting down webhook...") + webhook.Close() colorlog.Red("Shutting down conversation...") conversation.Close() colorlog.Red("Shutting down SLA...") diff --git a/cmd/messages.go b/cmd/messages.go index 712c97d..b9015bd 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -4,7 +4,6 @@ import ( "strconv" amodels "github.com/abhinavxd/libredesk/internal/auth/models" - "github.com/abhinavxd/libredesk/internal/automation/models" "github.com/abhinavxd/libredesk/internal/envelope" medModels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/valyala/fasthttp" @@ -170,8 +169,6 @@ func handleSendMessage(r *fastglue.Request) error { if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil { return sendErrorEnvelope(r, err) } - // Evaluate automation rules. - app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing) } return r.SendEnvelope(true) } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 1b0e8dd..8e9ec50 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -34,6 +34,7 @@ var migList = []migFunc{ {"v0.4.0", migrations.V0_4_0}, {"v0.5.0", migrations.V0_5_0}, {"v0.6.0", migrations.V0_6_0}, + {"v0.7.0", migrations.V0_7_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/webhooks.go b/cmd/webhooks.go new file mode 100644 index 0000000..b99a51e --- /dev/null +++ b/cmd/webhooks.go @@ -0,0 +1,180 @@ +package main + +import ( + "strconv" + "strings" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/abhinavxd/libredesk/internal/webhook/models" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +// handleGetWebhooks returns all webhooks from the database. +func handleGetWebhooks(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + ) + webhooks, err := app.webhook.GetAll() + if err != nil { + return sendErrorEnvelope(r, err) + } + // Hide secrets. + for i := range webhooks { + if webhooks[i].Secret != "" { + webhooks[i].Secret = strings.Repeat(stringutil.PasswordDummy, 10) + } + } + return r.SendEnvelope(webhooks) +} + +// handleGetWebhook returns a specific webhook by ID. +func handleGetWebhook(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + webhook, err := app.webhook.Get(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + + // Hide secret in the response. + if webhook.Secret != "" { + webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10) + } + + return r.SendEnvelope(webhook) +} + +// handleCreateWebhook creates a new webhook in the database. +func handleCreateWebhook(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + webhook = models.Webhook{} + ) + if err := r.Decode(&webhook, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError) + } + + // Validate webhook fields + if err := validateWebhook(app, webhook); err != nil { + return r.SendEnvelope(err) + } + + _, err := app.webhook.Create(webhook) + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} + +// handleUpdateWebhook updates an existing webhook in the database. +func handleUpdateWebhook(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + webhook = models.Webhook{} + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + if err := r.Decode(&webhook, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError) + } + + // Validate webhook fields + if err := validateWebhook(app, webhook); err != nil { + return r.SendEnvelope(err) + } + + // If secret is empty or contains dummy characters, fetch existing webhook and preserve the secret + if webhook.Secret == "" || strings.Contains(webhook.Secret, stringutil.PasswordDummy) { + existingWebhook, err := app.webhook.Get(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + webhook.Secret = existingWebhook.Secret + } + + if err := app.webhook.Update(id, webhook); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} + +// handleDeleteWebhook deletes a webhook from the database. +func handleDeleteWebhook(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + if err := app.webhook.Delete(id); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} + +// handleToggleWebhook toggles the active status of a webhook. +func handleToggleWebhook(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + if err := app.webhook.Toggle(id); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} + +// handleTestWebhook sends a test payload to a webhook. +func handleTestWebhook(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + ) + + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + if err := app.webhook.SendTestWebhook(id); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} + +// validateWebhook validates the webhook data. +func validateWebhook(app *App, webhook models.Webhook) error { + if webhook.Name == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) + } + if webhook.URL == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`url`"), nil) + } + if len(webhook.Events) == 0 { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`events`"), nil) + } + return nil +} diff --git a/config.sample.toml b/config.sample.toml index 2324063..90f771f 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -107,6 +107,14 @@ worker_count = 10 # How often to run automatic conversation assignment autoassign_interval = "5m" +[webhook] +# Number of webhook delivery workers +workers = 5 +# Maximum number of webhook deliveries that can be queued +queue_size = 10000 +# HTTP timeout for webhook requests +timeout = "15s" + [conversation] # How often to check for conversations to unsnooze unsnooze_interval = "5m" diff --git a/docs/docs/translations.md b/docs/docs/translations.md index 60b9154..4e65401 100644 --- a/docs/docs/translations.md +++ b/docs/docs/translations.md @@ -1,3 +1,3 @@ # Translations / Internationalization -You can help translate libreDesk into different languages by contributing here: [LibreDesk Translation Project](https://crowdin.com/project/libredesk) \ No newline at end of file +You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk) \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6c8f5e1..d879d53 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: LibreDesk Docs +site_name: Libredesk Docs theme: name: material language: en diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2f46d4b..55a330e 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -7,15 +7,15 @@ const http = axios.create({ }) function getCSRFToken () { - const name = 'csrf_token='; - const cookies = document.cookie.split(';'); + const name = 'csrf_token=' + const cookies = document.cookie.split(';') for (let i = 0; i < cookies.length; i++) { - let c = cookies[i].trim(); + let c = cookies[i].trim() if (c.indexOf(name) === 0) { - return c.substring(name.length, c.length); + return c.substring(name.length, c.length) } } - return ''; + return '' } // Request interceptor. @@ -33,9 +33,10 @@ http.interceptors.request.use((request) => { return request }) -const getCustomAttributes = (appliesTo) => http.get('/api/v1/custom-attributes', { - params: { applies_to: appliesTo } -}) +const getCustomAttributes = (appliesTo) => + http.get('/api/v1/custom-attributes', { + params: { applies_to: appliesTo } + }) const createCustomAttribute = (data) => http.post('/api/v1/custom-attributes', data, { headers: { @@ -54,7 +55,8 @@ const searchConversations = (params) => http.get('/api/v1/conversations/search', const searchMessages = (params) => http.get('/api/v1/messages/search', { params }) const searchContacts = (params) => http.get('/api/v1/contacts/search', { params }) const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email') -const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data) +const updateEmailNotificationSettings = (data) => + http.put('/api/v1/settings/notifications/email', data) const getPriorities = () => http.get('/api/v1/priorities') const getStatuses = () => http.get('/api/v1/statuses') const createStatus = (data) => http.post('/api/v1/statuses', data) @@ -81,11 +83,12 @@ const updateTemplate = (id, data) => const getAllBusinessHours = () => http.get('/api/v1/business-hours') const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`) -const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, { - headers: { - 'Content-Type': 'application/json' - } -}) +const createBusinessHours = (data) => + http.post('/api/v1/business-hours', data, { + headers: { + 'Content-Type': 'application/json' + } + }) const updateBusinessHours = (id, data) => http.put(`/api/v1/business-hours/${id}`, data, { headers: { @@ -96,16 +99,18 @@ const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`) const getAllSLAs = () => http.get('/api/v1/sla') const getSLA = (id) => http.get(`/api/v1/sla/${id}`) -const createSLA = (data) => http.post('/api/v1/sla', data, { - headers: { - 'Content-Type': 'application/json' - } -}) -const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data, { - headers: { - 'Content-Type': 'application/json' - } -}) +const createSLA = (data) => + http.post('/api/v1/sla', data, { + headers: { + 'Content-Type': 'application/json' + } + }) +const updateSLA = (id, data) => + http.put(`/api/v1/sla/${id}`, data, { + headers: { + 'Content-Type': 'application/json' + } + }) const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`) const createOIDC = (data) => http.post('/api/v1/oidc', data, { @@ -156,7 +161,8 @@ const updateAutomationRuleWeights = (data) => 'Content-Type': 'application/json' } }) -const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automations/rules/execution-mode`, data) +const updateAutomationRulesExecutionMode = (data) => + http.put(`/api/v1/automations/rules/execution-mode`, data) const getRoles = () => http.get('/api/v1/roles') const getRole = (id) => http.get(`/api/v1/roles/${id}`) const createRole = (data) => @@ -174,11 +180,12 @@ const updateRole = (id, data) => const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`) const getContacts = (params) => http.get('/api/v1/contacts', { params }) const getContact = (id) => http.get(`/api/v1/contacts/${id}`) -const updateContact = (id, data) => http.put(`/api/v1/contacts/${id}`, data, { - headers: { - 'Content-Type': 'multipart/form-data' - } -}) +const updateContact = (id, data) => + http.put(`/api/v1/contacts/${id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data) const getTeam = (id) => http.get(`/api/v1/teams/${id}`) const getTeams = () => http.get('/api/v1/teams') @@ -216,31 +223,39 @@ const createUser = (data) => }) const getTags = () => http.get('/api/v1/tags') const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data) -const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data) -const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`) -const updateContactCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data, - { +const updateAssignee = (uuid, assignee_type, data) => + http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data) +const removeAssignee = (uuid, assignee_type) => + http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`) +const updateContactCustomAttribute = (uuid, data) => + http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data, { headers: { 'Content-Type': 'application/json' } }) -const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data, - { +const updateConversationCustomAttribute = (uuid, data) => + http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data, { headers: { 'Content-Type': 'application/json' } }) -const createConversation = (data) => http.post('/api/v1/conversations', data, { - headers: { - 'Content-Type': 'application/json' - } -}) -const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data) -const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data) +const createConversation = (data) => + http.post('/api/v1/conversations', data, { + headers: { + 'Content-Type': 'application/json' + } + }) +const updateConversationStatus = (uuid, data) => + http.put(`/api/v1/conversations/${uuid}/status`, data) +const updateConversationPriority = (uuid, data) => + http.put(`/api/v1/conversations/${uuid}/priority`, data) const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`) -const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${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 }) +const getConversationMessage = (cuuid, uuid) => + http.get(`/api/v1/conversations/${cuuid}/messages/${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 }) const sendMessage = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/messages`, data, { headers: { @@ -251,28 +266,33 @@ const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`) const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`) const getAllMacros = () => http.get('/api/v1/macros') const getMacro = (id) => http.get(`/api/v1/macros/${id}`) -const createMacro = (data) => http.post('/api/v1/macros', data, { - headers: { - 'Content-Type': 'application/json' - } -}) -const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, { - headers: { - 'Content-Type': 'application/json' - } -}) +const createMacro = (data) => + http.post('/api/v1/macros', data, { + headers: { + 'Content-Type': 'application/json' + } + }) +const updateMacro = (id, data) => + http.put(`/api/v1/macros/${id}`, data, { + headers: { + 'Content-Type': 'application/json' + } + }) const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`) -const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, { - headers: { - 'Content-Type': 'application/json' - } -}) +const applyMacro = (uuid, id, data) => + http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, { + headers: { + 'Content-Type': 'application/json' + } + }) const getTeamUnassignedConversations = (teamID, params) => http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params }) const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params }) -const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params }) +const getUnassignedConversations = (params) => + http.get('/api/v1/conversations/unassigned', { params }) const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params }) -const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params }) +const getViewConversations = (id, params) => + http.get(`/api/v1/views/${id}/conversations`, { params }) const uploadMedia = (data) => http.post('/api/v1/media', data, { headers: { @@ -320,6 +340,23 @@ const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`) const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data) const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`) const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params }) +const getWebhooks = () => http.get('/api/v1/webhooks') +const getWebhook = (id) => http.get(`/api/v1/webhooks/${id}`) +const createWebhook = (data) => + http.post('/api/v1/webhooks', data, { + headers: { + 'Content-Type': 'application/json' + } + }) +const updateWebhook = (id, data) => + http.put(`/api/v1/webhooks/${id}`, data, { + headers: { + 'Content-Type': 'application/json' + } + }) +const deleteWebhook = (id) => http.delete(`/api/v1/webhooks/${id}`) +const toggleWebhook = (id) => http.put(`/api/v1/webhooks/${id}/toggle`) +const testWebhook = (id) => http.post(`/api/v1/webhooks/${id}/test`) export default { login, @@ -448,5 +485,12 @@ export default { getContactNotes, createContactNote, deleteContactNote, - getActivityLogs + getActivityLogs, + getWebhooks, + getWebhook, + createWebhook, + updateWebhook, + deleteWebhook, + toggleWebhook, + testWebhook } diff --git a/frontend/src/constants/navigation.js b/frontend/src/constants/navigation.js index b3a3594..17af572 100644 --- a/frontend/src/constants/navigation.js +++ b/frontend/src/constants/navigation.js @@ -1,149 +1,159 @@ export const reportsNavItems = [ - { - titleKey: 'globals.terms.overview', - href: '/reports/overview', - permission: 'reports:manage' - } + { + titleKey: 'globals.terms.overview', + href: '/reports/overview', + permission: 'reports:manage' + } ] export const adminNavItems = [ - { - titleKey: 'globals.terms.workspace', - children: [ - { - titleKey: 'globals.terms.general', - href: '/admin/general', - permission: 'general_settings:manage' - }, - { - titleKey: 'globals.terms.businessHour', - href: '/admin/business-hours', - permission: 'business_hours:manage' - }, - { - titleKey: 'globals.terms.slaPolicy', - href: '/admin/sla', - permission: 'sla:manage' - } - ] - }, - { - titleKey: 'globals.terms.conversation', - children: [ - { - titleKey: 'globals.terms.tag', - href: '/admin/conversations/tags', - permission: 'tags:manage' - }, - { - titleKey: 'globals.terms.macro', - href: '/admin/conversations/macros', - permission: 'macros:manage' - }, - { - titleKey: 'globals.terms.status', - href: '/admin/conversations/statuses', - permission: 'status:manage' - } - ] - }, - { + { + titleKey: 'globals.terms.workspace', + children: [ + { + titleKey: 'globals.terms.general', + href: '/admin/general', + permission: 'general_settings:manage' + }, + { + titleKey: 'globals.terms.businessHour', + href: '/admin/business-hours', + permission: 'business_hours:manage' + }, + { + titleKey: 'globals.terms.slaPolicy', + href: '/admin/sla', + permission: 'sla:manage' + } + ] + }, + { + titleKey: 'globals.terms.conversation', + children: [ + { + titleKey: 'globals.terms.tag', + href: '/admin/conversations/tags', + permission: 'tags:manage' + }, + { + titleKey: 'globals.terms.macro', + href: '/admin/conversations/macros', + permission: 'macros:manage' + }, + { + titleKey: 'globals.terms.status', + href: '/admin/conversations/statuses', + permission: 'status:manage' + } + ] + }, + { + titleKey: 'globals.terms.inbox', + children: [ + { titleKey: 'globals.terms.inbox', - children: [ - { - titleKey: 'globals.terms.inbox', - href: '/admin/inboxes', - permission: 'inboxes:manage' - } - ] - }, - { - titleKey: 'globals.terms.teammate', - children: [ - { - titleKey: 'globals.terms.agent', - href: '/admin/teams/agents', - permission: 'users:manage' - }, - { - titleKey: 'globals.terms.team', - href: '/admin/teams/teams', - permission: 'teams:manage' - }, - { - titleKey: 'globals.terms.role', - href: '/admin/teams/roles', - permission: 'roles:manage' - }, - { - titleKey: 'globals.terms.activityLog', - href: '/admin/teams/activity-log', - permission: 'activity_logs:manage' - } - ] - }, - { + href: '/admin/inboxes', + permission: 'inboxes:manage' + } + ] + }, + { + titleKey: 'globals.terms.teammate', + children: [ + { + titleKey: 'globals.terms.agent', + href: '/admin/teams/agents', + permission: 'users:manage' + }, + { + titleKey: 'globals.terms.team', + href: '/admin/teams/teams', + permission: 'teams:manage' + }, + { + titleKey: 'globals.terms.role', + href: '/admin/teams/roles', + permission: 'roles:manage' + }, + { + titleKey: 'globals.terms.activityLog', + href: '/admin/teams/activity-log', + permission: 'activity_logs:manage' + } + ] + }, + { + titleKey: 'globals.terms.automation', + children: [ + { titleKey: 'globals.terms.automation', - children: [ - { - titleKey: 'globals.terms.automation', - href: '/admin/automations', - permission: 'automations:manage' - } - ] - }, - { + href: '/admin/automations', + permission: 'automations:manage' + } + ] + }, + { + titleKey: 'globals.terms.customAttribute', + children: [ + { titleKey: 'globals.terms.customAttribute', - children: [ - { - titleKey: 'globals.terms.customAttribute', - href: '/admin/custom-attributes', - permission: 'custom_attributes:manage' - } - ] - }, - { - titleKey: 'globals.terms.notification', - children: [ - { - titleKey: 'globals.terms.email', - href: '/admin/notification', - permission: 'notification_settings:manage' - } - ] - }, - { + href: '/admin/custom-attributes', + permission: 'custom_attributes:manage' + } + ] + }, + { + titleKey: 'globals.terms.notification', + children: [ + { + titleKey: 'globals.terms.email', + href: '/admin/notification', + permission: 'notification_settings:manage' + } + ] + }, + { + titleKey: 'globals.terms.template', + children: [ + { titleKey: 'globals.terms.template', - children: [ - { - titleKey: 'globals.terms.template', - href: '/admin/templates', - permission: 'templates:manage' - } - ] - }, - { - titleKey: 'globals.terms.security', - children: [ - { - titleKey: 'globals.terms.sso', - href: '/admin/sso', - permission: 'oidc:manage' - } - ] - }, + href: '/admin/templates', + permission: 'templates:manage' + } + ] + }, + { + titleKey: 'globals.terms.security', + children: [ + { + titleKey: 'globals.terms.sso', + href: '/admin/sso', + permission: 'oidc:manage' + } + ] + }, + { + titleKey: 'globals.terms.integration', + children: [ + { + titleKey: 'globals.terms.webhook', + href: '/admin/webhooks', + permission: 'webhooks:manage' + } + ] + } ] export const accountNavItems = [ - { - titleKey: 'globals.terms.profile', - href: '/account/profile', - }, + { + titleKey: 'globals.terms.profile', + href: '/account/profile' + } ] export const contactNavItems = [ - { - titleKey: 'globals.terms.contact', - href: '/contacts', - } -] \ No newline at end of file + { + titleKey: 'globals.terms.contact', + href: '/contacts' + } +] diff --git a/frontend/src/constants/permissions.js b/frontend/src/constants/permissions.js index a2a882b..e5ad11a 100644 --- a/frontend/src/constants/permissions.js +++ b/frontend/src/constants/permissions.js @@ -1,41 +1,42 @@ export const permissions = { - CONVERSATIONS_READ: 'conversations:read', - CONVERSATIONS_WRITE: 'conversations:write', - CONVERSATIONS_READ_ASSIGNED: 'conversations:read_assigned', - CONVERSATIONS_READ_ALL: 'conversations:read_all', - CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned', - CONVERSATIONS_READ_TEAM_INBOX: 'conversations:read_team_inbox', - CONVERSATIONS_UPDATE_USER_ASSIGNEE: 'conversations:update_user_assignee', - CONVERSATIONS_UPDATE_TEAM_ASSIGNEE: 'conversations:update_team_assignee', - CONVERSATIONS_UPDATE_PRIORITY: 'conversations:update_priority', - CONVERSATIONS_UPDATE_STATUS: 'conversations:update_status', - CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags', - MESSAGES_READ: 'messages:read', - MESSAGES_WRITE: 'messages:write', - VIEW_MANAGE: 'view:manage', - GENERAL_SETTINGS_MANAGE: 'general_settings:manage', - NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage', - STATUS_MANAGE: 'status:manage', - OIDC_MANAGE: 'oidc:manage', - TAGS_MANAGE: 'tags:manage', - MACROS_MANAGE: 'macros:manage', - USERS_MANAGE: 'users:manage', - TEAMS_MANAGE: 'teams:manage', - AUTOMATIONS_MANAGE: 'automations:manage', - INBOXES_MANAGE: 'inboxes:manage', - ROLES_MANAGE: 'roles:manage', - TEMPLATES_MANAGE: 'templates:manage', - REPORTS_MANAGE: 'reports:manage', - BUSINESS_HOURS_MANAGE: 'business_hours:manage', - SLA_MANAGE: 'sla:manage', - AI_MANAGE: 'ai:manage', - CUSTOM_ATTRIBUTES_MANAGE: 'custom_attributes:manage', - CONTACTS_READ_ALL: 'contacts:read_all', - CONTACTS_READ: 'contacts:read', - CONTACTS_WRITE: 'contacts:write', - CONTACTS_BLOCK: 'contacts:block', - CONTACT_NOTES_READ: 'contact_notes:read', - CONTACT_NOTES_WRITE: 'contact_notes:write', - CONTACT_NOTES_DELETE: 'contact_notes:delete', - ACTIVITY_LOGS_MANAGE: 'activity_logs:manage', -}; \ No newline at end of file + CONVERSATIONS_READ: 'conversations:read', + CONVERSATIONS_WRITE: 'conversations:write', + CONVERSATIONS_READ_ASSIGNED: 'conversations:read_assigned', + CONVERSATIONS_READ_ALL: 'conversations:read_all', + CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned', + CONVERSATIONS_READ_TEAM_INBOX: 'conversations:read_team_inbox', + CONVERSATIONS_UPDATE_USER_ASSIGNEE: 'conversations:update_user_assignee', + CONVERSATIONS_UPDATE_TEAM_ASSIGNEE: 'conversations:update_team_assignee', + CONVERSATIONS_UPDATE_PRIORITY: 'conversations:update_priority', + CONVERSATIONS_UPDATE_STATUS: 'conversations:update_status', + CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags', + MESSAGES_READ: 'messages:read', + MESSAGES_WRITE: 'messages:write', + VIEW_MANAGE: 'view:manage', + GENERAL_SETTINGS_MANAGE: 'general_settings:manage', + NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage', + STATUS_MANAGE: 'status:manage', + OIDC_MANAGE: 'oidc:manage', + TAGS_MANAGE: 'tags:manage', + MACROS_MANAGE: 'macros:manage', + USERS_MANAGE: 'users:manage', + TEAMS_MANAGE: 'teams:manage', + AUTOMATIONS_MANAGE: 'automations:manage', + INBOXES_MANAGE: 'inboxes:manage', + ROLES_MANAGE: 'roles:manage', + TEMPLATES_MANAGE: 'templates:manage', + REPORTS_MANAGE: 'reports:manage', + BUSINESS_HOURS_MANAGE: 'business_hours:manage', + SLA_MANAGE: 'sla:manage', + AI_MANAGE: 'ai:manage', + CUSTOM_ATTRIBUTES_MANAGE: 'custom_attributes:manage', + CONTACTS_READ_ALL: 'contacts:read_all', + CONTACTS_READ: 'contacts:read', + CONTACTS_WRITE: 'contacts:write', + CONTACTS_BLOCK: 'contacts:block', + CONTACT_NOTES_READ: 'contact_notes:read', + CONTACT_NOTES_WRITE: 'contact_notes:write', + CONTACT_NOTES_DELETE: 'contact_notes:delete', + ACTIVITY_LOGS_MANAGE: 'activity_logs:manage', + WEBHOOKS_MANAGE: 'webhooks:manage' +} diff --git a/frontend/src/features/admin/roles/RoleForm.vue b/frontend/src/features/admin/roles/RoleForm.vue index 3794cad..60be99e 100644 --- a/frontend/src/features/admin/roles/RoleForm.vue +++ b/frontend/src/features/admin/roles/RoleForm.vue @@ -166,7 +166,8 @@ const permissions = ref([ { name: perms.SLA_MANAGE, label: t('admin.role.sla.manage') }, { name: perms.AI_MANAGE, label: t('admin.role.ai.manage') }, { name: perms.CUSTOM_ATTRIBUTES_MANAGE, label: t('admin.role.customAttributes.manage') }, - { name: perms.ACTIVITY_LOGS_MANAGE, label: t('admin.role.activityLog.manage') } + { name: perms.ACTIVITY_LOGS_MANAGE, label: t('admin.role.activityLog.manage') }, + { name: perms.WEBHOOKS_MANAGE, label: t('admin.role.webhooks.manage') } ] }, { diff --git a/frontend/src/features/admin/webhooks/WebhookForm.vue b/frontend/src/features/admin/webhooks/WebhookForm.vue new file mode 100644 index 0000000..6386c2c --- /dev/null +++ b/frontend/src/features/admin/webhooks/WebhookForm.vue @@ -0,0 +1,176 @@ + + + diff --git a/frontend/src/features/admin/webhooks/dataTableColumns.js b/frontend/src/features/admin/webhooks/dataTableColumns.js new file mode 100644 index 0000000..7cf391f --- /dev/null +++ b/frontend/src/features/admin/webhooks/dataTableColumns.js @@ -0,0 +1,78 @@ +import { h } from 'vue' +import dropdown from './dataTableDropdown.vue' +import { format } from 'date-fns' +import { Badge } from '@/components/ui/badge' + +export const createColumns = (t) => [ + { + accessorKey: 'name', + header: function () { + return h('div', { class: 'text-center' }, t('globals.terms.name')) + }, + cell: function ({ row }) { + return h('div', { class: 'text-center font-medium' }, row.getValue('name')) + } + }, + { + accessorKey: 'url', + header: function () { + return h('div', { class: 'text-center' }, t('globals.terms.url')) + }, + cell: function ({ row }) { + const url = row.getValue('url') + return h('div', { class: 'text-center font-mono text-sm max-w-xs truncate' }, url) + } + }, + { + accessorKey: 'events', + header: function () { + return h('div', { class: 'text-center' }, 'Events') + }, + cell: function ({ row }) { + const events = row.getValue('events') + return h('div', { class: 'text-center' }, [ + h(Badge, { variant: 'secondary', class: 'text-xs' }, `${events.length} events`) + ]) + } + }, + { + accessorKey: 'is_active', + header: () => h('div', { class: 'text-center' }, t('globals.terms.status')), + cell: ({ row }) => { + const isActive = row.getValue('is_active') + return h('div', { class: 'text-center' }, [ + h( + Badge, + { + variant: isActive ? 'default' : 'secondary', + class: 'text-xs' + }, + isActive ? t('globals.terms.active') : t('globals.terms.inactive') + ) + ]) + } + }, + { + accessorKey: 'updated_at', + header: function () { + return h('div', { class: 'text-center' }, t('globals.terms.updatedAt')) + }, + cell: function ({ row }) { + return h('div', { class: 'text-center text-sm' }, format(row.getValue('updated_at'), 'PPpp')) + } + }, + { + id: 'actions', + enableHiding: false, + cell: ({ row }) => { + const webhook = row.original + return h( + 'div', + { class: 'relative' }, + h(dropdown, { + webhook + }) + ) + } + } +] diff --git a/frontend/src/features/admin/webhooks/dataTableDropdown.vue b/frontend/src/features/admin/webhooks/dataTableDropdown.vue new file mode 100644 index 0000000..ebc357b --- /dev/null +++ b/frontend/src/features/admin/webhooks/dataTableDropdown.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/features/admin/webhooks/formSchema.js b/frontend/src/features/admin/webhooks/formSchema.js new file mode 100644 index 0000000..c2f1398 --- /dev/null +++ b/frontend/src/features/admin/webhooks/formSchema.js @@ -0,0 +1,25 @@ +import * as z from 'zod' + +export const createFormSchema = (t) => + z.object({ + name: z + .string({ + required_error: t('globals.messages.required') + }) + .min(1, { + message: t('globals.messages.required') + }), + url: z + .string({ + required_error: t('globals.messages.required') + }) + .url({ + message: t('form.error.validUrl') + }), + events: z.array(z.string()).min(1, { + message: t('globals.messages.required') + }), + secret: z.string().optional(), + is_active: z.boolean().default(true).optional(), + headers: z.string().optional() + }) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2a02f9b..0f81303 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -45,7 +45,7 @@ const routes = [ path: 'contacts/:id', name: 'contact-detail', component: () => import('@/views/contact/ContactDetailView.vue'), - meta: { title: 'Contacts' }, + meta: { title: 'Contacts' } }, { path: '/reports', @@ -57,7 +57,7 @@ const routes = [ name: 'overview', component: () => import('@/views/reports/OverviewView.vue'), meta: { title: 'Overview' } - }, + } ] }, { @@ -108,7 +108,7 @@ const routes = [ path: 'inboxes/search', name: 'search', component: () => import('@/views/search/SearchView.vue'), - meta: { title: 'Search', hidePageHeader: true }, + meta: { title: 'Search', hidePageHeader: true } }, { path: '/inboxes/:type(assigned|unassigned|all)?', @@ -124,7 +124,7 @@ const routes = [ component: () => import('@/views/inbox/InboxView.vue'), meta: { title: 'Inbox', - type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type + type: (route) => (route.params.type === 'assigned' ? 'My inbox' : route.params.type) }, children: [ { @@ -134,12 +134,13 @@ const routes = [ props: true, meta: { title: 'Inbox', - type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type, + type: (route) => + route.params.type === 'assigned' ? 'My inbox' : route.params.type, hidePageHeader: true } - }, + } ] - }, + } ] }, { @@ -184,21 +185,23 @@ const routes = [ { path: '', name: 'business-hours-list', - component: () => import('@/views/admin/business-hours/BusinessHoursList.vue'), + component: () => import('@/views/admin/business-hours/BusinessHoursList.vue') }, { path: 'new', name: 'new-business-hours', - component: () => import('@/views/admin/business-hours/CreateOrEditBusinessHours.vue'), + component: () => + import('@/views/admin/business-hours/CreateOrEditBusinessHours.vue'), meta: { title: 'New Business Hours' } }, { path: ':id/edit', name: 'edit-business-hours', props: true, - component: () => import('@/views/admin/business-hours/CreateOrEditBusinessHours.vue'), + component: () => + import('@/views/admin/business-hours/CreateOrEditBusinessHours.vue'), meta: { title: 'Edit Business Hours' } - }, + } ] }, { @@ -209,7 +212,7 @@ const routes = [ { path: '', name: 'sla-list', - component: () => import('@/views/admin/sla/SLAList.vue'), + component: () => import('@/views/admin/sla/SLAList.vue') }, { path: 'new', @@ -223,7 +226,7 @@ const routes = [ name: 'edit-sla', component: () => import('@/views/admin/sla/CreateEditSLA.vue'), meta: { title: 'Edit SLA' } - }, + } ] }, { @@ -234,7 +237,7 @@ const routes = [ { path: '', name: 'inbox-list', - component: () => import('@/views/admin/inbox/InboxList.vue'), + component: () => import('@/views/admin/inbox/InboxList.vue') }, { path: 'new', @@ -248,8 +251,8 @@ const routes = [ name: 'edit-inbox', component: () => import('@/views/admin/inbox/EditInbox.vue'), meta: { title: 'Edit Inbox' } - }, - ], + } + ] }, { path: 'notification', @@ -268,7 +271,7 @@ const routes = [ { path: '', name: 'agent-list', - component: () => import('@/views/admin/agents/AgentList.vue'), + component: () => import('@/views/admin/agents/AgentList.vue') }, { path: 'new', @@ -281,7 +284,7 @@ const routes = [ props: true, component: () => import('@/views/admin/agents/EditAgent.vue'), meta: { title: 'Edit agent' } - }, + } ] }, { @@ -292,7 +295,7 @@ const routes = [ { path: '', name: 'team-list', - component: () => import('@/views/admin/teams/TeamList.vue'), + component: () => import('@/views/admin/teams/TeamList.vue') }, { path: 'new', @@ -306,7 +309,7 @@ const routes = [ name: 'edit-team', component: () => import('@/views/admin/teams/EditTeamForm.vue'), meta: { title: 'Edit Team' } - }, + } ] }, { @@ -317,7 +320,7 @@ const routes = [ { path: '', name: 'role-list', - component: () => import('@/views/admin/roles/RoleList.vue'), + component: () => import('@/views/admin/roles/RoleList.vue') }, { path: 'new', @@ -338,7 +341,7 @@ const routes = [ path: 'activity-log', name: 'activity-log', component: () => import('@/views/admin/activity-log/ActivityLog.vue'), - meta: { title: 'Activity Log' }, + meta: { title: 'Activity Log' } } ] }, @@ -395,7 +398,7 @@ const routes = [ { path: '', name: 'sso-list', - component: () => import('@/views/admin/oidc/OIDCList.vue'), + component: () => import('@/views/admin/oidc/OIDCList.vue') }, { path: ':id/edit', @@ -412,6 +415,32 @@ const routes = [ } ] }, + { + path: 'webhooks', + component: () => import('@/views/admin/webhooks/Webhooks.vue'), + name: 'webhooks', + meta: { title: 'Webhooks' }, + children: [ + { + path: '', + name: 'webhook-list', + component: () => import('@/views/admin/webhooks/WebhookList.vue') + }, + { + path: ':id/edit', + props: true, + name: 'edit-webhook', + component: () => import('@/views/admin/webhooks/CreateEditWebhook.vue'), + meta: { title: 'Edit Webhook' } + }, + { + path: 'new', + name: 'new-webhook', + component: () => import('@/views/admin/webhooks/CreateEditWebhook.vue'), + meta: { title: 'New Webhook' } + } + ] + }, { path: 'conversations', meta: { title: 'Conversations' }, @@ -434,7 +463,7 @@ const routes = [ { path: '', name: 'macro-list', - component: () => import('@/views/admin/macros/MacroList.vue'), + component: () => import('@/views/admin/macros/MacroList.vue') }, { path: 'new', @@ -448,7 +477,7 @@ const routes = [ name: 'edit-macro', component: () => import('@/views/admin/macros/EditMacro.vue'), meta: { title: 'Edit Macro' } - }, + } ] } ] diff --git a/frontend/src/views/admin/webhooks/CreateEditWebhook.vue b/frontend/src/views/admin/webhooks/CreateEditWebhook.vue new file mode 100644 index 0000000..8916008 --- /dev/null +++ b/frontend/src/views/admin/webhooks/CreateEditWebhook.vue @@ -0,0 +1,155 @@ + + + diff --git a/frontend/src/views/admin/webhooks/WebhookList.vue b/frontend/src/views/admin/webhooks/WebhookList.vue new file mode 100644 index 0000000..f030d1b --- /dev/null +++ b/frontend/src/views/admin/webhooks/WebhookList.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/views/admin/webhooks/Webhooks.vue b/frontend/src/views/admin/webhooks/Webhooks.vue new file mode 100644 index 0000000..f3129d6 --- /dev/null +++ b/frontend/src/views/admin/webhooks/Webhooks.vue @@ -0,0 +1,24 @@ + + + diff --git a/i18n/en.json b/i18n/en.json index 73cc0ed..8b2f10d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -11,6 +11,7 @@ "globals.terms.conversation": "Conversation | Conversations", "globals.terms.provider": "Provider | Providers", "globals.terms.state": "State | States", + "globals.terms.webhook": "Webhook | Webhooks", "globals.terms.session": "Session | Sessions", "globals.terms.media": "Media | Medias", "globals.terms.permission": "Permission | Permissions", @@ -28,6 +29,9 @@ "globals.terms.businessHour": "Business Hour | Business Hours", "globals.terms.priority": "Priority | Priorities", "globals.terms.status": "Status | Statuses", + "globals.terms.secret": "Secret | Secrets", + "globals.terms.inactive": "Inactive | Inactives", + "globals.terms.integration": "Integration | Integrations", "globals.terms.content": "Content | Contents", "globals.terms.appRootURL": "App Root URL", "globals.terms.dashboard": "Dashboard | Dashboards", @@ -600,4 +604,4 @@ "contact.alreadyExistsWithEmail": "Another contact with same email already exists", "contact.notes.empty": "No notes yet", "contact.notes.help": "Add note for this contact to keep track of important information and conversations." -} \ No newline at end of file +} diff --git a/internal/authz/models/models.go b/internal/authz/models/models.go index 45f05e1..ba95fe4 100644 --- a/internal/authz/models/models.go +++ b/internal/authz/models/models.go @@ -43,6 +43,9 @@ const ( // Roles PermRolesManage = "roles:manage" + // Webhooks + PermWebhooksManage = "webhooks:manage" + // Templates PermTemplatesManage = "templates:manage" @@ -125,6 +128,7 @@ var validPermissions = map[string]struct{}{ PermContactNotesWrite: {}, PermContactNotesDelete: {}, PermActivityLogsManage: {}, + PermWebhooksManage: {}, } // PermissionExists returns true if the permission exists else false diff --git a/internal/automation/automation.go b/internal/automation/automation.go index 452ad62..fd756e5 100644 --- a/internal/automation/automation.go +++ b/internal/automation/automation.go @@ -40,9 +40,9 @@ const ( // ConversationTask represents a unit of work for processing conversations. type ConversationTask struct { - taskType TaskType - eventType string - conversationUUID string + taskType TaskType + eventType string + conversation cmodels.Conversation } type Engine struct { @@ -151,9 +151,9 @@ func (e *Engine) worker(ctx context.Context) { } switch task.taskType { case NewConversation: - e.handleNewConversation(task.conversationUUID) + e.handleNewConversation(task.conversation) case UpdateConversation: - e.handleUpdateConversation(task.conversationUUID, task.eventType) + e.handleUpdateConversation(task.conversation, task.eventType) case TimeTrigger: e.handleTimeTrigger() } @@ -272,7 +272,7 @@ func (e *Engine) UpdateRuleExecutionMode(ruleType, mode string) error { } // EvaluateNewConversationRules enqueues a new conversation for rule evaluation. -func (e *Engine) EvaluateNewConversationRules(conversationUUID string) { +func (e *Engine) EvaluateNewConversationRules(conversation cmodels.Conversation) { e.closedMu.RLock() defer e.closedMu.RUnlock() if e.closed { @@ -280,8 +280,8 @@ func (e *Engine) EvaluateNewConversationRules(conversationUUID string) { } select { case e.taskQueue <- ConversationTask{ - taskType: NewConversation, - conversationUUID: conversationUUID, + taskType: NewConversation, + conversation: conversation, }: default: // Queue is full. @@ -290,7 +290,7 @@ func (e *Engine) EvaluateNewConversationRules(conversationUUID string) { } // EvaluateConversationUpdateRules enqueues an updated conversation for rule evaluation. -func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string, eventType string) { +func (e *Engine) EvaluateConversationUpdateRules(conversation cmodels.Conversation, eventType string) { if eventType == "" { e.lo.Error("error evaluating conversation update rules: eventType is empty") return @@ -302,9 +302,9 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string, eventT } select { case e.taskQueue <- ConversationTask{ - taskType: UpdateConversation, - conversationUUID: conversationUUID, - eventType: eventType, + taskType: UpdateConversation, + eventType: eventType, + conversation: conversation, }: default: // Queue is full. @@ -313,32 +313,22 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string, eventT } // handleNewConversation handles new conversation events. -func (e *Engine) handleNewConversation(conversationUUID string) { - e.lo.Debug("handling new conversation", "uuid", conversationUUID) - conversation, err := e.conversationStore.GetConversation(0, conversationUUID) - if err != nil { - e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err) - return - } +func (e *Engine) handleNewConversation(conversation cmodels.Conversation) { + e.lo.Debug("handling new conversation for automation rule evaluation", "uuid", conversation.UUID) rules := e.filterRulesByType(models.RuleTypeNewConversation, "") if len(rules) == 0 { - e.lo.Warn("no rules to evaluate for new conversation", "uuid", conversationUUID) + e.lo.Warn("no rules to evaluate for new conversation rule evaluation", "uuid", conversation.UUID) return } e.evalConversationRules(rules, conversation) } // handleUpdateConversation handles update conversation events with specific eventType. -func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) { - e.lo.Debug("handling update conversation", "uuid", conversationUUID, "event_type", eventType) - conversation, err := e.conversationStore.GetConversation(0, conversationUUID) - if err != nil { - e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err) - return - } +func (e *Engine) handleUpdateConversation(conversation cmodels.Conversation, eventType string) { + e.lo.Debug("handling update conversation for automation rule evaluation", "uuid", conversation.UUID, "event_type", eventType) rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType) if len(rules) == 0 { - e.lo.Warn("no rules to evaluate for conversation update", "uuid", conversationUUID, "event_type", eventType) + e.lo.Warn("no rules to evaluate for conversation update", "uuid", conversation.UUID, "event_type", eventType) return } e.evalConversationRules(rules, conversation) @@ -346,7 +336,7 @@ func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) { // handleTimeTrigger handles time trigger events. func (e *Engine) handleTimeTrigger() { - e.lo.Debug("handling time triggers") + e.lo.Info("running time trigger evaluation for automation rules") thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour) conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo) if err != nil { @@ -358,7 +348,7 @@ func (e *Engine) handleTimeTrigger() { e.lo.Warn("no rules to evaluate for time trigger") return } - e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules)) + e.lo.Info("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules)) for _, c := range conversations { // Fetch entire conversation. conversation, err := e.conversationStore.GetConversation(0, c.UUID) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index bf4545f..c34f818 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -32,6 +32,7 @@ import ( tmodels "github.com/abhinavxd/libredesk/internal/team/models" "github.com/abhinavxd/libredesk/internal/template" umodels "github.com/abhinavxd/libredesk/internal/user/models" + wmodels "github.com/abhinavxd/libredesk/internal/webhook/models" "github.com/abhinavxd/libredesk/internal/ws" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" @@ -65,6 +66,7 @@ type Manager struct { slaStore slaStore settingsStore settingsStore csatStore csatStore + webhookStore webhookStore notifier *notifier.Service lo *logf.Logger db *sqlx.DB @@ -128,6 +130,10 @@ type csatStore interface { MakePublicURL(appBaseURL, uuid string) string } +type webhookStore interface { + TriggerEvent(event wmodels.WebhookEvent, data any) +} + // Opts holds the options for creating a new Manager. type Opts struct { DB *sqlx.DB @@ -152,6 +158,7 @@ func New( csatStore csatStore, automation *automation.Engine, template *template.Manager, + webhook webhookStore, opts Opts) (*Manager, error) { var q queries @@ -170,6 +177,7 @@ func New( mediaStore: mediaStore, settingsStore: settingsStore, csatStore: csatStore, + webhookStore: webhook, slaStore: slaStore, statusStore: statusStore, priorityStore: priorityStore, @@ -499,6 +507,14 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac if err := c.RecordAssigneeUserChange(uuid, assigneeID, actor); err != nil { return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil) } + + // Trigger webhook for conversation assigned and evaluate automation rules. + conversation, err = c.GetConversation(0, uuid) + if err == nil { + c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationUserAssigned) + c.webhookStore.TriggerEvent(wmodels.EventConversationAssigned, conversation) + } + return nil } @@ -587,6 +603,13 @@ func (c *Manager) UpdateConversationPriority(uuid string, priorityID int, priori return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil) } c.BroadcastConversationUpdate(uuid, "priority", priority) + + // Evaluate automation rules for conversation priority change. + conversation, err := c.GetConversation(0, uuid) + if err == nil { + c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationPriorityChange) + } + return nil } @@ -643,6 +666,13 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn // Broadcast updates using websocket. c.BroadcastConversationUpdate(uuid, "status", status) + // Trigger webhook for conversation status change & evaluate automation rules. + conversation, err := c.GetConversation(0, uuid) + if err == nil { + c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationStatusChange) + c.webhookStore.TriggerEvent(wmodels.EventConversationStatusChanged, conversation) + } + // Broadcast `resolved_at` if the status is changed to resolved, `resolved_at` is set only once when the conversation is resolved for the first time. // Subsequent status changes to resolved will not update the `resolved_at` field. if oldStatus != models.StatusResolved && status == models.StatusResolved { @@ -693,6 +723,12 @@ func (c *Manager) SetConversationTags(uuid string, action string, tagNames []str return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.tag}"), nil) } + // Trigger webhook for conversation tags changed. + c.webhookStore.TriggerEvent(wmodels.EventConversationTagsChanged, map[string]any{ + "conversation_uuid": uuid, + "tags": newTags, + }) + // Find actually removed tags. for _, tag := range prevTags { if slices.Contains(newTags, tag) { @@ -895,6 +931,15 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string) error { m.lo.Error("error removing conversation assignee", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorRemovingConversationAssignee"), nil) } + + // Trigger webhook for conversation unassigned from user. + if typ == models.AssigneeTypeUser { + conversation, err := m.GetConversation(0, uuid) + if err == nil { + m.webhookStore.TriggerEvent(wmodels.EventConversationUnassigned, conversation) + } + } + return nil } diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 0fc451f..810a2e3 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -22,6 +22,7 @@ import ( "github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" + wmodels "github.com/abhinavxd/libredesk/internal/webhook/models" "github.com/lib/pq" "github.com/volatiletech/null/v9" ) @@ -332,6 +333,12 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error { // Broadcast message status update to all conversation subscribers. conversationUUID, _ := m.getConversationUUIDFromMessageUUID(messageUUID) m.BroadcastMessageUpdate(conversationUUID, messageUUID, "status" /*property*/, status) + + // Trigger webhook for message update. + if message, err := m.GetMessage(messageUUID); err == nil { + m.webhookStore.TriggerEvent(wmodels.EventMessageUpdated, message) + } + return nil } @@ -452,6 +459,16 @@ func (m *Manager) InsertMessage(message *models.Message) error { // Broadcast new message. m.BroadcastNewMessage(message) + + // Trigger webhook for message created. + m.webhookStore.TriggerEvent(wmodels.EventMessageCreated, message) + + // Evaluate automation rules for outgoing message event. + conversation, err := m.GetConversation(message.ConversationID, message.ConversationUUID) + if err == nil { + m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageOutgoing) + } + return nil } @@ -604,9 +621,13 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { return err } - // Evaluate automation rules for new conversation. + // Evaluate automation rules & send webhook events. if isNewConversation { - m.automation.EvaluateNewConversationRules(in.Message.ConversationUUID) + conversation, err := m.GetConversation(in.Message.ConversationID, "") + if err == nil { + m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation) + m.automation.EvaluateNewConversationRules(conversation) + } return nil } @@ -624,26 +645,27 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { now := time.Now() m.UpdateConversationWaitingSince(in.Message.ConversationUUID, &now) - // Trigger automations on incoming message event. - m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID, amodels.EventConversationMessageIncoming) - // Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met. // This cycle continues for next response time SLA metric. conversation, err := m.GetConversation(in.Message.ConversationID, "") if err != nil { m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err) - } - if conversation.SLAPolicyID.Int == 0 { - m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation") - return nil - } - if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) { - m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err) - } else if !deadline.IsZero() { - m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) - m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339)) - // Clear next response met at timestamp as this event was just created. - m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_met_at", nil) + } else { + // Trigger automations on incoming message event. + m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming) + + if conversation.SLAPolicyID.Int == 0 { + m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation") + return nil + } + if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) { + m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err) + } else if !deadline.IsZero() { + m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) + m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339)) + // Clear next response met at timestamp as this event was just created. + m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_met_at", nil) + } } return nil } diff --git a/internal/migrations/v0.7.0.go b/internal/migrations/v0.7.0.go new file mode 100644 index 0000000..36fa3dc --- /dev/null +++ b/internal/migrations/v0.7.0.go @@ -0,0 +1,69 @@ +package migrations + +import ( + "github.com/jmoiron/sqlx" + "github.com/knadh/koanf/v2" + "github.com/knadh/stuffbin" +) + +// V0_7_0 updates the database schema to v0.7.0. +func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { + // Create webhook_event enum type if it doesn't exist + _, err := db.Exec(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'webhook_event' + ) THEN + CREATE TYPE webhook_event AS ENUM ( + 'conversation.created', + 'conversation.status_changed', + 'conversation.tags_changed', + 'conversation.assigned', + 'conversation.unassigned', + 'message.created', + 'message.updated' + ); + END IF; + END + $$; + `) + if err != nil { + return err + } + + // Create webhooks table if it doesn't exist + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS webhooks ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + name TEXT NOT NULL, + url TEXT NOT NULL, + events webhook_event[] NOT NULL DEFAULT '{}', + secret TEXT DEFAULT '', + is_active BOOLEAN DEFAULT true, + headers JSONB DEFAULT '{}', + CONSTRAINT constraint_webhooks_on_name CHECK (length(name) <= 255), + CONSTRAINT constraint_webhooks_on_url CHECK (length(url) <= 2048), + CONSTRAINT constraint_webhooks_on_secret CHECK (length(secret) <= 255), + CONSTRAINT constraint_webhooks_on_events_not_empty CHECK (array_length(events, 1) > 0) + ); + CREATE INDEX IF NOT EXISTS index_webhooks_on_is_active ON webhooks (is_active); + `) + if err != nil { + return err + } + + // Add webhooks:manage permission to Admin role + _, err = db.Exec(` + UPDATE roles + SET permissions = array_append(permissions, 'webhooks:manage') + WHERE name = 'Admin' AND NOT ('webhooks:manage' = ANY(permissions)); + `) + if err != nil { + return err + } + + return nil +} diff --git a/internal/webhook/models/models.go b/internal/webhook/models/models.go new file mode 100644 index 0000000..4adad01 --- /dev/null +++ b/internal/webhook/models/models.go @@ -0,0 +1,47 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/lib/pq" +) + +// Webhook represents a webhook configuration +type Webhook struct { + ID int `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + URL string `db:"url" json:"url"` + Events pq.StringArray `db:"events" json:"events"` + Secret string `db:"secret" json:"secret,omitempty"` + IsActive bool `db:"is_active" json:"is_active"` + Headers json.RawMessage `db:"headers" json:"headers"` +} + +// WebhookEvent represents an event that can trigger a webhook +type WebhookEvent string + +const ( + // Conversation events + EventConversationCreated WebhookEvent = "conversation.created" + EventConversationStatusChanged WebhookEvent = "conversation.status_changed" + EventConversationTagsChanged WebhookEvent = "conversation.tags_changed" + EventConversationAssigned WebhookEvent = "conversation.assigned" + EventConversationUnassigned WebhookEvent = "conversation.unassigned" + + // Message events + EventMessageCreated WebhookEvent = "message.created" + EventMessageUpdated WebhookEvent = "message.updated" + + // Test event + EventWebhookTest WebhookEvent = "webhook.test" +) + +// WebhookPayload represents the payload sent to a webhook +type WebhookPayload struct { + Event WebhookEvent `json:"event"` + Timestamp time.Time `json:"timestamp"` + Data json.RawMessage `json:",inline"` +} diff --git a/internal/webhook/queries.sql b/internal/webhook/queries.sql new file mode 100644 index 0000000..0a97730 --- /dev/null +++ b/internal/webhook/queries.sql @@ -0,0 +1,100 @@ +-- name: get-all-webhooks +SELECT + id, + created_at, + updated_at, + name, + url, + events, + secret, + is_active, + headers +FROM + webhooks +ORDER BY created_at DESC; + +-- name: get-webhook +SELECT + id, + created_at, + updated_at, + name, + url, + events, + secret, + is_active, + headers +FROM + webhooks +WHERE + id = $1; + +-- name: get-active-webhooks +SELECT + id, + created_at, + updated_at, + name, + url, + events, + secret, + is_active, + headers +FROM + webhooks +WHERE + is_active = true +ORDER BY created_at DESC; + +-- name: get-webhooks-by-event +SELECT + id, + created_at, + updated_at, + name, + url, + events, + secret, + is_active, + headers +FROM + webhooks +WHERE + is_active = true AND + $1 = ANY(events); + +-- name: insert-webhook +INSERT INTO + webhooks (name, url, events, secret, is_active, headers) +VALUES + ($1, $2, $3, $4, $5, $6) +RETURNING id; + +-- name: update-webhook +UPDATE + webhooks +SET + name = $2, + url = $3, + events = $4, + secret = $5, + is_active = $6, + headers = $7, + updated_at = NOW() +WHERE + id = $1; + +-- name: delete-webhook +DELETE FROM + webhooks +WHERE + id = $1; + +-- name: toggle-webhook +UPDATE + webhooks +SET + is_active = NOT is_active, + updated_at = NOW() +WHERE + id = $1; diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go new file mode 100644 index 0000000..33c4107 --- /dev/null +++ b/internal/webhook/webhook.go @@ -0,0 +1,353 @@ +// Package webhook handles the management of webhooks and webhook deliveries. +package webhook + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "database/sql" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/abhinavxd/libredesk/internal/dbutil" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/webhook/models" + "github.com/jmoiron/sqlx" + "github.com/knadh/go-i18n" + "github.com/lib/pq" + "github.com/zerodha/logf" +) + +var ( + //go:embed queries.sql + efs embed.FS +) + +// Manager handles webhook-related operations. +type Manager struct { + q queries + lo *logf.Logger + i18n *i18n.I18n + db *sqlx.DB + deliveryQueue chan DeliveryTask + httpClient *http.Client + workers int + closed bool + closedMu sync.RWMutex + wg sync.WaitGroup +} + +// Opts contains options for initializing the Manager. +type Opts struct { + DB *sqlx.DB + Lo *logf.Logger + I18n *i18n.I18n + Workers int + QueueSize int + Timeout time.Duration +} + +// DeliveryTask represents a webhook delivery task +type DeliveryTask struct { + WebhookID int + Event models.WebhookEvent + Payload any +} + +// queries contains prepared SQL queries. +type queries struct { + GetAllWebhooks *sqlx.Stmt `query:"get-all-webhooks"` + GetWebhook *sqlx.Stmt `query:"get-webhook"` + GetActiveWebhooks *sqlx.Stmt `query:"get-active-webhooks"` + GetWebhooksByEvent *sqlx.Stmt `query:"get-webhooks-by-event"` + InsertWebhook *sqlx.Stmt `query:"insert-webhook"` + UpdateWebhook *sqlx.Stmt `query:"update-webhook"` + DeleteWebhook *sqlx.Stmt `query:"delete-webhook"` + ToggleWebhook *sqlx.Stmt `query:"toggle-webhook"` +} + +// New creates and returns a new instance of the Manager. +func New(opts Opts) (*Manager, error) { + var q queries + + if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { + return nil, err + } + + return &Manager{ + q: q, + lo: opts.Lo, + i18n: opts.I18n, + db: opts.DB, + deliveryQueue: make(chan DeliveryTask, opts.QueueSize), + httpClient: &http.Client{ + Timeout: opts.Timeout, + }, + workers: opts.Workers, + }, nil +} + +// GetAll retrieves all webhooks. +func (m *Manager) GetAll() ([]models.Webhook, error) { + var webhooks = make([]models.Webhook, 0) + if err := m.q.GetAllWebhooks.Select(&webhooks); err != nil { + m.lo.Error("error fetching webhooks", "error", err) + return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "webhooks"), nil) + } + return webhooks, nil +} + +// Get retrieves a webhook by ID. +func (m *Manager) Get(id int) (models.Webhook, error) { + var webhook models.Webhook + if err := m.q.GetWebhook.Get(&webhook, id); err != nil { + if err == sql.ErrNoRows { + return webhook, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "webhook"), nil) + } + m.lo.Error("error fetching webhook", "error", err) + return webhook, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "webhook"), nil) + } + return webhook, nil +} + +// Create creates a new webhook. +func (m *Manager) Create(webhook models.Webhook) (int, error) { + var id int + if err := m.q.InsertWebhook.Get(&id, webhook.Name, webhook.URL, pq.Array(webhook.Events), webhook.Secret, webhook.IsActive, webhook.Headers); err != nil { + if dbutil.IsUniqueViolationError(err) { + return 0, envelope.NewError(envelope.ConflictError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", "webhook"), nil) + } + m.lo.Error("error inserting webhook", "error", err) + return 0, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "webhook"), nil) + } + return id, nil +} + +// Update updates a webhook by ID. +func (m *Manager) Update(id int, webhook models.Webhook) error { + if _, err := m.q.UpdateWebhook.Exec(id, webhook.Name, webhook.URL, pq.Array(webhook.Events), webhook.Secret, webhook.IsActive, webhook.Headers); err != nil { + m.lo.Error("error updating webhook", "error", err) + return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "webhook"), nil) + } + return nil +} + +// Delete deletes a webhook by ID. +func (m *Manager) Delete(id int) error { + if _, err := m.q.DeleteWebhook.Exec(id); err != nil { + m.lo.Error("error deleting webhook", "error", err) + return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "webhook"), nil) + } + return nil +} + +// Toggle toggles the active status of a webhook by ID. +func (m *Manager) Toggle(id int) error { + if _, err := m.q.ToggleWebhook.Exec(id); err != nil { + m.lo.Error("error toggling webhook", "error", err) + return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "webhook"), nil) + } + return nil +} + +// SendTestWebhook sends a test webhook to the specified webhook ID. +func (m *Manager) SendTestWebhook(id int) error { + webhook, err := m.Get(id) + if err != nil { + return envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "webhook"), nil) + } + + m.deliverWebhook(DeliveryTask{ + WebhookID: webhook.ID, + Event: models.EventWebhookTest, + Payload: map[string]any{ + "id": webhook.ID, + "name": webhook.Name, + "url": webhook.URL, + "events": webhook.Events, + }, + }) + + return nil +} + +// TriggerEvent triggers webhooks for a specific event with the provided data. +func (m *Manager) TriggerEvent(event models.WebhookEvent, data any) { + m.closedMu.RLock() + defer m.closedMu.RUnlock() + if m.closed { + return + } + + webhooks, err := m.getWebhooksByEvent(string(event)) + if err != nil { + m.lo.Error("error fetching webhooks for event", "event", event, "error", err) + return + } + + for _, webhook := range webhooks { + select { + case m.deliveryQueue <- DeliveryTask{ + WebhookID: webhook.ID, + Event: event, + Payload: data, + }: + default: + m.lo.Warn("webhook delivery queue is full, dropping webhook delivery", "webhook_id", webhook.ID, "event", event, "queue_size", len(m.deliveryQueue)) + } + } +} + +// Run starts the webhook delivery worker pool. +func (m *Manager) Run(ctx context.Context) { + for i := 0; i < m.workers; i++ { + m.wg.Add(1) + go func() { + defer m.wg.Done() + m.worker(ctx) + }() + } +} + +// Close signals the manager to stop processing and waits for all workers to finish. +func (m *Manager) Close() { + m.closedMu.Lock() + defer m.closedMu.Unlock() + if m.closed { + return + } + m.closed = true + close(m.deliveryQueue) + m.wg.Wait() +} + +// worker processes webhook delivery tasks from the queue. +func (m *Manager) worker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case task, ok := <-m.deliveryQueue: + if !ok { + return + } + m.deliverWebhook(task) + } + } +} + +// deliverWebhook delivers a webhook by making an HTTP request. +func (m *Manager) deliverWebhook(task DeliveryTask) { + webhook, err := m.Get(task.WebhookID) + if err != nil { + m.lo.Error("error fetching webhook for delivery", "webhook_id", task.WebhookID, "error", err) + return + } + + basePayload := map[string]any{ + "event": task.Event, + "timestamp": time.Now().Format(time.RFC3339), + "payload": task.Payload, + } + + payloadBytes, err := json.Marshal(basePayload) + if err != nil { + m.lo.Error("error marshaling webhook payload", "webhook_id", task.WebhookID, "event", task.Event, "error", err) + return + } + + // Create HTTP request + req, err := http.NewRequest("POST", webhook.URL, bytes.NewReader(payloadBytes)) + if err != nil { + m.lo.Error("error creating webhook request", "webhook_id", task.WebhookID, "url", webhook.URL, "event", task.Event, "error", err) + return + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Libredesk-Webhook/1.0") + + // Add custom headers + if len(webhook.Headers) > 0 { + var headers map[string]string + if err := json.Unmarshal(webhook.Headers, &headers); err == nil { + for key, value := range headers { + req.Header.Set(key, value) + } + } + } + + // Add signature if secret is provided + if webhook.Secret != "" { + signature := m.generateSignature(payloadBytes, webhook.Secret) + req.Header.Set("X-Libredesk-Signature", signature) + } + + m.lo.Debug("delivering webhook", + "webhook_id", task.WebhookID, + "url", webhook.URL, + "event", task.Event, + "payload", string(payloadBytes), + "headers", req.Header, + ) + + // Make the request + resp, err := m.httpClient.Do(req) + if err != nil { + m.lo.Error("webhook delivery failed - HTTP request error", + "webhook_id", task.WebhookID, + "url", webhook.URL, + "event", task.Event, + "error", err) + return + } + defer resp.Body.Close() + + // Read response body + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + m.lo.Error("error reading webhook response", "webhook_id", task.WebhookID, "error", err) + responseBody = []byte(fmt.Sprintf("Error reading response: %v", err)) + } + + // Check if delivery was successful (2xx status codes) + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + + if success { + m.lo.Info("webhook delivered successfully", + "webhook_id", task.WebhookID, + "event", task.Event, + "url", webhook.URL, + "status_code", resp.StatusCode) + } else { + m.lo.Error("webhook delivery failed", + "webhook_id", task.WebhookID, + "event", task.Event, + "url", webhook.URL, + "status_code", resp.StatusCode, + "response", string(responseBody)) + } +} + +// generateSignature generates HMAC-SHA256 signature for webhook payload. +func (m *Manager) generateSignature(payload []byte, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write(payload) + return "sha256=" + hex.EncodeToString(h.Sum(nil)) +} + +// getWebhooksByEvent retrieves active webhooks that are subscribed to a specific event. +func (m *Manager) getWebhooksByEvent(event string) ([]models.Webhook, error) { + var webhooks = make([]models.Webhook, 0) + if err := m.q.GetWebhooksByEvent.Select(&webhooks, event); err != nil { + return nil, err + } + return webhooks, nil +} diff --git a/schema.sql b/schema.sql index 90cc44e..5a7f340 100644 --- a/schema.sql +++ b/schema.sql @@ -20,6 +20,15 @@ DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('fir DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach'); DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online'); DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note'); +DROP TYPE IF EXISTS "webhook_event" CASCADE; CREATE TYPE webhook_event AS ENUM ( + 'conversation.created', + 'conversation.status_changed', + 'conversation.tags_changed', + 'conversation.assigned', + 'conversation.unassigned', + 'message.created', + 'message.updated' +); -- Sequence to generate reference number for conversations. DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100; @@ -138,7 +147,7 @@ CREATE TABLE users ( CONSTRAINT constraint_users_on_first_name CHECK (LENGTH(first_name) <= 140), CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140) ); -CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type) +CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type) WHERE deleted_at IS NULL; CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops); @@ -211,8 +220,8 @@ CREATE TABLE conversations ( -- Restrict delete. contact_channel_id INT REFERENCES contact_channels(id) ON DELETE RESTRICT ON UPDATE CASCADE NOT NULL, status_id INT REFERENCES conversation_statuses(id) ON DELETE RESTRICT ON UPDATE CASCADE NOT NULL, - priority_id INT REFERENCES conversation_priorities(id) ON DELETE RESTRICT ON UPDATE CASCADE, - + priority_id INT REFERENCES conversation_priorities(id) ON DELETE RESTRICT ON UPDATE CASCADE, + meta JSONB DEFAULT '{}'::jsonb NOT NULL, custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL, assignee_last_seen_at TIMESTAMPTZ DEFAULT NOW(), @@ -406,7 +415,7 @@ CREATE TABLE conversation_tags ( tag_id INT REFERENCES tags(id) ON DELETE CASCADE ON UPDATE CASCADE, conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE ); -CREATE UNIQUE INDEX index_conversation_tags_on_conversation_id_and_tag_id ON conversation_tags (conversation_id, tag_id); +CREATE UNIQUE INDEX index_conversation_tags_on_conversation_id_and_tag_id ON conversation_tags (conversation_id, tag_id); DROP TABLE IF EXISTS csat_responses CASCADE; CREATE TABLE csat_responses ( @@ -570,6 +579,24 @@ CREATE INDEX IF NOT EXISTS index_activity_logs_on_actor_id ON activity_logs (act CREATE INDEX IF NOT EXISTS index_activity_logs_on_activity_type ON activity_logs (activity_type); CREATE INDEX IF NOT EXISTS index_activity_logs_on_created_at ON activity_logs (created_at); +DROP TABLE IF EXISTS webhooks CASCADE; +CREATE TABLE webhooks ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + name TEXT NOT NULL, + url TEXT NOT NULL, + events webhook_event[] NOT NULL DEFAULT '{}', + secret TEXT DEFAULT '', + is_active BOOLEAN DEFAULT true, + CONSTRAINT constraint_webhooks_on_name CHECK (length(name) <= 255), + CONSTRAINT constraint_webhooks_on_url CHECK (length(url) <= 2048), + CONSTRAINT constraint_webhooks_on_secret CHECK (length(secret) <= 255), + CONSTRAINT constraint_webhooks_on_events_not_empty CHECK (array_length(events, 1) > 0) +); +CREATE INDEX index_webhooks_on_created_at ON webhooks (created_at); +CREATE INDEX index_webhooks_on_events ON webhooks USING GIN (events); + INSERT INTO ai_providers ("name", provider, config, is_default) VALUES('openai', 'openai', '{"api_key": ""}'::jsonb, true); @@ -618,7 +645,7 @@ INSERT INTO conversation_priorities (name) VALUES -- Default conversation statuses INSERT INTO conversation_statuses (name) VALUES -('Open'), +('Open'), ('Snoozed'), ('Resolved'), ('Closed');