Skip to content

feat: sync program mailing lists into Google Groups#960

Open
jee7s wants to merge 1 commit into
mainfrom
feat/program-google-group-sync
Open

feat: sync program mailing lists into Google Groups#960
jee7s wants to merge 1 commit into
mainfrom
feat/program-google-group-sync

Conversation

@jee7s

@jee7s jee7s commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What & why

Programs want a single mailing list their active participants can reach. This
wires program → Google Group membership sync so the app keeps a group in
step with who is actively enrolled — add on activation, remove on withdrawal,
plus a nightly reconcile and a manual "Sync now" that self-heal drift.

Design: checkin-app/docs/designs/PROGRAM_GOOGLE_GROUP_SYNC.md.

Decisions (from the product interview)

  • Config — board-set Program.googleGroupEmail (@sensitivity:internal),
    validated as an email on the program-ops general tab. Null = no group.
  • Recipients — the participant's own email plus every household lead,
    mirroring exactly how lib/notifications.ts › sendCheckinNotifications resolves
    recipients today (documented in the design doc). A dependent with no email is
    represented by their household lead(s).
  • Event-driven push, best-effort — fired at the shared service level from the
    three post-Shopify single-pool capacity + scholarship lifecycle inventory (supersedes two-pool mirror) #930 activation points (orders/paid webhook, payment-plan approval,
    free/board-override enroll) and from the one shared exit path
    (withdrawAndReleaseHold). A Google failure never fails the triggering user
    action — it logs + reports to System Status › Link Status; the reconcile heals.
  • Nightly reconcile cron + manual "Sync now" — full diff per program: add
    missing, remove MEMBER-role extras (OWNER/MANAGER are never removed).
  • Auth — Admin SDK Directory API via a Workspace service account with
    domain-wide delegation. OAuth2 is a JWT-bearer assertion signed with node
    cryptono new npm dependency (avoids googleapis for three endpoints).
  • Cleanly OFF when unconfigured — when GOOGLE_SA_KEY_JSON /
    GOOGLE_SA_SUBJECT are absent the integration is off: config UI still works,
    sync paths log-and-skip, and lib/configHealth.ts reports it unconfigured
    (mirrors the Zoho/Shopify idiom; a gap only in prod).

Infra follow-up (NOT in this PR)

Provisioning the service account (DWD for
admin.directory.group.member), setting the two env vars, and scheduling the
reconcile cron are an explicit infra follow-up — see the design doc's
"Provisioning" section. The cron route exists but nothing invokes it yet.

Data model

Migration 20260708050000_program_google_group — additive, nullable
Program.googleGroupEmail TEXT. npx prisma generate regenerated
classifications.ts (committed).

Testing

  • Unitlib/googleGroups.test.ts (11): JWT assertion shape, token caching,
    idempotent add(409)/remove(404), list pagination, failure + unconfigured paths.
    lib/program/__tests__/groupSync.test.ts (11): recipient rule, removal safety
    net (shared lead stays), reconcile diff, best-effort never-throws contract.
    configHealth.test.ts updated for the new check.
  • Integration (real Postgres, mocked fetch) —
    programGoogleGroupSync.integration.test.ts (5): config save (persist/normalize
    valid, reject malformed, clear on empty) + a reconcile sync run.
    authzRoleRejection.integration.test.ts gains the 401/403 deny-path for the new
    role-gated route (registered in the authz drift guard).
  • Full pre-commit suite (test:ci), tsc --noEmit, and eslint --max-warnings 0
    on all touched files: green. Route auth drift-guard + config-health route tests: green.

🤖 Generated with Claude Code

Keep a program's Google Group membership in step with its active
participants. Config is a board-set Program.googleGroupEmail (validated as
an email); membership resolves to the participant plus their household
leads, mirroring how lib/notifications resolves recipients today.

- lib/googleGroups.ts: Directory API client — JWT-bearer OAuth2 signed with
  node crypto (no new dep), token cache, timeouts, typed errors, idempotent
  add(409)/remove(404)/list. fetch seam mirrors lib/shopify.ts.
- lib/program/groupSync.ts: the shared service. Event pushes on activation
  (orders/paid webhook, payment-plan approval, free/override enroll) and on
  removal (inside withdrawAndReleaseHold, the one shared exit path) are
  best-effort — a Google failure never fails the triggering user action.
- Nightly reconcile cron + board-only "Sync now" button do the full diff
  (add missing, remove MEMBER-role extras; OWNER/MANAGER kept).
- Cleanly OFF when GOOGLE_SA_KEY_JSON / GOOGLE_SA_SUBJECT are unset: config
  UI works, sync paths log-and-skip, config-health reports it unconfigured
  (mirrors the Zoho/Shopify idiom). Real creds + cron schedule are an infra
  follow-up — see docs/designs/PROGRAM_GOOGLE_GROUP_SYNC.md.

Migration 20260708050000: additive nullable Program.googleGroupEmail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jee7s jee7s added the post-first-release Deferred until after the first production release label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

post-first-release Deferred until after the first production release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant