Files
gochat/internal/handler/api/v1/portal_handler.go
T

539 lines
17 KiB
Go

package v1
import (
"encoding/json"
"html"
"net/http"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
// PortalHandler handles Portal CRUD endpoints.
type PortalHandler struct {
svc *service.PortalService
}
// NewPortalHandler creates a new PortalHandler.
func NewPortalHandler(svc *service.PortalService) *PortalHandler {
return &PortalHandler{svc: svc}
}
// PublicRedirectDefaultLocale redirects /hc/:slug to the portal default locale.
// GET /hc/:slug
func (h *PortalHandler) PublicRedirectDefaultLocale(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
c.Redirect(http.StatusFound, "/hc/"+portal.Slug+"/"+portalDefaultLocale(portal))
}
// PublicGet returns the public help-center portal JSON payload.
// GET /hc/:slug/:locale
func (h *PortalHandler) PublicGet(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
c.JSON(http.StatusOK, publicPortalPayload(portal))
}
// PublicSitemap returns the public help-center XML sitemap.
// GET /hc/:slug/sitemap.xml
func (h *PortalHandler) PublicSitemap(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
baseURL := publicHelpCenterBaseURL(c, portal)
var b strings.Builder
b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
b.WriteString("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n")
for i := range portal.Articles {
article := &portal.Articles[i]
if article.Status != string(model.ArticleStatusPublished) {
continue
}
b.WriteString(" <url>\n")
b.WriteString(" <loc>")
b.WriteString(html.EscapeString(baseURL + "/hc/" + portal.Slug + "/articles/" + article.Slug))
b.WriteString("</loc>\n")
b.WriteString(" <lastmod>")
b.WriteString(article.UpdatedAt.Format("2006-01-02"))
b.WriteString("</lastmod>\n")
b.WriteString(" </url>\n")
}
b.WriteString("</urlset>\n")
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
}
// Create creates a new portal.
// POST /api/v1/accounts/:account_id/portals
func (h *PortalHandler) Create(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
var req service.CreatePortalRequest
if err := bindChatwootPayload(c, "portal", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
portal, err := h.svc.Create(c.Request.Context(), accountID, &req)
if err != nil {
applogger.L().Errorf("Create portal: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create portal")
return
}
c.JSON(http.StatusOK, portalPayload(portal, "", 0))
}
// Get retrieves a portal by ID.
// GET /api/v1/accounts/:account_id/portals/:portal_id
func (h *PortalHandler) Get(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
applogger.L().Errorf("Get portal: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return
}
c.JSON(http.StatusOK, portalPayload(portal, c.Query("locale"), currentUserID(c)))
}
// Update modifies an existing portal.
// PUT /api/v1/accounts/:account_id/portals/:portal_id
func (h *PortalHandler) Update(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return
}
var req service.PatchPortalRequest
if err := bindChatwootPayload(c, "portal", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
portal, err = h.svc.UpdatePatch(c.Request.Context(), portal, &req)
if err != nil {
applogger.L().Errorf("Update portal: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update portal")
return
}
c.JSON(http.StatusOK, portalPayload(portal, c.Query("locale"), currentUserID(c)))
}
// Delete soft-deletes a portal.
// DELETE /api/v1/accounts/:account_id/portals/:portal_id
func (h *PortalHandler) Delete(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return
}
if err := h.svc.Delete(c.Request.Context(), portal.ID); err != nil {
applogger.L().Errorf("Delete portal: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete portal")
return
}
c.Status(http.StatusOK)
}
// List retrieves all portals for an account (paginated).
// GET /api/v1/accounts/:account_id/portals
func (h *PortalHandler) List(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portals, _, err := h.svc.ListByAccountIDWithAssociations(c.Request.Context(), accountID, 0, 0)
if err != nil {
applogger.L().Errorf("List portals: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list portals")
return
}
payload := make([]gin.H, 0, len(portals))
for i := range portals {
payload = append(payload, portalPayload(&portals[i], "", 0))
}
currentPage := c.DefaultQuery("page", "1")
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"current_page": currentPage, "portals_count": len(portals)}})
}
// Archive sets archived=true on a portal.
// POST /api/v1/accounts/:account_id/portals/:portal_id/archive
func (h *PortalHandler) Archive(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return
}
_, err = h.svc.Archive(c.Request.Context(), portal.ID)
if err != nil {
applogger.L().Errorf("Archive portal: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to archive portal")
return
}
c.Status(http.StatusOK)
}
// RemoveLogo clears the logo_url on a portal.
// DELETE /api/v1/accounts/:account_id/portals/:portal_id/logo
func (h *PortalHandler) RemoveLogo(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return
}
_, err = h.svc.RemoveLogo(c.Request.Context(), portal.ID)
if err != nil {
applogger.L().Errorf("Remove portal logo: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove portal logo")
return
}
c.Status(http.StatusOK)
}
// SendInstructions sends CNAME configuration instructions to an email address.
// POST /api/v1/accounts/:account_id/portals/:portal_id/send_instructions
func (h *PortalHandler) SendInstructions(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return
}
var req service.SendInstructionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.SendInstructions(c.Request.Context(), portal.ID, &req); err != nil {
applogger.L().Errorf("Send portal instructions: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": portalInstructionError(err.Error())})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Instructions sent successfully"})
}
// SSLStatus returns the SSL certificate status for a portal's custom domain.
// GET /api/v1/accounts/:account_id/portals/:portal_id/ssl_status
func (h *PortalHandler) SSLStatus(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return
}
if portal.CustomDomain == "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Custom domain is not configured"})
return
}
c.JSON(http.StatusOK, portalSSLStatusPayload(portal))
}
func (h *PortalHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, bool) {
slug := strings.TrimSuffix(c.Param("slug"), ".json")
portal, err := h.svc.ResolvePublicBySlug(c.Request.Context(), slug)
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return nil, false
}
return portal, true
}
func publicHelpCenterBaseURL(c *gin.Context, portal *model.Portal) string {
baseURL := ""
if portal != nil {
baseURL = strings.TrimSpace(portal.CustomDomain)
}
if baseURL == "" {
baseURL = strings.TrimSpace(os.Getenv("FRONTEND_URL"))
}
if baseURL == "" && c != nil && c.Request != nil {
baseURL = c.Request.Host
}
baseURL = strings.TrimRight(baseURL, "/")
if baseURL == "" {
baseURL = "localhost:3000"
}
if !strings.Contains(baseURL, "://") {
baseURL = "https://" + baseURL
}
return baseURL
}
func portalDefaultLocale(portal *model.Portal) string {
if portal == nil {
return "en"
}
config := portalConfig(portal.PortalConfiguration)
defaultLocale := configString(config, "default_locale", portal.Locale)
if defaultLocale == "" {
return "en"
}
return strings.TrimSuffix(defaultLocale, ".json")
}
func portalPayload(portal *model.Portal, locale string, currentUserID uint) gin.H {
if portal == nil {
return gin.H{}
}
config := portalConfig(portal.PortalConfiguration)
defaultLocale := configString(config, "default_locale", portal.Locale)
if defaultLocale == "" {
defaultLocale = "en"
}
allowedLocales := configStringSlice(config, "allowed_locales")
if len(allowedLocales) == 0 {
allowedLocales = []string{defaultLocale}
}
draftLocales := configStringSet(configStringSlice(config, "draft_locales"))
selectedArticles := portal.Articles
if locale != "" {
selectedArticles = make([]model.Article, 0, len(portal.Articles))
for _, article := range portal.Articles {
if article.Locale == locale {
selectedArticles = append(selectedArticles, article)
}
}
}
payload := gin.H{
"id": portal.ID,
"color": portal.Color,
"custom_domain": portal.CustomDomain,
"header_text": portal.HeaderText,
"homepage_link": portal.HomepageLink,
"name": portal.Name,
"page_title": portal.PageTitle,
"slug": portal.Slug,
"archived": portal.Archived,
"account_id": portal.AccountID,
"config": gin.H{
"allowed_locales": portalAllowedLocalePayloads(allowedLocales, draftLocales, portal.Articles, portal.Categories),
"default_locale": defaultLocale,
"layout": configString(config, "layout", "classic"),
"social_profiles": configMap(config, "social_profiles"),
},
"meta": portalMeta(portal, selectedArticles, defaultLocale, currentUserID),
}
if portal.ChannelWebWidgetID != nil {
payload["inbox"] = gin.H{"id": *portal.ChannelWebWidgetID}
}
if portal.LogoURL != "" {
payload["logo"] = gin.H{"file_url": portal.LogoURL, "portal_id": portal.ID, "account_id": portal.AccountID}
}
sslSettings := portalSSLStatusPayload(portal)
if sslSettings["status"] != nil || sslSettings["verification_errors"] != nil {
payload["ssl_settings"] = sslSettings
}
return payload
}
func portalMeta(portal *model.Portal, articles []model.Article, defaultLocale string, currentUserID uint) gin.H {
meta := gin.H{
"all_articles_count": len(articles),
"archived_articles_count": articleStatusCount(articles, "archived"),
"published_count": articleStatusCount(articles, "published"),
"draft_articles_count": articleStatusCount(articles, "draft"),
"categories_count": len(portal.Categories),
"default_locale": defaultLocale,
}
if currentUserID != 0 && len(articles) > 0 {
mine := 0
for _, article := range articles {
if article.AuthorID != nil && *article.AuthorID == currentUserID {
mine++
}
}
meta["mine_articles_count"] = mine
}
return meta
}
func articleStatusCount(articles []model.Article, status string) int {
count := 0
for _, article := range articles {
if article.Status == status {
count++
}
}
return count
}
func portalAllowedLocalePayloads(locales []string, draftLocales map[string]bool, articles []model.Article, categories []model.Category) []gin.H {
items := make([]gin.H, 0, len(locales))
for _, locale := range locales {
items = append(items, gin.H{
"code": locale,
"articles_count": articleLocaleCount(articles, locale),
"categories_count": categoryLocaleCount(categories, locale),
"draft": draftLocales[locale],
})
}
return items
}
func articleLocaleCount(articles []model.Article, locale string) int {
count := 0
for _, article := range articles {
if article.Locale == locale {
count++
}
}
return count
}
func categoryLocaleCount(categories []model.Category, locale string) int {
count := 0
for _, category := range categories {
if category.Locale == locale {
count++
}
}
return count
}
func portalConfig(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
return map[string]any{}
}
return cfg
}
func configString(cfg map[string]any, key, fallback string) string {
if value, ok := cfg[key].(string); ok && value != "" {
return value
}
return fallback
}
func configStringSlice(cfg map[string]any, key string) []string {
items, ok := cfg[key].([]any)
if !ok {
return nil
}
values := make([]string, 0, len(items))
for _, item := range items {
if value, ok := item.(string); ok && value != "" {
values = append(values, value)
}
}
return values
}
func configStringSet(values []string) map[string]bool {
set := make(map[string]bool, len(values))
for _, value := range values {
set[value] = true
}
return set
}
func configMap(cfg map[string]any, key string) map[string]any {
if value, ok := cfg[key].(map[string]any); ok {
return value
}
return map[string]any{}
}
func portalSSLStatusPayload(portal *model.Portal) gin.H {
settings := portalConfig(portal.SSLSettings)
return gin.H{"status": settings["cf_status"], "verification_errors": settings["cf_verification_errors"]}
}
func currentUserID(c *gin.Context) uint {
if id, err := strconv.ParseUint(c.GetHeader("X-User-ID"), 10, 32); err == nil {
return uint(id)
}
if userID, exists := c.Get("user_id"); exists {
switch v := userID.(type) {
case uint:
return v
case int:
return uint(v)
case float64:
return uint(v)
}
}
return 0
}
func portalInstructionError(message string) string {
lower := strings.ToLower(message)
switch {
case strings.Contains(lower, "no custom domain") || strings.Contains(lower, "custom domain"):
return "Custom domain is not configured"
case strings.Contains(lower, "invalid email"):
return "Invalid email format"
case strings.Contains(lower, "email is required") || strings.Contains(lower, "email"):
return "Email is required"
default:
return message
}
}