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("\n") b.WriteString("\n") for i := range portal.Articles { article := &portal.Articles[i] if article.Status != string(model.ArticleStatusPublished) { continue } b.WriteString(" \n") b.WriteString(" ") b.WriteString(html.EscapeString(baseURL + "/hc/" + portal.Slug + "/articles/" + article.Slug)) b.WriteString("\n") b.WriteString(" ") b.WriteString(article.UpdatedAt.Format("2006-01-02")) b.WriteString("\n") b.WriteString(" \n") } b.WriteString("\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 } }