667 lines
22 KiB
Go
667 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// LinearProviderError mirrors Chatwoot's `{ error: ... }`, 422 provider failures.
|
|
type LinearProviderError struct {
|
|
Message interface{}
|
|
}
|
|
|
|
func (e *LinearProviderError) Error() string {
|
|
if e == nil || e.Message == nil {
|
|
return "linear provider error"
|
|
}
|
|
if msg, ok := e.Message.(string); ok {
|
|
return msg
|
|
}
|
|
b, err := json.Marshal(e.Message)
|
|
if err != nil {
|
|
return fmt.Sprint(e.Message)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// LinearIntegrationService implements Linear integration business logic.
|
|
// Reference: Chatwoot Integrations::LinearController
|
|
// Linear integration creates and links issues from conversations.
|
|
type LinearIntegrationService struct {
|
|
hookRepo *repository.IntegrationHookRepo
|
|
conversationRepo *repository.ConversationRepo
|
|
messageRepo *repository.MessageRepo
|
|
userRepo *repository.UserRepo
|
|
client *linearAPIClient
|
|
}
|
|
|
|
// NewLinearIntegrationService creates a new LinearIntegrationService.
|
|
func NewLinearIntegrationService(hookRepo *repository.IntegrationHookRepo) *LinearIntegrationService {
|
|
svc := &LinearIntegrationService{hookRepo: hookRepo, client: newLinearAPIClientFromEnv()}
|
|
if hookRepo != nil && hookRepo.DB() != nil {
|
|
db := hookRepo.DB()
|
|
svc.conversationRepo = repository.NewConversationRepo(db)
|
|
svc.messageRepo = repository.NewMessageRepo(db)
|
|
svc.userRepo = repository.NewUserRepo(db)
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// Delete removes a Linear integration hook for an account.
|
|
func (s *LinearIntegrationService) Delete(ctx context.Context, accountID uint) error {
|
|
hooks, err := s.findLinearHooks(ctx, accountID)
|
|
if err != nil || len(hooks) == 0 {
|
|
return fmt.Errorf("Linear integration not found for account %d", accountID)
|
|
}
|
|
|
|
for _, hook := range hooks {
|
|
_ = s.client.revokeToken(ctx, hook.AccessToken, linearRefreshToken(hook.Settings))
|
|
if err := s.hookRepo.Delete(ctx, hook.ID); err != nil {
|
|
return fmt.Errorf("failed to delete Linear integration: %w", err)
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("Linear integration deleted: account=%d", accountID)
|
|
return nil
|
|
}
|
|
|
|
// GetTeams retrieves available Linear teams (proxy to Linear API).
|
|
// GET /api/v1/accounts/:account_id/integrations/linear/teams
|
|
func (s *LinearIntegrationService) GetTeams(ctx context.Context, accountID uint) ([]map[string]interface{}, error) {
|
|
client, err := s.linearClient(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := client.teams(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applogger.L().Infof("Listing Linear teams for account=%d", accountID)
|
|
return nodesFromPath(data, "teams"), nil
|
|
}
|
|
|
|
// GetTeamEntities retrieves entities from a Linear team.
|
|
// GET /api/v1/accounts/:account_id/integrations/linear/team_entities
|
|
func (s *LinearIntegrationService) GetTeamEntities(ctx context.Context, accountID uint, teamID string) (map[string]interface{}, error) {
|
|
client, err := s.linearClient(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := client.teamEntities(ctx, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applogger.L().Infof("Listing Linear team entities for account=%d", accountID)
|
|
return map[string]interface{}{
|
|
"users": nodesFromPath(data, "users"),
|
|
"projects": nodesFromPath(data, "projects"),
|
|
"states": nodesFromPath(data, "workflowStates"),
|
|
"labels": nodesFromPath(data, "issueLabels"),
|
|
}, nil
|
|
}
|
|
|
|
// CreateIssueRequest is the DTO for creating a Linear issue.
|
|
type CreateIssueRequest struct {
|
|
Title string `json:"title" form:"title"`
|
|
Description string `json:"description,omitempty" form:"description"`
|
|
TeamID string `json:"team_id,omitempty" form:"team_id"`
|
|
ProjectID string `json:"project_id,omitempty" form:"project_id"`
|
|
ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"`
|
|
AssigneeID string `json:"assignee_id,omitempty" form:"assignee_id"`
|
|
Priority interface{} `json:"priority,omitempty" form:"priority"`
|
|
StateID string `json:"state_id,omitempty" form:"state_id"`
|
|
LabelIDs []string `json:"label_ids,omitempty" form:"label_ids[]"`
|
|
}
|
|
|
|
// CreateIssue creates a Linear issue from a conversation.
|
|
// POST /api/v1/accounts/:account_id/integrations/linear/create_issue
|
|
func (s *LinearIntegrationService) CreateIssue(ctx context.Context, accountID uint, req CreateIssueRequest, userID ...uint) (map[string]interface{}, error) {
|
|
conversation, err := s.findConversation(ctx, accountID, req.ConversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := s.linearClient(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
user := s.findUser(ctx, optionalUint(userID))
|
|
data, err := client.createIssue(ctx, req, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
issue := map[string]interface{}{
|
|
"id": nestedString(data, "issueCreate", "issue", "id"),
|
|
"title": nestedString(data, "issueCreate", "issue", "title"),
|
|
"identifier": nestedString(data, "issueCreate", "issue", "identifier"),
|
|
}
|
|
s.createLinearActivity(ctx, conversation, user, "created", fmt.Sprint(issue["identifier"]))
|
|
applogger.L().Infof("Creating Linear issue for account=%d: title=%s", accountID, req.Title)
|
|
return issue, nil
|
|
}
|
|
|
|
// LinkIssueRequest is the DTO for linking a Linear issue to a conversation.
|
|
type LinkIssueRequest struct {
|
|
IssueID string `json:"issue_id" form:"issue_id"`
|
|
ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"`
|
|
Title string `json:"title,omitempty" form:"title"`
|
|
}
|
|
|
|
// LinkIssue links a Linear issue to a conversation.
|
|
// POST /api/v1/accounts/:account_id/integrations/linear/link_issue
|
|
func (s *LinearIntegrationService) LinkIssue(ctx context.Context, accountID uint, req LinkIssueRequest, userID ...uint) (map[string]interface{}, error) {
|
|
conversation, err := s.findConversation(ctx, accountID, req.ConversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := s.linearClient(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
user := s.findUser(ctx, optionalUint(userID))
|
|
link := linearConversationLink(accountID, conversation)
|
|
data, err := client.linkIssue(ctx, link, req.IssueID, req.Title, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := map[string]interface{}{
|
|
"id": req.IssueID,
|
|
"link": link,
|
|
"link_id": nestedString(data, "attachmentLinkURL", "attachment", "id"),
|
|
}
|
|
s.createLinearActivity(ctx, conversation, user, "linked", req.IssueID)
|
|
applogger.L().Infof("Linking Linear issue %s for account=%d", req.IssueID, accountID)
|
|
return result, nil
|
|
}
|
|
|
|
// UnlinkIssueRequest is the DTO for unlinking a Linear issue from a conversation.
|
|
type UnlinkIssueRequest struct {
|
|
IssueID string `json:"issue_id" form:"issue_id"`
|
|
LinkID string `json:"link_id" form:"link_id"`
|
|
ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"`
|
|
}
|
|
|
|
// UnlinkIssue unlinks a Linear issue from a conversation.
|
|
// POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue
|
|
func (s *LinearIntegrationService) UnlinkIssue(ctx context.Context, accountID uint, req UnlinkIssueRequest, userID ...uint) (map[string]interface{}, error) {
|
|
conversation, err := s.findConversation(ctx, accountID, req.ConversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := s.linearClient(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := client.unlinkIssue(ctx, req.LinkID); err != nil {
|
|
return nil, err
|
|
}
|
|
user := s.findUser(ctx, optionalUint(userID))
|
|
s.createLinearActivity(ctx, conversation, user, "unlinked", req.IssueID)
|
|
applogger.L().Infof("Unlinking Linear issue %s for account=%d", req.IssueID, accountID)
|
|
return map[string]interface{}{
|
|
"link_id": req.LinkID,
|
|
}, nil
|
|
}
|
|
|
|
// SearchIssue searches Linear issues.
|
|
// GET /api/v1/accounts/:account_id/integrations/linear/search_issue
|
|
func (s *LinearIntegrationService) SearchIssue(ctx context.Context, accountID uint, query string) ([]map[string]interface{}, error) {
|
|
client, err := s.linearClient(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := client.searchIssue(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applogger.L().Infof("Searching Linear issues for account=%d, query=%s", accountID, query)
|
|
return nodesFromPath(data, "searchIssues"), nil
|
|
}
|
|
|
|
// GetLinkedIssues retrieves Linear issues linked to a conversation.
|
|
// GET /api/v1/accounts/:account_id/integrations/linear/linked_issues
|
|
func (s *LinearIntegrationService) GetLinkedIssues(ctx context.Context, accountID uint, conversationID uint) ([]map[string]interface{}, error) {
|
|
conversation, err := s.findConversation(ctx, accountID, conversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := s.linearClient(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := client.linkedIssues(ctx, linearConversationLink(accountID, conversation))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applogger.L().Infof("Listing linked Linear issues for account=%d, conversation=%d", accountID, conversationID)
|
|
return nodesFromPath(data, "attachmentsForURL"), nil
|
|
}
|
|
|
|
func (s *LinearIntegrationService) findLinearHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) {
|
|
hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "linear")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(hooks) > 0 {
|
|
return hooks, nil
|
|
}
|
|
return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
|
}
|
|
|
|
func (s *LinearIntegrationService) linearClient(ctx context.Context, accountID uint) (*linearBoundClient, error) {
|
|
hooks, err := s.findLinearHooks(ctx, accountID)
|
|
if err != nil || len(hooks) == 0 {
|
|
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
|
}
|
|
token := strings.TrimSpace(hooks[0].AccessToken)
|
|
if token == "" {
|
|
var settings model.LinearSettings
|
|
_ = json.Unmarshal(hooks[0].Settings, &settings)
|
|
token = strings.TrimSpace(settings.AccessToken)
|
|
}
|
|
if token == "" {
|
|
return nil, &LinearProviderError{Message: "Missing Credentials"}
|
|
}
|
|
return &linearBoundClient{client: s.client, token: token}, nil
|
|
}
|
|
|
|
func (s *LinearIntegrationService) findConversation(ctx context.Context, accountID, displayID uint) (*model.Conversation, error) {
|
|
if s.conversationRepo == nil || displayID == 0 {
|
|
return nil, fmt.Errorf("conversation not found")
|
|
}
|
|
return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, displayID)
|
|
}
|
|
|
|
func (s *LinearIntegrationService) findUser(ctx context.Context, userID uint) *model.User {
|
|
if s.userRepo == nil || userID == 0 {
|
|
return nil
|
|
}
|
|
user, err := s.userRepo.FindByID(ctx, userID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return user
|
|
}
|
|
|
|
func (s *LinearIntegrationService) createLinearActivity(ctx context.Context, conversation *model.Conversation, user *model.User, action, issueID string) {
|
|
if s.messageRepo == nil || conversation == nil || user == nil || issueID == "" {
|
|
return
|
|
}
|
|
content := fmt.Sprintf("Linear issue %s was %s by %s", issueID, action, user.Name)
|
|
message := &model.Message{
|
|
ConversationID: conversation.ID,
|
|
AccountID: conversation.AccountID,
|
|
InboxID: conversation.InboxID,
|
|
MessageType: "activity",
|
|
ContentType: "text",
|
|
Status: "sent",
|
|
Content: content,
|
|
}
|
|
if err := s.messageRepo.Create(ctx, message); err != nil {
|
|
applogger.L().Warnf("failed to create Linear activity message: %v", err)
|
|
}
|
|
}
|
|
|
|
func linearConversationLink(accountID uint, conversation *model.Conversation) string {
|
|
displayID := conversation.ID
|
|
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
|
displayID = *conversation.DisplayID
|
|
}
|
|
base := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
|
|
return fmt.Sprintf("%s/app/accounts/%d/conversations/%d", base, accountID, displayID)
|
|
}
|
|
|
|
func optionalUint(values []uint) uint {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
return values[0]
|
|
}
|
|
|
|
func linearRefreshToken(settingsJSON []byte) string {
|
|
var settings map[string]interface{}
|
|
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
|
|
return ""
|
|
}
|
|
if token, ok := settings["refresh_token"].(string); ok {
|
|
return token
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type linearAPIClient struct {
|
|
graphqlURL string
|
|
revokeURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type linearBoundClient struct {
|
|
client *linearAPIClient
|
|
token string
|
|
}
|
|
|
|
func newLinearAPIClientFromEnv() *linearAPIClient {
|
|
baseURL := strings.TrimRight(os.Getenv("LINEAR_API_BASE"), "/")
|
|
if baseURL == "" {
|
|
baseURL = "https://api.linear.app"
|
|
}
|
|
return &linearAPIClient{
|
|
graphqlURL: baseURL + "/graphql",
|
|
revokeURL: baseURL + "/oauth/revoke",
|
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *linearBoundClient) teams(ctx context.Context) (map[string]interface{}, error) {
|
|
return c.postGraphQL(ctx, `query { teams { nodes { id name } } }`)
|
|
}
|
|
|
|
func (c *linearBoundClient) teamEntities(ctx context.Context, teamID string) (map[string]interface{}, error) {
|
|
if strings.TrimSpace(teamID) == "" {
|
|
return nil, &LinearProviderError{Message: "Missing team id"}
|
|
}
|
|
query := fmt.Sprintf(`query { users { nodes { id name } } projects { nodes { id name } } workflowStates(filter: { team: { id: { eq: %s } } }) { nodes { id name } } issueLabels(filter: { team: { id: { eq: %s } } }) { nodes { id name } } }`, graphqlValue(teamID), graphqlValue(teamID))
|
|
return c.postGraphQL(ctx, query)
|
|
}
|
|
|
|
func (c *linearBoundClient) createIssue(ctx context.Context, req CreateIssueRequest, user *model.User) (map[string]interface{}, error) {
|
|
input := map[string]interface{}{
|
|
"title": req.Title,
|
|
"teamId": req.TeamID,
|
|
"description": req.Description,
|
|
"assigneeId": req.AssigneeID,
|
|
"priority": req.Priority,
|
|
"labelIds": req.LabelIDs,
|
|
"projectId": req.ProjectID,
|
|
"stateId": req.StateID,
|
|
}
|
|
if user != nil && user.Name != "" {
|
|
input["createAsUser"] = user.Name
|
|
if user.AvatarURL != "" {
|
|
input["displayIconUrl"] = user.AvatarURL
|
|
}
|
|
}
|
|
query := fmt.Sprintf(`mutation { issueCreate(input: { %s }) { success issue { id title identifier } } }`, graphqlInput(input))
|
|
return c.postGraphQL(ctx, query)
|
|
}
|
|
|
|
func (c *linearBoundClient) linkIssue(ctx context.Context, link, issueID, title string, user *model.User) (map[string]interface{}, error) {
|
|
if strings.TrimSpace(link) == "" {
|
|
return nil, &LinearProviderError{Message: "Missing link"}
|
|
}
|
|
if strings.TrimSpace(issueID) == "" {
|
|
return nil, &LinearProviderError{Message: "Missing issue id"}
|
|
}
|
|
parts := []string{
|
|
"url: " + graphqlValue(link),
|
|
"issueId: " + graphqlValue(issueID),
|
|
"title: " + graphqlValue(title),
|
|
}
|
|
if user != nil && user.Name != "" {
|
|
parts = append(parts, "createAsUser: "+graphqlValue(user.Name))
|
|
if user.AvatarURL != "" {
|
|
parts = append(parts, "displayIconUrl: "+graphqlValue(user.AvatarURL))
|
|
}
|
|
}
|
|
query := fmt.Sprintf(`mutation { attachmentLinkURL(%s) { success attachment { id } } }`, strings.Join(parts, ", "))
|
|
return c.postGraphQL(ctx, query)
|
|
}
|
|
|
|
func (c *linearBoundClient) unlinkIssue(ctx context.Context, linkID string) (map[string]interface{}, error) {
|
|
if strings.TrimSpace(linkID) == "" {
|
|
return nil, &LinearProviderError{Message: "Missing link id"}
|
|
}
|
|
query := fmt.Sprintf(`mutation { attachmentDelete(id: %s) { success } }`, graphqlValue(linkID))
|
|
return c.postGraphQL(ctx, query)
|
|
}
|
|
|
|
func (c *linearBoundClient) searchIssue(ctx context.Context, term string) (map[string]interface{}, error) {
|
|
if strings.TrimSpace(term) == "" {
|
|
return nil, &LinearProviderError{Message: "Missing search term"}
|
|
}
|
|
query := fmt.Sprintf(`query { searchIssues(term: %s) { nodes { id title description identifier url state { name color } } } }`, graphqlValue(term))
|
|
return c.postGraphQL(ctx, query)
|
|
}
|
|
|
|
func (c *linearBoundClient) linkedIssues(ctx context.Context, link string) (map[string]interface{}, error) {
|
|
if strings.TrimSpace(link) == "" {
|
|
return nil, &LinearProviderError{Message: "Missing link"}
|
|
}
|
|
query := fmt.Sprintf(`query { attachmentsForURL(url: %s) { nodes { id title issue { id identifier title description priority createdAt url assignee { name avatarUrl } state { name color } labels { nodes { id name color description } } } } } }`, graphqlValue(link))
|
|
return c.postGraphQL(ctx, query)
|
|
}
|
|
|
|
func (c *linearBoundClient) postGraphQL(ctx context.Context, query string) (map[string]interface{}, error) {
|
|
return c.client.postGraphQL(ctx, c.token, query)
|
|
}
|
|
|
|
func (c *linearAPIClient) postGraphQL(ctx context.Context, token, query string) (map[string]interface{}, error) {
|
|
body, err := json.Marshal(map[string]string{"query": query})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var payload map[string]interface{}
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 || payload["data"] == nil {
|
|
if len(payload) > 0 {
|
|
return nil, &LinearProviderError{Message: payload}
|
|
}
|
|
return nil, &LinearProviderError{Message: strings.TrimSpace(string(raw))}
|
|
}
|
|
data, ok := payload["data"].(map[string]interface{})
|
|
if !ok {
|
|
return nil, &LinearProviderError{Message: payload}
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (c *linearAPIClient) revokeToken(ctx context.Context, accessToken, refreshToken string) error {
|
|
token := strings.TrimSpace(refreshToken)
|
|
tokenType := "refresh_token"
|
|
if token == "" {
|
|
token = strings.TrimSpace(accessToken)
|
|
tokenType = "access_token"
|
|
}
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
form := url.Values{}
|
|
form.Set("token", token)
|
|
form.Set("token_type_hint", tokenType)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.revokeURL, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return errors.New("linear revoke failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nodesFromPath(data map[string]interface{}, key string) []map[string]interface{} {
|
|
container, _ := data[key].(map[string]interface{})
|
|
rawNodes, _ := container["nodes"].([]interface{})
|
|
nodes := make([]map[string]interface{}, 0, len(rawNodes))
|
|
for _, raw := range rawNodes {
|
|
if item, ok := raw.(map[string]interface{}); ok {
|
|
nodes = append(nodes, item)
|
|
}
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
func nestedString(data map[string]interface{}, keys ...string) string {
|
|
var current interface{} = data
|
|
for _, key := range keys {
|
|
m, ok := current.(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
current = m[key]
|
|
}
|
|
if s, ok := current.(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func graphqlInput(input map[string]interface{}) string {
|
|
ordered := []string{"title", "teamId", "description", "assigneeId", "priority", "labelIds", "projectId", "stateId", "createAsUser", "displayIconUrl"}
|
|
parts := make([]string, 0, len(input))
|
|
for _, key := range ordered {
|
|
value, ok := input[key]
|
|
if !ok || isBlankGraphQLValue(value) {
|
|
continue
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s: %s", key, graphqlValue(value)))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func isBlankGraphQLValue(value interface{}) bool {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
return v == ""
|
|
case []string:
|
|
return len(v) == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func graphqlValue(value interface{}) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
case []string:
|
|
items := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
items = append(items, graphqlValue(item))
|
|
}
|
|
return "[" + strings.Join(items, ", ") + "]"
|
|
default:
|
|
return fmt.Sprint(v)
|
|
}
|
|
}
|
|
|
|
// ---- Notion Integration Service ----
|
|
|
|
// NotionIntegrationService implements Notion integration business logic.
|
|
// Reference: Chatwoot Integrations::NotionController
|
|
// Notion integration creates notes/pages from conversations.
|
|
type NotionIntegrationService struct {
|
|
hookRepo *repository.IntegrationHookRepo
|
|
}
|
|
|
|
// NotionAuthorizationResponse mirrors Chatwoot's Notion authorization payload.
|
|
type NotionAuthorizationResponse struct {
|
|
Success bool `json:"success"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// NewNotionIntegrationService creates a new NotionIntegrationService.
|
|
func NewNotionIntegrationService(hookRepo *repository.IntegrationHookRepo) *NotionIntegrationService {
|
|
return &NotionIntegrationService{hookRepo: hookRepo}
|
|
}
|
|
|
|
// BuildAuthorizationURL returns the Notion OAuth authorize URL for an account.
|
|
func (s *NotionIntegrationService) BuildAuthorizationURL(accountID uint) (*NotionAuthorizationResponse, error) {
|
|
clientID := strings.TrimSpace(os.Getenv("NOTION_CLIENT_ID"))
|
|
clientSecret := strings.TrimSpace(os.Getenv("NOTION_CLIENT_SECRET"))
|
|
if clientID == "" || clientSecret == "" {
|
|
return nil, fmt.Errorf("Notion OAuth is not configured")
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": accountID,
|
|
"iat": time.Now().Unix(),
|
|
})
|
|
state, err := token.SignedString([]byte(clientSecret))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate Notion state: %w", err)
|
|
}
|
|
|
|
frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
|
|
if frontendURL == "" {
|
|
frontendURL = "http://localhost:3000"
|
|
}
|
|
params := url.Values{}
|
|
params.Set("client_id", clientID)
|
|
params.Set("owner", "user")
|
|
params.Set("redirect_uri", frontendURL+"/notion/callback")
|
|
params.Set("response_type", "code")
|
|
params.Set("state", state)
|
|
|
|
return &NotionAuthorizationResponse{Success: true, URL: "https://api.notion.com/v1/oauth/authorize?" + params.Encode()}, nil
|
|
}
|
|
|
|
// Delete removes a Notion integration hook for an account.
|
|
func (s *NotionIntegrationService) Delete(ctx context.Context, accountID uint) error {
|
|
hooks, err := s.findNotionHooks(ctx, accountID)
|
|
if err != nil || len(hooks) == 0 {
|
|
return fmt.Errorf("Notion integration not found for account %d", accountID)
|
|
}
|
|
|
|
for _, hook := range hooks {
|
|
if err := s.hookRepo.Delete(ctx, hook.ID); err != nil {
|
|
return fmt.Errorf("failed to delete Notion integration: %w", err)
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("Notion integration deleted: account=%d", accountID)
|
|
return nil
|
|
}
|
|
|
|
func (s *NotionIntegrationService) findNotionHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) {
|
|
hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "notion")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(hooks) > 0 {
|
|
return hooks, nil
|
|
}
|
|
return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeNotion)
|
|
}
|