feat(profile): send confirmation invitations

This commit is contained in:
2026-06-06 12:13:56 +08:00
parent 34fdb077be
commit 682d0ab78b
11 changed files with 512 additions and 30 deletions
+14 -14
View File
@@ -49,10 +49,10 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 documentation checkpoint, prepared as `docs: land invitation parity tracker`.
- Latest implementation checkpoint: `2acd938 feat(account-users): align inviter id`.
- Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: land invitation parity tracker`; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this documentation checkpoint: clean before docs edits. P3.2 account-user invitation column drift from Chatwoot `account_users.inviter_id`, `AgentBuilder`, `_user.json.jbuilder`, and enterprise audit metadata is implemented. GoChat now persists AccountUser invitation source through the Chatwoot `inviter_id` column/JSON name, migrates existing `invited_by` values forward, keeps agent serializers from leaking inviter fields, and preserves profile `inviter_id` output through the existing `_user` serializer. The next active implementation slice is P3.2a invitation and confirmation mail parity: `ProfileService.ResendConfirmation` still only logs, and invited unconfirmed users need Chatwoot-shaped confirmation/invitation delivery behavior. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Current tracking checkpoint: 2026-06-06 implementation checkpoint, prepared as `feat(profile): send confirmation invitations`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(profile): send confirmation invitations`.
- Latest documentation/tooling checkpoint: `34fdb07 docs: land invitation parity tracker`; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: P3.2a invitation/confirmation mail parity is implemented. `ProfileService.ResendConfirmation` now generates confirmation or invitation tokens and calls a fakeable mailer; invited unconfirmed users with `inviter_id` receive workspace invitation copy with account/inviter context and an `Accept invitation` reset-password action; normal unconfirmed users receive confirmation copy; confirmed users remain a no-op. Agent creation now sends the same invitation mail for newly created invited users, and the app bootstrap wires the account agent handler plus environment-backed mailers. SSO/SAML/LDAP/OIDC mail variants remain excluded. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active slice is Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or a fresh reference/smoke finding.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 905` after removing the non-Chatwoot `GET /platform/api/v1/users/:id/token` route and keeping the Chatwoot `GET /platform/api/v1/users/:id/login` route.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -78,19 +78,17 @@ Next ordered checkpoints:
| Order | Slice | Required outcome | Primary verification |
| --- | --- | --- | --- |
| 1 | P3.2a invitation/confirmation mail parity | `ProfileService.ResendConfirmation` sends or queues Chatwoot-shaped confirmation mail, including invitation wording for users with `inviter_id`; SSO/SAML/OIDC variants remain excluded. | Focused profile/agent invitation mail tests plus full `go test ./...`. |
| 2 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
| 3 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
| 4 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. |
| 1 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
| 2 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
| 3 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. |
Execution queue for the next agent turn:
| Order | Slice ID | Why now | Required commit contents |
| --- | --- | --- | --- |
| 1 | P3.2a invitation/confirmation mail parity | Fresh reference audit found the next concrete reused-frontend/account-user gap: confirmation resend is a TODO-only log, while Chatwoot sends invitation-style confirmation mail for invited unconfirmed users. | Mail delivery boundary, profile resend behavior, focused tests, tracker update, and no route artifacts unless route wiring changes. |
| 2 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. |
| 3 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. |
| 4 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. |
| 1 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. |
| 2 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. |
| 3 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. |
Slice lifecycle:
@@ -140,7 +138,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 1 | P3.2a invitation/confirmation mail parity | Account-user inviter persistence is implemented, but `ProfileService.ResendConfirmation` still logs instead of delivering confirmation mail. Chatwoot sends invitation wording for invited unconfirmed users with blank `unconfirmed_email`; normal unconfirmed users get confirmation wording. Enterprise SAML/OIDC variants are explicitly out of scope. | Implement a fakeable mail delivery boundary and wire resend/create invitation paths to Chatwoot-shaped confirmation/invitation email payloads. | Focused profile and agent invitation tests cover confirmed no-op, normal confirmation, invited workspace invitation, inviter/account wording, and excluded SSO variants; full `go test ./...` and `git diff --check` pass. |
| 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. |
| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 362-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.2a or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
| 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. |
| 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
@@ -157,7 +155,7 @@ These rows are the executable development plan from this point forward. A checkp
| ID | Owner files | Reference files | Work to land | Exit gate |
| --- | --- | --- | --- | --- |
| P3.2a invitation/confirmation mail parity | `internal/service/profile_service.go`, `internal/handler/api/v1/profile_handler.go`, `internal/service/agent_service.go`, `internal/repository/agent_repo.go`, existing fakeable mail delivery boundaries, profile/agent handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/agents_controller.rb`, `reference/chatwoot/app/builders/agent_builder.rb`, `reference/chatwoot/app/views/devise/mailer/confirmation_instructions.html.erb`, `reference/chatwoot/spec/mailers/confirmation_instructions_spec.rb`, `reference/chatwoot/spec/enterprise/mailers/devise_mailer_spec.rb` for non-SAML invitation wording only | Replace TODO-only resend behavior with durable or fakeable confirmation mail delivery. For confirmed users, resend remains a no-op. For ordinary unconfirmed users, send confirmation-style mail. For invited unconfirmed users whose account membership has `inviter_id` and whose `unconfirmed_email` is blank, send workspace invitation-style confirmation mail with account and inviter context plus `Accept invitation` action text. Keep enterprise SSO/SAML/LDAP/OIDC mail variants out of scope per user decision. | Review by `feat(profile): send confirmation invitations`; focused tests cover resend no-op for confirmed users, normal confirmation mail, invited-user workspace invitation mail, agent creation/inviter context where applicable, and no network in default tests; full `go test ./...` and `git diff --check` pass. |
| P3.2a invitation/confirmation mail parity | `internal/service/profile_service.go`, `internal/service/profile_confirmation_mailer.go`, `internal/handler/api/v1/profile_handler.go`, `internal/service/agent_service.go`, `internal/repository/agent_repo.go`, `internal/model/user.go`, `migrations/000032_add_users_unconfirmed_email.*`, profile/agent handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/agents_controller.rb`, `reference/chatwoot/app/builders/agent_builder.rb`, `reference/chatwoot/app/views/devise/mailer/confirmation_instructions.html.erb`, `reference/chatwoot/spec/mailers/confirmation_instructions_spec.rb`, `reference/chatwoot/spec/enterprise/mailers/devise_mailer_spec.rb` for non-SAML invitation wording only | Done. A shared fakeable confirmation mailer builds Chatwoot-shaped confirmation/invitation payloads; profile resend persists confirmation/reset tokens and delivers no-op/confirmation/invitation states; newly created invited agents get workspace invitation mail; `unconfirmed_email` is modeled for email-update branch routing; environment SMTP remains a no-op when not configured. SSO/SAML/LDAP/OIDC mail variants stay excluded. | Review by `feat(profile): send confirmation invitations`; focused tests cover confirmed no-op, normal confirmation mail, invited workspace invitation mail, agent creation/inviter context, hashed reset-token persistence, and no network in default tests; full `go test ./...` and `git diff --check` passed. |
| P5.11a Captain document crawl/schedule | `internal/service/captain_document_service.go`, `internal/service/captain_document_worker.go`, `internal/app/bootstrap.go` | `reference/chatwoot/enterprise/app/jobs/captain/documents/crawl_job.rb`, `schedule_syncs_job.rb`, `perform_sync_job.rb`, Firecrawl/simple parser jobs | Durable schedule/crawl producers and handlers with fakeable crawl/parser boundaries. Missing provider config is a failed `crawl_disabled` state, not placeholder success. | Review by `feat(captain): queue document crawl jobs`; focused worker tests prove enqueue, replay, account scope, idempotent scheduler, and disabled/failure states. |
| P5.11b Captain response/embedding fan-out | Captain document/assistant-response services and repositories, Meilisearch/embedding boundaries | `response_builder_job.rb`, `enterprise/app/jobs/captain/llm/update_embedding_job.rb`, FAQ generator/embedding services | Queue FAQ response generation after successful document content changes, reset unedited responses, create/update assistant responses, and fan out embedding update work behind fakeable LLM gates. | Review by `feat(captain): queue response embedding jobs`; tests cover response reset/create, embedding-disabled retry, fake embedding success, account scope, and no external network in default tests. |
| P5.11c Copilot and conversation response jobs | `internal/service/copilot_service.go`, `internal/service/copilot_response_worker.go`, `internal/service/captain_conversation_service.go`, `internal/service/message_service.go`, `internal/app/bootstrap.go` | `enterprise/app/jobs/captain/copilot/response_job.rb`, `enterprise/app/jobs/captain/conversation/response_builder_job.rb`, `enterprise/app/services/captain/copilot/chat_service.rb`, `enterprise/app/services/enterprise/message_templates/hook_execution_service.rb`, `enterprise/app/models/copilot_message.rb` | Queue assistant replies after Copilot user messages and Captain pending-conversation triggers. Persist assistant messages, enqueue Captain conversation replies/handoff messages, open handoff conversations, and keep fakeable provider disabled/failure states observable through durable retry. | Review by `feat(captain): queue copilot response jobs`; focused tests cover Copilot enqueue/persist/fallback/retry, Captain conversation enqueue/handoff/retry/non-pending skip, and service/worker/app package replay. |
@@ -213,6 +211,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(profile): send confirmation invitations` | Advances P3.2a invitation/confirmation mail parity by matching Chatwoot `AgentBuilder`, Devise confirmation instructions view/specs, and profile resend controller behavior for non-SSO flows. GoChat now has a fakeable/environment SMTP confirmation mail boundary; profile resend persists confirmation or reset-password tokens and sends normal confirmation or workspace invitation payloads; confirmed resends remain no-op; newly created invited agents send invitation mail; `users.unconfirmed_email` is modeled for email-update branch routing; and bootstrap wires the account agent handler plus confirmation mailers. | `go test ./internal/handler/api/v1 -run 'ProfileHandlerSuite/TestResendConfirmation\|AgentHandlerSuite/TestCreateAgent' -count=1`; `go test ./internal/service ./internal/repository ./cmd/migrate ./internal/app -run 'Profile\|Agent\|App\|Migrate' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/migrate ./internal/app -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | P3.2a moves to Review for current non-SSO reference evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `docs: land invitation parity tracker` | Documentation-only checkpoint requested before continuing implementation. It records the clean `2acd938 feat(account-users): align inviter id` baseline, promotes P3.2a invitation/confirmation mail parity to the top active slice, maps the inspected Chatwoot Devise/AgentBuilder references into an executable row, and keeps SSO/SAML/LDAP/OIDC mail variants excluded. | `git diff --check`. No Go code changed and no route artifacts changed. | Start `feat(profile): send confirmation invitations`, then continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from concrete reference/smoke evidence. |
| `feat(account-users): align inviter id` | Advances P3.2 invitation/account-user parity by matching Chatwoot `account_users.inviter_id`, `AgentBuilder#create_account_user`, `app/views/api/v1/models/_user.json.jbuilder`, and enterprise `Audit::AccountUser`. GoChat now maps `model.AccountUser.InvitedBy` to the Chatwoot `inviter_id` column and JSON name, emits account-user lifecycle payloads with `inviter_id`, adds migration `000031` to copy existing `invited_by` values forward, and keeps account agent serializers free of inviter fields. | `go test ./internal/model -run AccountUserDefaultValues -count=1`; `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestCreateAgent' -count=1`; `go test ./internal/service ./internal/repository -run 'AccountUser\|Agent' -count=1`; `go test ./cmd/migrate ./internal/app -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | AccountUser inviter persistence moves to Review for current reference evidence; continue remaining P3.2 invitation mail/confirmation drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. |
| `feat(profile): expose hmac identifier` | Advances P3.1 profile `_user` serializer parity by matching Chatwoot `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `spec/models/user_spec.rb`, and reused frontend `scriptHelpers.js`. GoChat profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is not configured and include the SHA-256 HMAC of the user email keyed by that installation config when present. | `go test ./internal/handler/api/v1 -run 'ProfileHandlerSuite/TestGet' -count=1`; `go test ./internal/service -run Profile -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | Profile `_user` HMAC field moves to Review for current reference evidence; continue remaining P3.2 invitations drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. |
@@ -2379,3 +2378,4 @@ Verification milestone gates:
- 2026-06-06: Placeholder/smoke audit checkpoint prepared as `docs: record placeholder smoke audit`; `docs/parity/placeholder_audit.md` now records the `chatwootParityStub` audit and classifies all remaining call sites as public webhook nil-handler guards, with no reused dashboard account/contact/conversation/message/inbox/widget/public critical-path stubs. `scripts/parity_frontend_smoke.sh --check` passed and refreshed `docs/parity/frontend_smoke_report.md`; live API/browser/enterprise smoke remains pending on the full external stack. Continue B12 live smoke when available or the next evidence-backed P2/P3 drift.
- 2026-06-06: P3.2 agent serializer-field checkpoint prepared as `feat(agents): align serializer fields`; audited Chatwoot `AgentsController` Jbuilder views and `app/views/api/v1/models/_agent.json.jbuilder`. Account agent list/create/show/update responses now remove local `invited_by` and `account_user_id` fields and keep only Chatwoot `_agent` fields plus enterprise `custom_role_id`, preserving account-user role, availability, auto-offline, and custom-role behavior behind the serializer. Focused agent handler/service/repository/router tests, escalated full `go test ./...`, and `git diff --check` passed; continue remaining P3.2 account users/invitations drift or B12 live smoke.
- 2026-06-06: Documentation checkpoint prepared as `docs: land invitation parity tracker`; worktree was clean at `2acd938 feat(account-users): align inviter id`, and the active plan now makes P3.2a invitation/confirmation mail parity the first executable slice. The row records Chatwoot AgentBuilder/Devise confirmation references, current Go owner files, normal confirmation vs invited workspace invitation behavior, excluded SSO/SAML/LDAP/OIDC mail variants, and focused exit gates. Verification for this docs-only checkpoint: `git diff --check`; next slice is `feat(profile): send confirmation invitations`.
- 2026-06-06: P3.2a invitation/confirmation mail checkpoint prepared as `feat(profile): send confirmation invitations`; audited Chatwoot `AgentBuilder`, Devise confirmation mail view/specs, and profile resend specs. GoChat now persists confirmation/reset tokens, sends fakeable Chatwoot-shaped confirmation or workspace invitation mail from profile resend, sends invitation mail for newly created invited agents, models `users.unconfirmed_email`, wires the account agent handler in bootstrap, and keeps SSO/SAML/LDAP/OIDC variants excluded. Focused profile/agent tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
+5
View File
@@ -500,6 +500,7 @@ func Bootstrap(env string) (*App, error) {
inboxMemberService := service.NewInboxMemberService(inboxMemberRepo)
accountUserRepo := repository.NewAccountUserRepo(db)
agentRepo := repository.NewAgentRepo(db)
conversationService := service.NewConversationService(conversationRepo, messageRepo, channelDispatcher, inboxMemberService, accountUserRepo, teamRepo, teamMemberRepo)
appliedSlaService := service.NewAppliedSlaService(appliedSlaRepo, slaEventRepo, slaPolicyRepo, conversationRepo)
conversationService.SetAppliedSlaService(appliedSlaService)
@@ -645,7 +646,10 @@ func Bootstrap(env string) (*App, error) {
// Team + Profile services (P5 — Teams + Team Members + User Profiles)
teamService := service.NewTeamService(teamRepo, teamMemberRepo, db)
agentService := service.NewAgentService(agentRepo, db)
agentService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer())
profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
profileService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer())
// Campaign + AutoAssignment services
campaignInternalSvc := campaign.NewCampaignService(db)
@@ -841,6 +845,7 @@ func Bootstrap(env string) (*App, error) {
Upload: uploadHandler,
// Lane B: AssignableAgent handler (find agents available for assignment)
AssignableAgent: v1.NewAssignableAgentHandler(assignableAgentService),
Agent: v1.NewAgentHandler(agentService),
AgentBulk: v1.NewAgentBulkHandler(conversationService),
BulkAction: v1.NewBulkActionHandler(conversationService, contactService).WithWorkerPool(workerPool),
// Lane C: CSAT template (singular per inbox) + Inbox limits
+28 -1
View File
@@ -24,6 +24,7 @@ type AgentHandlerTestSuite struct {
suite.Suite
db *gorm.DB
handler *AgentHandler
mailer *fakeProfileConfirmationMailer
account *model.Account
user *model.User
}
@@ -34,11 +35,13 @@ func (s *AgentHandlerTestSuite) SetupSuite() {
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}))
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.InstallationConfig{}))
s.db = db
agentRepo := repository.NewAgentRepo(db)
svc := service.NewAgentService(agentRepo, db)
s.mailer = &fakeProfileConfirmationMailer{}
svc.SetConfirmationMailer(s.mailer)
s.handler = NewAgentHandler(svc)
s.account = &model.Account{Name: "test-agent-account"}
@@ -58,6 +61,7 @@ func (s *AgentHandlerTestSuite) SetupTest() {
// Don't delete users — we need the inviter user to persist
// Only delete agent users (not the inviter)
s.db.Exec("DELETE FROM users WHERE id != ?", s.user.ID)
s.mailer.Reset()
}
func (s *AgentHandlerTestSuite) TearDownSuite() {
@@ -188,6 +192,29 @@ func (s *AgentHandlerTestSuite) TestCreateAgent() {
assert.Equal(s.T(), s.user.ID, membership.InvitedBy)
}
func (s *AgentHandlerTestSuite) TestCreateAgentSendsWorkspaceInvitation() {
req := service.CreateAgentRequest{Email: "invite-mail@test.com", Name: "Invite Mail", Role: "agent"}
w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID)
s.handler.Create(c)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "invitation", mail.Kind)
assert.Equal(s.T(), "invite-mail@test.com", mail.ToEmail)
assert.Equal(s.T(), "You're invited to join test-agent-account", mail.Heading)
assert.Equal(s.T(), "Inviter Admin invited you to join the test-agent-account workspace on Chatwoot.", mail.IntroText)
assert.Equal(s.T(), "Accept invitation", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/password/edit?reset_password_token=")
assert.NotEmpty(s.T(), mail.ResetPasswordToken)
var invited model.User
s.Require().NoError(s.db.Where("email = ?", "invite-mail@test.com").First(&invited).Error)
assert.NotEmpty(s.T(), invited.ResetPasswordToken)
assert.NotEqual(s.T(), mail.ResetPasswordToken, invited.ResetPasswordToken)
assert.NotNil(s.T(), invited.ResetPasswordSentAt)
}
func (s *AgentHandlerTestSuite) TestCreateAgentDefaultsBlankNameFromEmail() {
req := map[string]interface{}{
"agent": map[string]interface{}{
+102 -10
View File
@@ -2,6 +2,7 @@ package v1
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -11,6 +12,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -34,6 +36,7 @@ type ProfileHandlerTestSuite struct {
router *gin.Engine
handler *ProfileHandler
db *gorm.DB
mailer *fakeProfileConfirmationMailer
user *model.User
account *model.Account
@@ -100,6 +103,8 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
accessTokenRepo := repository.NewAccessTokenRepo(db)
installationConfigRepo := repository.NewInstallationConfigRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
s.mailer = &fakeProfileConfirmationMailer{}
profileSvc.SetConfirmationMailer(s.mailer)
s.handler = NewProfileHandler(profileSvc)
// Build router with profile routes and auth middleware
@@ -135,26 +140,49 @@ func (s *ProfileHandlerTestSuite) SetupTest() {
s.Require().NoError(err)
// Reset user to original state before each test
s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{
"name": "ProfileUser",
"email": "profile@example.com",
"password": passwordDigest,
"password_digest": passwordDigest,
"avatar_url": "",
"available": false,
"display_name": "Profile Display",
"message_signature": "Regards",
"pubsub_token": "pubsub-profile-user",
"name": "ProfileUser",
"email": "profile@example.com",
"password": passwordDigest,
"password_digest": passwordDigest,
"avatar_url": "",
"available": false,
"display_name": "Profile Display",
"message_signature": "Regards",
"pubsub_token": "pubsub-profile-user",
"confirmation_token": "",
"unconfirmed_email": "",
})
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).Updates(map[string]interface{}{
s.db.Model(&model.User{}).Where("id = ?", s.userID).UpdateColumns(map[string]interface{}{
"confirmed_at": nil,
"confirmation_sent_at": nil,
"reset_password_token": "",
"reset_password_sent_at": nil,
})
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).UpdateColumns(map[string]interface{}{
"role": "administrator",
"custom_role_id": 0,
"availability": "offline",
"auto_offline": true,
"inviter_id": 0,
})
s.db.Unscoped().Where("account_id = ?", s.accountID).Delete(&model.CustomRole{})
s.db.Unscoped().Where("owner_type = ? AND owner_id = ?", model.AccessTokenOwnerTypeUser, s.userID).Delete(&model.AccessToken{})
s.db.Unscoped().Where("name = ?", "CHATWOOT_INBOX_HMAC_KEY").Delete(&model.InstallationConfig{})
s.Require().NoError(s.db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: s.userID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
s.mailer.Reset()
}
type fakeProfileConfirmationMailer struct {
calls []service.ProfileConfirmationMailRequest
}
func (m *fakeProfileConfirmationMailer) SendConfirmationInstructions(_ context.Context, req service.ProfileConfirmationMailRequest) error {
m.calls = append(m.calls, req)
return nil
}
func (m *fakeProfileConfirmationMailer) Reset() {
m.calls = nil
}
func (s *ProfileHandlerTestSuite) decodeProfileBody(w *httptest.ResponseRecorder) map[string]interface{} {
@@ -724,6 +752,70 @@ func (s *ProfileHandlerTestSuite) TestResetAccessToken_RegeneratesTokenInChatwoo
assert.NotEqual(s.T(), "profile-token-1", token)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_DoesNotSendForConfirmedUser() {
now := time.Now().UTC()
s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("confirmed_at", now).Error)
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
assert.Empty(s.T(), s.mailer.calls)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsConfirmationInstructions() {
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "confirmation", mail.Kind)
assert.Equal(s.T(), "profile@example.com", mail.ToEmail)
assert.Equal(s.T(), "Confirm your email to get started", mail.Heading)
assert.Equal(s.T(), "Confirm my account", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/confirmation?confirmation_token=")
assert.NotEmpty(s.T(), mail.ConfirmationToken)
assert.Empty(s.T(), mail.ResetPasswordToken)
var user model.User
s.Require().NoError(s.db.First(&user, s.userID).Error)
assert.Equal(s.T(), mail.ConfirmationToken, user.ConfirmationToken)
assert.NotNil(s.T(), user.ConfirmationSentAt)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsWorkspaceInvitationForInvitedUser() {
inviter := &model.User{Name: "Inviter Admin", Email: "inviter-profile@example.com", Provider: "email", Active: true}
s.Require().NoError(s.db.Create(inviter).Error)
s.Require().NoError(s.db.Model(&model.AccountUser{}).
Where("account_id = ? AND user_id = ?", s.accountID, s.userID).
Update("inviter_id", inviter.ID).Error)
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "invitation", mail.Kind)
assert.Equal(s.T(), "Workspace invitation", mail.Eyebrow)
assert.Equal(s.T(), "You're invited to join TestAccount", mail.Heading)
assert.Equal(s.T(), "Inviter Admin invited you to join the TestAccount workspace on Chatwoot.", mail.IntroText)
assert.Equal(s.T(), "Accept invitation", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/password/edit?reset_password_token=")
assert.Empty(s.T(), mail.ConfirmationToken)
assert.NotEmpty(s.T(), mail.ResetPasswordToken)
var user model.User
s.Require().NoError(s.db.First(&user, s.userID).Error)
assert.NotEmpty(s.T(), user.ResetPasswordToken)
assert.NotEqual(s.T(), mail.ResetPasswordToken, user.ResetPasswordToken)
assert.NotNil(s.T(), user.ResetPasswordSentAt)
}
// ===================== Edge Cases =====================
func (s *ProfileHandlerTestSuite) TestNewProfileHandler() {
+1
View File
@@ -37,6 +37,7 @@ type User struct {
ResetPasswordSentAt *time.Time `json:"-"`
ConfirmationToken string `gorm:"size:255;index" json:"-"`
ConfirmationSentAt *time.Time `json:"-"`
UnconfirmedEmail string `gorm:"size:255" json:"unconfirmed_email,omitempty"`
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"`
CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"`
+4
View File
@@ -36,6 +36,7 @@ type AgentDetail struct {
InvitedBy uint `json:"invited_by"`
AccountUserID uint `json:"account_user_id"`
CustomRoleID uint `json:"custom_role_id,omitempty"`
IsNewUser bool `json:"-"`
}
// ListByAccount retrieves all agents (users) for an account with pagination.
@@ -145,6 +146,7 @@ func (r *AgentRepo) FindAgentByID(ctx context.Context, userID, accountID uint) (
func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint) (*AgentDetail, error) {
// Find or create the user
var user model.User
isNewUser := false
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if err == gorm.ErrRecordNotFound {
// Create new user
@@ -163,6 +165,7 @@ func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID u
if err := r.db.WithContext(ctx).Create(&user).Error; err != nil {
return nil, err
}
isNewUser = true
} else if err != nil {
return nil, err
}
@@ -201,6 +204,7 @@ func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID u
InvitedBy: au.InvitedBy,
AccountUserID: au.ID,
CustomRoleID: au.CustomRoleID,
IsNewUser: isNewUser,
}, nil
}
+65 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/mail"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -19,8 +20,9 @@ import (
// Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb
// An "agent" in Chatwoot is a User with an AccountUser association in a specific account.
type AgentService struct {
agentRepo *repository.AgentRepo
db *gorm.DB
agentRepo *repository.AgentRepo
db *gorm.DB
confirmationMailer ProfileConfirmationMailer
}
var ErrAgentNameBlank = errors.New("agent name cannot be blank")
@@ -30,6 +32,10 @@ func NewAgentService(agentRepo *repository.AgentRepo, db *gorm.DB) *AgentService
return &AgentService{agentRepo: agentRepo, db: db}
}
func (s *AgentService) SetConfirmationMailer(mailer ProfileConfirmationMailer) {
s.confirmationMailer = mailer
}
func (s *AgentService) DB() *gorm.DB {
if s == nil {
return nil
@@ -130,10 +136,67 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin
applogger.L().Errorf("AgentService.Create: %v", err)
return nil, err
}
if detail.IsNewUser {
if err := s.sendAgentInvitationConfirmation(ctx, accountID, inviterID, detail); err != nil {
return nil, err
}
}
return detail, nil
}
func (s *AgentService) sendAgentInvitationConfirmation(ctx context.Context, accountID, inviterID uint, detail *repository.AgentDetail) error {
if s == nil || s.db == nil || detail == nil || detail.ConfirmedAt != nil {
return nil
}
resetPasswordToken, err := generateAuthToken()
if err != nil {
return fmt.Errorf("generate invitation reset token: %w", err)
}
now := time.Now().UTC()
digestedResetToken := digestAuthToken(resetPasswordToken)
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{
"reset_password_token": digestedResetToken,
"reset_password_sent_at": now,
}).Error; err != nil {
return fmt.Errorf("persist invitation reset token: %w", err)
}
detail.ResetPasswordToken = digestedResetToken
detail.ResetPasswordSentAt = &now
var account model.Account
if err := s.db.WithContext(ctx).First(&account, accountID).Error; err != nil {
return fmt.Errorf("load invitation account: %w", err)
}
var inviter *model.User
if inviterID != 0 {
var inviterUser model.User
if err := s.db.WithContext(ctx).First(&inviterUser, inviterID).Error; err != nil {
return fmt.Errorf("load invitation inviter: %w", err)
}
inviter = &inviterUser
}
if s.confirmationMailer == nil {
applogger.L().Infof("confirmation mailer not configured for invited user %d (%s)", detail.ID, detail.Email)
return nil
}
brandName := s.confirmationBrandName(ctx)
req := buildProfileConfirmationMailRequest(&detail.User, &account, inviter, brandName, envConfirmationFrontendURL(), "", resetPasswordToken)
return s.confirmationMailer.SendConfirmationInstructions(ctx, req)
}
func (s *AgentService) confirmationBrandName(ctx context.Context) string {
if s == nil || s.db == nil {
return "Chatwoot"
}
var cfg model.InstallationConfig
if err := s.db.WithContext(ctx).Where("name = ?", "BRAND_NAME").First(&cfg).Error; err != nil {
return "Chatwoot"
}
return cfg.Value
}
// Update modifies an agent's details (name on User, role/availability on AccountUser).
func (s *AgentService) Update(ctx context.Context, userID, accountID uint, req UpdateAgentRequest) (*repository.AgentDetail, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
@@ -0,0 +1,209 @@
package service
import (
"context"
"fmt"
"net/mail"
"net/smtp"
"os"
"strings"
"github.com/gochat/gochat/internal/model"
)
const confirmationInstructionsSubject = "Confirmation Instructions"
// ProfileConfirmationMailer delivers Devise-compatible confirmation instructions.
// Reference: Chatwoot Devise::Mailer#confirmation_instructions.
type ProfileConfirmationMailer interface {
SendConfirmationInstructions(ctx context.Context, req ProfileConfirmationMailRequest) error
}
type ProfileConfirmationMailRequest struct {
Kind string
UserID uint
AccountID uint
InviterID uint
ToEmail string
RecipientName string
BrandName string
AccountName string
InviterName string
Subject string
Eyebrow string
Heading string
IntroText string
SupportingText string
ActionText string
ActionURL string
ConfirmationToken string
ResetPasswordToken string
DetailRows [][2]string
}
type SMTPProfileConfirmationMailer struct {
Address string
Port int
Username string
Password string
From string
FrontendURL string
}
func NewEnvProfileConfirmationMailer() *SMTPProfileConfirmationMailer {
return &SMTPProfileConfirmationMailer{
Address: strings.TrimSpace(os.Getenv("SMTP_ADDRESS")),
Port: envInt("SMTP_PORT", 587),
Username: firstEnv("SMTP_USERNAME", "SMTP_LOGIN"),
Password: os.Getenv("SMTP_PASSWORD"),
From: firstEnv("MAILER_SENDER_EMAIL", "SMTP_FROM"),
FrontendURL: envConfirmationFrontendURL(),
}
}
func (m *SMTPProfileConfirmationMailer) SendConfirmationInstructions(ctx context.Context, req ProfileConfirmationMailRequest) error {
_ = ctx
if m == nil || strings.TrimSpace(m.Address) == "" || strings.TrimSpace(req.ToEmail) == "" {
return nil
}
fromHeader := strings.TrimSpace(m.From)
if fromHeader == "" {
fromHeader = "Chatwoot <accounts@chatwoot.com>"
}
fromAddress := fromHeader
if parsed, err := mail.ParseAddress(fromHeader); err == nil {
fromAddress = parsed.Address
}
if strings.TrimSpace(req.Subject) == "" {
req.Subject = confirmationInstructionsSubject
}
message := smtpMessage(fromHeader, req.ToEmail, req.Subject, profileConfirmationEmailBody(req))
addr := fmt.Sprintf("%s:%d", strings.TrimSpace(m.Address), m.Port)
var auth smtp.Auth
if strings.TrimSpace(m.Username) != "" {
auth = smtp.PlainAuth("", strings.TrimSpace(m.Username), m.Password, strings.TrimSpace(m.Address))
}
return smtp.SendMail(addr, auth, fromAddress, []string{req.ToEmail}, []byte(message))
}
func buildProfileConfirmationMailRequest(user *model.User, account *model.Account, inviter *model.User, brandName, frontendURL, confirmationToken, resetPasswordToken string) ProfileConfirmationMailRequest {
recipientName := strings.TrimSpace(user.Name)
if recipientName == "" {
recipientName = strings.TrimSpace(user.Email)
}
req := ProfileConfirmationMailRequest{
Kind: "confirmation",
UserID: user.ID,
ToEmail: strings.TrimSpace(user.Email),
RecipientName: recipientName,
BrandName: brandName,
Subject: confirmationInstructionsSubject,
Eyebrow: "Welcome",
Heading: "Confirm your email to get started",
IntroText: fmt.Sprintf("Welcome to %s. We just need to verify your email address before you can start using your account.", brandName),
SupportingText: "This only takes a moment.",
ActionText: "Confirm my account",
ActionURL: frontendActionURL(frontendURL, "auth/confirmation", "confirmation_token", confirmationToken),
ConfirmationToken: confirmationToken,
}
if strings.TrimSpace(user.UnconfirmedEmail) != "" {
req.Kind = "email_update"
req.Eyebrow = "Email update"
req.Heading = "Confirm your new email address"
req.IntroText = fmt.Sprintf("We received a request to update the email address on your %s account.", brandName)
req.SupportingText = "Confirm the new address below to finish the change."
req.ActionText = "Confirm email address"
req.DetailRows = append(req.DetailRows, [2]string{"New email", user.UnconfirmedEmail})
return req
}
if user.ConfirmedAt != nil {
req.Kind = "already_confirmed"
req.Eyebrow = "Account ready"
req.Heading = "Your account is ready"
req.IntroText = fmt.Sprintf("Your %s account is already active.", brandName)
req.SupportingText = "Use the button below to sign in and continue where you left off."
req.ActionText = "Open my account"
req.ActionURL = frontendActionURL(frontendURL, "auth/sign_in", "", "")
req.ConfirmationToken = ""
return req
}
if inviter != nil {
req.Kind = "invitation"
req.InviterID = inviter.ID
req.InviterName = strings.TrimSpace(inviter.Name)
if req.InviterName == "" {
req.InviterName = strings.TrimSpace(inviter.Email)
}
req.Eyebrow = "Workspace invitation"
req.ActionText = "Accept invitation"
req.ActionURL = frontendActionURL(frontendURL, "auth/password/edit", "reset_password_token", resetPasswordToken)
req.ConfirmationToken = ""
req.ResetPasswordToken = resetPasswordToken
req.SupportingText = "Create your account to start collaborating with your team."
req.DetailRows = append(req.DetailRows, [2]string{"Invited by", req.InviterName})
if account != nil {
req.AccountID = account.ID
req.AccountName = strings.TrimSpace(account.Name)
}
if req.AccountName != "" {
req.Heading = fmt.Sprintf("You're invited to join %s", req.AccountName)
req.IntroText = fmt.Sprintf("%s invited you to join the %s workspace on %s.", req.InviterName, req.AccountName, brandName)
req.DetailRows = append(req.DetailRows, [2]string{"Workspace", req.AccountName})
} else {
req.Heading = fmt.Sprintf("You're invited to try %s", brandName)
req.IntroText = fmt.Sprintf("%s invited you to try %s.", req.InviterName, brandName)
}
}
return req
}
func profileConfirmationEmailBody(req ProfileConfirmationMailRequest) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("Hi %s,\n\n", req.RecipientName))
if req.Eyebrow != "" {
b.WriteString(req.Eyebrow + "\n")
}
if req.Heading != "" {
b.WriteString(req.Heading + "\n\n")
}
if req.IntroText != "" {
b.WriteString(req.IntroText + "\n")
}
if req.SupportingText != "" {
b.WriteString(req.SupportingText + "\n")
}
for _, row := range req.DetailRows {
if strings.TrimSpace(row[1]) != "" {
b.WriteString(fmt.Sprintf("%s: %s\n", row[0], row[1]))
}
}
if req.ActionText != "" && req.ActionURL != "" {
b.WriteString(fmt.Sprintf("\n%s: %s\n", req.ActionText, req.ActionURL))
}
return b.String()
}
func frontendActionURL(frontendURL, path, key, value string) string {
base := strings.TrimRight(strings.TrimSpace(frontendURL), "/")
if base == "" {
base = "/app"
} else {
base += "/app"
}
url := base + "/" + strings.TrimLeft(path, "/")
if key != "" && value != "" {
url += "?" + key + "=" + value
}
return url
}
func envConfirmationFrontendURL() string {
return strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
}
+78 -3
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/datatypes"
@@ -27,6 +28,7 @@ type ProfileService struct {
accountUserRepo *repository.AccountUserRepo
accessTokenRepo *repository.AccessTokenRepo
installationConfigRepo *repository.InstallationConfigRepo
confirmationMailer ProfileConfirmationMailer
}
// NewProfileService creates a new Profile service.
@@ -38,11 +40,17 @@ func NewProfileService(userRepo *repository.UserRepo, accountUserRepo *repositor
svc.accessTokenRepo = repo
case *repository.InstallationConfigRepo:
svc.installationConfigRepo = repo
case ProfileConfirmationMailer:
svc.confirmationMailer = repo
}
}
return svc
}
func (s *ProfileService) SetConfirmationMailer(mailer ProfileConfirmationMailer) {
s.confirmationMailer = mailer
}
// ProfileUserResponse matches Chatwoot app/views/api/v1/models/_user.json.jbuilder.
type ProfileUserResponse struct {
AccessToken string `json:"access_token"`
@@ -303,9 +311,76 @@ func (s *ProfileService) ResendConfirmation(ctx context.Context, userID uint) er
// Already confirmed, do nothing (per chatwoot: skip if confirmed)
return nil
}
// TODO: integrate email sending service for confirmation emails
applogger.L().Infof("ResendConfirmation called for user %d (%s)", userID, user.Email)
return nil
return s.sendConfirmationInstructions(ctx, user)
}
func (s *ProfileService) sendConfirmationInstructions(ctx context.Context, user *model.User) error {
account, inviter, err := s.confirmationAccountContext(ctx, user)
if err != nil {
return err
}
confirmationToken := ""
resetPasswordToken := ""
now := time.Now().UTC()
if inviter != nil && strings.TrimSpace(user.UnconfirmedEmail) == "" {
resetPasswordToken, err = generateAuthToken()
if err != nil {
return fmt.Errorf("generate reset password token: %w", err)
}
user.ResetPasswordToken = digestAuthToken(resetPasswordToken)
user.ResetPasswordSentAt = &now
} else {
confirmationToken, err = generateAuthToken()
if err != nil {
return fmt.Errorf("generate confirmation token: %w", err)
}
user.ConfirmationToken = confirmationToken
user.ConfirmationSentAt = &now
}
if err := s.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("persist confirmation tokens: %w", err)
}
req := buildProfileConfirmationMailRequest(user, account, inviter, s.confirmationBrandName(ctx), envConfirmationFrontendURL(), confirmationToken, resetPasswordToken)
if s.confirmationMailer == nil {
applogger.L().Infof("confirmation mailer not configured for user %d (%s)", user.ID, user.Email)
return nil
}
return s.confirmationMailer.SendConfirmationInstructions(ctx, req)
}
func (s *ProfileService) confirmationAccountContext(ctx context.Context, user *model.User) (*model.Account, *model.User, error) {
if s.accountUserRepo == nil || user == nil {
return nil, nil, nil
}
accountUsers, err := s.accountUserRepo.FindByUserWithAccounts(ctx, user.ID)
if err != nil {
return nil, nil, fmt.Errorf("load account memberships: %w", err)
}
if len(accountUsers) == 0 {
return nil, nil, nil
}
accountUser := accountUsers[0]
var inviter *model.User
if accountUser.InvitedBy != 0 && strings.TrimSpace(user.UnconfirmedEmail) == "" {
inviter, err = s.userRepo.FindByID(ctx, accountUser.InvitedBy)
if err != nil {
return nil, nil, fmt.Errorf("load inviter: %w", err)
}
}
return &accountUser.Account, inviter, nil
}
func (s *ProfileService) confirmationBrandName(ctx context.Context) string {
if s.installationConfigRepo == nil {
return "Chatwoot"
}
cfg, err := s.installationConfigRepo.FindByName(ctx, "BRAND_NAME")
if err != nil {
return "Chatwoot"
}
return cfg.Value
}
// ResetAccessToken regenerates the user's access token.
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_users_unconfirmed_email;
ALTER TABLE users DROP COLUMN IF EXISTS unconfirmed_email;
@@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS unconfirmed_email VARCHAR(255);
CREATE INDEX IF NOT EXISTS idx_users_unconfirmed_email ON users(unconfirmed_email) WHERE unconfirmed_email IS NOT NULL AND unconfirmed_email <> '';