package v1 // MicrosoftChannelHandler handles Microsoft (Azure AD/Teams) channel CRUD and OAuth flow. // This is a gochat addition — Chatwoot does not have a native Microsoft channel. // // gochat maps these to: // - GET /api/v1/accounts/:id/microsoft_channels/authorization → OAuth authorize URL // - POST /api/v1/accounts/:id/microsoft_channels/oauth_callback → OAuth token exchange + inbox creation // - DELETE /api/v1/accounts/:id/microsoft_channels/:ms_id → destroy Microsoft inbox // - POST /api/v1/accounts/:id/microsoft_channels/reauthorize → refresh token import ( "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" microsoft "github.com/gochat/gochat/internal/channel/microsoft" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) // MicrosoftChannelHandler handles Microsoft/Azure AD channel management. type MicrosoftChannelHandler struct { msService *service.ChannelMicrosoftService msProvider *microsoft.MicrosoftProvider inboxSvc *service.InboxService msRepo *repository.ChannelMicrosoftRepo } // NewMicrosoftChannelHandler creates a new Microsoft channel handler. func NewMicrosoftChannelHandler( msService *service.ChannelMicrosoftService, msProvider *microsoft.MicrosoftProvider, inboxSvc *service.InboxService, msRepo *repository.ChannelMicrosoftRepo, ) *MicrosoftChannelHandler { return &MicrosoftChannelHandler{ msService: msService, msProvider: msProvider, inboxSvc: inboxSvc, msRepo: msRepo, } } // === OAuth Authorization === // MicrosoftAuthorizationRequest is the DTO for initiating Microsoft OAuth flow. type MicrosoftAuthorizationRequest struct { RedirectURL string `json:"redirect_url" validate:"required,url"` } // Authorization generates a Microsoft Azure AD OAuth 2.0 authorize URL. // GET /api/v1/accounts/:id/microsoft_channels/authorization func (h *MicrosoftChannelHandler) Authorization(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req MicrosoftAuthorizationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } authURL, err := h.msProvider.BuildAuthURL(c.Request.Context(), uint(accountID), req.RedirectURL) if err != nil { applogger.L().Errorf("Failed to build Microsoft auth URL: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to generate authorization URL"}) return } c.JSON(http.StatusOK, gin.H{"authorization_url": authURL}) } // ChatwootAuthorization creates a Microsoft email OAuth authorization URL. // POST /api/v1/accounts/:account_id/microsoft/authorization func (h *MicrosoftChannelHandler) ChatwootAuthorization(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } redirectURL, err := buildChatwootEmailOAuthAuthorizationURL(accountID, chatwootEmailOAuthAuthorizationConfig{ ClientIDEnv: "AZURE_APP_ID", ClientSecretEnv: "AZURE_APP_SECRET", AuthorizeURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", RedirectPath: "/microsoft/callback", Scope: "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email", }) if err != nil { applogger.L().Errorf("Failed to build Microsoft authorization URL: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false}) return } c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL}) } // === OAuth Callback === // MicrosoftOAuthCallbackRequest is the DTO for Microsoft OAuth callback. type MicrosoftOAuthCallbackRequest struct { Code string `json:"code" validate:"required"` RedirectURL string `json:"redirect_url" validate:"required,url"` Name string `json:"name" validate:"required,min=2"` } // OAuthCallback exchanges the Microsoft OAuth 2.0 code for tokens and creates a channel. // POST /api/v1/accounts/:id/microsoft_channels/oauth_callback func (h *MicrosoftChannelHandler) OAuthCallback(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req MicrosoftOAuthCallbackRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil { if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create microsoft inbox"}) return } // Exchange the code for tokens tokenResult, err := h.msProvider.ExchangeToken(ctx, req.Code, req.RedirectURL) if err != nil { applogger.L().Errorf("Microsoft OAuth token exchange failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to exchange OAuth token"}) return } // Validate the access token valid, err := h.msProvider.ValidateAccessToken(ctx, tokenResult.AccessToken) if err != nil || !valid { applogger.L().Errorf("Microsoft token validation failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to validate Microsoft token"}) return } // Create the Microsoft channel record msChannel := &channelmodel.ChannelMicrosoft{ AccountID: uint(accountID), AccessToken: tokenResult.AccessToken, RefreshToken: tokenResult.RefreshToken, TenantID: h.msProvider.GetTenantID(), } if err := h.msService.Create(ctx, msChannel); err != nil { applogger.L().Errorf("Failed to create Microsoft channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create microsoft channel"}) return } // Create the inbox inboxReq := service.CreateInboxRequest{ Name: req.Name, ChannelType: string(model.InboxChannelTypeMicrosoft), } inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq) if err != nil { applogger.L().Errorf("Failed to create Microsoft inbox: %v", err) if delErr := h.msService.Delete(ctx, msChannel.ID); delErr != nil { applogger.L().Warnf("Failed to rollback Microsoft channel after inbox creation failure: %v", delErr) } if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create microsoft inbox"}) return } // Link the channel to the inbox msChannel.InboxID = inbox.ID if err := h.msService.Update(ctx, msChannel); err != nil { applogger.L().Warnf("Failed to link Microsoft channel to inbox: %v", err) } c.JSON(http.StatusCreated, gin.H{ "inbox": gin.H{ "id": inbox.ID, "name": inbox.Name, "channel_type": inbox.ChannelType, }, "microsoft_channel": gin.H{ "id": msChannel.ID, "tenant_id": msChannel.TenantID, }, }) } // === Delete === // Delete removes a Microsoft channel and its associated inbox. // DELETE /api/v1/accounts/:id/microsoft_channels/:ms_id func (h *MicrosoftChannelHandler) Delete(c *gin.Context) { msIDStr := c.Param("ms_id") msID, err := strconv.ParseUint(msIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid microsoft_id"}) return } ctx := c.Request.Context() // Delete the Microsoft channel record if err := h.msService.Delete(ctx, uint(msID)); err != nil { applogger.L().Errorf("Failed to delete Microsoft channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete microsoft channel"}) return } c.JSON(http.StatusOK, gin.H{"message": "microsoft channel deleted"}) } // === Webhook Validation === // MicrosoftWebhookValidation handles Microsoft Graph API webhook validation request. // Microsoft sends a validation request when creating a subscription. // POST /api/v1/microsoft_webhook/validation func (h *MicrosoftChannelHandler) WebhookValidation(c *gin.Context) { var validationReq struct { Value []struct { State string `json:"state"` ClientState string `json:"clientState"` ValidationToken string `json:"validationToken"` } `json:"value"` } if err := c.ShouldBindJSON(&validationReq); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(validationReq.Value) > 0 { c.JSON(http.StatusOK, gin.H{"validationToken": validationReq.Value[0].ValidationToken}) return } c.JSON(http.StatusBadRequest, gin.H{"error": "no validation token provided"}) } // === Chatwoot-style GET OAuth callback (G10) === // OAuthCallbackGET handles the OAuth redirect callback from Microsoft. // GET /api/v1/accounts/:id/microsoft/callback?code=...&state=... // This is the redirect endpoint that the Microsoft OAuth provider calls back to. func (h *MicrosoftChannelHandler) OAuthCallbackGET(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } code := c.Query("code") state := c.Query("state") if code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing code parameter"}) return } ctx := c.Request.Context() // Use the configured redirect URL from the Microsoft provider redirectURL := h.msProvider.GetOAuthRedirectURL() tokenResult, err := h.msProvider.ExchangeToken(ctx, code, redirectURL) if err != nil { applogger.L().Errorf("Microsoft OAuth GET callback token exchange failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to exchange OAuth token"}) return } // Validate the access token valid, err := h.msProvider.ValidateAccessToken(ctx, tokenResult.AccessToken) if err != nil || !valid { applogger.L().Errorf("Microsoft token validation failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to validate Microsoft token"}) return } // Create the Microsoft channel record msChannel := &channelmodel.ChannelMicrosoft{ AccountID: uint(accountID), AccessToken: tokenResult.AccessToken, RefreshToken: tokenResult.RefreshToken, TenantID: h.msProvider.GetTenantID(), } if err := h.msService.Create(ctx, msChannel); err != nil { applogger.L().Errorf("Failed to create Microsoft channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create microsoft channel"}) return } c.JSON(http.StatusOK, gin.H{ "channel_id": msChannel.ID, "access_token": msChannel.AccessToken, "refresh_token": msChannel.RefreshToken, "tenant_id": msChannel.TenantID, "state": state, }) } // === Webhook Registration (G10) === // RegisterWebhook creates a Microsoft Graph API webhook subscription. // POST /api/v1/accounts/:id/microsoft/webhooks // This uses the Microsoft Graph API subscriptions endpoint. func (h *MicrosoftChannelHandler) RegisterWebhook(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req struct { AccessToken string `json:"access_token" binding:"required"` Resource string `json:"resource" binding:"required"` NotificationURL string `json:"notification_url" binding:"required"` ClientState string `json:"client_state"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } clientState := req.ClientState if clientState == "" { clientState = fmt.Sprintf("gochat_ms_%d", accountID) } ctx := c.Request.Context() subscriptionID, err := h.msProvider.CreateSubscription(ctx, req.AccessToken, req.Resource, req.NotificationURL, clientState) if err != nil { applogger.L().Errorf("Microsoft webhook registration failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to register Microsoft webhook"}) return } c.JSON(http.StatusOK, gin.H{ "subscription_id": subscriptionID, "account_id": accountID, "resource": req.Resource, }) } // ListWebhooks lists all Microsoft Graph API webhook subscriptions. // GET /api/v1/accounts/:id/microsoft/webhooks func (h *MicrosoftChannelHandler) ListWebhooks(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } accessToken := c.Query("access_token") if accessToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "access_token query parameter is required"}) return } ctx := c.Request.Context() subscriptions, err := h.msProvider.ListSubscriptions(ctx, accessToken) if err != nil { applogger.L().Errorf("Microsoft webhook listing failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Microsoft webhooks"}) return } c.JSON(http.StatusOK, gin.H{ "account_id": accountID, "subscriptions": subscriptions, }) } // === Webhook Event Processing === // MicrosoftWebhookEvent processes incoming Microsoft Graph API notifications. // POST /api/v1/microsoft_webhook/events func (h *MicrosoftChannelHandler) WebhookEvent(c *gin.Context) { var payload map[string]interface{} if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } applogger.L().Infof("Microsoft webhook event received") // Event processing will be handled by the Microsoft incoming message service // (to be implemented in a future milestone) c.JSON(http.StatusOK, gin.H{"status": "received"}) }