feat(accounts): align onboarding update

This commit is contained in:
2026-06-06 21:18:38 +08:00
parent 3aa21996f6
commit 4736d67839
11 changed files with 254 additions and 22 deletions
+83 -13
View File
@@ -154,6 +154,32 @@ func (h *AccountHandler) Update(c *gin.Context) {
c.JSON(http.StatusOK, serializeAccount(account))
}
// UpdateOnboarding updates account details from the dashboard onboarding flow.
// PATCH /api/v1/accounts/:account_id/onboarding
// Reference: Chatwoot Api::V1::Accounts::OnboardingsController#update.
func (h *AccountHandler) UpdateOnboarding(c *gin.Context) {
id := parseAccountIDParam(c)
if id == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
var req service.UpdateAccountOnboardingRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
account, err := h.svc.UpdateOnboarding(c.Request.Context(), id, req)
if err != nil {
applogger.L().Errorf("Update onboarding account: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update account onboarding")
return
}
c.JSON(http.StatusOK, serializeAccount(account))
}
// @Summary Delete an account
// @Description Soft-deletes an account by ID
// @Tags Accounts
@@ -361,23 +387,67 @@ func serializeAccount(account *model.Account) map[string]any {
if account == nil {
return map[string]any{}
}
customAttributes := serializeAccountCustomAttributes(account)
return map[string]any{
"settings": serializeAccountSettings(account),
"created_at": account.CreatedAt,
"domain": account.Domain,
"features": parseAccountFeatures(account.FeatureFlags),
"id": account.ID,
"locale": nonEmpty(account.Locale, "en"),
"name": account.Name,
"support_email": nil,
"status": nonEmpty(account.Status, "active"),
"cache_keys": map[string]string{"label": "0000000000", "inbox": "0000000000", "team": "0000000000"},
"custom_attributes": map[string]any{
"timezone": account.Timezone,
},
"settings": serializeAccountSettings(account),
"created_at": account.CreatedAt,
"domain": account.Domain,
"features": parseAccountFeatures(account.FeatureFlags),
"id": account.ID,
"locale": nonEmpty(account.Locale, "en"),
"name": account.Name,
"support_email": nil,
"status": nonEmpty(account.Status, "active"),
"cache_keys": map[string]string{"label": "0000000000", "inbox": "0000000000", "team": "0000000000"},
"custom_attributes": customAttributes,
}
}
func serializeAccountCustomAttributes(account *model.Account) map[string]any {
attrs := account.CustomAttributesMap()
out := map[string]any{
"plan_name": attrs["plan_name"],
"subscribed_quantity": attrs["subscribed_quantity"],
"subscription_status": attrs["subscription_status"],
"subscription_ends_on": attrs["subscription_ends_on"],
}
copyPresentAttribute(out, attrs, "website")
copyPresentAttribute(out, attrs, "industry")
copyPresentAttribute(out, attrs, "company_size")
copyPresentAttribute(out, attrs, "timezone")
copyPresentAttribute(out, attrs, "logo")
copyPresentAttribute(out, attrs, "referral_source")
copyPresentAttribute(out, attrs, "brand_info")
if account.OnboardingStep != "" {
out["onboarding_step"] = account.OnboardingStep
} else if isPresent(attrs["onboarding_step"]) {
out["onboarding_step"] = attrs["onboarding_step"]
}
copyPresentAttribute(out, attrs, "marked_for_deletion_at")
copyPresentAttribute(out, attrs, "marked_for_deletion_reason")
if _, ok := out["timezone"]; !ok && account.Timezone != "" {
out["timezone"] = account.Timezone
}
return out
}
func copyPresentAttribute(out map[string]any, attrs map[string]any, key string) {
if value, ok := attrs[key]; ok && isPresent(value) {
out[key] = value
}
}
func isPresent(value any) bool {
if value == nil {
return false
}
if s, ok := value.(string); ok {
return s != ""
}
return true
}
func serializeAccountSettings(account *model.Account) map[string]any {
settings := map[string]any{
"auto_resolve_after": account.AutoResolveDuration,
@@ -83,6 +83,7 @@ func (s *AccountHandlerTestSuite) SetupSuite() {
accountsGroup.GET("/:account_id", s.handler.Get)
accountsGroup.POST("", s.handler.Create)
accountsGroup.PUT("/:account_id", s.handler.Update)
accountsGroup.PATCH("/:account_id/onboarding", s.handler.UpdateOnboarding)
accountsGroup.DELETE("/:account_id", s.handler.Delete)
accountsGroup.PUT("/:account_id/settings", s.handler.UpdateSettings)
accountsGroup.GET("/:account_id/agents", s.handler.GetAgents)
@@ -272,6 +273,73 @@ func (s *AccountHandlerTestSuite) TestUpdate_Success() {
assert.Equal(s.T(), "After Update", resp["name"])
}
func (s *AccountHandlerTestSuite) TestUpdateOnboarding_FinalizesAccountDetails() {
acc := &model.Account{Name: "Before Onboarding", Active: true, Status: "active", OnboardingStep: "account_details"}
s.Require().NoError(acc.SetCustomAttributesMap(map[string]any{
"onboarding_step": "account_details",
"brand_info": map[string]any{"brand_name": "Before"},
}))
s.Require().NoError(s.db.Create(acc).Error)
body := `{"name":"After Onboarding","locale":"pt","website":"https://acme.test","industry":"software","company_size":"51-200","timezone":"Asia/Shanghai","referral_source":"friend","user_role":"founder"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/onboarding", acc.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
resp := s.unmarshalResponse(w)
assert.NotContains(s.T(), resp, "success")
assert.Equal(s.T(), "After Onboarding", resp["name"])
assert.Equal(s.T(), "pt", resp["locale"])
customAttrs := resp["custom_attributes"].(map[string]interface{})
assert.Equal(s.T(), "https://acme.test", customAttrs["website"])
assert.Equal(s.T(), "software", customAttrs["industry"])
assert.Equal(s.T(), "51-200", customAttrs["company_size"])
assert.Equal(s.T(), "Asia/Shanghai", customAttrs["timezone"])
assert.Equal(s.T(), "friend", customAttrs["referral_source"])
assert.Contains(s.T(), customAttrs, "brand_info")
assert.NotContains(s.T(), customAttrs, "onboarding_step")
assert.NotContains(s.T(), customAttrs, "user_role")
var stored model.Account
require.NoError(s.T(), s.db.First(&stored, acc.ID).Error)
assert.Empty(s.T(), stored.OnboardingStep)
storedAttrs := stored.CustomAttributesMap()
assert.NotContains(s.T(), storedAttrs, "onboarding_step")
assert.Equal(s.T(), "founder", storedAttrs["user_role"])
assert.Equal(s.T(), "Asia/Shanghai", stored.Timezone)
}
func (s *AccountHandlerTestSuite) TestUpdateOnboarding_PreservesNonFinalizingAttributes() {
acc := &model.Account{Name: "Onboarding Merge", Active: true, Status: "active"}
s.Require().NoError(acc.SetCustomAttributesMap(map[string]any{
"onboarding_step": "invite_team",
"logo": "logo-id",
}))
s.Require().NoError(s.db.Create(acc).Error)
body := `{"industry":"healthcare"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/onboarding", acc.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
resp := s.unmarshalResponse(w)
customAttrs := resp["custom_attributes"].(map[string]interface{})
assert.Equal(s.T(), "invite_team", customAttrs["onboarding_step"])
assert.Equal(s.T(), "logo-id", customAttrs["logo"])
assert.Equal(s.T(), "healthcare", customAttrs["industry"])
var stored model.Account
require.NoError(s.T(), s.db.First(&stored, acc.ID).Error)
storedAttrs := stored.CustomAttributesMap()
assert.Equal(s.T(), "invite_team", storedAttrs["onboarding_step"])
assert.Equal(s.T(), "logo-id", storedAttrs["logo"])
assert.Equal(s.T(), "healthcare", storedAttrs["industry"])
}
// ====== Delete Account ======
func (s *AccountHandlerTestSuite) TestDelete_Success() {