chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
201
internal/handlers/api/auth_handler.go
Normal file
201
internal/handlers/api/auth_handler.go
Normal 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
|
||||
}
|
||||
65
internal/handlers/api/channel_handler.go
Normal file
65
internal/handlers/api/channel_handler.go
Normal 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")
|
||||
}
|
||||
}
|
||||
56
internal/handlers/api/channel_handler_test.go
Normal file
56
internal/handlers/api/channel_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
176
internal/handlers/api/conversation_handler.go
Normal file
176
internal/handlers/api/conversation_handler.go
Normal 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))
|
||||
}
|
||||
336
internal/handlers/api/conversation_handler_test.go
Normal file
336
internal/handlers/api/conversation_handler_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
35
internal/handlers/api/customer_handler.go
Normal file
35
internal/handlers/api/customer_handler.go
Normal 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)
|
||||
}
|
||||
15
internal/handlers/api/health_handler.go
Normal file
15
internal/handlers/api/health_handler.go
Normal 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"})
|
||||
}
|
||||
196
internal/handlers/api/message_handler.go
Normal file
196
internal/handlers/api/message_handler.go
Normal 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))
|
||||
}
|
||||
12
internal/handlers/api/support_config_handler.go
Normal file
12
internal/handlers/api/support_config_handler.go
Normal 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())
|
||||
}
|
||||
356
internal/handlers/api/support_handler.go
Normal file
356
internal/handlers/api/support_handler.go
Normal 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
|
||||
}
|
||||
55
internal/handlers/api/webhook_handler.go
Normal file
55
internal/handlers/api/webhook_handler.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user