539 lines
17 KiB
Go
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
|
|
}
|
|
}
|