# 26-Jun-2026 — per-field Head Office cutover policy and member notification queue [ref #267](https://github.com/nbarrett/ngx-ramblers/issues/267)

## [build 721](https://github.com/nbarrett/ngx-ramblers/actions/runs/28248078096) — [commit 93ada1c](https://github.com/nbarrett/ngx-ramblers/commit/93ada1c9dd4d9f9c5b55ce76df5728d094d4546e)

_____

Introduces a field-by-field policy that controls how inbound member data from Head Office (Insight Hub xlsx upload or the Salesforce member API) is applied to the local member record, plus an admin-reviewed notification queue so members can be told when their record and Head Office disagree. The feature ships dormant: every field defaults to today's rules, so a fresh install behaves exactly as before.

## What's new

Admin can now decide, per field, whether Head Office wins, today's rules apply, or Head Office is ignored, so fields can be graduated off the legacy protection model at admin's own pace during the Salesforce cutover. Separately, when a sync finds a member whose local value differs from Head Office, it queues a notification instead of emailing; admin reviews the queue and sends a branded summary email on demand.

## At a glance

- Three modes per field: Use legacy rules (today's behaviour), Always apply Head Office (write unconditionally, field becomes read-only for the member), Skip (ignore Head Office, keep the local value).
- A default mode applies to any field without an explicit override. Out of the box that default is Use legacy rules, so nothing changes until admin opts a field in.
- Syncs never send email. They build a per-member, per-field queue of differences, de-duplicated so repeated runs do not re-queue or re-send the same difference.
- Members without an email on file are kept in the queue and skipped at send, marked "no email on file".

## Where to find it

- Policy editor: System Settings > External Systems > Member Sync Policy. Saved with the rest of System Settings; there is no separate Save button.
- Notification queue: /admin/member-sync-notifications, also reachable from a "Sync notifications" tab on the Member Bulk Load page.
- A pending-count badge appears on the member admin modal so the queue is visible during routine member work.
- Members see read-only fields (those set to Always apply Head Office) on their own /admin/contact-details page, with a short note explaining why.

## How it behaves

- The merge step looks up each field's effective mode (override, else default) before applying the existing write rule. Always apply writes the incoming value; Skip leaves the local value untouched; Use legacy runs today's rule unchanged.
- Audit messages are phrased distinctly so the two cases can be separated after a sync: "applied as ... (Head Office override)" and "kept as ... (skipped, admin policy)", alongside the unchanged legacy phrasing.
- Both the xlsx route and the Salesforce route honour the policy automatically, because both reach the same merge code.
- Sending a member's queued fields produces one branded transactional email itemising what we held, what Head Office says, and the resolution, with a button back to their contact-details page. The rows are then marked sent (with the time and the acting admin), and one mail-list audit row is written.
- When a difference later resolves itself (the two sides agree on a subsequent sync), any matching pending row is retired automatically.

## Why it is restricted

- Always apply Head Office is the only mode that makes a field read-only for the member, because it is the only mode where a local edit would be guaranteed to be overwritten on the next sync. Use legacy and Skip leave the field editable.
- The notification emails are transactional, describing the member's own record, so they go out regardless of marketing-consent state. There are no accept or decline links in the email; any action the member wants to take is on the site.

## How to try it out

1. System Settings > External Systems > Member Sync Policy: set one field (for example postcode) to Always apply Head Office, leave the rest on the default, and save.
2. As that member, open /admin/contact-details and confirm postcode is read-only with the explanatory note, while other fields stay editable.
3. Run an Insight Hub xlsx upload (or a Salesforce sync) where that member's local value differs from the incoming value. Confirm the field is overwritten and the audit message reads "applied as ... (Head Office override)".
4. Set another field to Use legacy rules and arrange a difference the legacy rule would not write; confirm a pending notification appears at /admin/member-sync-notifications grouped under that member.
5. Send for that member (or use Send all pending) and confirm one branded email arrives, the rows move to sent, and a mail-list audit row exists.
6. Re-run the sync with no further change and confirm nothing is re-queued or re-sent.

## Technical changes

- New config key MEMBER_SYNC_POLICY (config.model.ts) with PUBLIC_WITH_REDACTION access (server config controller), holding { defaultMode, overrides }. A seed migration writes the default on deploy; absence also resolves to the default, so behaviour is unchanged until configured.
- MemberSyncPolicyMode enum and MemberSyncPolicy model; MemberSyncPolicyService with effectiveMode(fieldName) = overrides[fieldName] ?? defaultMode.
- MemberBulkLoadService.changeAndAuditMemberField now branches on the effective mode before the legacy WriteDataRule, emits the two new audit phrases, and collects per-member notification candidates that are reconciled to the server after the run.
- New memberSyncNotifications collection (model, crud controller, routes, server registration) with MemberSyncNotificationStatus and MemberSyncNotificationResolution enums.
- POST /reconcile applies the documented de-duplication state table (pending/sent against matches/differs) and retires pending rows for processed members whose difference no longer recurs. POST /send builds the email, marks rows sent with sentAt and sentBy, and writes the mail-list audit row.
- Branded email built via the editable-body path of the existing transactional sender, with a membership-role sender resolved from committee config.
- UI: Member Sync Policy sub-tab under System Settings External Systems (saved by the form), read-only bindings plus note on contact-details, the /admin/member-sync-notifications page (grouped, per-member and batch send, status/field/date/name filters), the bulk-load tab link, and the member-admin-modal pending badge.

## Member sync notification email is now a built-in process

The member sync notification email is now driven by the standard email-configuration infrastructure rather than a bespoke send. A member-sync notification config is seeded and exposed as its own row in System Settings > Mail > Process Mappings, so admin can choose which email configuration the process uses, exactly like Contact Us, Forgot Password, Walk, Expense and Booking.

- New memberSyncNotificationConfigId on the Brevo mail config (default null), added to the config defaults, the environment-setup template, and the Process Mappings UI.
- A "Member Sync Notification" entry in NOTIFICATION_CONFIG_DEFAULTS and a seed migration that creates the config and wires the id, de-duplicated by subject text so the existing notification-defaults backfill cannot double-seed it.
- A branded member-sync-notification template, and the sender now resolves config, sender and reply-to role, subject, banner image, accent colour and signoff from the notification config like every other transactional email.

## Admin menu item for the notifications page

The Member Sync Notifications page now has its own admin menu item, added by the member-sync setup migration (idempotently, via the same ensureActionButton helper the other admin menu items use) alongside the other admin action buttons, rather than only being reachable from a tab on the bulk-load page. The whole feature ships as a single migration: it seeds the sync policy, seeds and wires the notification config, and adds this menu item.

## Stable member identity via salesforceId

Member identity no longer depends on membershipNumber, which is unreliable: members can be created manually without one, and non-Ramblers members will be stored as members too. Every member already has a stable internal identity in its Mongo _id; this adds salesforceId as the external key for members sourced from Salesforce.

- New salesforceId on the member record (sparse-indexed), populated by the Salesforce mapper instead of being collapsed into membershipNumber.
- Bulk-load matching keys on salesforceId first, then membershipNumber, then the existing name and email signals. A legacy bridge matches records whose salesforceId was previously stored as their membership number, and the matched salesforceId is stamped onto the member, so the link self-heals over the first sync or two with no separate backfill.
- The incremental full-list rebuild keys on salesforceId and membershipNumber together, so a changed membership number no longer duplicates a member, and manually-created or non-Ramblers members (which have neither key) are never removed by a sync.
- contactId is left untouched; it remains the Ramblers Walks Manager walk-leader identifier used for walk-leader matching.

Consent writeback still keys on membershipNumber, matching the current #209 API contract; moving it to salesforceId is a separate wire-contract change.

## Member bulk-load audit as a From/To table

The per-member bulk-load audit (and the bulk-load Upload History) no longer shows a single run-on string. Each audit now records structured field changes, displayed as an expandable From/To table like the walk history, with a Resolution column showing whether each field was a Head Office override, kept by admin policy, updated, or not overwritten.

- New MemberAuditFieldChange recorded on each member update audit as the sync runs; the redundant joined auditMessage string is removed from the model, the server schema and both displays.
- The per-member modal shows the From/To accordion; the Upload History table shows a concise "N fields: ..." summary, both derived from the structured field changes.
- Historic audit records are rebuilt in place by the member-sync setup migration: it parses each stored auditMessage into structured field changes and drops the old string, so past audits display in the new table too.

## Member sync notification config and queue polish

- The member sync notification email always uses its built-in branded template, and the config is wired to that template so the editor shows it as automatically generated with no editable content.
- The notification config is recognised as a built-in workflow process (Member Selection shows it as automatically selected, and the process map labels it).
- The notifications queue gains a "Quick range" date preset (All time / 7 / 30 / 90 days / year) alongside the slider.

## Shareable, deep-linkable notifications page

The /admin/member-sync-notifications filters are reflected in the URL via the StoredValue enum, so a filtered view can be copied to a colleague and survives a reload:

- Status, field, free-text search, the quick-range preset, the date range and the set of expanded member rows are written to the query string on change and restored on load.
- Status carries an explicit `all` sentinel, so choosing "All" persists in the URL and a refresh no longer snaps back to the Pending default (absence of the param still means the sensible Pending default for an unparameterised link).
- The date range is shared as friendly `yyyy-MM-dd` values rather than epoch milliseconds.

## Resend a notification from the UI

A "Resend to selected" action lets an admin re-send to members already marked Sent, not just Pending ones. The send route accepts a `resend` flag that widens the status query to include sent rows, so there is no need to reach into the database to re-issue a notification.

## Display fixes

- The notifications list uses the shared green rounded house table style, matching the member bulk-load page, with the expanded From/To detail table striped.
- The date-range slider always spans at least a year, so it stays draggable even when every queued difference falls on a single day; previously the bounds collapsed and the thumbs froze.
- The notification subject shows the member's real full name. FULL_NAME is built from firstName and lastName (falling back to displayName only when those are empty), so the subject suffix reads "Nick Barrett" and matches the email-configuration preview, rather than the shortened public display name "Nick B".

## Granular marketing consent stays dormant until go-live

The three granular consent fields (group, area and other marketing consent) are only audited and synced during bulk load when enableGranularConsent is set on the Salesforce config. This keys off the same flag the send-time consent logic already uses and touches only those three fields, never emailMarketingConsent, so it complements the existing "Respect Head Office marketing consent when sending" control rather than duplicating it. The granular field names are defined once as the MarketingConsentField enum on the member model.