chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
package api
import (
"agent-desk/internal/builders"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/loginaudience"
"agent-desk/internal/services"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
func Login(ctx *gin.Context) {
cfg := config.Current()
if !cfg.Auth.IsPasswordLoginEnabled() {
httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled"))
return
}
req := request.LoginRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
ret, err := services.AuthService.Login(req, cfg.Auth, ctx.ClientIP(), ctx.GetHeader("User-Agent"))
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, ret)
}
func PublicConfig(ctx *gin.Context) {
cfg := config.Current()
// Saved branding wins over configuration, so an operator can rename a
// deployment from the dashboard without touching the file it is running from.
branding := services.ResolveBranding()
httpx.WriteJSON(ctx, &response.PublicConfigResponse{
Language: cfg.LanguageOrDefault(),
CompanyName: branding.CompanyName,
CompanyLogoURL: branding.CompanyLogoURL,
PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(),
OIDCEnabled: cfg.OIDC.Enabled,
})
}
// redirectPortalSSORejected sends a portal-initiated enterprise SSO attempt back
// to the portal login with a reason, instead of starting a flow that would hand
// a visitor a staff session.
func redirectPortalSSORejected(ctx *gin.Context, errorParam string) {
ctx.Redirect(
http.StatusFound,
"/support/login?"+errorParam+"="+url.QueryEscape(i18nx.T(ctx, "error.auth.staffSsoOnPortal")),
)
}
func rejectPortalSSO(ctx *gin.Context, errorParam string) bool {
if !loginaudience.Resolve(ctx.Query("next")).IsPortal() {
return false
}
slog.Warn("rejected enterprise SSO started from the visitor portal",
"provider", errorParam,
"next", ctx.Query("next"),
"clientIp", ctx.ClientIP(),
)
redirectPortalSSORejected(ctx, errorParam)
return true
}
func OIDCLogin(ctx *gin.Context) {
if rejectPortalSSO(ctx, "oidcError") {
return
}
loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(ctx.Query("next"))
if err != nil {
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, loginURL)
}
func OIDCCallback(ctx *gin.Context) {
if oauthErr := ctx.Query("error"); oauthErr != "" {
desc := ctx.Query("error_description")
slog.Warn("oidc callback returned oauth error", "error", oauthErr, "description", desc)
errMsg := oauthErr
if desc != "" {
errMsg += ": " + desc
}
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(errMsg))
return
}
code := ctx.Query("code")
state := ctx.Query("state")
if strings.TrimSpace(code) == "" {
slog.Warn("oidc callback missing code", "rawQuery", ctx.Request.URL.RawQuery)
}
cfg := config.Current()
ticket, next, err := services.OIDCLoginService.LoginByOIDC(
ctx.Request.Context(),
code,
state,
cfg.Auth,
ctx.ClientIP(),
ctx.GetHeader("User-Agent"),
)
if err != nil {
slog.Error("oidc callback login failed", "error", err)
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, "/dashboard/login/oidc/callback?ticket="+url.QueryEscape(ticket)+"&next="+url.QueryEscape(next))
}
func OIDCExchange(ctx *gin.Context) {
req := request.OIDCExchangeRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
ret, err := services.OIDCLoginService.ExchangeOIDCLoginTicket(req.Ticket)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, ret)
}
func Logout(ctx *gin.Context) {
if err := services.AuthService.Logout(ctx.GetHeader("Authorization")); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func Profile(ctx *gin.Context) {
ret, err := services.AuthService.CurrentProfile(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, ret)
}
func UpdateProfile(ctx *gin.Context) {
req := request.UpdateProfileRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
ret, err := services.AuthService.UpdateProfile(ctx, req)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, ret)
}
func UploadProfileAvatar(ctx *gin.Context) {
principal, err := services.AuthService.Authenticate(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
header, err := ctx.FormFile("file")
if err != nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0323"))
return
}
if !strings.HasPrefix(strings.ToLower(header.Header.Get("Content-Type")), "image/") {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0090"))
return
}
item, err := services.AssetService.UploadFile(header, "avatars", principal)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildAsset(item))
}
func loginErrorMessage(message string) string {
if idx := strings.Index(message, ": "); idx >= 0 {
message = message[idx+2:]
}
return message
}

View File

@@ -0,0 +1,65 @@
package api
import (
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
)
func ChannelAnyConfig(ctx *gin.Context) {
channel := services.ChannelService.GetEnabledChannel(ctx)
if channel == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
cfg, err := resolveWidgetConfig(channel.ChannelType, channel.ConfigJSON)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
ret := response.WidgetConfigResponse{
ChannelID: channel.ChannelID,
ChannelType: channel.ChannelType,
Title: cfg.Title,
Subtitle: cfg.Subtitle,
ThemeColor: cfg.ThemeColor,
Position: cfg.Position,
Width: cfg.Width,
PromptStarters: cfg.PromptStarters,
}
httpx.WriteJSON(ctx, ret)
}
type webLikeWidgetConfig struct {
Title string
Subtitle string
ThemeColor string
Position string
Width string
PromptStarters []string
}
func resolveWidgetConfig(channelType, rawConfig string) (*webLikeWidgetConfig, error) {
switch channelType {
case enums.ChannelTypeWeb:
cfg, err := services.ChannelService.ParseWebChannelConfig(rawConfig)
if err != nil {
return nil, err
}
return &webLikeWidgetConfig{
Title: cfg.Title,
Subtitle: cfg.Subtitle,
ThemeColor: cfg.ThemeColor,
Position: cfg.Position,
Width: cfg.Width,
PromptStarters: cfg.PromptStarters,
}, nil
default:
return nil, errorsx.InvalidParamI18n("error.e0313")
}
}

View File

@@ -0,0 +1,56 @@
package api
import (
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"reflect"
"testing"
)
func TestChannelConfigPromptStarters(t *testing.T) {
rawConfig := `{
"title": "Support Desk",
"subtitle": "How can we help?",
"themeColor": "#2563eb",
"position": "right",
"width": "380px",
"promptStarters": [
"How do I reset my password?",
"What are your business hours?",
"Can I talk to an agent?"
]
}`
cfg, err := resolveWidgetConfig(enums.ChannelTypeWeb, rawConfig)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatalf("expected non-nil config")
}
expectedStarters := []string{
"How do I reset my password?",
"What are your business hours?",
"Can I talk to an agent?",
}
if !reflect.DeepEqual(cfg.PromptStarters, expectedStarters) {
t.Fatalf("expected prompt starters %v, got %v", expectedStarters, cfg.PromptStarters)
}
resp := response.WidgetConfigResponse{
ChannelID: "test-channel",
ChannelType: enums.ChannelTypeWeb,
Title: cfg.Title,
Subtitle: cfg.Subtitle,
ThemeColor: cfg.ThemeColor,
Position: cfg.Position,
Width: cfg.Width,
PromptStarters: cfg.PromptStarters,
}
if !reflect.DeepEqual(resp.PromptStarters, expectedStarters) {
t.Fatalf("expected response prompt starters %v, got %v", expectedStarters, resp.PromptStarters)
}
}

View File

@@ -0,0 +1,176 @@
package api
import (
"agent-desk/internal/builders"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/services"
"agent-desk/internal/pkg/httpx/params"
"github.com/gin-gonic/gin"
"github.com/mlogclub/simple/web"
)
func ConversationGetBy(ctx *gin.Context) {
id, ok := httpx.GetPathInt64(ctx, "id")
if !ok {
return
}
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
item := services.ConversationService.Get(id)
if item == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0116"))
return
}
if !services.ConversationService.IsCustomerConversationOwner(item, *external) {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0222"))
return
}
detail := response.ConversationDetailResponse{
ConversationResponse: builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)),
Participants: builders.BuildParticipantResponses(id),
}
httpx.WriteJSON(ctx, detail)
}
func ConversationPostCreate_or_match(ctx *gin.Context) {
channel := services.ChannelService.GetEnabledChannel(ctx)
if channel == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
item, err := services.ConversationService.Create(*external, channel.ID, channel.AIAgentID)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)))
}
// ConversationPostCreate starts a brand new request instead of rejoining the
// visitor's unfinished one, which is what the "new request" action needs.
func ConversationPostCreate(ctx *gin.Context) {
channel := services.ChannelService.GetEnabledChannel(ctx)
if channel == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
// An absent body is valid: the subject is optional and the widget may post
// nothing at all when the visitor skips the prompt.
req := request.CreateCustomerConversationRequest{}
if ctx.Request.ContentLength > 0 {
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
}
item, err := services.ConversationService.CreateNew(*external, channel.ID, channel.AIAgentID, req.Subject)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)))
}
// ConversationAnyList returns the visitor's own requests so the widget can offer
// a request list. Scoping comes from the external identity, never from a
// client-supplied customer id.
func ConversationAnyList(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
cnd := params.NewPagedSqlCnd(ctx,
params.QueryFilter{ParamName: "status"},
)
list, paging, err := services.ConversationService.ListCustomerConversations(*external, cnd)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
locale := i18nx.Locale(ctx)
results := make([]response.ConversationResponse, 0, len(list))
for i := range list {
results = append(results, builders.BuildConversationWithLocale(&list[i], locale))
}
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
}
func ConversationPostClose(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
req := request.CloseConversationRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.ConversationService.CloseCustomerConversation(req.ConversationID, *external); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func ConversationPostSatisfaction(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
req := request.SubmitSatisfactionRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.ConversationSatisfactionService.SubmitSatisfaction(req.ConversationID, *external, req.Score, req.Feedback)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildSatisfactionResponse(item))
}

View File

@@ -0,0 +1,336 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func setupConversationAPITestDB(t *testing.T) (*gorm.DB, *models.Channel, *models.AIAgent, openidentity.ExternalUser) {
t.Helper()
gin.SetMode(gin.TestMode)
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(
&models.Customer{},
&models.CustomerIdentity{},
&models.Channel{},
&models.AIAgent{},
&models.Conversation{},
&models.ConversationAssignment{},
&models.ConversationParticipant{},
&models.ConversationEventLog{},
&models.ConversationReadState{},
&models.ConversationSatisfaction{},
&models.Message{},
&models.User{},
); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
previousConfig := config.GetCurrent()
config.SetCurrent(&config.Config{
Conversation: config.ConversationConfig{CustomerMaxOpen: 5},
})
t.Cleanup(func() {
config.SetCurrent(previousConfig)
})
aiAgent := models.AIAgent{
Name: "CSAT Bot",
ServiceMode: enums.IMConversationServiceModeAIFirst,
Status: enums.StatusOk,
}
if err := db.Create(&aiAgent).Error; err != nil {
t.Fatalf("create ai agent error = %v", err)
}
channel := models.Channel{
ChannelID: "web-channel-csat",
Name: "Web Channel",
ChannelType: enums.ChannelTypeWeb,
AIAgentID: aiAgent.ID,
Status: enums.StatusOk,
}
if err := db.Create(&channel).Error; err != nil {
t.Fatalf("create channel error = %v", err)
}
external := openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceUser,
ExternalID: "visitor_123",
ExternalName: "Visitor 123",
}
return db, &channel, &aiAgent, external
}
func TestConversationSatisfactionAPI(t *testing.T) {
_, channel, aiAgent, external := setupConversationAPITestDB(t)
// Create conversation and close it
conv, err := services.ConversationService.CreateNew(external, channel.ID, aiAgent.ID, "Need help with service")
if err != nil {
t.Fatalf("CreateNew error = %v", err)
}
if err := services.ConversationService.CloseCustomerConversation(conv.ID, external); err != nil {
t.Fatalf("CloseCustomerConversation error = %v", err)
}
router := gin.New()
router.POST("/api/conversation/satisfaction", func(c *gin.Context) {
ConversationPostSatisfaction(c)
})
router.GET("/api/conversation/:id", func(c *gin.Context) {
ConversationGetBy(c)
})
t.Run("missing channel returns error", func(t *testing.T) {
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
ConversationID: conv.ID,
Score: 5,
Feedback: "Great",
})
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
// no channel header
httpx.SetExternalUser(&gin.Context{}, &external)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["success"] == true {
t.Fatalf("expected failure without channel, got success: true")
}
})
t.Run("missing external user returns error", func(t *testing.T) {
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
ConversationID: conv.ID,
Score: 5,
Feedback: "Great",
})
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Channel-ID", channel.ChannelID)
// no external user set in context
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["success"] == true {
t.Fatalf("expected failure without external user, got success: true")
}
})
// Helper for authorized requests
authRouter := gin.New()
authRouter.Use(func(c *gin.Context) {
httpx.SetExternalUser(c, &external)
c.Next()
})
authRouter.POST("/api/conversation/satisfaction", ConversationPostSatisfaction)
authRouter.GET("/api/conversation/:id", ConversationGetBy)
t.Run("rejects invalid score", func(t *testing.T) {
invalidScores := []int{0, -1, 6, 10}
for _, score := range invalidScores {
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
ConversationID: conv.ID,
Score: score,
Feedback: "Invalid score test",
})
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Channel-ID", channel.ChannelID)
rec := httptest.NewRecorder()
authRouter.ServeHTTP(rec, req)
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["success"] == true {
t.Fatalf("expected failure for score %d, got success: true", score)
}
}
})
t.Run("rejects unauthorized external user", func(t *testing.T) {
attacker := openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceUser,
ExternalID: "attacker_999",
ExternalName: "Attacker",
}
attackerRouter := gin.New()
attackerRouter.Use(func(c *gin.Context) {
httpx.SetExternalUser(c, &attacker)
c.Next()
})
attackerRouter.POST("/api/conversation/satisfaction", ConversationPostSatisfaction)
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
ConversationID: conv.ID,
Score: 5,
Feedback: "Hacked rating",
})
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Channel-ID", channel.ChannelID)
rec := httptest.NewRecorder()
attackerRouter.ServeHTTP(rec, req)
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["success"] == true {
t.Fatalf("expected forbidden failure for non-owner, got success: true")
}
})
t.Run("successful submission and response mapping", func(t *testing.T) {
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
ConversationID: conv.ID,
Score: 5,
Feedback: "Outstanding support!",
})
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Channel-ID", channel.ChannelID)
rec := httptest.NewRecorder()
authRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d, want 200", rec.Code)
}
type JSONResult struct {
Code int `json:"code"`
Msg string `json:"msg"`
Ok bool `json:"success"`
Data *response.ConversationSatisfactionResponse `json:"data"`
}
var result JSONResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if !result.Ok {
t.Fatalf("expected success: true, got code=%d msg=%s", result.Code, result.Msg)
}
if result.Data == nil {
t.Fatalf("expected non-nil data")
}
if result.Data.ConversationID != conv.ID {
t.Fatalf("ConversationID = %d, want %d", result.Data.ConversationID, conv.ID)
}
if result.Data.Score != 5 {
t.Fatalf("Score = %d, want 5", result.Data.Score)
}
if result.Data.Feedback != "Outstanding support!" {
t.Fatalf("Feedback = %q, want %q", result.Data.Feedback, "Outstanding support!")
}
if result.Data.ID == 0 {
t.Fatalf("expected non-zero satisfaction ID")
}
if result.Data.CreatedAt == "" {
t.Fatalf("expected non-empty CreatedAt")
}
// Verify ConversationGetBy now returns satisfaction
getReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/conversation/%d", conv.ID), nil)
getReq.Header.Set("X-Channel-ID", channel.ChannelID)
getRec := httptest.NewRecorder()
authRouter.ServeHTTP(getRec, getReq)
type DetailResult struct {
Ok bool `json:"success"`
Data *response.ConversationDetailResponse `json:"data"`
}
var detailResult DetailResult
if err := json.Unmarshal(getRec.Body.Bytes(), &detailResult); err != nil {
t.Fatalf("failed to unmarshal detail response: %v", err)
}
if !detailResult.Ok || detailResult.Data == nil {
t.Fatalf("expected success detail result")
}
if detailResult.Data.Satisfaction == nil {
t.Fatalf("expected ConversationResponse to include satisfaction, got nil")
}
if detailResult.Data.Satisfaction.Score != 5 {
t.Fatalf("satisfaction score = %d, want 5", detailResult.Data.Satisfaction.Score)
}
if detailResult.Data.Satisfaction.Feedback != "Outstanding support!" {
t.Fatalf("satisfaction feedback = %q, want %q", detailResult.Data.Satisfaction.Feedback, "Outstanding support!")
}
})
t.Run("idempotent update submission", func(t *testing.T) {
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
ConversationID: conv.ID,
Score: 4,
Feedback: "Updated to 4 stars",
})
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Channel-ID", channel.ChannelID)
rec := httptest.NewRecorder()
authRouter.ServeHTTP(rec, req)
var result struct {
Ok bool `json:"success"`
Data *response.ConversationSatisfactionResponse `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !result.Ok || result.Data == nil {
t.Fatalf("expected success result")
}
if result.Data.Score != 4 {
t.Fatalf("updated score = %d, want 4", result.Data.Score)
}
if result.Data.Feedback != "Updated to 4 stars" {
t.Fatalf("updated feedback = %q, want %q", result.Data.Feedback, "Updated to 4 stars")
}
})
}

View File

@@ -0,0 +1,35 @@
package api
import (
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
)
func CustomerPostSession_exchange(ctx *gin.Context) {
channel := services.ChannelService.GetEnabledChannel(ctx)
if channel == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209"))
return
}
identity := config.Current().Identity
externalUser, err := openidentity.GetExternalUser(ctx, openidentity.Secrets{
UserToken: services.ChannelService.GetUserTokenSecret(channel),
IdentityHMAC: identity.Secret,
IdentityMaxAge: identity.MaxAge(),
ChannelID: channel.ChannelID,
})
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
resp, err := services.CustomerSessionService.Exchange(channel, *externalUser)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, resp)
}

View File

@@ -0,0 +1,15 @@
package api
import (
"agent-desk/internal/pkg/httpx"
"github.com/gin-gonic/gin"
)
type healthResponse struct {
Status string `json:"status"`
}
func Health(ctx *gin.Context) {
httpx.WriteJSON(ctx, &healthResponse{Status: "ok"})
}

View File

@@ -0,0 +1,196 @@
package api
import (
"agent-desk/internal/builders"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/services"
"strconv"
"strings"
"agent-desk/internal/pkg/httpx/params"
"github.com/gin-gonic/gin"
"github.com/spf13/cast"
)
func MessageAnyList(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
conversationID, _ := params.GetInt64(ctx, "conversationId")
if conversationID <= 0 {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0064"))
return
}
conversation := services.ConversationService.Get(conversationID)
if conversation == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0116"))
return
}
if !services.ConversationService.IsCustomerConversationOwner(conversation, *external) {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0222"))
return
}
var (
senderType, _ = params.Get(ctx, "senderType")
messageType, _ = params.Get(ctx, "messageType")
cursor, _ = params.GetInt64(ctx, "cursor")
limit, _ = params.GetInt(ctx, "limit")
)
list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor(
conversationID, cursor, limit, senderType, messageType,
)
results := builders.BuildMessagesWithLocale(list, i18nx.Locale(ctx))
httpx.WriteJSON(ctx, httpx.CursorData(results, cast.ToString(nextCursor), hasMore))
}
func MessagePostSend(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
req := request.SendConversationMessageRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.MessageService.SendCustomerMessageWithRequestID(req.ConversationID, req.ClientMsgID, req.MessageType, req.Content, req.Payload, *external, httpx.GetRequestID(ctx))
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
}
func MessagePostRead(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
req := request.ReadConversationRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.ConversationService.MarkCustomerConversationReadToMessage(req.ConversationID, req.MessageID, external); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func MessagePostUpload_image(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
rawConv := strings.TrimSpace(params.FormValue(ctx, "conversationId"))
if rawConv == "" {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0064"))
return
}
conversationID, err := strconv.ParseInt(rawConv, 10, 64)
if err != nil || conversationID <= 0 {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0064"))
return
}
conversation := services.ConversationService.Get(conversationID)
if conversation == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0116"))
return
}
if !services.ConversationService.IsCustomerConversationOwner(conversation, *external) {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0222"))
return
}
if _, err := services.MessageService.ValidateConversationSender(conversationID, enums.IMSenderTypeCustomer, nil, external); err != nil {
httpx.WriteJSON(ctx, err)
return
}
header, err := ctx.FormFile("file")
if err != nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0322"))
return
}
if !strings.HasPrefix(strings.ToLower(header.Header.Get("Content-Type")), "image/") {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0090"))
return
}
item, err := services.AssetService.UploadFile(header, "images", nil)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildAsset(item))
}
func MessagePostUpload_attachment(ctx *gin.Context) {
if services.ChannelService.GetEnabledChannel(ctx) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0211"))
return
}
external := httpx.GetExternalUser(ctx)
if external == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0150"))
return
}
rawConv := strings.TrimSpace(params.FormValue(ctx, "conversationId"))
if rawConv == "" {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0064"))
return
}
conversationID, err := strconv.ParseInt(rawConv, 10, 64)
if err != nil || conversationID <= 0 {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0064"))
return
}
if _, err := services.MessageService.ValidateConversationSender(conversationID, enums.IMSenderTypeCustomer, nil, external); err != nil {
httpx.WriteJSON(ctx, err)
return
}
header, err := ctx.FormFile("file")
if err != nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0324"))
return
}
item, err := services.AssetService.UploadFile(header, "attachments", nil)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildAsset(item))
}

View File

@@ -0,0 +1,12 @@
package api
import (
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
)
func SupportConfigGetConfig(ctx *gin.Context) {
httpx.WriteJSON(ctx, services.SystemConfigService.GetPublicSupportConfig())
}

View File

@@ -0,0 +1,356 @@
package api
import (
"agent-desk/internal/builders"
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/repositories"
"agent-desk/internal/services"
"strings"
"github.com/gin-gonic/gin"
"github.com/mlogclub/simple/sqls"
"github.com/mlogclub/simple/web"
"github.com/spf13/cast"
"gorm.io/gorm"
)
func SupportAuthPostRegister(ctx *gin.Context) {
cfg := config.Current()
if !cfg.Auth.IsPasswordLoginEnabled() {
httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled"))
return
}
req := request.SupportCustomerRegisterRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
ret, err := services.SupportService.RegisterUser(req, cfg.Auth, ctx.ClientIP(), ctx.GetHeader("User-Agent"))
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, ret)
}
func SupportGetMe(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
user := supportUser(principal.UserID)
email := ""
if user != nil && user.Email != nil {
email = *user.Email
}
httpx.WriteJSON(ctx, response.SupportUserResponse{ID: principal.UserID, Name: supportPrincipalDisplayName(principal.UserID), Email: email, UserType: principal.UserType})
}
func DocPageAnyList(ctx *gin.Context) {
cnd := params.NewPagedSqlCnd(ctx,
params.QueryFilter{ParamName: "parentId"},
params.QueryFilter{ParamName: "title", Op: params.Like},
).Eq("status", enums.DocPageStatusPublished).Asc("sort_no").Desc("id")
if keyword := strings.TrimSpace(ctx.Query("keyword")); keyword != "" {
pattern := "%" + keyword + "%"
cnd.Where("(title LIKE ? OR summary LIKE ? OR content LIKE ? OR tags_json LIKE ? OR slug LIKE ?)", pattern, pattern, pattern, pattern, pattern)
}
list, paging := repositories.DocPageRepository.FindPageByCnd(sqls.DB(), cnd)
results := buildDocPageList(list, false)
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
}
func DocPageGetNavigation(ctx *gin.Context) {
list := services.SupportService.FindPublicDocNavigation()
httpx.WriteJSON(ctx, builders.BuildDocPageNavigationTree(list))
}
func DocPageGetBy(ctx *gin.Context) {
id, ok := httpx.GetPathInt64(ctx, "id")
if !ok {
return
}
item := repositories.DocPageRepository.Get(sqls.DB(), id)
if item == nil || item.Status != enums.DocPageStatusPublished {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.notFound"))
return
}
_ = repositories.DocPageRepository.UpdateColumn(sqls.DB(), item.ID, "view_count", gorm.Expr("view_count + ?", 1))
httpx.WriteJSON(ctx, builders.BuildDocPage(item, true))
}
func DocPagePostFeedback(ctx *gin.Context) {
req := request.DocPageFeedbackRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, services.SupportService.FeedbackDocPage(req))
}
func CategoryAnyList(ctx *gin.Context) {
list := repositories.CategoryRepository.Find(sqls.DB(), sqls.NewCnd().Eq("status", enums.StatusOk).Asc("sort_no").Desc("id"))
httpx.WriteJSON(ctx, builders.BuildPostCategories(list))
}
func PostAnyList(ctx *gin.Context) {
cursor, _ := params.GetInt64(ctx, "cursor")
limit, _ := params.GetInt(ctx, "limit")
if limit <= 0 || limit > 50 {
limit = 20
}
cnd := params.NewSqlCnd(ctx,
params.QueryFilter{ParamName: "categoryId"},
params.QueryFilter{ParamName: "userId"},
params.QueryFilter{ParamName: "status"},
params.QueryFilter{ParamName: "title", Op: params.Like},
).Where("status NOT IN ?", []enums.PostStatus{enums.PostStatusHidden, enums.PostStatusDeleted}).Desc("id").Limit(limit + 1)
if cursor > 0 {
cnd.Lt("id", cursor)
}
list := repositories.PostRepository.Find(sqls.DB(), cnd)
hasMore := len(list) > limit
if hasMore {
list = list[:limit]
}
nextCursor := ""
if hasMore && len(list) > 0 {
nextCursor = cast.ToString(list[len(list)-1].ID)
}
httpx.WriteJSON(ctx, httpx.CursorData(buildPostList(list), nextCursor, hasMore))
}
func PostGetBy(ctx *gin.Context) {
id, ok := httpx.GetPathInt64(ctx, "id")
if !ok {
return
}
post := repositories.PostRepository.Get(sqls.DB(), id)
if post == nil || post.Status == enums.PostStatusHidden || post.Status == enums.PostStatusDeleted {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.notFound"))
return
}
_ = repositories.PostRepository.UpdateColumn(sqls.DB(), post.ID, "view_count", gorm.Expr("view_count + ?", 1))
comments, err := services.SupportService.ListPostComments(id, 0, "default", 1, 20)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, response.PostDetailResponse{Post: *builders.BuildPost(post, supportCategoryName(post.CategoryID), supportUser(post.UserID)), Comments: buildCommentListWithReplies(comments.Comments, comments.Replies)})
}
func PostPostCreate(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.CreatePostRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.SupportService.CreatePost(req, principal)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildPost(item, supportCategoryName(item.CategoryID), supportUser(principal.UserID)))
}
func PostPostUpdate(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.UpdatePostRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, services.SupportService.UpdatePost(req, principal))
}
func PostPostAcceptComment(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.AcceptCommentRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, services.SupportService.AcceptComment(req, principal, nil))
}
func CommentPostCreate(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.CreateCommentRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
item, err := services.SupportService.CreateCustomerComment(req, principal)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildComment(item, supportPrincipalDisplayName(principal.UserID)))
}
func CommentAnyList(ctx *gin.Context) {
postID, _ := params.GetInt64(ctx, "postId")
parentID, _ := params.GetInt64(ctx, "parentId")
page, _ := params.GetInt(ctx, "page")
limit, _ := params.GetInt(ctx, "limit")
sort, _ := params.Get(ctx, "sort")
result, err := services.SupportService.ListPostComments(postID, parentID, sort, page, limit)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, &web.PageResult{Results: buildCommentListWithReplies(result.Comments, result.Replies), Page: result.Paging})
}
func CommentPostUpdate(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.UpdateCommentRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, services.SupportService.UpdateComment(req, principal))
}
func CommentPostDelete(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.IDRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, services.SupportService.DeleteComment(req.ID, principal))
}
func CommentPostReport(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.ReportCommentRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, services.SupportService.ReportComment(req, principal))
}
func ReactionPostToggle(ctx *gin.Context) {
principal, err := services.SupportService.RequireSupportUser(ctx)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.ReactionRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, services.SupportService.ToggleReaction(req.TargetType, req.TargetID, req.ReactionType, principal))
}
func buildDocPageList(list []models.DocPage, includeContent bool) []response.DocPageResponse {
results := make([]response.DocPageResponse, 0, len(list))
for _, item := range list {
if resp := builders.BuildDocPage(&item, includeContent); resp != nil {
results = append(results, *resp)
}
}
return results
}
func buildPostList(list []models.Post) []response.PostResponse {
results := make([]response.PostResponse, 0, len(list))
for _, item := range list {
if resp := builders.BuildPost(&item, supportCategoryName(item.CategoryID), supportUser(item.UserID)); resp != nil {
results = append(results, *resp)
}
}
return results
}
func buildCommentList(list []models.Comment) []response.CommentResponse {
return buildCommentListWithReplies(list, nil)
}
func buildCommentListWithReplies(list []models.Comment, replies map[int64][]models.Comment) []response.CommentResponse {
results := make([]response.CommentResponse, 0, len(list))
for _, item := range list {
if resp := builders.BuildComment(&item, supportCommentAuthorName(item)); resp != nil {
if len(replies[item.ID]) > 0 {
resp.Replies = buildCommentListWithReplies(replies[item.ID], nil)
}
results = append(results, *resp)
}
}
return results
}
func supportCategoryName(id int64) string {
item := repositories.CategoryRepository.Get(sqls.DB(), id)
if item == nil {
return ""
}
return item.Name
}
func supportUser(id int64) *models.User {
return repositories.UserRepository.Get(sqls.DB(), id)
}
func supportPrincipalDisplayName(id int64) string {
user := supportUser(id)
if user == nil {
return ""
}
if user.Nickname != "" {
return user.Nickname
}
return user.Username
}
func supportCommentAuthorName(item models.Comment) string {
user := repositories.UserRepository.Get(sqls.DB(), item.AuthorID)
if user == nil {
return ""
}
if user.Nickname != "" {
return user.Nickname
}
return user.Username
}

View File

@@ -0,0 +1,55 @@
package api
import (
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/services"
"bytes"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func OrgSyncWebhook(ctx *gin.Context) {
bodyBytes, err := io.ReadAll(ctx.Request.Body)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
signature := firstHeader(ctx, "X-Webhook-Signature", "X-Org-Signature", "X-DOS-Signature", "X-Hub-Signature-256")
if !services.WebhookSyncService.VerifySignature(bodyBytes, signature) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
return
}
req := request.OrgSyncWebhookRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.WebhookSyncService.HandleOrgSync(req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, gin.H{"success": true})
}
func DOSOrgSyncWebhook(ctx *gin.Context) {
OrgSyncWebhook(ctx)
}
func firstHeader(ctx *gin.Context, keys ...string) string {
for _, k := range keys {
if val := strings.TrimSpace(ctx.GetHeader(k)); val != "" {
return val
}
}
return ""
}