From bd35ff502e67b4ff42199de8ea33cd2c8b79f00d Mon Sep 17 00:00:00 2001 From: jaeyunha Date: Tue, 16 Jun 2026 01:49:42 -0700 Subject: [PATCH 1/3] fix: add zendesk ticket issue linking --- apps/api/cmd/api/main.go | 1 + apps/api/internal/demo/demo.go | 1 + apps/api/internal/http/router.go | 4 + apps/api/internal/integrations/handler.go | 7 +- .../api/internal/integrations/handler_test.go | 85 +++ apps/api/internal/integrations/zendesk.go | 671 ++++++++++++++++++ .../api/internal/integrations/zendesk_jobs.go | 214 ++++++ apps/api/internal/issues/sentry_sources.go | 88 ++- .../app/(app)/settings/integrations/page.tsx | 157 +++- apps/web/src/components/issue-detail-view.tsx | 27 +- apps/web/tests/integrations-view.test.tsx | 73 ++ .../migrations/0010_zendesk_ticket_links.sql | 27 + packages/proto/openapi.yaml | 178 +++++ packages/sdk/src/generated.ts | 308 ++++++++ 14 files changed, 1821 insertions(+), 20 deletions(-) create mode 100644 apps/api/internal/integrations/zendesk.go create mode 100644 apps/api/internal/integrations/zendesk_jobs.go create mode 100644 packages/proto/migrations/0010_zendesk_ticket_links.sql diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 5700f7c6..bcff2837 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -53,6 +53,7 @@ func main() { go (integrations.SlackWorker{DB: db}).Start(workerCtx) go (integrations.MicrosoftTeamsWorker{DB: db}).Start(workerCtx) go (integrations.SentryWorker{DB: db}).Start(workerCtx) + go (integrations.ZendeskWorker{DB: db}).Start(workerCtx) server := &http.Server{ Addr: cfg.Addr, diff --git a/apps/api/internal/demo/demo.go b/apps/api/internal/demo/demo.go index e4049959..7016a3fd 100644 --- a/apps/api/internal/demo/demo.go +++ b/apps/api/internal/demo/demo.go @@ -301,6 +301,7 @@ func resetTx(ctx context.Context, tx pgx.Tx) error { `delete from provider_job where workspace_id=$1::uuid`, `delete from provider_credential where workspace_integration_id in (select id from workspace_integration where workspace_id=$1::uuid)`, `delete from integration_thread_link where workspace_id=$1::uuid`, + `delete from zendesk_ticket_link where workspace_id=$1::uuid`, `delete from team_notification_integration where team_id in (select id from team where workspace_id=$1::uuid)`, `delete from workspace_integration where workspace_id=$1::uuid`, `delete from personal_access_token_audit_log where workspace_id=$1::uuid`, diff --git a/apps/api/internal/http/router.go b/apps/api/internal/http/router.go index c79fef52..ace56fb5 100644 --- a/apps/api/internal/http/router.go +++ b/apps/api/internal/http/router.go @@ -132,6 +132,10 @@ func mountAPIRoutes(r chi.Router, prefix string, db *pgxpool.Pool, emailSender e publicProvider.Post("/integrations/sentry/issues/search", integrationsHandler.SentryIssueSearch) publicProvider.Post("/integrations/sentry/issues/link", integrationsHandler.SentryIssueLink) publicProvider.Post("/integrations/sentry/issues/create", integrationsHandler.SentryIssueCreate) + publicProvider.Post("/integrations/zendesk/tickets/search", integrationsHandler.ZendeskTicketSearch) + publicProvider.Post("/integrations/zendesk/tickets/link", integrationsHandler.ZendeskTicketLink) + publicProvider.Post("/integrations/zendesk/tickets/create", integrationsHandler.ZendeskTicketCreate) + publicProvider.Post("/integrations/zendesk/tickets/status", integrationsHandler.ZendeskTicketStatus) publicProvider.Post("/integrations/microsoft-teams/activities", integrationsHandler.MicrosoftTeamsActivities) publicProvider.Post("/integrations/slack/events", integrationsHandler.SlackEvents) publicProvider.Post("/integrations/gitlab/webhook/{integrationID}", integrationsHandler.GitLabWebhook) diff --git a/apps/api/internal/integrations/handler.go b/apps/api/internal/integrations/handler.go index e78bed64..0756258c 100644 --- a/apps/api/internal/integrations/handler.go +++ b/apps/api/internal/integrations/handler.go @@ -128,6 +128,8 @@ func (h Handler) Routes() chi.Router { r.Post("/microsoft-teams/disconnect", h.MicrosoftTeamsDisconnect) r.Post("/sentry/connect", h.SentryConnect) r.Post("/sentry/disconnect", h.SentryDisconnect) + r.Post("/zendesk/setup", h.ZendeskSetup) + r.Post("/zendesk/disconnect", h.ZendeskDisconnect) r.Post("/slack/disconnect", h.SlackDisconnect) return r } @@ -406,14 +408,11 @@ func setupRequirement(provider string) *SetupRequirement { if provider == "sentry" && !sentryConfigured() { return &SetupRequirement{Type: "configuration_required", Message: "Sentry credentials are not configured. Add AUTH_SENTRY_ID, AUTH_SENTRY_SECRET, and SENTRY_WEBHOOK_SECRET to enable installation and signed issue actions."} } - if provider == "github" || provider == "jira" || provider == "zendesk" { + if provider == "github" || provider == "jira" { name := "GitHub" if provider == "jira" { name = "Jira" } - if provider == "zendesk" { - name = "Zendesk" - } return &SetupRequirement{Type: "configuration_required", Message: name + " setup is not configured in this environment yet."} } return nil diff --git a/apps/api/internal/integrations/handler_test.go b/apps/api/internal/integrations/handler_test.go index 3e4558dc..94621af2 100644 --- a/apps/api/internal/integrations/handler_test.go +++ b/apps/api/internal/integrations/handler_test.go @@ -795,3 +795,88 @@ func TestSentryIssueActionFromPayload(t *testing.T) { t.Fatalf("parsed Sentry issue = %#v", got) } } + +func TestZendeskSetupRequirementAndSubdomain(t *testing.T) { + if got := setupRequirement("zendesk"); got != nil { + t.Fatalf("zendesk should be configurable from admin setup, got %#v", got) + } + subdomain, accountURL, err := normalizeZendeskSubdomain("https://Acme.zendesk.com/") + if err != nil { + t.Fatal(err) + } + if subdomain != "acme" || accountURL != "https://acme.zendesk.com" { + t.Fatalf("normalized = %q %q", subdomain, accountURL) + } + if _, _, err := normalizeZendeskSubdomain("https://acme.zendesk.com/path"); err == nil { + t.Fatal("Zendesk origin with path was accepted") + } +} + +func TestZendeskSignatureVerification(t *testing.T) { + body := []byte(`{"ticket":{"id":"42"}}`) + mac := hmac.New(sha256.New, []byte("secret")) + _, _ = mac.Write(body) + signature := hex.EncodeToString(mac.Sum(nil)) + if !verifyZendeskSignature("secret", signature, body) { + t.Fatal("valid Zendesk signature was rejected") + } + if !verifyZendeskSignature("secret", "sha256="+signature, body) { + t.Fatal("prefixed Zendesk signature was rejected") + } + if verifyZendeskSignature("secret", signature, []byte(`{"ticket":{"id":"43"}}`)) { + t.Fatal("tampered Zendesk body was accepted") + } +} + +func TestZendeskTicketActionFromPayload(t *testing.T) { + payload := map[string]any{ + "query": "ENG", + "teamKey": "ENG", + "issueIdentifier": "ENG-42", + "ticket": map[string]any{ + "id": "123", + "url": "https://acme.zendesk.com/agent/tickets/123", + "subject": "Customer cannot export", + "description": "Export fails", + "status": "open", + "requester": map[string]any{"id": "9", "name": "Ada", "email": "ADA@EXAMPLE.COM"}, + "organization": map[string]any{"id": "7", "name": "Acme"}, + }, + } + got := zendeskTicketActionFromPayload(payload) + if got.Query != "ENG" || got.TeamKey != "ENG" || got.IssueID != "ENG-42" { + t.Fatalf("parsed action = %#v", got) + } + if got.Subdomain != "acme" || got.Ticket.ID != "123" || got.Ticket.Requester.Email != "ada@example.com" || got.Ticket.Organization.Name != "Acme" { + t.Fatalf("parsed ticket = %#v", got.Ticket) + } +} + +func TestUpdateZendeskTicket(t *testing.T) { + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/tickets/123.json" { + t.Fatalf("path = %s", r.URL.Path) + } + username, password, ok := r.BasicAuth() + if !ok || username != "admin@example.com/token" || password != "token" { + t.Fatalf("basic auth = %q %q %v", username, password, ok) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ticket":{"id":123}}`)) + })) + defer server.Close() + t.Setenv("ZENDESK_API_BASE_URL", server.URL) + + err := updateZendeskTicket(t.Context(), server.Client(), zendeskCredential{Subdomain: "acme", AccountURL: "https://acme.zendesk.com", Email: "admin@example.com", APIToken: "token"}, "123", "Done") + if err != nil { + t.Fatal(err) + } + ticket := got["ticket"].(map[string]any) + comment := ticket["comment"].(map[string]any) + if comment["body"] != "Done" || comment["public"] != false || ticket["status"] != "open" { + t.Fatalf("payload = %#v", got) + } +} diff --git a/apps/api/internal/integrations/zendesk.go b/apps/api/internal/integrations/zendesk.go new file mode 100644 index 00000000..9bd340d1 --- /dev/null +++ b/apps/api/internal/integrations/zendesk.go @@ -0,0 +1,671 @@ +package integrations + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/namuh-eng/exponential/apps/api/internal/auth" + "github.com/namuh-eng/exponential/apps/api/internal/problem" + "github.com/namuh-eng/exponential/apps/api/internal/sanitizehtml" +) + +type zendeskSetupRequest struct { + Subdomain string `json:"subdomain"` + Email string `json:"email"` + APIToken string `json:"apiToken"` +} + +type zendeskSetupResponse struct { + Connected bool `json:"connected"` + IntegrationID string `json:"integrationId"` + Subdomain string `json:"subdomain"` + AccountURL string `json:"accountUrl"` + DisplayName string `json:"displayName"` + ActionBaseURL string `json:"actionBaseUrl"` + ActionSecret string `json:"actionSecret"` +} + +type zendeskCredential struct { + Subdomain string `json:"subdomain"` + AccountURL string `json:"accountUrl"` + Email string `json:"email"` + APIToken string `json:"apiToken"` + ActionSecret string `json:"actionSecret"` + CloseNoteBody string `json:"closeNoteBody"` +} + +type zendeskCurrentUser struct { + User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` +} + +type zendeskInstallRecord struct { + WorkspaceID string + IntegrationID string + ConnectedBy string + Subdomain string + AccountURL string + DisplayName string + Metadata map[string]any + Credential zendeskCredential +} + +type zendeskRequesterRef struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +type zendeskOrganizationRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type zendeskTicketRef struct { + ID string `json:"id"` + URL string `json:"url"` + Subject string `json:"subject"` + Description string `json:"description"` + Status string `json:"status"` + Requester zendeskRequesterRef `json:"requester"` + Organization zendeskOrganizationRef `json:"organization"` +} + +type zendeskTicketActionRequest struct { + Query string + IssueID string + Identifier string + Title string + Description string + TeamID string + TeamKey string + Priority string + Subdomain string + Ticket zendeskTicketRef + Raw map[string]any +} + +type zendeskIssueActionResponse struct { + WebURL string `json:"webUrl"` + Project string `json:"project"` + Identifier string `json:"identifier"` + Title string `json:"title,omitempty"` + StateName string `json:"stateName,omitempty"` + StateCategory string `json:"stateCategory,omitempty"` +} + +type zendeskIssueSearchResponse struct { + Issues []zendeskIssueActionResponse `json:"issues"` +} + +type zendeskTicketStatusResponse struct { + TicketID string `json:"ticketId"` + Linked bool `json:"linked"` + Issues []zendeskIssueActionResponse `json:"issues"` +} + +func (h Handler) ZendeskSetup(w http.ResponseWriter, r *http.Request) { + p, _ := auth.FromContext(r.Context()) + if !canManage(p.Role) { + problem.Write(w, http.StatusForbidden, "Forbidden", "") + return + } + var input zendeskSetupRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + problem.Write(w, http.StatusBadRequest, "Zendesk setup payload is invalid", err.Error()) + return + } + subdomain, accountURL, err := normalizeZendeskSubdomain(input.Subdomain) + if err != nil { + problem.Write(w, http.StatusBadRequest, "Zendesk subdomain is invalid", err.Error()) + return + } + email := strings.ToLower(strings.TrimSpace(input.Email)) + token := strings.TrimSpace(input.APIToken) + if email == "" || token == "" { + problem.Write(w, http.StatusBadRequest, "Zendesk email and API token are required", "") + return + } + user, err := validateZendeskCredential(r.Context(), http.DefaultClient, subdomain, accountURL, email, token) + if err != nil { + problem.Write(w, http.StatusBadGateway, "Zendesk credential validation failed", err.Error()) + return + } + actionSecret, err := randomGitLabSecret() + if err != nil { + problem.Write(w, http.StatusInternalServerError, "Create Zendesk action secret failed", err.Error()) + return + } + integrationID, err := h.saveZendeskIntegration(r.Context(), p.WorkspaceID, p.UserID, subdomain, accountURL, email, token, actionSecret, user) + if err != nil { + problem.Write(w, http.StatusInternalServerError, "Save Zendesk integration failed", err.Error()) + return + } + problem.JSON(w, http.StatusOK, zendeskSetupResponse{Connected: true, IntegrationID: integrationID, Subdomain: subdomain, AccountURL: accountURL, DisplayName: zendeskDisplayName(subdomain, user), ActionBaseURL: zendeskActionBaseURL(), ActionSecret: actionSecret}) +} + +func (h Handler) ZendeskDisconnect(w http.ResponseWriter, r *http.Request) { + p, _ := auth.FromContext(r.Context()) + if !canManage(p.Role) { + problem.Write(w, http.StatusForbidden, "Forbidden", "") + return + } + if err := h.revokeProvider(r.Context(), p.WorkspaceID, "zendesk", p.UserID); err != nil { + problem.Write(w, http.StatusInternalServerError, "Disconnect Zendesk failed", err.Error()) + return + } + problem.JSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +func (h Handler) ZendeskTicketSearch(w http.ResponseWriter, r *http.Request) { + install, input, ok := h.zendeskSignedAction(w, r) + if !ok { + return + } + query := strings.TrimSpace(input.Query) + if query == "" { + problem.JSON(w, http.StatusOK, zendeskIssueSearchResponse{Issues: []zendeskIssueActionResponse{}}) + return + } + rows, err := h.DB.Query(r.Context(), ` + select i.identifier,i.title,t.key,ws.name,ws.category::text + from issue i + join team t on t.id=i.team_id + join workflow_state ws on ws.id=i.state_id + where t.workspace_id=$1::uuid + and t.deleted_at is null + and i.archived_at is null + and coalesce(t.is_private,false)=false + and (i.identifier ilike $2 or i.title ilike $2) + order by i.updated_at desc + limit 10`, install.WorkspaceID, "%"+escapeSentryLike(query)+"%") + if err != nil { + _ = h.recordZendeskEvent(r.Context(), install, "issue_search_failed", "error", err.Error(), input.Raw) + problem.Write(w, http.StatusInternalServerError, "Search issues failed", err.Error()) + return + } + defer rows.Close() + out := []zendeskIssueActionResponse{} + for rows.Next() { + var identifier, title, teamKey, stateName, stateCategory string + if err := rows.Scan(&identifier, &title, &teamKey, &stateName, &stateCategory); err != nil { + problem.Write(w, http.StatusInternalServerError, "Search issues failed", err.Error()) + return + } + out = append(out, zendeskIssueActionResponse{WebURL: issueBacklink(teamKey, identifier), Project: teamKey, Identifier: identifier, Title: title, StateName: stateName, StateCategory: stateCategory}) + } + if err := rows.Err(); err != nil { + problem.Write(w, http.StatusInternalServerError, "Search issues failed", err.Error()) + return + } + _ = h.recordZendeskEvent(r.Context(), install, "issue_search_succeeded", "info", "Zendesk issue search completed.", map[string]any{"query": query, "count": len(out)}) + problem.JSON(w, http.StatusOK, zendeskIssueSearchResponse{Issues: out}) +} + +func (h Handler) ZendeskTicketLink(w http.ResponseWriter, r *http.Request) { + install, input, ok := h.zendeskSignedAction(w, r) + if !ok { + return + } + issue, err := h.zendeskIssueForLink(r.Context(), install.WorkspaceID, firstNonEmpty(input.IssueID, input.Identifier)) + if errors.Is(err, pgx.ErrNoRows) { + problem.Write(w, http.StatusNotFound, "Issue not found", "") + return + } + if err != nil { + problem.Write(w, http.StatusInternalServerError, "Link Zendesk ticket failed", err.Error()) + return + } + if err := h.insertZendeskTicketLink(r.Context(), h.DB, install, issue.ID, input.Ticket, input.Raw); err != nil { + problem.Write(w, http.StatusInternalServerError, "Link Zendesk ticket failed", err.Error()) + return + } + _ = h.recordZendeskEvent(r.Context(), install, "ticket_linked", "info", "Zendesk ticket linked to Exponential issue.", map[string]any{"issueId": issue.ID, "ticketId": input.Ticket.ID}) + problem.JSON(w, http.StatusOK, zendeskIssueActionResponse{WebURL: issueBacklink(issue.TeamKey, issue.Identifier), Project: issue.TeamKey, Identifier: issue.Identifier, Title: issue.Title, StateName: issue.StateName, StateCategory: issue.StateCategory}) +} + +func (h Handler) ZendeskTicketCreate(w http.ResponseWriter, r *http.Request) { + install, input, ok := h.zendeskSignedAction(w, r) + if !ok { + return + } + if existing, err := h.zendeskIssueForTicket(r.Context(), install.IntegrationID, input.Ticket.ID); err == nil { + problem.JSON(w, http.StatusOK, zendeskIssueActionResponse{WebURL: issueBacklink(existing.TeamKey, existing.Identifier), Project: existing.TeamKey, Identifier: existing.Identifier, Title: existing.Title, StateName: existing.StateName, StateCategory: existing.StateCategory}) + return + } else if !errors.Is(err, pgx.ErrNoRows) { + problem.Write(w, http.StatusInternalServerError, "Create Zendesk issue failed", err.Error()) + return + } + issue, err := h.createZendeskIssue(r.Context(), install, input) + if err != nil { + _ = h.recordZendeskEvent(r.Context(), install, "issue_creation_failed", "error", err.Error(), input.Raw) + problem.Write(w, http.StatusBadRequest, "Create Zendesk issue failed", err.Error()) + return + } + _ = h.recordZendeskEvent(r.Context(), install, "issue_created", "info", "Zendesk ticket created an Exponential issue.", map[string]any{"issueId": issue.ID, "ticketId": input.Ticket.ID}) + problem.JSON(w, http.StatusOK, zendeskIssueActionResponse{WebURL: issueBacklink(issue.TeamKey, issue.Identifier), Project: issue.TeamKey, Identifier: issue.Identifier, Title: issue.Title, StateName: issue.StateName, StateCategory: issue.StateCategory}) +} + +func (h Handler) ZendeskTicketStatus(w http.ResponseWriter, r *http.Request) { + install, input, ok := h.zendeskSignedAction(w, r) + if !ok { + return + } + rows, err := h.DB.Query(r.Context(), ` + select i.identifier,i.title,t.key,ws.name,ws.category::text + from zendesk_ticket_link ztl + join issue i on i.id=ztl.issue_id + join team t on t.id=i.team_id + join workflow_state ws on ws.id=i.state_id + where ztl.workspace_integration_id=$1::uuid and ztl.ticket_id=$2 and t.workspace_id=$3::uuid and i.archived_at is null + order by ztl.updated_at desc`, install.IntegrationID, input.Ticket.ID, install.WorkspaceID) + if err != nil { + problem.Write(w, http.StatusInternalServerError, "Load Zendesk ticket status failed", err.Error()) + return + } + defer rows.Close() + issues := []zendeskIssueActionResponse{} + for rows.Next() { + var issue zendeskIssueActionResponse + if err := rows.Scan(&issue.Identifier, &issue.Title, &issue.Project, &issue.StateName, &issue.StateCategory); err != nil { + problem.Write(w, http.StatusInternalServerError, "Load Zendesk ticket status failed", err.Error()) + return + } + issue.WebURL = issueBacklink(issue.Project, issue.Identifier) + issues = append(issues, issue) + } + if err := rows.Err(); err != nil { + problem.Write(w, http.StatusInternalServerError, "Load Zendesk ticket status failed", err.Error()) + return + } + _ = h.recordZendeskEvent(r.Context(), install, "ticket_status_viewed", "info", "Zendesk ticket status requested.", map[string]any{"ticketId": input.Ticket.ID, "count": len(issues)}) + problem.JSON(w, http.StatusOK, zendeskTicketStatusResponse{TicketID: input.Ticket.ID, Linked: len(issues) > 0, Issues: issues}) +} + +func (h Handler) zendeskSignedAction(w http.ResponseWriter, r *http.Request) (zendeskInstallRecord, zendeskTicketActionRequest, bool) { + body, err := io.ReadAll(r.Body) + if err != nil { + problem.Write(w, http.StatusBadRequest, "Zendesk request body could not be read", err.Error()) + return zendeskInstallRecord{}, zendeskTicketActionRequest{}, false + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + problem.Write(w, http.StatusBadRequest, "Zendesk payload is invalid", err.Error()) + return zendeskInstallRecord{}, zendeskTicketActionRequest{}, false + } + input := zendeskTicketActionFromPayload(raw) + install, err := h.resolveZendeskInstall(r.Context(), input) + if errors.Is(err, pgx.ErrNoRows) { + problem.Write(w, http.StatusNotFound, "Zendesk integration is not connected", "") + return zendeskInstallRecord{}, zendeskTicketActionRequest{}, false + } + if err != nil { + problem.Write(w, http.StatusInternalServerError, "Zendesk integration could not be resolved", err.Error()) + return zendeskInstallRecord{}, zendeskTicketActionRequest{}, false + } + if !verifyZendeskSignature(install.Credential.ActionSecret, firstNonEmpty(r.Header.Get("X-Exponential-Signature"), r.Header.Get("X-Zendesk-Exponential-Signature")), body) { + problem.Write(w, http.StatusUnauthorized, "Invalid Zendesk signature", "") + return zendeskInstallRecord{}, zendeskTicketActionRequest{}, false + } + if strings.TrimSpace(input.Ticket.ID) == "" { + problem.Write(w, http.StatusBadRequest, "Zendesk ticket id is required", "") + return zendeskInstallRecord{}, zendeskTicketActionRequest{}, false + } + return install, input, true +} + +func zendeskTicketActionFromPayload(raw map[string]any) zendeskTicketActionRequest { + data := recordValue(raw["data"]) + if data == nil { + data = raw + } + ticketRecord := firstRecord(raw["ticket"], data["ticket"]) + requesterRecord := firstRecord(raw["requester"], data["requester"], ticketRecord["requester"]) + organizationRecord := firstRecord(raw["organization"], data["organization"], ticketRecord["organization"]) + ticketID := firstNonEmpty(stringValue(raw["ticketId"]), stringValue(raw["ticket_id"]), stringValue(data["ticketId"]), stringValue(data["ticket_id"]), stringValue(ticketRecord["id"])) + ticketURL := firstNonEmpty(stringValue(raw["ticketUrl"]), stringValue(raw["ticket_url"]), stringValue(raw["url"]), stringValue(data["ticketUrl"]), stringValue(data["ticket_url"]), stringValue(data["url"]), stringValue(ticketRecord["url"]), stringValue(ticketRecord["html_url"])) + subdomain := firstNonEmpty(stringValue(raw["subdomain"]), stringValue(raw["accountSubdomain"]), stringValue(data["subdomain"]), stringValue(data["accountSubdomain"]), zendeskSubdomainFromURL(ticketURL)) + return zendeskTicketActionRequest{ + Query: firstNonEmpty(stringValue(raw["query"]), stringValue(raw["q"]), stringValue(data["query"]), stringValue(data["q"])), + IssueID: firstNonEmpty(stringValue(raw["exponentialIssueId"]), stringValue(raw["issueIdentifier"]), stringValue(raw["identifier"]), stringValue(data["exponentialIssueId"]), stringValue(data["issueIdentifier"]), stringValue(data["identifier"])), + Identifier: firstNonEmpty(stringValue(raw["identifier"]), stringValue(data["identifier"])), + Title: firstNonEmpty(stringValue(raw["title"]), stringValue(data["title"]), stringValue(ticketRecord["subject"]), stringValue(ticketRecord["title"])), + Description: firstNonEmpty(stringValue(raw["description"]), stringValue(data["description"]), stringValue(ticketRecord["description"]), stringValue(ticketRecord["body"])), + TeamID: firstNonEmpty(stringValue(raw["teamId"]), stringValue(raw["team_id"]), stringValue(data["teamId"]), stringValue(data["team_id"])), + TeamKey: firstNonEmpty(stringValue(raw["teamKey"]), stringValue(raw["team"]), stringValue(data["teamKey"]), stringValue(data["team"])), + Priority: firstNonEmpty(stringValue(raw["priority"]), stringValue(data["priority"])), + Subdomain: subdomain, + Ticket: zendeskTicketRef{ID: ticketID, URL: ticketURL, Subject: firstNonEmpty(stringValue(ticketRecord["subject"]), stringValue(raw["title"]), stringValue(data["title"])), Description: firstNonEmpty(stringValue(ticketRecord["description"]), stringValue(ticketRecord["body"]), stringValue(raw["description"]), stringValue(data["description"])), Status: firstNonEmpty(stringValue(ticketRecord["status"]), stringValue(raw["ticketStatus"]), stringValue(data["ticketStatus"])), Requester: zendeskRequesterRef{ID: firstNonEmpty(stringValue(requesterRecord["id"]), stringValue(raw["requesterId"]), stringValue(data["requesterId"])), Name: firstNonEmpty(stringValue(requesterRecord["name"]), stringValue(raw["requesterName"]), stringValue(data["requesterName"])), Email: strings.ToLower(firstNonEmpty(stringValue(requesterRecord["email"]), stringValue(raw["requesterEmail"]), stringValue(data["requesterEmail"])))}, Organization: zendeskOrganizationRef{ID: firstNonEmpty(stringValue(organizationRecord["id"]), stringValue(raw["organizationId"]), stringValue(data["organizationId"])), Name: firstNonEmpty(stringValue(organizationRecord["name"]), stringValue(raw["organizationName"]), stringValue(data["organizationName"]))}}, + Raw: raw, + } +} + +type zendeskIssueRow struct { + ID string + Identifier string + Title string + TeamKey string + StateName string + StateCategory string +} + +func (h Handler) zendeskIssueForLink(ctx context.Context, workspaceID, requested string) (zendeskIssueRow, error) { + var issue zendeskIssueRow + requested = strings.TrimSpace(requested) + where := "upper(i.identifier)=upper($2)" + if isUUIDish(requested) { + where = "(i.id=$2::uuid or upper(i.identifier)=upper($2))" + } + err := h.DB.QueryRow(ctx, ` + select i.id::text,i.identifier,i.title,t.key,ws.name,ws.category::text + from issue i + join team t on t.id=i.team_id + join workflow_state ws on ws.id=i.state_id + where t.workspace_id=$1::uuid + and coalesce(t.is_private,false)=false + and i.archived_at is null + and `+where+` + limit 1`, workspaceID, requested).Scan(&issue.ID, &issue.Identifier, &issue.Title, &issue.TeamKey, &issue.StateName, &issue.StateCategory) + return issue, err +} + +func (h Handler) zendeskIssueForTicket(ctx context.Context, integrationID, ticketID string) (zendeskIssueRow, error) { + var issue zendeskIssueRow + err := h.DB.QueryRow(ctx, ` + select i.id::text,i.identifier,i.title,t.key,ws.name,ws.category::text + from zendesk_ticket_link ztl + join issue i on i.id=ztl.issue_id + join team t on t.id=i.team_id + join workflow_state ws on ws.id=i.state_id + where ztl.workspace_integration_id=$1::uuid and ztl.ticket_id=$2 + limit 1`, integrationID, ticketID).Scan(&issue.ID, &issue.Identifier, &issue.Title, &issue.TeamKey, &issue.StateName, &issue.StateCategory) + return issue, err +} + +func (h Handler) createZendeskIssue(ctx context.Context, install zendeskInstallRecord, input zendeskTicketActionRequest) (zendeskIssueRow, error) { + title := strings.TrimSpace(firstNonEmpty(input.Title, input.Ticket.Subject)) + if title == "" { + return zendeskIssueRow{}, fmt.Errorf("title is required") + } + tx, err := h.DB.Begin(ctx) + if err != nil { + return zendeskIssueRow{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + team, err := h.sentryPublicTeam(ctx, tx, install.WorkspaceID, input.TeamID, input.TeamKey) + if err != nil { + return zendeskIssueRow{}, err + } + stateID, err := slackIssueStateByCategory(ctx, tx, team.ID, "triage") + if errors.Is(err, pgx.ErrNoRows) { + stateID, err = slackIssueStateByCategory(ctx, tx, team.ID, "backlog") + } + if err != nil { + return zendeskIssueRow{}, err + } + creatorID := install.ConnectedBy + if creatorID == "" { + creatorID, err = h.workspaceIssueCreator(ctx, install.WorkspaceID) + if err != nil { + return zendeskIssueRow{}, err + } + } + var nextNumber int32 + if err := tx.QueryRow(ctx, `select coalesce(max(number),0)+1 from issue where team_id=$1::uuid`, team.ID).Scan(&nextNumber); err != nil { + return zendeskIssueRow{}, err + } + identifier := fmt.Sprintf("%s-%d", team.Key, nextNumber) + description := zendeskIssueDescriptionHTML(input.Ticket, input.Description) + var issueID string + if err := tx.QueryRow(ctx, ` + insert into issue (number,identifier,title,description,team_id,state_id,creator_id,priority) + values ($1,$2,$3,$4,$5::uuid,$6::uuid,$7,$8) + returning id::text`, nextNumber, identifier, title, description, team.ID, stateID, creatorID, sentryPriority(input.Priority)).Scan(&issueID); err != nil { + return zendeskIssueRow{}, err + } + history := map[string]any{"identifier": identifier, "title": title, "teamId": team.ID, "source": "zendesk_ticket", "backlink": issueBacklink(team.Key, identifier), "zendesk": zendeskHistoryMetadata(input.Ticket)} + historyRaw, _ := json.Marshal(history) + if _, err := tx.Exec(ctx, `insert into issue_history (issue_id,actor_id,actor_name,actor_email,event_type,metadata) values ($1::uuid,$2,'Zendesk',null,'created',$3::jsonb)`, issueID, nullString(creatorID), historyRaw); err != nil { + return zendeskIssueRow{}, err + } + if err := h.insertZendeskTicketLink(ctx, tx, install, issueID, input.Ticket, input.Raw); err != nil { + return zendeskIssueRow{}, err + } + if err := tx.Commit(ctx); err != nil { + return zendeskIssueRow{}, err + } + return zendeskIssueRow{ID: issueID, Identifier: identifier, Title: title, TeamKey: team.Key, StateName: "", StateCategory: ""}, nil +} + +type zendeskLinkExecutor interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +} + +func (h Handler) insertZendeskTicketLink(ctx context.Context, q zendeskLinkExecutor, install zendeskInstallRecord, issueID string, ticket zendeskTicketRef, raw map[string]any) error { + ticketID := strings.TrimSpace(ticket.ID) + if ticketID == "" { + return fmt.Errorf("Zendesk ticket id is required") + } + metadata := map[string]any{"ticket": ticket, "raw": raw} + metadataRaw, err := json.Marshal(metadata) + if err != nil { + return err + } + if _, err := q.Exec(ctx, ` + insert into zendesk_ticket_link (workspace_id, workspace_integration_id, issue_id, ticket_id, ticket_url, ticket_status, requester_id, requester_name, requester_email, organization_id, organization_name, metadata, updated_at) + values ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,now()) + on conflict (workspace_integration_id, ticket_id) where workspace_integration_id is not null + do update set issue_id=excluded.issue_id, ticket_url=excluded.ticket_url, ticket_status=excluded.ticket_status, requester_id=excluded.requester_id, requester_name=excluded.requester_name, requester_email=excluded.requester_email, organization_id=excluded.organization_id, organization_name=excluded.organization_name, metadata=excluded.metadata, updated_at=now()`, install.WorkspaceID, install.IntegrationID, issueID, ticketID, nullString(ticket.URL), nullString(ticket.Status), nullString(ticket.Requester.ID), nullString(ticket.Requester.Name), nullString(ticket.Requester.Email), nullString(ticket.Organization.ID), nullString(ticket.Organization.Name), metadataRaw); err != nil { + return err + } + _, err = q.Exec(ctx, ` + insert into integration_thread_link (workspace_id, workspace_integration_id, provider, issue_id, external_team_id, external_channel_id, external_thread_ts, external_message_ts, external_permalink, direction, source_event_id) + values ($1::uuid,$2::uuid,'zendesk',$3::uuid,$4,'tickets',$5,$5,$6,'inbound',$7) + on conflict (workspace_integration_id, source_event_id) where workspace_integration_id is not null and source_event_id is not null + do update set issue_id=excluded.issue_id, external_permalink=excluded.external_permalink, updated_at=now()`, install.WorkspaceID, install.IntegrationID, issueID, install.Subdomain, ticketID, nullString(ticket.URL), zendeskTicketSourceEventID(ticketID)) + return err +} + +func zendeskIssueDescriptionHTML(ticket zendeskTicketRef, fallback string) string { + description := strings.TrimSpace(firstNonEmpty(ticket.Description, fallback)) + if strings.Contains(description, "<") { + description = sanitizehtml.RichText(description) + } else if description != "" { + description = "

" + htmlEscapeParagraph(description) + "

" + } + if ticket.URL == "" { + return description + } + link := `

View source in Zendesk

` + return sanitizehtml.RichText(description + link) +} + +func zendeskHistoryMetadata(ticket zendeskTicketRef) map[string]any { + return map[string]any{"ticketId": ticket.ID, "ticketUrl": ticket.URL, "status": ticket.Status, "requester": ticket.Requester, "organization": ticket.Organization} +} + +func (h Handler) resolveZendeskInstall(ctx context.Context, input zendeskTicketActionRequest) (zendeskInstallRecord, error) { + integrationID := firstNonEmpty(stringValue(input.Raw["integrationId"]), stringValue(input.Raw["workspaceIntegrationId"]), stringValue(recordValue(input.Raw["data"])["integrationId"])) + args := []any{} + where := "wi.provider='zendesk' and wi.status in ('connected','degraded')" + if integrationID != "" { + args = append(args, integrationID) + where += " and wi.id=$1::uuid" + } else if input.Subdomain != "" { + args = append(args, strings.ToLower(strings.TrimSpace(input.Subdomain))) + where += " and (wi.external_id=$1 or wi.metadata->>'subdomain'=$1)" + } + var install zendeskInstallRecord + var metadataRaw []byte + var credentialRaw []byte + err := h.DB.QueryRow(ctx, `select wi.workspace_id::text,wi.id::text,coalesce(wi.connected_by_user_id,''),coalesce(wi.external_id,''),coalesce(wi.display_name,''),coalesce(wi.metadata,'{}'::jsonb),pc.encrypted_payload from workspace_integration wi join provider_credential pc on pc.workspace_integration_id=wi.id and pc.provider='zendesk' and pc.active where `+where+` order by wi.connected_at desc limit 1`, args...).Scan(&install.WorkspaceID, &install.IntegrationID, &install.ConnectedBy, &install.Subdomain, &install.DisplayName, &metadataRaw, &credentialRaw) + if err != nil { + return install, err + } + install.Metadata = readJSONRecord(metadataRaw) + _ = json.Unmarshal(credentialRaw, &install.Credential) + install.AccountURL = firstNonEmpty(install.Credential.AccountURL, stringValue(install.Metadata["accountUrl"])) + return install, nil +} + +func normalizeZendeskSubdomain(input string) (string, string, error) { + value := strings.TrimSpace(strings.ToLower(input)) + if value == "" { + return "", "", fmt.Errorf("subdomain is required") + } + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + if err != nil { + return "", "", err + } + if parsed.Scheme != "https" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.EscapedPath(), "/") != "" { + return "", "", fmt.Errorf("Zendesk URL must be an HTTPS origin without credentials, query, fragment, or path") + } + value = parsed.Hostname() + } + value = strings.TrimSuffix(value, ".zendesk.com") + if value == "" || strings.ContainsAny(value, "/:@") { + return "", "", fmt.Errorf("Zendesk subdomain is invalid") + } + return value, "https://" + value + ".zendesk.com", nil +} + +func validateZendeskCredential(ctx context.Context, client *http.Client, subdomain string, accountURL string, email string, token string) (zendeskCurrentUser, error) { + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, zendeskAPIURL(subdomain, accountURL, "/api/v2/users/me.json"), nil) + if err != nil { + return zendeskCurrentUser{}, err + } + req.SetBasicAuth(email+"/token", token) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return zendeskCurrentUser{}, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return zendeskCurrentUser{}, fmt.Errorf("Zendesk rejected the API token") + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return zendeskCurrentUser{}, fmt.Errorf("Zendesk returned HTTP %d", resp.StatusCode) + } + var user zendeskCurrentUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return zendeskCurrentUser{}, err + } + if user.User.ID == 0 && strings.TrimSpace(user.User.Email) == "" { + return zendeskCurrentUser{}, fmt.Errorf("Zendesk current user response was incomplete") + } + return user, nil +} + +func (h Handler) saveZendeskIntegration(ctx context.Context, workspaceID, userID, subdomain, accountURL, email, token, actionSecret string, user zendeskCurrentUser) (string, error) { + tx, err := h.DB.Begin(ctx) + if err != nil { + return "", err + } + defer func() { _ = tx.Rollback(ctx) }() + now := time.Now().UTC() + metadata := map[string]any{"subdomain": subdomain, "accountUrl": accountURL, "zendeskUserId": user.User.ID, "zendeskUserEmail": user.User.Email, "configuredBy": userID, "actionSecretHash": hashSlackSecret(actionSecret)} + metadataRaw, err := json.Marshal(metadata) + if err != nil { + return "", err + } + displayName := zendeskDisplayName(subdomain, user) + var integrationID string + if err := tx.QueryRow(ctx, ` + insert into workspace_integration (workspace_id, provider, status, external_id, display_name, metadata, connected_by_user_id, connected_at, last_event_at, last_success_at, credentials_revoked_at, revoked_at, revoked_by_user_id, updated_at) + values ($1::uuid, 'zendesk', 'connected', $2, $3, $4::jsonb, $5, $6, $6, $6, null, null, null, $6) + on conflict (workspace_id, provider) do update set + status='connected', external_id=excluded.external_id, display_name=excluded.display_name, metadata=excluded.metadata, + connected_by_user_id=excluded.connected_by_user_id, connected_at=coalesce(workspace_integration.connected_at, excluded.connected_at), + last_event_at=excluded.last_event_at, last_success_at=excluded.last_success_at, last_failure_at=null, last_failure_message=null, + credentials_revoked_at=null, revoked_at=null, revoked_by_user_id=null, updated_at=excluded.updated_at + returning id::text`, workspaceID, subdomain, displayName, metadataRaw, userID, now).Scan(&integrationID); err != nil { + return "", err + } + if _, err := tx.Exec(ctx, `update provider_credential set active=false, revoked_at=coalesce(revoked_at, $2), updated_at=$2 where workspace_integration_id=$1::uuid and active`, integrationID, now); err != nil { + return "", err + } + credential := zendeskCredential{Subdomain: subdomain, AccountURL: accountURL, Email: email, APIToken: token, ActionSecret: actionSecret, CloseNoteBody: "Linked Exponential issue reached a terminal state."} + credentialRaw, err := json.Marshal(credential) + if err != nil { + return "", err + } + credentialMetadataRaw, _ := json.Marshal(map[string]any{"subdomain": subdomain, "accountUrl": accountURL, "zendeskUserId": user.User.ID, "zendeskUserEmail": user.User.Email}) + if _, err := tx.Exec(ctx, `insert into provider_credential (workspace_integration_id, provider, encrypted_payload, metadata, created_by_user_id, created_at, updated_at) values ($1::uuid, 'zendesk', $2, $3::jsonb, $4, $5, $5)`, integrationID, credentialRaw, credentialMetadataRaw, userID, now); err != nil { + return "", err + } + if _, err := tx.Exec(ctx, `insert into provider_event (workspace_id, workspace_integration_id, provider, event_type, severity, message, payload, created_at) values ($1::uuid, $2::uuid, 'zendesk', 'token_validated', 'info', 'Zendesk integration connected.', $3::jsonb, $4)`, workspaceID, integrationID, metadataRaw, now); err != nil { + return "", err + } + return integrationID, tx.Commit(ctx) +} + +func (h Handler) recordZendeskEvent(ctx context.Context, install zendeskInstallRecord, eventType, severity, message string, payload map[string]any) error { + raw, _ := json.Marshal(payload) + _, err := h.DB.Exec(ctx, `insert into provider_event (workspace_id, workspace_integration_id, provider, event_type, severity, message, payload) values ($1::uuid,$2::uuid,'zendesk',$3,$4,$5,$6::jsonb)`, install.WorkspaceID, install.IntegrationID, eventType, severity, message, raw) + return err +} + +func verifyZendeskSignature(secret string, signature string, body []byte) bool { + secret = strings.TrimSpace(secret) + signature = strings.TrimSpace(strings.TrimPrefix(signature, "sha256=")) + if secret == "" || signature == "" { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return subtle.ConstantTimeCompare([]byte(expected), []byte(signature)) == 1 +} + +func zendeskAPIURL(subdomain string, accountURL string, path string) string { + base := strings.TrimSpace(zendeskAPIBaseURL()) + if base == "" { + base = accountURL + } + return strings.TrimRight(base, "/") + path +} + +func zendeskAPIBaseURL() string { return strings.TrimSpace(os.Getenv("ZENDESK_API_BASE_URL")) } + +func zendeskDisplayName(subdomain string, user zendeskCurrentUser) string { + if name := strings.TrimSpace(user.User.Name); name != "" { + return subdomain + " · " + name + } + return subdomain + ".zendesk.com" +} + +func zendeskActionBaseURL() string { + return strings.TrimRight(configuredAppURL(), "/") + "/api/integrations/zendesk/tickets" +} + +func zendeskSubdomainFromURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Hostname() == "" { + return "" + } + return strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".zendesk.com") +} + +func zendeskTicketSourceEventID(ticketID string) string { return "zendesk_ticket:" + strings.TrimSpace(ticketID) } diff --git a/apps/api/internal/integrations/zendesk_jobs.go b/apps/api/internal/integrations/zendesk_jobs.go new file mode 100644 index 00000000..48b9194e --- /dev/null +++ b/apps/api/internal/integrations/zendesk_jobs.go @@ -0,0 +1,214 @@ +package integrations + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type ZendeskWorker struct { + DB *pgxpool.Pool + Client *http.Client + Interval time.Duration +} + +type zendeskJob struct { + ID string + WorkspaceID string + IntegrationID string + Payload map[string]any + Attempts int + MaxAttempts int +} + +func (w ZendeskWorker) Start(ctx context.Context) { + interval := w.Interval + if interval <= 0 { + interval = 5 * time.Second + } + for { + _ = w.RunOnce(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(interval): + } + } +} + +func (w ZendeskWorker) RunOnce(ctx context.Context) error { + if w.DB == nil { + return nil + } + job, err := w.claimZendeskOutboundJob(ctx) + if err != nil { + if err == pgx.ErrNoRows { + return nil + } + return err + } + if err := w.deliverZendeskJob(ctx, job); err != nil { + return w.failZendeskJob(ctx, job, err) + } + return w.succeedZendeskJob(ctx, job) +} + +func (w ZendeskWorker) claimZendeskOutboundJob(ctx context.Context) (zendeskJob, error) { + var job zendeskJob + var payloadRaw []byte + err := w.DB.QueryRow(ctx, ` + update provider_job + set status='running', attempts=attempts+1, started_at=now(), updated_at=now() + where id = ( + select id + from provider_job + where provider='zendesk' + and kind='outbound_delivery' + and status in ('queued','failed') + and (next_run_at is null or next_run_at <= now()) + order by scheduled_at asc + for update skip locked + limit 1 + ) + returning id::text, workspace_id::text, workspace_integration_id::text, payload, attempts, max_attempts`).Scan(&job.ID, &job.WorkspaceID, &job.IntegrationID, &payloadRaw, &job.Attempts, &job.MaxAttempts) + if err != nil { + return zendeskJob{}, err + } + job.Payload = map[string]any{} + if err := json.Unmarshal(payloadRaw, &job.Payload); err != nil { + return zendeskJob{}, err + } + return job, nil +} + +func (w ZendeskWorker) deliverZendeskJob(ctx context.Context, job zendeskJob) error { + credential, err := w.activeZendeskCredential(ctx, job.IntegrationID) + if err != nil { + return err + } + if stringValue(job.Payload["type"]) != "ticket_followup" { + return fmt.Errorf("unsupported Zendesk outbound job type %q", stringValue(job.Payload["type"])) + } + ticketID := stringValue(job.Payload["ticketId"]) + identifier := stringValue(job.Payload["identifier"]) + category := stringValue(job.Payload["category"]) + if ticketID == "" || identifier == "" { + return fmt.Errorf("Zendesk follow-up job requires ticketId and identifier") + } + note := strings.TrimSpace(stringValue(job.Payload["note"])) + if note == "" { + note = strings.TrimSpace(credential.CloseNoteBody) + } + if note == "" { + note = "Linked Exponential issue " + identifier + " reached " + category + "." + } + return updateZendeskTicket(ctx, w.httpClient(), credential, ticketID, note) +} + +func (w ZendeskWorker) activeZendeskCredential(ctx context.Context, integrationID string) (zendeskCredential, error) { + var payloadRaw []byte + err := w.DB.QueryRow(ctx, `select encrypted_payload from provider_credential where workspace_integration_id=$1::uuid and provider='zendesk' and active limit 1`, integrationID).Scan(&payloadRaw) + if err != nil { + return zendeskCredential{}, err + } + var credential zendeskCredential + if err := json.Unmarshal(payloadRaw, &credential); err != nil { + return zendeskCredential{}, err + } + return credential, nil +} + +func updateZendeskTicket(ctx context.Context, client *http.Client, credential zendeskCredential, ticketID string, note string) error { + if client == nil { + client = http.DefaultClient + } + body := map[string]any{ + "ticket": map[string]any{ + "comment": map[string]any{"body": note, "public": false}, + "status": "open", + }, + } + raw, err := json.Marshal(body) + if err != nil { + return err + } + endpoint := zendeskAPIURL(credential.Subdomain, credential.AccountURL, "/api/v2/tickets/"+ticketID+".json") + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(raw)) + if err != nil { + return err + } + req.SetBasicAuth(credential.Email+"/token", credential.APIToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("Zendesk rejected outbound credentials") + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("Zendesk ticket update returned HTTP %d", resp.StatusCode) + } + return nil +} + +func (w ZendeskWorker) succeedZendeskJob(ctx context.Context, job zendeskJob) error { + now := time.Now().UTC() + tx, err := w.DB.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, `update provider_job set status='succeeded', completed_at=$2, last_error=null, updated_at=$2 where id=$1::uuid`, job.ID, now); err != nil { + return err + } + if _, err := tx.Exec(ctx, `update workspace_integration set status='connected', last_success_at=$2, last_event_at=$2, last_failure_at=null, last_failure_message=null, updated_at=$2 where id=$1::uuid`, job.IntegrationID, now); err != nil { + return err + } + _, err = tx.Exec(ctx, `insert into provider_event (workspace_id, workspace_integration_id, provider, job_id, event_type, severity, message, payload, created_at) values ($1::uuid,$2::uuid,'zendesk',$3::uuid,'outbound_delivery_succeeded','info','Zendesk ticket follow-up delivered.','{}'::jsonb,$4)`, job.WorkspaceID, job.IntegrationID, job.ID, now) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +func (w ZendeskWorker) failZendeskJob(ctx context.Context, job zendeskJob, cause error) error { + now := time.Now().UTC() + status, nextRunAt := providerJobFailureStatus(job.Attempts, job.MaxAttempts) + tx, err := w.DB.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, `update provider_job set status=$2, last_error=$3, next_run_at=$4, completed_at=case when $2='dead' then $5 else completed_at end, updated_at=$5 where id=$1::uuid`, job.ID, status, cause.Error(), nextRunAt, now); err != nil { + return err + } + integrationStatus := "degraded" + if status == "dead" { + integrationStatus = "error" + } + if _, err := tx.Exec(ctx, `update workspace_integration set status=$2, last_failure_at=$3, last_failure_message=$4, last_event_at=$3, updated_at=$3 where id=$1::uuid`, job.IntegrationID, integrationStatus, now, cause.Error()); err != nil { + return err + } + _, err = tx.Exec(ctx, `insert into provider_event (workspace_id, workspace_integration_id, provider, job_id, event_type, severity, message, payload, created_at) values ($1::uuid,$2::uuid,'zendesk',$3::uuid,'outbound_delivery_failed','error',$4,'{}'::jsonb,$5)`, job.WorkspaceID, job.IntegrationID, job.ID, cause.Error(), now) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +func (w ZendeskWorker) httpClient() *http.Client { + if w.Client != nil { + return w.Client + } + return http.DefaultClient +} diff --git a/apps/api/internal/issues/sentry_sources.go b/apps/api/internal/issues/sentry_sources.go index 154eea14..4ac959c3 100644 --- a/apps/api/internal/issues/sentry_sources.go +++ b/apps/api/internal/issues/sentry_sources.go @@ -19,13 +19,26 @@ type issueExternalSource struct { func (h Handler) issueExternalSources(ctx context.Context, issueID string) ([]issueExternalSource, error) { rows, err := h.DB.Query(ctx, ` - select provider, - coalesce(external_permalink,''), - coalesce(source_event_id,''), - coalesce(external_channel_id,''), - coalesce(workspace_integration_id::text,'') - from integration_thread_link - where issue_id=$1::uuid and provider in ('sentry') and external_permalink is not null + select provider, url, external_id, project, integration_id + from ( + select provider, + coalesce(external_permalink,'') as url, + coalesce(source_event_id,'') as external_id, + coalesce(external_channel_id,'') as project, + coalesce(workspace_integration_id::text,'') as integration_id, + created_at + from integration_thread_link + where issue_id=$1::uuid and provider in ('sentry') and external_permalink is not null + union all + select 'zendesk' as provider, + coalesce(ticket_url,'') as url, + ticket_id as external_id, + coalesce(organization_name,'') as project, + coalesce(workspace_integration_id::text,'') as integration_id, + created_at + from zendesk_ticket_link + where issue_id=$1::uuid and ticket_url is not null + ) sources order by created_at asc`, issueID) if err != nil { return nil, err @@ -54,6 +67,16 @@ func sourceLabel(provider string, project string, externalID string) string { } return strings.Join(parts, " · ") } + if provider == "zendesk" { + parts := []string{"Zendesk"} + if strings.TrimSpace(project) != "" { + parts = append(parts, project) + } + if strings.TrimSpace(externalID) != "" { + parts = append(parts, "ticket "+externalID) + } + return strings.Join(parts, " · ") + } return provider } @@ -101,9 +124,6 @@ func (h Handler) queueSentryAutomations(ctx context.Context, tx pgx.Tx, workspac if err := rows.Err(); err != nil { return err } - if len(links) == 0 { - return nil - } category := "" if stateChanged { if err := tx.QueryRow(ctx, `select category::text from workflow_state where id=$1::uuid`, after.StateID).Scan(&category); err != nil { @@ -132,6 +152,11 @@ func (h Handler) queueSentryAutomations(ctx context.Context, tx pgx.Tx, workspac } } } + if stateChanged && (category == "completed" || category == "canceled") { + if err := h.queueZendeskAutomations(ctx, tx, workspaceID, after, category); err != nil { + return err + } + } return nil } @@ -144,6 +169,43 @@ func insertSentryProviderJob(ctx context.Context, tx pgx.Tx, workspaceID string, return err } +func (h Handler) queueZendeskAutomations(ctx context.Context, tx pgx.Tx, workspaceID string, after Issue, category string) error { + rows, err := tx.Query(ctx, ` + select ztl.workspace_integration_id::text, ztl.ticket_id, coalesce(wi.metadata,'{}'::jsonb) + from zendesk_ticket_link ztl + join workspace_integration wi on wi.id=ztl.workspace_integration_id + where ztl.issue_id=$1::uuid and wi.provider='zendesk' and wi.status in ('connected','degraded')`, after.ID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var integrationID, ticketID string + var metadataRaw []byte + if err := rows.Scan(&integrationID, &ticketID, &metadataRaw); err != nil { + return err + } + metadata := readJSONRecord(metadataRaw) + if !boolSetting(metadata, "autoFollowUp", true) { + continue + } + payload := map[string]any{"type": "ticket_followup", "ticketId": ticketID, "issueId": after.ID, "identifier": after.Identifier, "category": category, "note": stringValue(metadata["closeNoteBody"])} + if err := insertZendeskProviderJob(ctx, tx, workspaceID, integrationID, payload); err != nil { + return err + } + } + return rows.Err() +} + +func insertZendeskProviderJob(ctx context.Context, tx pgx.Tx, workspaceID string, integrationID string, payload map[string]any) error { + raw, err := json.Marshal(payload) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `insert into provider_job (workspace_id, workspace_integration_id, provider, kind, status, payload, scheduled_at, updated_at) values ($1::uuid,$2::uuid,'zendesk','outbound_delivery','queued',$3::jsonb,now(),now())`, workspaceID, integrationID, raw) + return err +} + func boolSetting(metadata map[string]any, key string, defaultValue bool) bool { value, ok := metadata[key] if !ok { @@ -170,6 +232,12 @@ func sentryUserForEmail(metadata map[string]any, email string) string { return "" } +func readJSONRecord(raw []byte) map[string]any { + out := map[string]any{} + _ = json.Unmarshal(raw, &out) + return out +} + func stringValue(value any) string { if value == nil { return "" diff --git a/apps/web/src/app/(app)/settings/integrations/page.tsx b/apps/web/src/app/(app)/settings/integrations/page.tsx index 9ac31fba..428ef4cc 100644 --- a/apps/web/src/app/(app)/settings/integrations/page.tsx +++ b/apps/web/src/app/(app)/settings/integrations/page.tsx @@ -47,6 +47,12 @@ type GitLabSetupDetails = { webhookSecret: string; }; +type ZendeskSetupDetails = { + accountUrl: string; + actionBaseUrl: string; + actionSecret: string; +}; + type IntegrationsPayload = { integrations?: Integration[]; canManageIntegrations?: boolean; @@ -110,6 +116,11 @@ export default function IntegrationsSettingsPage() { const [gitLabToken, setGitLabToken] = useState(""); const [gitLabSetupDetails, setGitLabSetupDetails] = useState(null); + const [zendeskSubdomain, setZendeskSubdomain] = useState(""); + const [zendeskEmail, setZendeskEmail] = useState(""); + const [zendeskAPIToken, setZendeskAPIToken] = useState(""); + const [zendeskSetupDetails, setZendeskSetupDetails] = + useState(null); const loadIntegrations = useCallback(async () => { setLoading(true); @@ -231,6 +242,57 @@ export default function IntegrationsSettingsPage() { } } + async function setupZendesk() { + setPendingProvider("zendesk"); + setNotice(null); + setError(null); + setZendeskSetupDetails(null); + try { + const response = await fetch("/api/integrations/zendesk/setup", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + subdomain: zendeskSubdomain, + email: zendeskEmail, + apiToken: zendeskAPIToken, + }), + }); + const data = (await response.json().catch(() => ({}))) as { + accountUrl?: string; + actionBaseUrl?: string; + actionSecret?: string; + error?: string; + message?: string; + }; + if (!response.ok) { + throw new Error(data.message || data.error || "Zendesk setup failed."); + } + if (data.accountUrl && data.actionBaseUrl && data.actionSecret) { + setZendeskSetupDetails({ + accountUrl: data.accountUrl, + actionBaseUrl: data.actionBaseUrl, + actionSecret: data.actionSecret, + }); + } + setZendeskAPIToken(""); + setNotice( + "Zendesk connected. Copy the action URL and secret into the Zendesk app.", + ); + await loadIntegrations(); + } catch (setupError) { + setError( + setupError instanceof Error + ? setupError.message + : "Zendesk setup failed.", + ); + } finally { + setPendingProvider(null); + } + } + async function disconnect(provider: string) { setPendingProvider(provider); setNotice(null); @@ -245,13 +307,16 @@ export default function IntegrationsSettingsPage() { ? "/api/integrations/microsoft-teams/disconnect" : provider === "sentry" ? "/api/integrations/sentry/disconnect" - : `/api/integrations?provider=${encodeURIComponent(provider)}`; + : provider === "zendesk" + ? "/api/integrations/zendesk/disconnect" + : `/api/integrations?provider=${encodeURIComponent(provider)}`; const response = await fetch(endpoint, { method: provider === "slack" || provider === "discord" || provider === "microsoft_teams" || - provider === "sentry" + provider === "sentry" || + provider === "zendesk" ? "POST" : "DELETE", headers: { Accept: "application/json" }, @@ -331,6 +396,28 @@ export default function IntegrationsSettingsPage() { ) : null} + {zendeskSetupDetails ? ( +
+
+ Zendesk app details +
+
+
+ Action base URL + + {zendeskSetupDetails.actionBaseUrl} + +
+
+ Signing secret + + {zendeskSetupDetails.actionSecret} + +
+
Connected account: {zendeskSetupDetails.accountUrl}
+
+
+ ) : null}
{installedIntegrations.length ? ( @@ -360,7 +447,8 @@ export default function IntegrationsSettingsPage() {
{integration.actions.canReconnect && - integration.provider !== "gitlab" ? ( + integration.provider !== "gitlab" && + integration.provider !== "zendesk" ? (
) : null} + {integration.provider === "zendesk" && + (integration.actions.canConnect || + integration.actions.canReconnect) ? ( +
+ + + + +
+ ) : null} {integration.actions.canDisconnect ? (