Hermes 284313f908
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
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

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.email stays 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.email remains 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.email and 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, and other, 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.email exactly 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.email in several places:
    • shared/services/emailService.ts
    • shared/workflow/actions/emailWorkflowActions.ts
    • shared/workflow/runtime/actions/businessOperations/contacts.ts
    • server/src/lib/api/services/ContactService.ts
    • packages/clients/src/actions/queryActions.ts
    • packages/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.ts
  • packages/types/src/interfaces/contact.interfaces.ts
  • shared/models/contactModel.ts
  • server/src/lib/api/schemas/contact.ts
  • server/src/lib/api/services/ContactService.ts
  • packages/clients/src/components/contacts/ContactDetailsEdit.tsx
  • packages/clients/src/components/contacts/ContactDetailsView.tsx
  • packages/clients/src/components/contacts/QuickAddContact.tsx
  • packages/clients/src/components/clients/QuickAddClient.tsx
  • packages/clients/src/components/contacts/Contacts.tsx
  • packages/clients/src/components/contacts/ContactsImportDialog.tsx
  • packages/clients/src/actions/queryActions.ts
  • shared/services/emailService.ts
  • shared/services/email/processInboundEmailInApp.ts
  • shared/workflow/actions/emailWorkflowActions.ts
  • shared/workflow/runtime/actions/registerEmailWorkflowActions.ts
  • shared/workflow/runtime/actions/businessOperations/contacts.ts
  • shared/workflow/streams/domainEventBuilders/contactEventBuilders.ts
  • packages/portal-shared/src/actions/portalInvitationActions.ts
  • packages/auth/src/lib/registrationHelpers.ts
  • packages/users/src/actions/user-actions/registrationActions.ts
  • packages/client-portal/src/actions/portal-actions/tenantRecoveryActions.ts
  • server/src/services/surveyService.ts
  • server/src/lib/jobs/handlers/invoiceEmailHandler.ts
  • packages/billing/src/actions/invoiceJobActions.ts
  • shared/lib/tickets/watchList.ts
  • packages/n8n-nodes-alga-psa/nodes/AlgaPsa/AlgaPsa.node.ts
  • packages/integrations/src/actions/clientLookupActions.ts
  • packages/integrations/src/actions/email-actions/emailActions.ts
  • packages/integrations/src/services/xeroCsvClientSyncService.ts

Suggested Delivery Order

  1. Schema and shared contracts
  2. Contact model validation, hydration, persistence, and swap behavior
  3. Shared email editor plus contact CRUD UI
  4. Query/search/import/export surfaces
  5. Inbound email and workflow lookup paths
  6. REST, n8n, and integrations
  7. Compatibility regressions for existing contacts.email consumers

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.email stays 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.email compatibility 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.cjs to support:
      • contacts.primary_email_canonical_type
      • contacts.primary_email_custom_type_id with tenant-scoped FK to contact_email_type_definitions
      • new contact_email_type_definitions table
      • new contact_additional_email_addresses table
      • normalized email uniqueness and tenant scoping
      • trigger-backed cross-table uniqueness checks
      • backfill of existing contacts with default primary email canonical type
  • Added tests:
    • server/src/test/unit/migrations/contactAdditionalEmailAddressesMigration.test.ts for migration-level assertions.
    • shared/models/__tests__/contactInterfaceParity.test.ts for contract parity between shared interfaces and @alga-psa/types.
  • Decision made to model primary email label metadata as explicit columns on contacts and use child rows for additional emails only.
  • Constraint to keep: keep contacts.email as 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.ts to lock portal/auth, billing, survey, project, ticket, and scheduling flows onto contacts.email.
    • Extended shared/lib/tickets/__tests__/watchList.test.ts to prove watcher recipients remain email-keyed snapshots even if later contact metadata differs.
    • Added server/src/test/integration/contactEmailLookup.integration.test.ts regression covering create -> promote additional email -> lookup by both addresses -> uniqueness guards.
  • (2026-03-15) Found and fixed a real cross-table uniqueness bug while finishing T049:
    • check_contact_additional_email_uniqueness() originally compared against NEW.normalized_email_address inside a BEFORE trigger, but generated columns are not available there yet.
    • Fixed the migration trigger to normalize from NEW.email_address directly.
    • 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=false
    • cd shared && pnpm vitest run lib/tickets/__tests__/watchList.test.ts --coverage=false
  • Completed shared email editor milestone:
    • Added packages/clients/src/components/contacts/ContactEmailAddressesEditor.tsx with:
      • 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.
  • Commands run for the shared editor milestone:
    • ../../node_modules/.bin/vitest run src/components/contacts/ContactEmailAddressesEditor.test.ts --coverage=false from packages/clients
    • cd server && pnpm vitest run src/test/unit/contacts/ContactEmailAddressesEditor.test.tsx --coverage=false
  • Completed contact-surface wiring milestone:
    • ContactDetailsEdit.tsx now edits and saves the hybrid email payload through ContactEmailAddressesEditor, validates it on submit, and sends compacted email rows through updateContact.
    • ContactDetailsView.tsx now renders the primary/default email distinctly and lists additional email addresses with labels underneath.
    • QuickAddContact.tsx now authors the hybrid email payload, including additional email rows, through the shared editor before calling addContact.
    • QuickAddClient.tsx inline contact creation now authors the same hybrid email payload before calling createClientContact.
    • contactActions.tsx now forwards primary-email label metadata and additional email rows through addContact, updateContact, and createClientContact.
  • Added coverage for the contact-surface wiring milestone:
    • server/src/test/unit/contacts/ContactDetailsEmailAddresses.contract.test.ts
    • server/src/test/unit/contacts/QuickAddContact.phoneNumbers.test.tsx
    • server/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, and ContactPickerDialog.tsx already remained anchored on scalar contact.email for summary rendering and picker search.
    • Added a contract regression test to lock in that list and picker surfaces keep using the primary/default contacts.email field 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 F008 through F011 in the shared contact model and flipped T008 through T016 to implemented.
  • Added input support for primary_email_custom_type in shared and @alga-psa/types contact contracts so callers can create or update primary custom labels without pre-resolving definition IDs.
  • Tightened validation in shared/models/contactModel.ts to 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_id when writing the additional-email row.
  • Confirmed the current cleanup helpers already count both contacts.primary_email_custom_type_id and contact_additional_email_addresses.custom_email_type_id, so orphan detection/deletion works across both storage locations.
  • Updated tests:
    • shared/models/__tests__/contactModel.test.ts
    • shared/models/__tests__/contactInterfaceParity.test.ts
    • server/src/test/integration/contactModelEmailAddresses.integration.test.ts
  • Verification runbook used:
    • pnpm vitest run models/__tests__/contactModel.test.ts models/__tests__/contactInterfaceParity.test.ts from shared/
    • pnpm vitest run src/test/integration/contactModelEmailAddresses.integration.test.ts from server/
  • 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 F012 and flipped T017 to implemented.
  • Updated contact domain-event builders to emit:
    • primaryEmailCanonicalType
    • primaryEmailCustomTypeId
    • primaryEmailType
    • additionalEmailAddresses while still leaving the summary/default address on top-level email.
  • Added an alias rule in buildContactUpdatedPayload so workflow/event diffs treat input-only primary_email_custom_type changes as changes to the persisted primary_email_type and primary_email_custom_type_id fields.
  • Updated both schema copies of CRM event payloads:
    • shared/workflow/runtime/schemas/crmEventSchemas.ts
    • packages/event-schemas/src/schemas/domain/crmEventSchemas.ts
  • Updated both workflow event publishers that build CONTACT_CREATED payloads:
    • server/src/lib/api/services/ContactService.ts
    • packages/clients/src/actions/contact-actions/contactActions.tsx
  • Verification runbook used:
    • pnpm vitest run workflow/streams/domainEventBuilders/__tests__/contactEventBuilders.test.ts from shared/

Update (2026-03-15, contact search/export compatibility)

  • Completed F018 and flipped T026 and T027 to implemented.
  • Extended server-side contact email searching so ContactService now treats both the primary contacts.email column and contact_additional_email_addresses.email_address as valid matches for:
    • search clauses targeting the email field
    • list filters using email
    • free-text contact search
  • Kept compatibility boundaries intact:
    • contact lists, pickers, and exports continue rendering/emitting scalar contact.email as the summary/default address
    • local client-side filtering in Contacts.tsx now includes additional email rows without changing the visible summary email column
  • Added regression coverage:
    • server/src/test/integration/contactServiceEmailSearch.integration.test.ts
    • server/src/test/unit/contacts/ContactsAdditionalEmailSearch.contract.test.ts
    • server/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 F019 and flipped T028 through T030 to implemented.
  • Added a shared CSV email-field helper in packages/clients/src/lib/contactCsvEmailFields.ts so contact CSV import/export/template generation uses one representation for:
    • primary_email_type
    • additional_email_addresses as label:email@example.com | label:email@example.com
  • Updated contactActions.tsx so contact CSV flows now:
    • export primary email labels and formatted additional-email rows while keeping scalar email as the primary/default address
    • generate a CSV template with the new hybrid email columns and example values
    • check existing emails across both contacts.email and contact_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
  • Updated ContactsImportDialog.tsx so 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.ts for:
    • 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.ts
    • server/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.

Update (2026-03-15, shared lookup helpers)

  • Completed F020 and flipped T031 and T032 to implemented.
  • Updated ContactModel.getContactByEmail so shared lookup now resolves contacts by either:
    • contacts.email
    • contact_additional_email_addresses.normalized_email_address
  • Updated findContactByEmailAddress in packages/clients/src/actions/queryActions.ts to defer to the shared model helper instead of running a primary-only SQL query.
  • createOrFindContactByEmail now inherits the hybrid lookup behavior through ContactModel.getContactByEmail while still creating new contacts with only a primary email on contacts.email when no match exists.
  • Added focused no-DB regression coverage:
    • shared/models/__tests__/contactModel.getContactByEmail.test.ts
    • server/src/test/unit/contacts/contactEmailLookup.contract.test.ts
  • Verification runbook used:
    • cd shared && pnpm vitest run models/__tests__/contactModel.getContactByEmail.test.ts --coverage=false
    • cd server && pnpm vitest run src/test/unit/contacts/contactEmailLookup.contract.test.ts --coverage=false

Update (2026-03-15, shared email service lookup semantics)

  • Completed F021 and flipped T033 and T034 to implemented.
  • Updated shared/services/emailService.ts so EmailService.findContactByEmail now:
    • 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
  • Confirmed EmailService.createOrFindContact remains compatibility-safe:
    • it reuses the hybrid lookup path through findContactByEmail
    • it still creates new contacts with only contacts.email populated and no additional-email rows when no match exists
  • 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 F022 and flipped T035 to implemented.
  • Updated shared/workflow/actions/emailWorkflowActions.ts#findContactByEmail so workflow contact matching now searches both:
    • contacts.email
    • contact_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.
  • createOrFindContact in the workflow action already used ContactModel.getContactByEmail, so it now inherits the hybrid lookup behavior while continuing to create new contacts with only a primary email on contacts.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 F023 and flipped T036 and T037 to implemented.
  • Updated shared/services/email/processInboundEmailInApp.ts so inbound comment metadata now preserves both:
    • metadata.email.matchedAddress for the exact sender address that matched lookup
    • metadata.email.contactEmail for the matched contact's primary/default contacts.email
  • Kept the existing authorship routing intact:
    • contact_id, author_id, and client_id continue to resolve from the matched contact record
    • default-email consumers can continue to treat contacts.email as 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 F024 and flipped T038 to implemented.
  • Updated shared/workflow/actions/emailWorkflowActions.ts so workflow contact lookups now return matched_email alongside the primary/default email.
  • Updated shared/workflow/runtime/actions/registerEmailWorkflowActions.ts so runtime contact outputs now expose:
    • email for the primary/default contacts.email
    • matched_email for the exact sender email that matched when it differs from the primary email
  • Updated shared/workflow/runtime/schemas/emailWorkflowSchemas.ts so 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.ts
    • shared/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=false is currently blocked locally because the configured server test database does not exist in this environment.
  • Completed F025 and flipped T039 to implemented.
  • Updated shared/workflow/runtime/actions/businessOperations/contacts.ts so:
    • contacts.find resolves email lookups through ContactModel.getContactByEmail, which now covers both primary and additional contact emails
    • contacts.search keeps summary rows sourced from contacts.email while adding an EXISTS search clause for contact_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 F026 and flipped T040 and T041 to implemented.
  • Updated server/src/lib/api/schemas/contact.ts so REST contact schemas now accept and return:
    • scalar email as the primary/default address
    • primary_email_canonical_type
    • primary_email_custom_type
    • primary_email_custom_type_id
    • primary_email_type on responses
    • additional_email_addresses
  • Updated server/src/lib/api/services/ContactService.ts#create so create requests stop dropping the hybrid email fields before they reach ContactModel.createContact.
  • Added focused unit coverage:
    • server/src/test/unit/validation/contactPhoneSchemas.test.ts
    • server/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 ContactService integration variant was not kept because local Postgres connectivity to the .env.localtest harness is currently blocked (EPERM to 127.0.0.1:5438 / ::1:5438) in this environment.

Update (2026-03-15, n8n hybrid email payloads)

  • Completed F027 and flipped T042 to implemented.
  • Updated packages/n8n-nodes-alga-psa/nodes/AlgaPsa/helpers.ts so contact create/update payload builders now preserve:
    • scalar email as the primary/default address
    • primary_email_canonical_type
    • primary_email_custom_type
    • primary_email_custom_type_id
    • additional_email_addresses
  • Added parseContactEmailAddresses to 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.ts so contact create/update operations expose hybrid-email fields in the node UI.
  • Updated supporting docs and examples:
    • packages/n8n-nodes-alga-psa/README.md
    • packages/n8n-nodes-alga-psa/examples/create-update-contact.workflow.json
  • Added focused package coverage:
    • packages/n8n-nodes-alga-psa/__tests__/helpers.test.ts
    • packages/n8n-nodes-alga-psa/__tests__/node-description-loadoptions.test.ts
    • packages/n8n-nodes-alga-psa/__tests__/node-execute.test.ts
    • packages/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 F028 and flipped T043 to implemented.
  • Updated packages/integrations/src/actions/clientLookupActions.ts so integration contact lookup now routes through ContactModel.getContactByEmail instead of querying only contacts.email.
  • This broadens both:
    • findIntegrationContactByEmailAddress
    • createOrFindIntegrationContactByEmail to resolve contacts when the requested email matches an additional-email row while still returning the primary/default contacts.email on the hydrated contact object.
  • Confirmed the higher-level integration email helper inherits the same behavior because packages/integrations/src/actions/email-actions/emailActions.ts#findContactByEmail still 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 --noEmit currently fails in pre-existing shared-model code under shared/models/contactModel.ts because ContactEmailAddressInput typing there does not include normalized_email_address / normalized_custom_type. This check is not blocked by the F028 package changes themselves.

Update (2026-03-15, external sync email compatibility)

  • Completed F029 and flipped T044 to 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.ts does not resolve contact email aliases directly; it operates on client billing/summary email fields
  • Made the compatibility boundary explicit by extracting getClientSummaryEmail inside packages/integrations/src/services/xeroCsvClientSyncService.ts and 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 F030 and flipped T045 to implemented.
  • Updated reusable contact helpers to author the hybrid email model directly through ContactModel.createContact:
    • server/src/test/e2e/factories/contact.factory.ts
    • server/src/test/e2e/utils/contactTestDataFactory.ts
    • server/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_addresses row
  • 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_address directly because that column is generated by the database schema.