From 792786dbf0b2705850f4607713113011c0dc9c2f Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 06:28:44 +0800 Subject: [PATCH] feat(contacts): default contact type --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 ++-- internal/model/contact.go | 77 +++++++++++---------- internal/model/model_test.go | 41 +++++++---- internal/service/contact_service_g3_test.go | 18 +++++ 4 files changed, 95 insertions(+), 55 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 073b96c3..4d45f25e 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.73 conversation filter permission parity, prepared as `feat(conversations): scope filter permissions`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): scope filter permissions`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Conversations::PermissionFilterService` parity for the advanced conversation filter path. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.74 contact type default parity, prepared as `feat(contacts): default contact type`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): default contact type`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot contact default/lead sync parity for direct create and import paths. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: advanced conversation filtering now applies the same inbox visibility gate as Chatwoot `Conversations::PermissionFilterService`: administrators see all account conversations, agents see only conversations from inboxes they are assigned to, and account users missing from the account see no filter results. Existing P3.72 account enabled-feature serialization parity, P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, P3.69 contact index/filter/export resolved-scope parity, P3.68 contact filter payload parity, P3.64-P3.67 conversation filter payload parity, P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. -- Next executable implementation checkpoint: continue Phase 2/3 drift audit for message filters, import paths creating unexpected contact types, the next reused-frontend mismatch, or B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. +- Worktree status at this implementation checkpoint: contacts now follow Chatwoot default contact type semantics on all create/import paths: blank `contact_type` becomes `visitor`, and visitor contacts with email, phone, or social additional attributes upgrade to `lead`. This closes the CRM v2/import drift where CSV-created contacts without an explicit `contact_type` could remain empty and disappear from lead-only CRM surfaces. Existing P3.73 conversation filter permission parity, P3.72 account enabled-feature serialization parity, P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, P3.69 contact index/filter/export resolved-scope parity, P3.68 contact filter payload parity, P3.64-P3.67 conversation filter payload parity, P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Next executable implementation checkpoint: continue Phase 2/3 drift audit for message filters, the next reused-frontend mismatch, or B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.74 contact type default parity | Implemented for direct create and CSV import paths: blank GoChat contacts now default to Chatwoot `visitor`, and visitor contacts with email, phone, or social details upgrade to `lead`. | Keep in Review; reopen from B12 CRM/import smoke or fresh reference evidence for channel-specific social detail keys or bulk-import merge edge cases. | Focused contact model/import tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.73 conversation filter permission parity | Implemented for reused dashboard advanced filters: `POST /conversations/filter` now scopes results and meta counts through Chatwoot-style inbox visibility before applying payload/status/label filters. Administrators see all account conversations; agents see only assigned inbox conversations. | Keep in Review; reopen from B12 conversation filter smoke or fresh reference evidence for enterprise custom-role overrides beyond the current non-SSO scope. | Focused conversation service filter permission tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.72 account enabled-features serialization parity | Implemented for reused frontend account feature gates: GoChat account payloads now serialize `features` like Chatwoot `Account#enabled_features`, seeding default enabled flags from `config/features.yml`, including explicit true flags such as `crm_v2`, and omitting false flags. | Keep in Review; reopen from B12 account/CRM smoke or fresh reference evidence for installation-config default feature drift, cloud-only feature filtering, or bitfield migration needs. | Focused account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.71 contact referer filter parity | Implemented for reused CRM contact advanced filters: GoChat now accepts frontend `referer` payload conditions and filters `contacts.additional_attributes.referer` with equality and containment operators. | Keep in Review; reopen from B12 CRM filter smoke or fresh reference/frontend evidence for exact referer location drift, link case-sensitivity expectations, or export filter reuse gaps. | Focused contact filter handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -237,6 +238,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.74 contact type default parity | `internal/model/contact.go`, `internal/model/model_test.go`, `internal/service/contact_service_g3_test.go` | `reference/chatwoot/app/models/contact.rb`, `reference/chatwoot/app/services/contacts/sync_attributes.rb`, `reference/chatwoot/spec/models/contact_spec.rb`, `reference/chatwoot/spec/services/contacts/sync_attributes_spec.rb`, `reference/chatwoot/app/services/data_import/contact_manager.rb` | Contact persistence now mirrors Chatwoot enum/default behavior before CRM scopes see imported records: blank `contact_type` is normalized to `visitor`, and `visitor` contacts with email, phone number, or social additional attributes become `lead`. CSV imports without a `contact_type` column therefore create CRM-v2-visible leads when identity details are present and true visitors when they are absent. | Review by `feat(contacts): default contact type`; focused model/import tests assert default visitor, detail-based lead upgrade, and CSV import defaults; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.73 conversation filter permission parity | `internal/service/conversation_service.go`, `internal/service/conversation_service_test.go` | `reference/chatwoot/app/services/conversations/filter_service.rb`, `reference/chatwoot/app/services/conversations/permission_filter_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#filter` | Advanced conversation filters now apply the Chatwoot permission base relation before payload/status/label filters. Non-admin agents are restricted to conversations whose inbox is in their account-scoped assigned inboxes; administrators keep full account visibility; missing account memberships return an empty result set. | Review by `feat(conversations): scope filter permissions`; focused service tests assert agent filtering hides unassigned-inbox matches while administrators still see all matching inboxes; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.72 account enabled-features serialization parity | `internal/handler/api/v1/account_handler.go`, `internal/handler/api/v1/account_handler_test.go` | `reference/chatwoot/app/models/concerns/featurable.rb`, `reference/chatwoot/app/views/api/v1/models/_account.json.jbuilder`, `reference/chatwoot/config/features.yml`, `reference/chatwoot/app/javascript/dashboard/store/modules/accounts.js` | Account show/list payloads now serialize `features` as Chatwoot `enabled_features` instead of raw GoChat `feature_flags`: default enabled flags are present for empty accounts, explicit true flags like `crm_v2` are included, and false flags are omitted so reused frontend feature gates see the same truthy-key contract. | Review by `feat(accounts): serialize enabled features`; focused handler tests assert default enabled flags, explicit `crm_v2`, and false-flag omission; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.71 contact referer filter parity | `internal/repository/contact_repo.go`, `internal/handler/api/v1/contact_handler_crud_test.go` | `reference/chatwoot/app/javascript/dashboard/components-next/filter/contactProvider.js`, `reference/chatwoot/app/javascript/dashboard/components-next/filter/helper/filterHelper.js`, `reference/chatwoot/app/services/contacts/filter_service.rb`, `reference/chatwoot/lib/filters/filter_keys.yml` | Contact advanced filters now accept the reused frontend's built-in `referer` contact filter and evaluate it against `contacts.additional_attributes.referer` with `equal_to`, `not_equal_to`, `contains`, and `does_not_contain`. This closes the prior frontend/reference drift where the current dashboard exposed `referer` even though the backend filter key list does not declare it under contacts. | Review by `feat(contacts): filter by referer`; focused handler tests assert `referer contains` matches only contacts whose additional attributes carry the referer URL; full `go test ./...` and `git diff --check`. No route artifacts change. | @@ -336,6 +338,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(contacts): default contact type` | Advances P3.74 with Chatwoot contact default/sync parity. Contact persistence now normalizes blank `contact_type` to `visitor`, then upgrades visitor contacts with email, phone number, or social additional attributes to `lead`, so CSV imports without an explicit contact type no longer create empty-type records that drift from CRM v2 lead-only behavior. | `go test ./internal/model ./internal/service -run 'TestContact_Create|TestContactService_ImportCSV_DefaultsContactTypeLikeChatwoot' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.74 to Review; continue Phase 2/3 drift audit for message filters, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): scope filter permissions` | Advances P3.73 with Chatwoot `Conversations::PermissionFilterService` parity for `POST /conversations/filter`. Advanced filters now apply assigned-inbox visibility before filter payloads and meta counts, so non-admin agents cannot see conversations from inboxes they do not belong to while administrators retain account-wide visibility. | `go test ./internal/service -run 'TestConversationService_Filter_(RestrictsAgentToAssignedInboxes|AllowsAdministratorAllInboxes)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.73 to Review; continue Phase 2/3 drift audit for message filters, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. | | `feat(accounts): serialize enabled features` | Advances P3.72 with Chatwoot account `enabled_features` serialization parity. GoChat account payloads now seed default enabled features from the local reference `config/features.yml`, include explicit true flags such as `crm_v2`, and omit false flags instead of exposing raw `feature_flags` to reused dashboard feature gates. | `go test ./internal/handler/api/v1 -run 'AccountHandlerSuite/TestGet_(FeaturesUseChatwootEnabledFeatures|DefaultFeaturesMatchChatwootDefaults)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.72 to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. | | `feat(contacts): filter by referer` | Advances P3.71 with reused CRM contact filter parity for the frontend-exposed `referer` attribute. Contact advanced filters now evaluate `referer` against `contacts.additional_attributes.referer` with equality and containment operators instead of rejecting the current dashboard payload as an unknown attribute. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestFilter_ContactRefererFromFrontendProvider\|ContactHandlerCRUDTestSuite/TestFilter_ChatwootPayloadStandardAndAdditionalAttributes' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.71 to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, CRM v2 feature-flag serialization, Phase 6 placeholder audit, or B12 live smoke. | @@ -2677,3 +2680,4 @@ Verification milestone gates: - 2026-06-07: P3.71 contact referer filter checkpoint prepared as `feat(contacts): filter by referer`; audited reused `contactProvider.js`, `filterHelper.js`, `Contacts::FilterService`, and `filter_keys.yml`. GoChat contact advanced filters now accept the frontend-exposed `referer` attribute and match it against `contacts.additional_attributes.referer`, so CRM filter payloads using `contains` no longer fail as unknown attributes. Focused contact filter tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.72 account enabled-features serialization checkpoint prepared as `feat(accounts): serialize enabled features`; audited Chatwoot `Featurable#enabled_features`, account model serializer, `config/features.yml`, and reused dashboard account feature getter. GoChat account payloads now return Chatwoot-style truthy `features`: default enabled flags are present for empty accounts, explicit true flags such as `crm_v2` are included, and false flags are omitted. Focused account handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.73 conversation filter permission checkpoint prepared as `feat(conversations): scope filter permissions`; audited Chatwoot `Conversations::FilterService`, `Conversations::PermissionFilterService`, and the reused `POST /conversations/filter` path. GoChat advanced conversation filters now apply assigned-inbox visibility for non-admin agents before payload/status/label filters and meta counts, while administrators keep full account visibility. Focused conversation service permission tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.74 contact type default checkpoint prepared as `feat(contacts): default contact type`; audited Chatwoot contact enum defaults, `Contacts::SyncAttributes`, model specs, sync specs, and CSV import behavior. GoChat contacts now default blank `contact_type` to `visitor` and upgrade visitor contacts with email, phone, or social details to `lead`, closing the import/CRM-v2 drift where CSV-created contacts without an explicit type could stay empty. Focused contact model/import tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. diff --git a/internal/model/contact.go b/internal/model/contact.go index 527d4213..03686973 100644 --- a/internal/model/contact.go +++ b/internal/model/contact.go @@ -12,23 +12,23 @@ import ( // Reference: Chatwoot app/models/contact.rb — P2B M4 spec type Contact struct { Base - AccountID uint `gorm:"index;not null" json:"account_id"` - Name string `gorm:"size:255" json:"name"` - MiddleName string `gorm:"size:255;default:''" json:"middle_name,omitempty"` - LastName string `gorm:"size:255;default:''" json:"last_name,omitempty"` - Email string `gorm:"size:255;index" json:"email,omitempty"` - PhoneNumber string `gorm:"size:50;index" json:"phone_number,omitempty"` - AvatarURL string `gorm:"size:512" json:"avatar_url,omitempty"` - Identifier string `gorm:"size:255;index" json:"identifier,omitempty"` - CountryCode string `gorm:"size:10;default:''" json:"country_code,omitempty"` - Location string `gorm:"size:255;default:''" json:"location,omitempty"` + AccountID uint `gorm:"index;not null" json:"account_id"` + Name string `gorm:"size:255" json:"name"` + MiddleName string `gorm:"size:255;default:''" json:"middle_name,omitempty"` + LastName string `gorm:"size:255;default:''" json:"last_name,omitempty"` + Email string `gorm:"size:255;index" json:"email,omitempty"` + PhoneNumber string `gorm:"size:50;index" json:"phone_number,omitempty"` + AvatarURL string `gorm:"size:512" json:"avatar_url,omitempty"` + Identifier string `gorm:"size:255;index" json:"identifier,omitempty"` + CountryCode string `gorm:"size:10;default:''" json:"country_code,omitempty"` + Location string `gorm:"size:255;default:''" json:"location,omitempty"` AdditionalAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"additional_attributes,omitempty"` CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` - Blocked bool `gorm:"default:false" json:"blocked"` - ContactType string `gorm:"size:50;default:''" json:"contact_type,omitempty"` - SourceID string `gorm:"size:255" json:"source_id,omitempty"` - CompanyID *uint `json:"company_id,omitempty"` - LastActivityAt *int64 `gorm:"index" json:"last_activity_at,omitempty"` + Blocked bool `gorm:"default:false" json:"blocked"` + ContactType string `gorm:"size:50;default:''" json:"contact_type,omitempty"` + SourceID string `gorm:"size:255" json:"source_id,omitempty"` + CompanyID *uint `json:"company_id,omitempty"` + LastActivityAt *int64 `gorm:"index" json:"last_activity_at,omitempty"` } func (Contact) TableName() string { return "contacts" } @@ -45,21 +45,24 @@ func (c *Contact) BeforeSave(tx *gorm.DB) error { // Reference: Chatwoot Contacts::SyncAttributes#update_contact_location_and_country_code func (c *Contact) syncLocationAndCountryCode() { if c.AdditionalAttributes != nil { - attrs := make(map[string]interface{}) - if err := json.Unmarshal(c.AdditionalAttributes, &attrs); err == nil { - if city, ok := attrs["city"].(string); ok && city != "" { - c.Location = city - } - if country, ok := attrs["country"].(string); ok && country != "" { - c.CountryCode = country - } - } + attrs := make(map[string]interface{}) + if err := json.Unmarshal(c.AdditionalAttributes, &attrs); err == nil { + if city, ok := attrs["city"].(string); ok && city != "" { + c.Location = city + } + if country, ok := attrs["country"].(string); ok && country != "" { + c.CountryCode = country + } + } } } -// syncContactType upgrades visitor → lead if email/phone/social details present. +// syncContactType defaults blank contacts to visitor and upgrades visitor to lead if email/phone/social details are present. // Reference: Chatwoot Contacts::SyncAttributes#set_contact_type func (c *Contact) syncContactType() { + if strings.TrimSpace(c.ContactType) == "" { + c.ContactType = "visitor" + } if c.ContactType != "visitor" { return } @@ -69,16 +72,16 @@ func (c *Contact) syncContactType() { } // Check social details in additional_attributes (social_* keys) if c.AdditionalAttributes != nil { - attrs := make(map[string]interface{}) - if err := json.Unmarshal(c.AdditionalAttributes, &attrs); err == nil { - for key, val := range attrs { - if strings.HasPrefix(key, "social_") { - if s, ok := val.(string); ok && s != "" { - c.ContactType = "lead" - return - } - } - } - } + attrs := make(map[string]interface{}) + if err := json.Unmarshal(c.AdditionalAttributes, &attrs); err == nil { + for key, val := range attrs { + if strings.HasPrefix(key, "social_") { + if s, ok := val.(string); ok && s != "" { + c.ContactType = "lead" + return + } + } + } + } } -} \ No newline at end of file +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go index f0bf2f00..8c78f3c2 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -114,11 +115,11 @@ func TestAccount_FeatureFlags(t *testing.T) { defer cleanupDB(t, db) acc := &Account{ - Name: "FlagsOrg", - Locale: "en", - Timezone: "UTC", - Active: true, - FeatureFlags: `{"auto_resolve":true,"copilot":false}`, + Name: "FlagsOrg", + Locale: "en", + Timezone: "UTC", + Active: true, + FeatureFlags: `{"auto_resolve":true,"copilot":false}`, AutoResolveDuration: 7, } err := db.Create(acc).Error @@ -225,9 +226,9 @@ func TestAccountUser_Create(t *testing.T) { db.Create(user) au := &AccountUser{ - UserID: user.ID, - AccountID: acc.ID, - Role: string(AccountUserRoleAgent), + UserID: user.ID, + AccountID: acc.ID, + Role: string(AccountUserRoleAgent), Availability: "online", } err := db.Create(au).Error @@ -255,10 +256,10 @@ func TestInbox_Create(t *testing.T) { db.Create(acc) inbox := &Inbox{ - AccountID: acc.ID, - Name: "Support", - ChannelType: "web_widget", - ChannelID: 1, + AccountID: acc.ID, + Name: "Support", + ChannelType: "web_widget", + ChannelID: 1, EnableAutoAssignment: false, AutoAssignmentLimit: 0, } @@ -357,6 +358,20 @@ func TestContact_Create(t *testing.T) { assert.NoError(t, err) assert.NotZero(t, contact.ID) assert.Equal(t, "Jane Doe", contact.Name) + assert.Equal(t, "lead", contact.ContactType) +} + +func TestContact_CreateDefaultsToVisitorWithoutDetails(t *testing.T) { + db := setupModelTestDB(t) + defer cleanupDB(t, db) + + acc := &Account{Name: "ContactDefaultOrg", Locale: "en", Timezone: "UTC", Active: true} + require.NoError(t, db.Create(acc).Error) + + contact := &Contact{AccountID: acc.ID, Name: "Visitor"} + err := db.Create(contact).Error + assert.NoError(t, err) + assert.Equal(t, "visitor", contact.ContactType) } // --- Enum Tests --- @@ -410,4 +425,4 @@ func TestPermissionDimension_Constants(t *testing.T) { assert.Equal(t, "report_manage", string(DimensionReportManage)) assert.Equal(t, "knowledge_base_manage", string(DimensionKnowledgeBaseManage)) assert.Equal(t, "automation_manage", string(DimensionAutomationManage)) -} \ No newline at end of file +} diff --git a/internal/service/contact_service_g3_test.go b/internal/service/contact_service_g3_test.go index e884bc89..52c0b8bb 100644 --- a/internal/service/contact_service_g3_test.go +++ b/internal/service/contact_service_g3_test.go @@ -438,6 +438,24 @@ func TestContactService_ImportCSV_ImportsValidRows(t *testing.T) { assert.Equal(t, []string{"contact", "contact"}, indexer.indexed) } +func TestContactService_ImportCSV_DefaultsContactTypeLikeChatwoot(t *testing.T) { + db, _, svc := setupContactService(t) + account := createTestAccount(t, db) + + csvData := "name,email,phone_number\nLead,lead@test.com,+111\nVisitor,,\n" + result, err := svc.ImportCSV(context.Background(), account.ID, strings.NewReader(csvData)) + require.NoError(t, err) + assert.Equal(t, 2, result.Imported) + + var lead model.Contact + require.NoError(t, db.Where("account_id = ? AND name = ?", account.ID, "Lead").First(&lead).Error) + assert.Equal(t, "lead", lead.ContactType) + + var visitor model.Contact + require.NoError(t, db.Where("account_id = ? AND name = ?", account.ID, "Visitor").First(&visitor).Error) + assert.Equal(t, "visitor", visitor.ContactType) +} + func TestContactService_ImportCSV_AllowsRowsWithoutNameWhenIdentityPresent(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db)