feat(enterprise): align account limits API
This commit is contained in:
@@ -739,6 +739,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
MFA: v1.NewMFAHandler(mfaService),
|
||||
SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML),
|
||||
Account: v1.NewAccountHandler(accountService),
|
||||
EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService),
|
||||
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService),
|
||||
Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService),
|
||||
Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
// EnterpriseAccountHandler implements Chatwoot enterprise account billing and
|
||||
// limit endpoints consumed by the reused dashboard EnterpriseAccountAPI client.
|
||||
type EnterpriseAccountHandler struct {
|
||||
svc *service.AccountService
|
||||
}
|
||||
|
||||
func NewEnterpriseAccountHandler(svc *service.AccountService) *EnterpriseAccountHandler {
|
||||
return &EnterpriseAccountHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Limits returns account usage limits in Chatwoot's enterprise payload shape.
|
||||
// GET /enterprise/api/v1/accounts/:account_id/limits
|
||||
func (h *EnterpriseAccountHandler) Limits(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := h.svc.EnterpriseLimits(c.Request.Context(), accountID, getUserID(c))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// ToggleDeletion marks or unmarks an account for scheduled deletion.
|
||||
// POST /enterprise/api/v1/accounts/:account_id/toggle_deletion
|
||||
func (h *EnterpriseAccountHandler) ToggleDeletion(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ActionType string `json:"action_type" form:"action_type"`
|
||||
}
|
||||
_ = c.ShouldBind(&req)
|
||||
|
||||
switch req.ActionType {
|
||||
case "delete":
|
||||
if _, err := h.svc.MarkForDeletion(c.Request.Context(), accountID, getUserID(c), "manual_deletion"); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Account marked for deletion"})
|
||||
case "undelete":
|
||||
if _, err := h.svc.UnmarkForDeletion(c.Request.Context(), accountID, getUserID(c)); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Account unmarked for deletion"})
|
||||
default:
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Invalid action_type. Must be either \"delete\" or \"undelete\""})
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription mirrors the Cloud customer-creation guard and returns no content.
|
||||
// POST /enterprise/api/v1/accounts/:account_id/subscription
|
||||
func (h *EnterpriseAccountHandler) Subscription(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
if err := h.svc.EnsureEnterpriseAccountCustomerCreationFlag(c.Request.Context(), accountID, getUserID(c)); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Checkout returns Chatwoot's billing-details error when no Stripe session can be created locally.
|
||||
// POST /enterprise/api/v1/accounts/:account_id/checkout
|
||||
func (h *EnterpriseAccountHandler) Checkout(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
if _, err := h.svc.GetByUserAndID(c.Request.Context(), getUserID(c), accountID); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Please subscribe to a plan before viewing the billing details"})
|
||||
}
|
||||
|
||||
// TopupCheckout validates credits and exposes a provider-unavailable boundary for local installs.
|
||||
// POST /enterprise/api/v1/accounts/:account_id/topup_checkout
|
||||
func (h *EnterpriseAccountHandler) TopupCheckout(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
if _, err := h.svc.GetByUserAndID(c.Request.Context(), getUserID(c), accountID); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Credits int `json:"credits" form:"credits"`
|
||||
}
|
||||
_ = c.ShouldBind(&req)
|
||||
if req.Credits <= 0 {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Credits are required"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Top-up checkout provider is not configured"})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
|
||||
func TestEnterpriseAccountLimits_ChatwootPayload(t *testing.T) {
|
||||
router, db, account, user := setupEnterpriseAccountHandlerTest(t)
|
||||
|
||||
account.AgentLimit = 3
|
||||
account.InboxLimit = 4
|
||||
account.Limits = datatypes.JSON(`{"captain_documents":5,"captain_responses":7}`)
|
||||
require.NoError(t, account.SetCustomAttributesMap(map[string]any{"captain_responses_usage": 2}))
|
||||
require.NoError(t, db.Save(account).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: seedEnterpriseUser(t, db, "agent@example.com").ID, Role: "agent"}).Error)
|
||||
require.NoError(t, db.Create(&model.CaptainDocument{AccountID: account.ID, AssistantID: 1, Name: "Doc", ExternalLink: "https://example.com"}).Error)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/enterprise/api/v1/accounts/%d/limits", account.ID), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Equal(t, float64(account.ID), body["id"])
|
||||
|
||||
limits := body["limits"].(map[string]any)
|
||||
agents := limits["agents"].(map[string]any)
|
||||
require.Equal(t, float64(3), agents["allowed"])
|
||||
require.Equal(t, float64(2), agents["consumed"])
|
||||
|
||||
captain := limits["captain"].(map[string]any)
|
||||
documents := captain["documents"].(map[string]any)
|
||||
require.Equal(t, float64(5), documents["total_count"])
|
||||
require.Equal(t, float64(4), documents["current_available"])
|
||||
require.Equal(t, float64(1), documents["consumed"])
|
||||
responses := captain["responses"].(map[string]any)
|
||||
require.Equal(t, float64(7), responses["total_count"])
|
||||
require.Equal(t, float64(5), responses["current_available"])
|
||||
require.Equal(t, float64(2), responses["consumed"])
|
||||
}
|
||||
|
||||
func TestEnterpriseAccountLimits_DefaultPlanPayload(t *testing.T) {
|
||||
router, db, account, user := setupEnterpriseAccountHandlerTest(t)
|
||||
require.NoError(t, account.SetCustomAttributesMap(map[string]any{"default_plan": true}))
|
||||
require.NoError(t, db.Save(account).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error)
|
||||
old := time.Now().AddDate(0, 0, -31)
|
||||
require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: 1, ContactID: 1, ChannelType: "web_widget", Channel: "web_widget"}).Error)
|
||||
oldConversation := &model.Conversation{AccountID: account.ID, InboxID: 1, ContactID: 1, ChannelType: "web_widget", Channel: "web_widget"}
|
||||
oldConversation.CreatedAt = old
|
||||
require.NoError(t, db.Create(oldConversation).Error)
|
||||
require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1}).Error)
|
||||
require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Email", ChannelType: "email", ChannelID: 2}).Error)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/enterprise/api/v1/accounts/%d/limits", account.ID), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
limits := body["limits"].(map[string]any)
|
||||
conversation := limits["conversation"].(map[string]any)
|
||||
require.Equal(t, float64(500), conversation["allowed"])
|
||||
require.Equal(t, float64(1), conversation["consumed"])
|
||||
nonWeb := limits["non_web_inboxes"].(map[string]any)
|
||||
require.Equal(t, float64(0), nonWeb["allowed"])
|
||||
require.Equal(t, float64(1), nonWeb["consumed"])
|
||||
}
|
||||
|
||||
func TestEnterpriseAccountToggleDeletion(t *testing.T) {
|
||||
router, db, account, user := setupEnterpriseAccountHandlerTest(t)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error)
|
||||
|
||||
w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "toggle_deletion", `{"action_type":"delete"}`)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.NoError(t, db.First(account, account.ID).Error)
|
||||
attrs := account.CustomAttributesMap()
|
||||
require.Equal(t, "manual_deletion", attrs["marked_for_deletion_reason"])
|
||||
require.NotEmpty(t, attrs["marked_for_deletion_at"])
|
||||
|
||||
w = enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "toggle_deletion", `{"action_type":"undelete"}`)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.NoError(t, db.First(account, account.ID).Error)
|
||||
attrs = account.CustomAttributesMap()
|
||||
require.NotContains(t, attrs, "marked_for_deletion_reason")
|
||||
require.NotContains(t, attrs, "marked_for_deletion_at")
|
||||
}
|
||||
|
||||
func TestEnterpriseAccountSubscriptionSetsCreationFlag(t *testing.T) {
|
||||
router, db, account, user := setupEnterpriseAccountHandlerTest(t)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error)
|
||||
|
||||
w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "subscription", ``)
|
||||
require.Equal(t, http.StatusNoContent, w.Code, w.Body.String())
|
||||
require.NoError(t, db.First(account, account.ID).Error)
|
||||
require.Equal(t, true, account.CustomAttributesMap()["is_creating_customer"])
|
||||
}
|
||||
|
||||
func TestEnterpriseAccountRejectsAccountOutsideCurrentUser(t *testing.T) {
|
||||
router, _, account, _ := setupEnterpriseAccountHandlerTest(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/enterprise/api/v1/accounts/%d/limits", account.ID), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
func setupEnterpriseAccountHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.User) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Conversation{}, &model.Inbox{}, &model.CaptainDocument{}))
|
||||
|
||||
user := seedEnterpriseUser(t, db, "admin@example.com")
|
||||
account := &model.Account{Name: "Acme", Active: true, Status: "active", Locale: "en"}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
|
||||
svc := service.NewAccountService(repository.NewAccountRepo(db))
|
||||
handler := NewEnterpriseAccountHandler(svc)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("user_id", user.ID)
|
||||
c.Next()
|
||||
})
|
||||
accounts := router.Group("/enterprise/api/v1/accounts")
|
||||
accounts.GET("/:account_id/limits", handler.Limits)
|
||||
accounts.POST("/:account_id/toggle_deletion", handler.ToggleDeletion)
|
||||
accounts.POST("/:account_id/subscription", handler.Subscription)
|
||||
accounts.POST("/:account_id/checkout", handler.Checkout)
|
||||
accounts.POST("/:account_id/topup_checkout", handler.TopupCheckout)
|
||||
return router, db, account, user
|
||||
}
|
||||
|
||||
func seedEnterpriseUser(t *testing.T, db *gorm.DB, email string) *model.User {
|
||||
t.Helper()
|
||||
user := &model.User{Name: email, Email: email, Password: "hashed", Active: true}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
return user
|
||||
}
|
||||
|
||||
func enterpriseAccountRequest(t *testing.T, router *gin.Engine, accountID uint, method, action, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, fmt.Sprintf("/enterprise/api/v1/accounts/%d/%s", accountID, action), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
@@ -15,6 +15,55 @@ type AccountRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// FindByUserAndID retrieves an account only when the user belongs to it.
|
||||
func (r *AccountRepo) FindByUserAndID(ctx context.Context, userID, accountID uint) (*model.Account, error) {
|
||||
var account model.Account
|
||||
err := r.db.WithContext(ctx).Model(&model.Account{}).
|
||||
Joins("JOIN account_users ON account_users.account_id = accounts.id").
|
||||
Where("accounts.id = ? AND account_users.user_id = ?", accountID, userID).
|
||||
First(&account).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// CountConversationsSince counts account conversations created after a timestamp.
|
||||
func (r *AccountRepo) CountConversationsSince(ctx context.Context, accountID uint, since time.Time) (int64, error) {
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).Model(&model.Conversation{}).
|
||||
Where("account_id = ? AND created_at > ?", accountID, since).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// CountNonWebInboxes counts account inboxes excluding Chatwoot web widget channels.
|
||||
func (r *AccountRepo) CountNonWebInboxes(ctx context.Context, accountID uint) (int64, error) {
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).Model(&model.Inbox{}).
|
||||
Where("account_id = ? AND channel_type NOT IN ?", accountID, []string{"web_widget", "Channel::WebWidget"}).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// CountUsersByAccount counts account users through the account_users join table.
|
||||
func (r *AccountRepo) CountUsersByAccount(ctx context.Context, accountID uint) (int64, error) {
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).Model(&model.AccountUser{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// CountCaptainDocumentsByAccount counts Captain knowledge documents for account usage limits.
|
||||
func (r *AccountRepo) CountCaptainDocumentsByAccount(ctx context.Context, accountID uint) (int64, error) {
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).Model(&model.CaptainDocument{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// NewAccountRepo creates a new Account repository.
|
||||
func NewAccountRepo(db *gorm.DB) *AccountRepo {
|
||||
return &AccountRepo{db: db}
|
||||
|
||||
@@ -42,6 +42,7 @@ type Handlers struct {
|
||||
MFA *v1.MFAHandler
|
||||
SAML *v1.SAMLHandler
|
||||
Account *v1.AccountHandler
|
||||
EnterpriseAccount *v1.EnterpriseAccountHandler
|
||||
Contact *v1.ContactHandler
|
||||
Conversation *v1.ConversationHandler
|
||||
Inbox *v1.InboxHandler
|
||||
@@ -248,6 +249,12 @@ func RegisterRoutes(
|
||||
apiV1.Use(middleware.AuthMiddleware(jwtCfg))
|
||||
registerV1Routes(apiV1, handlers)
|
||||
|
||||
// Enterprise API routes consumed by the reused Chatwoot dashboard.
|
||||
// Reference: Chatwoot routes.rb namespace :enterprise/:api/:v1.
|
||||
enterpriseV1 := engine.Group("/enterprise/api/v1")
|
||||
enterpriseV1.Use(middleware.AuthMiddleware(jwtCfg))
|
||||
registerEnterpriseRoutes(enterpriseV1, handlers)
|
||||
|
||||
// Platform API routes — super admin only (ref: Chatwoot namespace :platform_app)
|
||||
// Chatwoot uses a single /platform/api/v1 prefix with AccessTokenable concern in controller layer
|
||||
// for auth differentiation. We merge SuperAdmin and AccessToken routes into one group.
|
||||
@@ -564,6 +571,21 @@ func RegisterRoutes(
|
||||
engine.GET("/cable", wsHandler.ServeCable) // ActionCable-compatible endpoint (Chatwoot convention)
|
||||
}
|
||||
|
||||
func registerEnterpriseRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
if h == nil || h.EnterpriseAccount == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accounts := g.Group("/accounts")
|
||||
{
|
||||
accounts.POST("/:account_id/checkout", h.EnterpriseAccount.Checkout)
|
||||
accounts.POST("/:account_id/subscription", h.EnterpriseAccount.Subscription)
|
||||
accounts.GET("/:account_id/limits", h.EnterpriseAccount.Limits)
|
||||
accounts.POST("/:account_id/toggle_deletion", h.EnterpriseAccount.ToggleDeletion)
|
||||
accounts.POST("/:account_id/topup_checkout", h.EnterpriseAccount.TopupCheckout)
|
||||
}
|
||||
}
|
||||
|
||||
// registerV1Routes maps all API v1 resource routes.
|
||||
// Reference: Chatwoot routes.rb namespace :api, scope :v1
|
||||
func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -18,6 +19,8 @@ type AccountService struct {
|
||||
repo *repository.AccountRepo
|
||||
}
|
||||
|
||||
const chatwootMaxLimit = 100000
|
||||
|
||||
// NewAccountService creates a new Account service.
|
||||
func NewAccountService(repo *repository.AccountRepo) *AccountService {
|
||||
return &AccountService{repo: repo}
|
||||
@@ -33,6 +36,11 @@ func (s *AccountService) GetByID(ctx context.Context, id uint) (*model.Account,
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
// GetByUserAndID retrieves an account only when the user belongs to it.
|
||||
func (s *AccountService) GetByUserAndID(ctx context.Context, userID, accountID uint) (*model.Account, error) {
|
||||
return s.repo.FindByUserAndID(ctx, userID, accountID)
|
||||
}
|
||||
|
||||
// CreateAccountRequest is the DTO for creating an account.
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
|
||||
@@ -293,3 +301,202 @@ func (s *AccountService) CacheKeys(ctx context.Context, accountID, userID uint)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// EnterpriseLimits returns the Chatwoot enterprise account limit payload consumed
|
||||
// by EnterpriseAccountAPI.getLimits in the reused dashboard frontend.
|
||||
func (s *AccountService) EnterpriseLimits(ctx context.Context, accountID, userID uint) (map[string]any, error) {
|
||||
account, err := s.repo.FindByUserAndID(ctx, userID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
agentsConsumed, err := s.repo.CountUsersByAccount(ctx, account.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
documentsConsumed, err := s.repo.CountCaptainDocumentsByAccount(ctx, account.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limits := map[string]any{
|
||||
"conversation": map[string]any{},
|
||||
"non_web_inboxes": map[string]any{},
|
||||
"agents": map[string]any{
|
||||
"allowed": accountUsageLimit(account.AgentLimit),
|
||||
"consumed": agentsConsumed,
|
||||
},
|
||||
"captain": captainUsageLimits(account, documentsConsumed),
|
||||
}
|
||||
|
||||
if accountDefaultCloudPlan(account) {
|
||||
conversationConsumed, err := s.repo.CountConversationsSince(ctx, account.ID, time.Now().AddDate(0, 0, -30))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonWebConsumed, err := s.repo.CountNonWebInboxes(ctx, account.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limits = map[string]any{
|
||||
"conversation": map[string]any{
|
||||
"allowed": 500,
|
||||
"consumed": conversationConsumed,
|
||||
},
|
||||
"non_web_inboxes": map[string]any{
|
||||
"allowed": 0,
|
||||
"consumed": nonWebConsumed,
|
||||
},
|
||||
"agents": map[string]any{
|
||||
"allowed": 2,
|
||||
"consumed": agentsConsumed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{"id": account.ID, "limits": limits}, nil
|
||||
}
|
||||
|
||||
// MarkForDeletion mirrors Enterprise::Account#mark_for_deletion by storing the
|
||||
// scheduled deletion timestamp and reason in account custom_attributes.
|
||||
func (s *AccountService) MarkForDeletion(ctx context.Context, accountID, userID uint, reason string) (*model.Account, error) {
|
||||
account, err := s.repo.FindByUserAndID(ctx, userID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attrs := account.CustomAttributesMap()
|
||||
if reason != "manual_deletion" {
|
||||
reason = "inactivity"
|
||||
}
|
||||
attrs["marked_for_deletion_at"] = time.Now().AddDate(0, 0, 7).Format(time.RFC3339)
|
||||
attrs["marked_for_deletion_reason"] = reason
|
||||
if err := account.SetCustomAttributesMap(attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.Update(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// UnmarkForDeletion removes Chatwoot's scheduled deletion custom attributes.
|
||||
func (s *AccountService) UnmarkForDeletion(ctx context.Context, accountID, userID uint) (*model.Account, error) {
|
||||
account, err := s.repo.FindByUserAndID(ctx, userID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attrs := account.CustomAttributesMap()
|
||||
delete(attrs, "marked_for_deletion_at")
|
||||
delete(attrs, "marked_for_deletion_reason")
|
||||
if err := account.SetCustomAttributesMap(attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.Update(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// EnsureEnterpriseAccountCustomerCreationFlag tracks the Cloud subscription side
|
||||
// effect that prevents duplicate Stripe customer creation jobs in Chatwoot.
|
||||
func (s *AccountService) EnsureEnterpriseAccountCustomerCreationFlag(ctx context.Context, accountID, userID uint) error {
|
||||
account, err := s.repo.FindByUserAndID(ctx, userID, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attrs := account.CustomAttributesMap()
|
||||
if attrs["stripe_customer_id"] != nil || attrs["is_creating_customer"] != nil {
|
||||
return nil
|
||||
}
|
||||
attrs["is_creating_customer"] = true
|
||||
if err := account.SetCustomAttributesMap(attrs); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Update(ctx, account)
|
||||
}
|
||||
|
||||
func accountUsageLimit(limit int) int {
|
||||
if limit > 0 {
|
||||
return limit
|
||||
}
|
||||
return chatwootMaxLimit
|
||||
}
|
||||
|
||||
func accountJSONMap(raw []byte) map[string]any {
|
||||
out := map[string]any{}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func accountDefaultCloudPlan(account *model.Account) bool {
|
||||
attrs := account.CustomAttributesMap()
|
||||
if value, ok := attrs["default_plan"]; ok {
|
||||
if enabled, ok := value.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func captainUsageLimits(account *model.Account, documentsConsumed int64) map[string]any {
|
||||
limits := accountJSONMap(account.Limits)
|
||||
attrs := account.CustomAttributesMap()
|
||||
documentsAllowed := intFromAccountMap(limits, "captain_documents", chatwootMaxLimit)
|
||||
responsesAllowed := intFromAccountMap(limits, "captain_responses", chatwootMaxLimit)
|
||||
responsesConsumed := intFromAccountMap(attrs, "captain_responses_usage", 0)
|
||||
if docsAttr, ok := optionalIntFromAccountMap(attrs, "captain_documents_usage"); ok {
|
||||
documentsConsumed = int64(docsAttr)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"documents": captainLimitBlock(documentsAllowed, int(documentsConsumed)),
|
||||
"responses": captainLimitBlock(responsesAllowed, responsesConsumed),
|
||||
}
|
||||
}
|
||||
|
||||
func captainLimitBlock(total, consumed int) map[string]any {
|
||||
if consumed < 0 {
|
||||
consumed = 0
|
||||
}
|
||||
available := total - consumed
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
if available > total {
|
||||
available = total
|
||||
}
|
||||
return map[string]any{"total_count": total, "current_available": available, "consumed": consumed}
|
||||
}
|
||||
|
||||
func intFromAccountMap(values map[string]any, key string, fallback int) int {
|
||||
if value, ok := optionalIntFromAccountMap(values, key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func optionalIntFromAccountMap(values map[string]any, key string) (int, bool) {
|
||||
value, ok := values[key]
|
||||
if !ok || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
n, err := v.Int64()
|
||||
return int(n), err == nil
|
||||
case string:
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user