feat(agents): align bulk invite behavior

This commit is contained in:
2026-06-06 01:33:45 +08:00
parent 1b76f8c166
commit bd1520f4b1
3 changed files with 57 additions and 9 deletions
+6 -4
View File
@@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
## Current Baseline
- Current tracking checkpoint: 2026-06-06 after `c76c25f feat(contacts): align active pagination payloads`, with this implementation checkpoint prepared as `feat(custom-roles): align update route semantics`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(custom-roles): align update route semantics`.
- Current tracking checkpoint: 2026-06-06 after `1b76f8c feat(custom-roles): align update route semantics`, with this implementation checkpoint prepared as `feat(agents): align bulk invite behavior`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(agents): align bulk invite behavior`.
- Latest documentation-only checkpoint: `8b378c7 docs: refresh parity tracker baseline`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: B10.3 CustomRole frontend update drift is closed for the reused enterprise settings client: `PATCH /custom_roles/:id` is now registered alongside `PUT`, matching `ApiClient.update`, and custom-role updates can clear `description` while preserving omitted-field behavior. The preceding Phase 6 contacts pagination/active payload drift is closed. Conversation transcript and custom-attribute response drifts are already closed. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence.
- Worktree status at this implementation checkpoint: P3.2 agents bulk invite drift is closed for the reused onboarding/settings client: `POST /agents/bulk_create` now skips invalid email entries like Chatwoot's rescued `AgentBuilder` loop, returns empty `200 OK` for empty/no-op arrays, creates valid agents from the same request, and clears the local onboarding step after the bulk flow. The preceding B10.3 CustomRole frontend update drift and Phase 6 contacts pagination/active payload drift are closed. Conversation transcript and custom-attribute response drifts are already closed. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 838` after adding the Chatwoot custom-role `PATCH /api/v1/accounts/:account_id/custom_roles/:id` update route.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -143,6 +143,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(agents): align bulk invite behavior` | Advances P3.2 agents/onboarding parity by aligning `POST /api/v1/accounts/:account_id/agents/bulk_create` with Chatwoot `AgentsController#bulk_create`. Bulk invite now treats the email list as a best-effort loop: invalid entries are skipped instead of failing the whole request, duplicate failures remain ignored, empty arrays return empty `200 OK`, valid agents in the same request are still created, and the local onboarding step is cleared after the bulk flow. | `go test ./internal/handler/api/v1 -run AgentHandler -count=1`; `go test ./internal/service -run Agent -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | Continue P3.2 account users/invitations/permissions drift or the next Phase 6 reference/smoke finding. |
| `feat(custom-roles): align update route semantics` | Advances B10.3 CustomRole frontend parity by registering Chatwoot's Rails resource `PATCH /api/v1/accounts/:account_id/custom_roles/:id` update route used by the reused `ApiClient.update` path, while keeping the existing `PUT` alias. Custom role updates now distinguish omitted `description` from an explicit empty string, so the enterprise settings form can clear descriptions like `CustomRolesController#update` with strong params. | `go test ./internal/handler/api/v1 -run CustomRole -count=1`; `go test ./internal/service -run CustomRole -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | Continue the next named Phase 2/3 or Phase 6 drift from reference/smoke evidence. |
| `feat(contacts): align active pagination payloads` | Advances Phase 6 dashboard CRM drift by aligning contact list/search/filter/active pagination with Chatwoot `ContactsController::RESULTS_PER_PAGE = 15` and `contacts/active.json.jbuilder`. The reused dashboard active-contacts route now returns `{ meta: { count, current_page }, payload }` using the contact serializer instead of the local `{ contacts, meta: { page, page_size } }` envelope, and selected-key `POST /contacts/:id/destroy_custom_attributes` is covered against Chatwoot's `{ payload }` response with `contact_inboxes`. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUD' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | Continue the next named Phase 2/3 or Phase 6 drift from reference/smoke evidence. |
| `feat(conversations): align custom attribute response` | Advances Phase 6 dashboard conversation drift by aligning `POST /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes` with Chatwoot `custom_attributes.json.jbuilder` and the reused conversation store action. The endpoint still persists the submitted custom attributes, but the response is now only `{ custom_attributes: ... }` and no longer leaks the full local conversation serializer, `id`, or local envelope fields. | `go test ./internal/handler/api/v1 -run 'TestConversationHandlerTestSuite/TestUpdateCustomAttributes' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | Continue the next named Phase 2/3 or Phase 6 drift from reference/smoke evidence. |
@@ -1558,7 +1559,7 @@ Frontend-critical API groups to audit first:
| ID | Area | Scope | Status |
| --- | --- | --- | --- |
| P3.1 | Auth/session/profile | Login, logout, current user, profile, availability, notification settings, Devise password reset, and confirmation. | Done |
| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member and assignable-agent settings payloads now have focused Chatwoot-style coverage. |
| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member, assignable-agent settings payloads, and agents bulk invite best-effort behavior now have focused Chatwoot-style coverage. |
| P3.3 | Inboxes/channels | Inbox CRUD, assignable agents, avatars, channel config, business hours, widget config. | Review; assignable-agent payload drift closed, broader inbox serializer drift remains evidence-driven. |
| P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review |
| P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation, and shared attachments. | Doing |
@@ -2072,6 +2073,7 @@ Verification milestone gates:
## Progress Log
- 2026-06-06: P3.2 agents bulk invite checkpoint prepared as `feat(agents): align bulk invite behavior`; audited Chatwoot `AgentsController#bulk_create`, dashboard `agents.js`, and the reused onboarding/settings bulk invite path. Bulk invite now skips invalid email entries instead of rejecting the full request, keeps duplicate failures best-effort, creates valid agents from mixed arrays, returns empty `200 OK` for empty/no-op arrays, and clears the local onboarding step after the bulk flow. Focused handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed; continue P3.2 account users/invitations drift next.
- 2026-06-06: B10.3 CustomRole update-route checkpoint prepared as `feat(custom-roles): align update route semantics`; audited Chatwoot `CustomRolesController`, custom role Jbuilder views, and dashboard `customRole.js`/`ApiClient.update`. CustomRole update now exposes `PATCH /api/v1/accounts/:account_id/custom_roles/:id` alongside the existing `PUT` alias, route dump is `TOTAL: 838`, tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`, and explicit empty `description` values now clear the role description while omitted descriptions are preserved. Focused handler/service tests, route generation/parity, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit next.
- 2026-06-06: Phase 6 contacts active/pagination checkpoint prepared as `feat(contacts): align active pagination payloads`; audited Chatwoot `ContactsController`, `contacts/index.json.jbuilder`, `contacts/search.json.jbuilder`, `contacts/filter.json.jbuilder`, `contacts/active.json.jbuilder`, `contacts/destroy_custom_attributes.json.jbuilder`, and dashboard `contacts.js`. Contact list/search/filter/active now use Chatwoot's fixed `RESULTS_PER_PAGE = 15`; active contacts now return `{ meta: { count, current_page }, payload }` through the contact serializer instead of the local `{ contacts, meta: { page, page_size } }` envelope; selected-key `destroy_custom_attributes` payload coverage confirms the Chatwoot `{ payload }` response and `contact_inboxes` inclusion. Focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next.
- 2026-06-06: P3.6 label CRUD checkpoint prepared as `feat(labels): align chatwoot label payloads`; audited Chatwoot `LabelsController`, label Jbuilder views, `Label` model/schema, and dashboard labels API/store. Account `/labels` list now returns `{ payload }`, create/show/update return raw label payloads, create/update accept nested `{ label }` bodies plus legacy raw `name`, title values are normalized/lowercased, color defaults to `#1f93ff`, `description` and `show_on_sidebar` are persisted, account-scoped get/update/delete prevents cross-account access, and delete returns empty `200 OK` while removing conversation/contact label associations and legacy conversation label text. Focused label handler/service/repository tests, combined handler/service/router tests, migration tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next.
+24 -1
View File
@@ -272,7 +272,30 @@ func (s *AgentHandlerTestSuite) TestBulkCreateValidation() {
}
w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID)
s.handler.BulkCreate(c)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
}
func (s *AgentHandlerTestSuite) TestBulkCreateSkipsInvalidEmailsAndClearsOnboardingStep() {
s.Require().NoError(s.db.Model(s.account).Update("onboarding_step", "invite_team").Error)
req := service.BulkCreateAgentRequest{
Emails: []string{"valid-bulk@test.com", "invalid-email"},
}
w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID)
s.handler.BulkCreate(c)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
var validUser model.User
s.Require().NoError(s.db.Where("email = ?", "valid-bulk@test.com").First(&validUser).Error)
var invalidCount int64
s.Require().NoError(s.db.Model(&model.User{}).Where("email = ?", "invalid-email").Count(&invalidCount).Error)
assert.Equal(s.T(), int64(0), invalidCount)
var account model.Account
s.Require().NoError(s.db.First(&account, s.account.ID).Error)
assert.Equal(s.T(), "", account.OnboardingStep)
}
func (s *AgentHandlerTestSuite) TestBulkCreateSkipsDuplicates() {
+27 -4
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"net/mail"
"strings"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -75,7 +77,7 @@ func (r UpdateAgentRequest) CustomRoleIDSet() bool { return r.customRoleSet }
// BulkCreateAgentRequest is the DTO for bulk creating agents.
// Reference: Chatwoot agents_controller.rb#bulk_create → params[:emails]
type BulkCreateAgentRequest struct {
Emails []string `json:"emails" validate:"required,min=1,dive,email"`
Emails []string `json:"emails"`
}
// List retrieves all agents for an account with pagination.
@@ -138,11 +140,32 @@ func (s *AgentService) Delete(ctx context.Context, userID, accountID uint) error
// Reference: Chatwoot agents_controller.rb#bulk_create — iterates emails, creates AgentBuilder for each.
// Silently skips emails that fail (duplicate, etc.).
func (s *AgentService) BulkCreate(ctx context.Context, accountID uint, inviterID uint, req BulkCreateAgentRequest) ([]repository.AgentDetail, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, fmt.Errorf("validation: %w", err)
emails := make([]string, 0, len(req.Emails))
for _, email := range req.Emails {
if isValidBulkAgentEmail(email) {
emails = append(emails, strings.TrimSpace(email))
}
}
return s.agentRepo.BulkCreateAgents(ctx, accountID, inviterID, req.Emails)
created, err := s.agentRepo.BulkCreateAgents(ctx, accountID, inviterID, emails)
if err != nil {
return nil, err
}
if s.db != nil {
if err := s.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", accountID).Update("onboarding_step", "").Error; err != nil {
return created, fmt.Errorf("clear onboarding step: %w", err)
}
}
return created, nil
}
func isValidBulkAgentEmail(email string) bool {
email = strings.TrimSpace(email)
if email == "" {
return false
}
addr, err := mail.ParseAddress(email)
return err == nil && addr.Address == email
}
// AvailableAgentCount returns the number of additional agents that can be added to an account.