Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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"})
|
||||
}
|
||||
Reference in New Issue
Block a user