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

412 lines
32 KiB
Markdown

# 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.
## Update (2026-03-15, workflow business contact email search)
- 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.