Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
32 KiB
32 KiB
Scratchpad — Multiple Email Addresses Per Contact
- Plan slug:
2026-03-15-multiple-email-addresses-per-contact - Created:
2026-03-15
What This Is
Working memory for adding multiple email addresses to contacts using a compatibility-preserving hybrid model:
contacts.emailstays required and remains the authoritative default email- primary/default email label metadata lives on
contacts - additional non-default emails live in a child table
- changing default swaps the selected additional email into
contacts.email
Decisions
- (2026-03-15) Contacts may have multiple email addresses, but every stored email address remains unique per tenant across both the primary and additional-email storage locations.
- (2026-03-15)
contacts.emailremains required and authoritative. This effort does not make it nullable or replace it with a derived default. - (2026-03-15) Changing the default email swaps the selected additional email into
contacts.emailand demotes the old primary email into the additional-email table. - (2026-03-15) The primary/default email also carries a label.
- (2026-03-15) Canonical email labels are
work,personal,billing, andother, with freeform tenant-scoped custom labels. - (2026-03-15) The implementation should mirror the existing phone-label architecture and editor behavior where practical.
Discoveries / Constraints
- (2026-03-15) The original fully normalized design was broader than necessary because many application surfaces already use
contacts.emailexactly the way we want the default email to behave. - (2026-03-15) The biggest remaining change surface under the hybrid model is:
- contact schema/model persistence
- contact edit/create/import/export UI
- lookup/query paths that must match additional emails
- inbound/workflow paths that must distinguish matched sender email from
contacts.email
- (2026-03-15) A large set of outbound and auth-adjacent consumers likely stays compatible with little or no contract change because they already read
contacts.email:- portal invitation and registration flows
- survey sends
- invoice/billing sends
- ticket/project notifications that already resolve a contact and then send to
contact.email - many summary payloads such as
contact_email/author_contact_email
- (2026-03-15) Search and lookup paths still need real changes because the app currently matches only
contacts.emailin several places:shared/services/emailService.tsshared/workflow/actions/emailWorkflowActions.tsshared/workflow/runtime/actions/businessOperations/contacts.tsserver/src/lib/api/services/ContactService.tspackages/clients/src/actions/queryActions.tspackages/integrations/src/actions/clientLookupActions.ts
- (2026-03-15) Watch-list and similar recipient stores are email-keyed snapshots today. Under the hybrid model, they are likely safest to leave snapshot-based unless a surface explicitly re-resolves a contact from user input.
Key Files To Revisit
shared/interfaces/contact.interfaces.tspackages/types/src/interfaces/contact.interfaces.tsshared/models/contactModel.tsserver/src/lib/api/schemas/contact.tsserver/src/lib/api/services/ContactService.tspackages/clients/src/components/contacts/ContactDetailsEdit.tsxpackages/clients/src/components/contacts/ContactDetailsView.tsxpackages/clients/src/components/contacts/QuickAddContact.tsxpackages/clients/src/components/clients/QuickAddClient.tsxpackages/clients/src/components/contacts/Contacts.tsxpackages/clients/src/components/contacts/ContactsImportDialog.tsxpackages/clients/src/actions/queryActions.tsshared/services/emailService.tsshared/services/email/processInboundEmailInApp.tsshared/workflow/actions/emailWorkflowActions.tsshared/workflow/runtime/actions/registerEmailWorkflowActions.tsshared/workflow/runtime/actions/businessOperations/contacts.tsshared/workflow/streams/domainEventBuilders/contactEventBuilders.tspackages/portal-shared/src/actions/portalInvitationActions.tspackages/auth/src/lib/registrationHelpers.tspackages/users/src/actions/user-actions/registrationActions.tspackages/client-portal/src/actions/portal-actions/tenantRecoveryActions.tsserver/src/services/surveyService.tsserver/src/lib/jobs/handlers/invoiceEmailHandler.tspackages/billing/src/actions/invoiceJobActions.tsshared/lib/tickets/watchList.tspackages/n8n-nodes-alga-psa/nodes/AlgaPsa/AlgaPsa.node.tspackages/integrations/src/actions/clientLookupActions.tspackages/integrations/src/actions/email-actions/emailActions.tspackages/integrations/src/services/xeroCsvClientSyncService.ts
Suggested Delivery Order
- Schema and shared contracts
- Contact model validation, hydration, persistence, and swap behavior
- Shared email editor plus contact CRUD UI
- Query/search/import/export surfaces
- Inbound email and workflow lookup paths
- REST, n8n, and integrations
- Compatibility regressions for existing
contacts.emailconsumers
Commands / Runbooks
- Search scalar contact-email consumers:
rg -n "\\bcontact\\.email\\b|contacts\\.email|contact_email|author_contact_email" server ee packages shared -g '!**/node_modules/**'
- Search current phone-label reference pattern:
rg -n "phone_numbers|default_phone_number|contact_phone|custom_phone_type" server ee packages shared -g '!**/node_modules/**'
- Search lookup paths that currently match only
contacts.email:rg -n "findContactByEmail|createOrFindContact|contacts\\.find|contacts\\.search|where\\(\\{ 'contacts\\.email'" server ee packages shared -g '!**/node_modules/**'
Open Questions
- None blocking the regenerated plan. The working assumptions are:
contacts.emailstays required and authoritative- primary email changes are swaps, not pointer changes
- additional emails are not independent login aliases
- snapshot recipient stores remain snapshot-based unless later product direction changes
Updates
- (2026-03-15) Original full-normalization plan replaced with a safer hybrid plan after design pivot.
- (2026-03-15) The regenerated plan reduces scope by preserving
contacts.emailcompatibility for many existing outbound and auth-adjacent consumers. - (2026-03-15) The regenerated feature inventory now concentrates on schema/model, editor UI, lookup/query behavior, inbound/workflow semantics, and contract extensions rather than rewriting every default-email consumer.
Update (2026-03-15)
- Completed first milestone for schema/interface foundation:
- Added shared email canonical labels and email row interfaces in
shared/interfaces/contact.interfaces.ts. - Mirrored contact email interface updates in
packages/types/src/interfaces/contact.interfaces.ts. - Added migration
20260315120000_create_contact_additional_email_addresses_schema.cjsto support:contacts.primary_email_canonical_typecontacts.primary_email_custom_type_idwith tenant-scoped FK tocontact_email_type_definitions- new
contact_email_type_definitionstable - new
contact_additional_email_addressestable - normalized email uniqueness and tenant scoping
- trigger-backed cross-table uniqueness checks
- backfill of existing contacts with default primary email canonical type
- Added shared email canonical labels and email row interfaces in
- Added tests:
server/src/test/unit/migrations/contactAdditionalEmailAddressesMigration.test.tsfor migration-level assertions.shared/models/__tests__/contactInterfaceParity.test.tsfor contract parity between shared interfaces and@alga-psa/types.
- Decision made to model primary email label metadata as explicit columns on
contactsand use child rows for additional emails only. - Constraint to keep: keep
contacts.emailas authoritative default and compatibility boundary for downstream consumers. - (2026-03-15) Completed compatibility/regression closeout for default-email consumers:
- Added
server/src/test/unit/contacts/ContactEmailDefaultConsumer.contract.test.tsto lock portal/auth, billing, survey, project, ticket, and scheduling flows ontocontacts.email. - Extended
shared/lib/tickets/__tests__/watchList.test.tsto prove watcher recipients remain email-keyed snapshots even if later contact metadata differs. - Added
server/src/test/integration/contactEmailLookup.integration.test.tsregression covering create -> promote additional email -> lookup by both addresses -> uniqueness guards.
- Added
- (2026-03-15) Found and fixed a real cross-table uniqueness bug while finishing
T049:check_contact_additional_email_uniqueness()originally compared againstNEW.normalized_email_addressinside aBEFOREtrigger, but generated columns are not available there yet.- Fixed the migration trigger to normalize from
NEW.email_addressdirectly. - Updated
ContactModel.updateContact()promotion flow to clear existing additional-email rows before swapping the primary email, then reinsert the final additional-email set after the primary update so immediate uniqueness triggers never observe an invalid intermediate state.
- (2026-03-15) Verification run for the final closeout:
cd server && pnpm vitest run src/test/unit/migrations/contactAdditionalEmailAddressesMigration.test.ts src/test/unit/contacts/ContactEmailDefaultConsumer.contract.test.ts src/test/integration/contactModelEmailAddresses.integration.test.ts src/test/integration/contactEmailLookup.integration.test.ts --coverage=falsecd shared && pnpm vitest run lib/tickets/__tests__/watchList.test.ts --coverage=false
- Completed shared email editor milestone:
- Added
packages/clients/src/components/contacts/ContactEmailAddressesEditor.tsxwith:- pinned primary/default row
- additional row add/remove/reorder/promote behavior
- canonical plus custom label editing
- helper exports for normalize/compact/reorder/promote/validate flows
- Validation now allows reuse of the same custom label across rows because labels are tenant-scoped reusable definitions, not per-contact unique values.
- Added focused helper coverage in
packages/clients/src/components/contacts/ContactEmailAddressesEditor.test.ts. - Added jsdom interaction coverage in
server/src/test/unit/contacts/ContactEmailAddressesEditor.test.tsx.
- Added
- Commands run for the shared editor milestone:
../../node_modules/.bin/vitest run src/components/contacts/ContactEmailAddressesEditor.test.ts --coverage=falsefrompackages/clientscd server && pnpm vitest run src/test/unit/contacts/ContactEmailAddressesEditor.test.tsx --coverage=false
- Completed contact-surface wiring milestone:
ContactDetailsEdit.tsxnow edits and saves the hybrid email payload throughContactEmailAddressesEditor, validates it on submit, and sends compacted email rows throughupdateContact.ContactDetailsView.tsxnow renders the primary/default email distinctly and lists additional email addresses with labels underneath.QuickAddContact.tsxnow authors the hybrid email payload, including additional email rows, through the shared editor before callingaddContact.QuickAddClient.tsxinline contact creation now authors the same hybrid email payload before callingcreateClientContact.contactActions.tsxnow forwards primary-email label metadata and additional email rows throughaddContact,updateContact, andcreateClientContact.
- Added coverage for the contact-surface wiring milestone:
server/src/test/unit/contacts/ContactDetailsEmailAddresses.contract.test.tsserver/src/test/unit/contacts/QuickAddContact.phoneNumbers.test.tsxserver/src/test/unit/contacts/QuickAddClient.phoneNumbers.test.tsx
- Commands run for the contact-surface wiring milestone:
cd server && pnpm vitest run src/test/unit/contacts/ContactEmailAddressesEditor.test.tsx src/test/unit/contacts/ContactDetailsEmailAddresses.contract.test.ts src/test/unit/contacts/QuickAddContact.phoneNumbers.test.tsx src/test/unit/contacts/QuickAddClient.phoneNumbers.test.tsx --coverage=false
- Completed summary-surface compatibility audit:
Contacts.tsx,ClientDetails.tsx,ContactPicker.tsx, andContactPickerDialog.tsxalready remained anchored on scalarcontact.emailfor summary rendering and picker search.- Added a contract regression test to lock in that list and picker surfaces keep using the primary/default
contacts.emailfield even as detailed contact rendering grows richer.
- Added coverage for the summary-surface compatibility audit:
server/src/test/unit/contacts/ContactSummaryEmail.contract.test.ts
- Command run for the summary-surface compatibility audit:
cd server && pnpm vitest run src/test/unit/contacts/ContactSummaryEmail.contract.test.ts --coverage=false
Update (2026-03-15, validation/persistence block)
- Completed
F008throughF011in the shared contact model and flippedT008throughT016to implemented. - Added input support for
primary_email_custom_typein shared and@alga-psa/typescontact contracts so callers can create or update primary custom labels without pre-resolving definition IDs. - Tightened validation in
shared/models/contactModel.tsto enforce canonical-vs-custom exclusivity for primary labels and to reject primary custom labels that duplicate canonical values. - Fixed a swap edge case: promoting an additional email with a custom label now preserves that custom label on the promoted primary row instead of dropping back to
null. - Fixed a persistence edge case: demoting a custom-labeled primary email during swap now preserves its existing
custom_email_type_idwhen writing the additional-email row. - Confirmed the current cleanup helpers already count both
contacts.primary_email_custom_type_idandcontact_additional_email_addresses.custom_email_type_id, so orphan detection/deletion works across both storage locations. - Updated tests:
shared/models/__tests__/contactModel.test.tsshared/models/__tests__/contactInterfaceParity.test.tsserver/src/test/integration/contactModelEmailAddresses.integration.test.ts
- Verification runbook used:
pnpm vitest run models/__tests__/contactModel.test.ts models/__tests__/contactInterfaceParity.test.tsfromshared/pnpm vitest run src/test/integration/contactModelEmailAddresses.integration.test.tsfromserver/
- Gotcha discovered:
- The first draft of the model only supported custom primary labels by stored definition ID, which was insufficient for create/update flows and broke promotion of custom-labeled additional emails. The fix was to treat primary labels like additional rows at the input boundary, then resolve/create the tenant-scoped label definition inside the model transaction.
Update (2026-03-15, event/workflow contracts)
- Completed
F012and flippedT017to implemented. - Updated contact domain-event builders to emit:
primaryEmailCanonicalTypeprimaryEmailCustomTypeIdprimaryEmailTypeadditionalEmailAddresseswhile still leaving the summary/default address on top-levelemail.
- Added an alias rule in
buildContactUpdatedPayloadso workflow/event diffs treat input-onlyprimary_email_custom_typechanges as changes to the persistedprimary_email_typeandprimary_email_custom_type_idfields. - Updated both schema copies of CRM event payloads:
shared/workflow/runtime/schemas/crmEventSchemas.tspackages/event-schemas/src/schemas/domain/crmEventSchemas.ts
- Updated both workflow event publishers that build
CONTACT_CREATEDpayloads:server/src/lib/api/services/ContactService.tspackages/clients/src/actions/contact-actions/contactActions.tsx
- Verification runbook used:
pnpm vitest run workflow/streams/domainEventBuilders/__tests__/contactEventBuilders.test.tsfromshared/
Update (2026-03-15, contact search/export compatibility)
- Completed
F018and flippedT026andT027to implemented. - Extended server-side contact email searching so
ContactServicenow treats both the primarycontacts.emailcolumn andcontact_additional_email_addresses.email_addressas valid matches for:- search clauses targeting the
emailfield - list filters using
email - free-text contact search
- search clauses targeting the
- Kept compatibility boundaries intact:
- contact lists, pickers, and exports continue rendering/emitting scalar
contact.emailas the summary/default address - local client-side filtering in
Contacts.tsxnow includes additional email rows without changing the visible summary email column
- contact lists, pickers, and exports continue rendering/emitting scalar
- Added regression coverage:
server/src/test/integration/contactServiceEmailSearch.integration.test.tsserver/src/test/unit/contacts/ContactsAdditionalEmailSearch.contract.test.tsserver/src/test/unit/contacts/ContactSummaryEmail.contract.test.ts
- Verification runbook used:
cd server && pnpm vitest run src/test/unit/contacts/ContactSummaryEmail.contract.test.ts src/test/unit/contacts/ContactsAdditionalEmailSearch.contract.test.ts src/test/integration/contactServiceEmailSearch.integration.test.ts --coverage=false
Update (2026-03-15, contact CSV hybrid email support)
- Completed
F019and flippedT028throughT030to implemented. - Added a shared CSV email-field helper in
packages/clients/src/lib/contactCsvEmailFields.tsso contact CSV import/export/template generation uses one representation for:primary_email_typeadditional_email_addressesaslabel:email@example.com | label:email@example.com
- Updated
contactActions.tsxso contact CSV flows now:- export primary email labels and formatted additional-email rows while keeping scalar
emailas the primary/default address - generate a CSV template with the new hybrid email columns and example values
- check existing emails across both
contacts.emailandcontact_additional_email_addresses - import/create/update contacts with primary label metadata and additional-email rows
- support updating an existing contact when the import row matches one of that contact's additional email addresses
- export primary email labels and formatted additional-email rows while keeping scalar
- Updated
ContactsImportDialog.tsxso CSV mapping, validation, preview, and upload copy understand the new hybrid-email fields and collision checks. - Added/updated regression coverage in
server/src/test/integration/contactCsvPhoneImportExport.integration.test.tsfor:- DB-backed create/update import behavior with primary and additional email rows when the local test Postgres harness is available
- Added no-DB contract coverage for the CSV email representation and import wiring:
server/src/test/unit/contacts/contactCsvEmailImportExport.contract.test.tsserver/src/test/unit/contacts/contactCsvImport.contract.test.ts
- Verification runbook used:
cd server && pnpm vitest run src/test/unit/contacts/contactCsvEmailImportExport.contract.test.ts src/test/unit/contacts/contactCsvImport.contract.test.ts --coverage=false
- Gotchas discovered:
- Import updates that match by an additional email need pre-normalization before calling
ContactModel.updateContact, otherwise the model correctly rejects a raw primary-email change that has not been expressed as a promote/swap. - When the import row promotes an existing additional email to primary, the import layer must omit the old primary from the incoming additional-email list because the model appends the demoted primary row transactionally during the swap.
- Import updates that match by an additional email need pre-normalization before calling
Update (2026-03-15, shared lookup helpers)
- Completed
F020and flippedT031andT032to implemented. - Updated
ContactModel.getContactByEmailso shared lookup now resolves contacts by either:contacts.emailcontact_additional_email_addresses.normalized_email_address
- Updated
findContactByEmailAddressinpackages/clients/src/actions/queryActions.tsto defer to the shared model helper instead of running a primary-only SQL query. createOrFindContactByEmailnow inherits the hybrid lookup behavior throughContactModel.getContactByEmailwhile still creating new contacts with only a primary email oncontacts.emailwhen no match exists.- Added focused no-DB regression coverage:
shared/models/__tests__/contactModel.getContactByEmail.test.tsserver/src/test/unit/contacts/contactEmailLookup.contract.test.ts
- Verification runbook used:
cd shared && pnpm vitest run models/__tests__/contactModel.getContactByEmail.test.ts --coverage=falsecd server && pnpm vitest run src/test/unit/contacts/contactEmailLookup.contract.test.ts --coverage=false
Update (2026-03-15, shared email service lookup semantics)
- Completed
F021and flippedT033andT034to implemented. - Updated
shared/services/emailService.tssoEmailService.findContactByEmailnow:- delegates contact resolution to
ContactModel.getContactByEmail - preserves the canonical primary/default contact email on
email - surfaces the exact lookup match separately on
matched_email - still hydrates default phone and client name for downstream consumers
- delegates contact resolution to
- Confirmed
EmailService.createOrFindContactremains compatibility-safe:- it reuses the hybrid lookup path through
findContactByEmail - it still creates new contacts with only
contacts.emailpopulated and no additional-email rows when no match exists
- it reuses the hybrid lookup path through
- Added focused no-DB coverage:
shared/services/__tests__/emailService.contactLookup.test.ts
- Added a DB-backed integration regression for environments where the local test harness is available:
server/src/test/integration/emailServiceContactLookup.integration.test.ts
- Verification runbook used:
cd shared && pnpm vitest run services/__tests__/emailService.contactLookup.test.ts --coverage=false
Update (2026-03-15, workflow contact email lookup)
- Completed
F022and flippedT035to implemented. - Updated
shared/workflow/actions/emailWorkflowActions.ts#findContactByEmailso workflow contact matching now searches both:contacts.emailcontact_additional_email_addresses.normalized_email_address
- Kept the existing context-aware ticket/default-client resolution logic intact after broadening the candidate query, so inbound workflow attribution still prefers ticket/default-client boundaries when multiple mocked candidates are present in tests.
createOrFindContactin the workflow action already usedContactModel.getContactByEmail, so it now inherits the hybrid lookup behavior while continuing to create new contacts with only a primary email oncontacts.email.- Extended the existing workflow unit suite:
shared/workflow/actions/__tests__/emailWorkflowActions.findContactByEmail.context.test.ts
- Verification runbook used:
cd shared && pnpm vitest run workflow/actions/__tests__/emailWorkflowActions.findContactByEmail.context.test.ts --coverage=false
Update (2026-03-15, inbound matched-email preservation)
- Completed
F023and flippedT036andT037to implemented. - Updated
shared/services/email/processInboundEmailInApp.tsso inbound comment metadata now preserves both:metadata.email.matchedAddressfor the exact sender address that matched lookupmetadata.email.contactEmailfor the matched contact's primary/defaultcontacts.email
- Kept the existing authorship routing intact:
contact_id,author_id, andclient_idcontinue to resolve from the matched contact record- default-email consumers can continue to treat
contacts.emailas authoritative
- Added a focused regression covering the additional-email-match path:
shared/services/email/__tests__/processInboundEmailInApp.additionalPaths.test.ts
- Verification runbook used:
cd shared && pnpm vitest run services/email/__tests__/processInboundEmailInApp.additionalPaths.test.ts --coverage=false
Update (2026-03-15, runtime matched-email contracts)
- Completed
F024and flippedT038to implemented. - Updated
shared/workflow/actions/emailWorkflowActions.tsso workflow contact lookups now returnmatched_emailalongside the primary/defaultemail. - Updated
shared/workflow/runtime/actions/registerEmailWorkflowActions.tsso runtime contact outputs now expose:emailfor the primary/defaultcontacts.emailmatched_emailfor the exact sender email that matched when it differs from the primary email
- Updated
shared/workflow/runtime/schemas/emailWorkflowSchemas.tsso the shared runtime schema vocabulary can describe both the primary/default email and the matched sender email separately. - Extended runtime registry coverage:
shared/workflow/actions/__tests__/emailWorkflowActions.findContactByEmail.context.test.tsshared/workflow/runtime/actions/__tests__/registerEmailWorkflowActions.contactAuthorship.test.ts
- Verification runbook used:
cd shared && pnpm vitest run workflow/actions/__tests__/emailWorkflowActions.findContactByEmail.context.test.ts workflow/runtime/actions/__tests__/registerEmailWorkflowActions.contactAuthorship.test.ts services/email/__tests__/processInboundEmailInApp.additionalPaths.test.ts --coverage=false
- Constraint observed:
cd server && pnpm vitest run src/test/integration/workflowRuntimeV2.email.integration.test.ts --coverage=falseis currently blocked locally because the configuredservertest database does not exist in this environment.
Update (2026-03-15, workflow business contact email search)
- Completed
F025and flippedT039to implemented. - Updated
shared/workflow/runtime/actions/businessOperations/contacts.tsso:contacts.findresolves email lookups throughContactModel.getContactByEmail, which now covers both primary and additional contact emailscontacts.searchkeeps summary rows sourced fromcontacts.emailwhile adding anEXISTSsearch clause forcontact_additional_email_addresses
- Added focused unit coverage:
shared/workflow/runtime/actions/__tests__/businessOperations.contacts.emailSearch.test.ts
- Verification runbook used:
cd shared && pnpm vitest run workflow/runtime/actions/__tests__/businessOperations.contacts.emailSearch.test.ts --coverage=false
Update (2026-03-15, REST contact hybrid email contracts)
- Completed
F026and flippedT040andT041to implemented. - Updated
server/src/lib/api/schemas/contact.tsso REST contact schemas now accept and return:- scalar
emailas the primary/default address primary_email_canonical_typeprimary_email_custom_typeprimary_email_custom_type_idprimary_email_typeon responsesadditional_email_addresses
- scalar
- Updated
server/src/lib/api/services/ContactService.ts#createso create requests stop dropping the hybrid email fields before they reachContactModel.createContact. - Added focused unit coverage:
server/src/test/unit/validation/contactPhoneSchemas.test.tsserver/src/test/unit/api/contactService.hybridEmailFields.test.ts
- Verification runbook used:
cd server && pnpm vitest run src/test/unit/validation/contactPhoneSchemas.test.ts src/test/unit/api/contactService.hybridEmailFields.test.ts --coverage=false
- Constraint observed:
- A DB-backed
ContactServiceintegration variant was not kept because local Postgres connectivity to the.env.localtestharness is currently blocked (EPERMto127.0.0.1:5438/::1:5438) in this environment.
- A DB-backed
Update (2026-03-15, n8n hybrid email payloads)
- Completed
F027and flippedT042to implemented. - Updated
packages/n8n-nodes-alga-psa/nodes/AlgaPsa/helpers.tsso contact create/update payload builders now preserve:- scalar
emailas the primary/default address primary_email_canonical_typeprimary_email_custom_typeprimary_email_custom_type_idadditional_email_addresses
- scalar
- Added
parseContactEmailAddressesto normalize the freeform JSON field used for additional email rows and keep n8n validation errors local to the node layer. - Updated
packages/n8n-nodes-alga-psa/nodes/AlgaPsa/AlgaPsa.node.tsso contact create/update operations expose hybrid-email fields in the node UI. - Updated supporting docs and examples:
packages/n8n-nodes-alga-psa/README.mdpackages/n8n-nodes-alga-psa/examples/create-update-contact.workflow.json
- Added focused package coverage:
packages/n8n-nodes-alga-psa/__tests__/helpers.test.tspackages/n8n-nodes-alga-psa/__tests__/node-description-loadoptions.test.tspackages/n8n-nodes-alga-psa/__tests__/node-execute.test.tspackages/n8n-nodes-alga-psa/__tests__/docs.test.ts
- Verification runbook used:
cd packages/n8n-nodes-alga-psa && ../../node_modules/.bin/vitest run --config vitest.config.ts __tests__/helpers.test.ts __tests__/node-description-loadoptions.test.ts __tests__/node-execute.test.ts __tests__/docs.test.ts
Update (2026-03-15, integration contact email lookup)
- Completed
F028and flippedT043to implemented. - Updated
packages/integrations/src/actions/clientLookupActions.tsso integration contact lookup now routes throughContactModel.getContactByEmailinstead of querying onlycontacts.email. - This broadens both:
findIntegrationContactByEmailAddresscreateOrFindIntegrationContactByEmailto resolve contacts when the requested email matches an additional-email row while still returning the primary/defaultcontacts.emailon the hydrated contact object.
- Confirmed the higher-level integration email helper inherits the same behavior because
packages/integrations/src/actions/email-actions/emailActions.ts#findContactByEmailstill delegates into the client lookup helper. - Added focused coverage:
packages/integrations/src/actions/clientLookupActions.emailLookup.test.ts
- Verification runbooks used:
cd packages/integrations && ../../node_modules/.bin/vitest run src/actions/clientLookupActions.emailLookup.test.ts
- Constraint observed:
node_modules/.bin/tsc -p packages/integrations/tsconfig.json --noEmitcurrently fails in pre-existing shared-model code undershared/models/contactModel.tsbecauseContactEmailAddressInputtyping there does not includenormalized_email_address/normalized_custom_type. This check is not blocked by theF028package changes themselves.
Update (2026-03-15, external sync email compatibility)
- Completed
F029and flippedT044to implemented. - Audited the remaining external sync/import/export adapter surface after
F028:- no remaining adapter-specific contact lookup path was bypassing the shared integration contact-email helpers
packages/integrations/src/services/xeroCsvClientSyncService.tsdoes not resolve contact email aliases directly; it operates on client billing/summary email fields
- Made the compatibility boundary explicit by extracting
getClientSummaryEmailinsidepackages/integrations/src/services/xeroCsvClientSyncService.tsand keeping export behavior anchored on the client's primary billing/summary email before any location fallback. - Added focused regression coverage:
packages/integrations/src/services/xeroCsvClientSyncService.emailSummary.test.ts
- Verification runbook used:
cd packages/integrations && ../../node_modules/.bin/vitest run src/services/xeroCsvClientSyncService.emailSummary.test.ts
Update (2026-03-15, contact helper fixtures and seeds)
- Completed
F030and flippedT045to implemented. - Updated reusable contact helpers to author the hybrid email model directly through
ContactModel.createContact:server/src/test/e2e/factories/contact.factory.tsserver/src/test/e2e/utils/contactTestDataFactory.tsserver/src/test/e2e/utils/email-test-factory.ts
- Those helpers now support:
- labeled primary email metadata
- optional additional email rows with normalized
display_order - backward-compatible defaults that still create a primary email label when tests only pass a scalar email
- Updated the dev seed fixture:
server/seeds/dev/05_contacts.cjs- added primary email labels on seeded contacts
- added an example
contact_additional_email_addressesrow
- Added DB-backed regression coverage:
server/src/test/integration/contactTestHelpersEmailRows.integration.test.ts
- Verification runbook used:
cd server && pnpm vitest run src/test/integration/contactTestHelpersEmailRows.integration.test.ts --coverage=false
- Constraint observed:
- the additional-email seed must not set
normalized_email_addressdirectly because that column is generated by the database schema.
- the additional-email seed must not set