Sync member email from CiviCRM to UniFi Access#14
Conversation
Email is functional in UniFi Access (credential/PIN delivery). Spec rides the existing CiviMember → ResolvedMember → Diff → UnifiUser flow, reusing to_update_credential (shared PUT /users endpoint), with case-insensitive comparison and a warn-but-provision path for members missing an email. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eight TDD tasks: models field, tier passthrough, reconciler case-insensitive compare, CiviCRM read, UniFi read/write of user_email, orchestrator warn-but- provision, and doc/verification. Default-None field keeps existing body assertions green; new behavior is additive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pass member.email to all three ResolvedMember constructions in resolve (no-memberships, unmapped, and tier-match branches). Pure passthrough — no logic added. Five new tests cover each branch and the None case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ry; doc fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Read the user_email field from UniFi API user rows and populate UnifiUser.email; absent or empty values map to None. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pdate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add test_apply_update_credential_email_cleared_to_none to verify that when
resolved.email=None and the UniFi user has an existing email, exactly one PUT
to /users/:id is issued with {"user_email": ""} to clear it, and no
nfc_cards calls are made.
Update _prepare_reactivation docstring to mention that it also sets
user_email when present, and describe the stale-card deletion behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add email: str | None = None to CiviMember, ResolvedMember, and UnifiUser in the §6 code block, and extend the Naming note to explain that email rides to_update_credential (same PUT endpoint, compared case-insensitively). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add test_apply_reactivate_includes_user_email_when_set to verify that _prepare_reactivation includes user_email in the profile PUT body when the resolved member has an email set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Warning Review limit reached
More reviews will be available in 46 minutes and 49 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (15)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds end-to-end syncing of a member’s primary CiviCRM email into UniFi Access user_email, carrying the field through the existing CiviMember → ResolvedMember → Diff → UnifiUser pipeline and reconciling email differences via the existing credential-update path.
Changes:
- Extend core models and pure pipeline (
tier_mapping,reconciler) to carry/compare optionalemail(case-insensitive;None/""treated as equal). - Update CiviCRM read to select/map
email_primary.email, and UniFi read/write to parse/setuser_emailon fetch/create/reactivate/credential update. - Add orchestrator warning for door-tier members missing email, plus broad test coverage and supporting documentation/spec/plan updates.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/door_sync/models.py |
Add optional email field to core dataclasses and update docstrings. |
src/door_sync/civicrm/client.py |
Select and map email_primary.email into CiviMember.email. |
src/door_sync/tier_mapping.py |
Pure passthrough of email into ResolvedMember. |
src/door_sync/reconciler.py |
Treat email changes as credential changes (case-insensitive compare). |
src/door_sync/unifi/client.py |
Parse user_email on read; write it on create/reactivate/update; dry-run logging. |
src/door_sync/orchestrator.py |
Warn when a tier-resolved member has no email, without halting provisioning. |
tests/test_models.py |
Assert email defaults and assignment on all three models. |
tests/test_civicrm_client.py |
Validate select list includes email and mapping/normalization behavior. |
tests/test_tier_mapping.py |
Validate email passthrough across resolution branches. |
tests/test_reconciler.py |
Validate email diff behavior and idempotency carry-through. |
tests/test_unifi_client.py |
Validate UniFi email parsing and request bodies for create/reactivate/update. |
tests/test_orchestrator.py |
Validate warn-but-provision behavior for tier members missing email. |
docs/superpowers/specs/2026-06-17-sync-member-email-design.md |
Design spec documenting intended behavior and constraints. |
docs/superpowers/plans/2026-06-17-sync-member-email.md |
Implementation plan detailing steps and verification. |
docs/architecture.md |
Data contract update documenting the new email field and diff semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| user_puts = [ | ||
| r | ||
| for r in httpx_mock.get_requests() | ||
| if r.method == "PUT" and r.url.path == "/api/v1/developer/users/uuid-42" | ||
| ] | ||
| assert len(user_puts) == 2 | ||
| profile_put_body = _json.loads( | ||
| next(r for r in user_puts if "employee_number" in _json.loads(r.content)).content | ||
| ) | ||
| assert profile_put_body["user_email"] == "react@example.com" | ||
| assert profile_put_body["employee_number"] == "42" | ||
| # The activate PUT must not contain user_email. | ||
| activate_put_body = _json.loads(user_puts[1].content) | ||
| assert activate_put_body == {"status": "ACTIVE"} |
Address Copilot PR review: - _prepare_reactivation now sends user_email unconditionally (empty string clears) so a removed CiviCRM email is cleared in the same reconcile cycle, matching the credential-update path. Prevents a stale address persisting — and potentially misdelivering invites — until the next cycle. True-create still omits user_email (a new record has nothing to clear). - Select the activate PUT in the reactivation test by body (absence of employee_number) rather than by list index, so it doesn't depend on request ordering. - Add a test asserting reactivation clears a stale UniFi email to "". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Provisions and keeps a member's CiviCRM primary email synced onto the UniFi Access
user_emailfield, so credential/PIN-invite delivery reaches the right address. Email is treated as functional in UniFi (not cosmetic), so differences reconcile every cycle.An optional
emailfield rides the existingCiviMember → ResolvedMember → Diff → UnifiUserflow — no new module, no new diff set, and the pure/impure boundary and safety guards are untouched.email_primary.emailon the existingContact.get; empty/missing →None.tier_mappingcarries email through unchanged.to_update_credentialset (samePUT /users/{id}endpoint asdisplay_name), compared case-insensitively so case-only differences don't churn.Noneand""are treated as equal.user_emailon read; writes it on create, reactivation, and credential update. A name+email change is sent as a single combined PUT. An empty string clears a removed email.Design spec:
docs/superpowers/specs/2026-06-17-sync-member-email-design.mdImplementation plan:
docs/superpowers/plans/2026-06-17-sync-member-email.mdArchitecture §6 data contracts updated.
Test Plan
uv run pytest— 307 passeduv run pyrefly check— 0 errorsuv run ruff check .— cleanto_update_credential; case-only diff → no-op;Nonevs""→ no-op; set-vs-None→ update; idempotency canary exercises an email-triggered update and re-diffs emptyemail_primary.email; missing and empty-string both →Noneuser_email; writes it on create / reactivate / credential-update; combined single PUT for name+email; clears to""when removed; omits the key entirely whenNoneGET /usersread-back field isuser_email(assumed to match the write field) and that UniFi doesn't rewrite stored emails beyond case — a dry-run (uv run door-sync run --once --dry-run) surfaces any mismatch as a persistentwould-update-credential ... email-changeline🤖 Generated with Claude Code