# CC Soccer D11 - Session Handoff
**Date:** June 16, 2026
**Branch:** `main`

---

## Session Work — June 16, 2026

### Andrew's `fix/enforce_max_teams` — Reviewed and Deployed ✅ COMPLETE

Reviewed Andrew's branch before merge. Tournament max_teams enforcement follows
the same three-layer defensive pattern as the earlier team-roster-capacity fix
(hide UI option when full, re-validate on submit, authoritative re-check in
`OrderCompleteSubscriber` with admin-flagging on the race-condition edge case).
No issues found. Bundled in the same branch: season/tournament players page
search, sortable columns, and player picture hover/rotate UI (unrelated feature,
scope mixed into the same branch but harmless).

Deployed to TEST and PROD — no schema/config changes, straightforward
`ccsDeploy && ccsUpdb && ccsCim && ccsCr` / `ccsProdDeploy && ccsProdUpdb &&
ccsProdCim && ccsProdCr` on both.

**Open item carried forward:** no admin view/report currently surfaces orders
flagged with `ccsoccer_team_name_collision`, `ccsoccer_team_capacity_exceeded`,
or the new `ccsoccer_tournament_full`. All three flagging mechanisms exist and
log correctly, but nothing finds them yet — worth a follow-up admin view.

### SecurityMetrics Test Product — Created ✅ COMPLETE

SecurityMetrics' PCI compliance scan target URL
(`https://ccsoccer.com/products/securitymetrics-test-product`) was returning
nothing. Investigation found the Commerce product type and product variation
type (`security_metrics_test_product_ty`) existed, but no actual product/
variation entity had ever been created on top of them. Created the product
directly via the Commerce UI on PROD: title "Security Metric Test Product",
SKU `SECMETRICS-TEST-001`, price $0.01, published, URL alias set to
`/products/securitymetrics-test-product`. Confirmed working end-to-end
(add-to-cart through checkout).

### old.ccsoccer.com / dev.ccsoccer.com Subdomains — Removed ✅ COMPLETE

- **`old.ccsoccer.com`** pointed at `ccsoccer_site_d7_archive` (the actual D7
  codebase). Subdomain mapping deleted via cPanel — D7 site is now unreachable
  from the public internet. Files remain on disk at
  `~/public_html/ccsoccer_site_d7_archive` for reference/recovery if needed.
  Recreating the subdomain later (pointing at the same directory) would restore
  it exactly.
- **`dev.ccsoccer.com`** was found to be pointing at the same document root as
  PROD (`ccsoccer_site/web`) — not a separate environment, just a redundant
  alias. Not in use, so it was also removed.
- Both removals are non-destructive and reversible (cPanel only removes the
  vhost/subdomain mapping, not underlying files/DB).

### DMARC Reports — Reviewed, No Issues ✅ COMPLETE

Reviewed several days of DMARC aggregate reports (Google and Outlook/Microsoft
senders) following the switch to Google for DMARC handling. All records show
SPF/DKIM pass, disposition `none`, for `ccsoccer.com`'s Google Workspace mail
sending IPs. Did not contain a record for the InMotion server's IP (the site's
transactional mail sender) — these reports are scoped per-24-hour window and
only include mail that was actually sent in that window, so this doesn't
confirm pass/fail for transactional emails specifically, just that nothing
sent so far has failed. Recommended setting up Gmail filters to skip-inbox +
label these reports (two filters needed — one for
`noreply-dmarc-support@google.com`, one for `dmarcreport@microsoft.com`) rather
than reviewing daily. **Still open:** watch for a report containing the
InMotion server's IP to confirm transactional mail (password resets,
registration confirmations) passes SPF/DKIM alignment under the `p=quarantine`
policy.

### Drupal Update Notification Settings — Fixed and Protected ✅ COMPLETE

**Problem:** Update notification settings
(`/admin/reports/updates/settings`) were set differently per environment in
the UI — TEST: weekly, `noreply@example.invalid`, security-only; PROD: weekly,
`ccsoccer@ccsoccer.com`, all newer versions — but `update.settings` is a
config entity, so the next `drush cim` on each environment silently reverted
both back to whatever was last committed. Confirmed via DMARC-report-adjacent
testing; caught before it caused confusion.

**Fix:** Re-set both environments' values via the UI, then added
`update.settings` to `config_ignore` (`config/sync/config_ignore.settings.yml`)
so `cim` no longer touches it on any environment going forward. Committed and
deployed to TEST and PROD as an isolated, hand-edited config file change (not
via `cex`, to avoid pulling in unrelated local config drift — see below).

### LOCAL Config Drift — Diagnosed, config_ignore Expanded ✅ COMPLETE

Running `drush cex` locally surfaced significant unexpected drift between
LOCAL's active config and what's committed:

- **reCAPTCHA disabled locally** (`captcha.captcha_point.*` status `false`,
  `captchaType: default` on login/registration/password-reset/contact forms;
  `recaptcha.settings` differs). **Root cause identified:** the reCAPTCHA
  site/secret key pair was only ever registered for the production domain —
  reCAPTCHA was intentionally never enabled on LOCAL for this reason. This is
  expected, permanent, and correct; TEST and PROD have it correctly enabled
  with a working key for their domains.
- **`captcha.captcha_point.contact_message_feedback_form` showing as a
  pending Delete** — expected. The feedback/contact form was removed from the
  site previously; its captcha point is now orphaned, not a sign of a problem.
- **Three role files modified** (`user.role.anonymous`,
  `user.role.board_member`, `user.role.tournament_director`) — cause **not**
  identified. Tested in isolation: `drush cr` alone does not reproduce it,
  `drush updb` alone does not reproduce it (no pending updates either time).
  Last legitimate committed change to these files was `ba8627f` (anonymous
  contact-form permission removal + module dependency bookkeeping for
  board_member/tournament_director), which is unrelated and already correct.
  **Decision: do NOT add roles to `config_ignore`** — unlike captcha/recaptcha,
  there's no known permanent reason for LOCAL's roles to differ from
  committed config, so masking drift here would hide a real signal if it's
  ever an intentional change. Reverted LOCAL back to committed state via
  `drush cim`. **Watch item:** if this reappears, check `git diff` on the
  three files before reverting — note what you were doing in the browser
  immediately beforehand, since this is currently a single, unexplained
  occurrence.

**Fix applied:** added `recaptcha.settings` and `captcha.captcha_point.*` to
`config_ignore` (joining `commerce_payment.commerce_payment_gateway.*` and
`update.settings`). LOCAL's no-CAPTCHA setup and TEST/PROD's real reCAPTCHA
config are now both permanently protected from `cim`/`cex` churn.

**Process note — exporting `config_ignore.settings` changes:** when adding an
entry to `config_ignore`, hand-edit `config/sync/config_ignore.settings.yml`
directly and `git add` that file by name. Do NOT run a blanket `drush cex` to
capture the change — if LOCAL has any other active config drift (as it did
here), a full `cex` will pull all of it into the export directory and risk
committing/shipping things that were never meant to leave LOCAL. If `cex` is
needed to inspect what's currently drifted (diagnostic only), review the
`git diff` for every file in the table before deciding to keep or discard each
one — then `git checkout -- config/sync/` to discard whatever shouldn't ship,
rather than committing the whole export.

**Current `config_ignore.settings.yml`:**
```yaml
ignored_config_entities:
  - 'commerce_payment.commerce_payment_gateway.*'
  - 'update.settings'
  - 'recaptcha.settings'
  - 'captcha.captcha_point.*'
```

### Files Changed — June 16, 2026
- `config/sync/config_ignore.settings.yml` (two commits: `update.settings`,
  then `recaptcha.settings` + `captcha.captcha_point.*`)

### Deploy Steps Applied
- TEST and PROD: `ccsDeploy && ccsUpdb && ccsCim && ccsCr` /
  `ccsProdDeploy && ccsProdUpdb && ccsProdCim && ccsProdCr`, twice (once per
  `config_ignore` commit). Both times `cim` imported only
  `config_ignore.settings`, confirming no other drift shipped.
- LOCAL: `drush cim` run afterward to bring LOCAL's active config in sync with
  the now-updated `config_ignore.settings.yml` (LOCAL had been doing `cex`
  only, so the ignore-list change wasn't reflected in LOCAL's active config
  until this import). Confirmed clean with
  `drush cex` → "active configuration is identical to the configuration in
  the export directory."

---

## Current State

### 🎉 SITE IS LIVE at ccsoccer.com
Soft-launch mode (IP whitelist active). Board members + beta testers testing. Tournament registration open for SLO Friendly 2026.

### Code — TEST and PROD in sync on main
All work from today's session committed and deployed to both TEST and PROD.

### old.ccsoccer.com and dev.ccsoccer.com — retired
Both subdomains removed from cPanel. D7 archive site no longer publicly
reachable; files remain on disk at `ccsoccer_site_d7_archive` for reference.

---

## Session Work — June 15, 2026

### Tournament — Enforce Maximum Teams ✅ COMPLETE

`max_teams` was set on the tournament and displayed in admin lists/stats, but it
was never enforced in the registration flow. A new player could always choose
"Create a new team (become captain)" even after the team limit was reached. The
`Tournament::isFull()` / `getTeamsRemaining()` helpers already existed but were
not used by the checkout pane. Enforcement added at three layers:

- **Form build** (`TournamentTeamPane::buildPaneForm`) — new
  `buildActionOptions($tournament_full)` helper omits the `create` option when
  `Tournament::isFull()`, in both the standard and token-fallback blocks. A
  capacity notice is shown explaining new teams can no longer be created but
  players can still join an existing team or register as a free agent. This also
  removes the previous duplication of the options array.
- **Validation** (`TournamentTeamPane::validatePaneForm`) — the `create` branch
  re-checks `isFull()` first and sets a form error, catching the race where the
  last slot fills between page load and submit (or a stale form).
- **Order completion** (`OrderCompleteSubscriber::createTournamentRegistration`)
  — before creating the team, re-checks `isFull()`. If full, tags the order with
  `ccsoccer_tournament_full` (team name, tournament id, user id, timestamp),
  logs an error, and returns without creating the team/registration. Deposit is already collected
  at this point, so it is flagged for admin follow-up.

**Note / open item:** the `ccsoccer_tournament_full` order flag is logged and
stored but has no admin UI surfacing it yet — same situation as the existing
`ccsoccer_team_name_collision` flag.

### Admin — Player List Search & Sortable Columns ✅ COMPLETE

Improved the Tournament and Season player admin lists. Search remains
client-side over the full list (≤ ~300 players, no pagination needed).

**Tournament players** (`/admin/ccsoccer/tournament/{id}/players`):
- Search now matches **team name** in addition to name/login/email (e.g. search
  "team 03" or "Mac Attack"). Added `data-team` row attribute; placeholder
  updated.
- Sortable column headers (ascending/descending): **Name, Login Name, Age,
  Skill (Self), Team**. Added `data-age` / `data-self` row attributes.

**Season players** (`/admin/ccsoccer/season/{id}/players`):
- Sortable column headers: **Name, Login Name, Age, Skill Level, Skill (Self)**.
  Added `data-age` / `data-skill` / `data-self` row attributes. (No Team column
  on the season page.) Default PHP sort (unassessed skill first) is unchanged on
  initial load; clicking a header takes over.

**Shared implementation:**
- `season-players.js` — added `team` to the search filter and a new
  `playersTableSort` behavior. Sorts rows by the matching `data-*` attribute via
  an `Intl.Collator` (`numeric: true`), toggling asc/desc with arrow indicator,
  empty values sink to the bottom. Sorting only reorders the DOM, so it composes
  with the active search filter. Keyboard accessible (Enter/Space).
- `season-players.css` — styles for `.ccsoccer-sort` clickable headers + arrows.
- Controllers render sortable headers via a `$sortable_header($label, $key)`
  helper that emits a `<span class="ccsoccer-sort" data-sort-key="...">`.

### Files Changed — June 15, 2026
- `web/modules/custom/ccsoccer/src/Plugin/Commerce/CheckoutPane/TournamentTeamPane.php`
- `web/modules/custom/ccsoccer/src/EventSubscriber/OrderCompleteSubscriber.php`
- `web/modules/custom/ccsoccer/src/Controller/TournamentController.php`
- `web/modules/custom/ccsoccer/src/Controller/SeasonController.php`
- `web/modules/custom/ccsoccer/js/season-players.js`
- `web/modules/custom/ccsoccer/css/season-players.css`

### Deploy Notes
- No schema changes — no `drush updb` needed. Run `drush cr` (and per-server
  `ccsCr` / `ccsProdCr`) so the render array, CSS, and JS changes take effect.

---

## Session Work — June 4, 2026

### Group Management — Leave Group ✅ COMPLETE

Players who accepted a group invitation can now leave the group themselves (when groups are not locked).

- New route `ccsoccer.group.leave` at `/my-group/{registration}/leave` (POST)
- New `leaveGroup()` method in `GroupController.php` — verifies non-manager, checks groups_locked, clears `group_id`/`invited_by`/`invitation_status`, marks the original accepted invitation as `declined` so the manager can re-invite
- `ccsoccer-group-manage.html.twig` — Actions column shows **Leave Group** button for non-manager season members when not locked
- `groups_locked` theme variable added to `ccsoccer_group_manage` in `ccsoccer.module`

### Group Management — Groups Locked Enforcement ✅ COMPLETE

`groups_locked` is now enforced server-side everywhere, not just in the template.

**Server-side blocks:**
- `invite()` — blocked if season groups locked
- `removeMember()` — blocked if season groups locked
- `acceptSeasonInvitation()` — blocked if season groups locked
- `leaveGroup()` — blocked if season groups locked

**Template changes:**
- `ccsoccer-group-manage.html.twig` — when locked: hides invite form, pending invitations table, and Remove/Leave buttons; shows 🔒 locked notice
- `ccsoccer-my-registrations.html.twig` — when locked: replaces Accept/Decline buttons with 🔒 locked message
- `myRegistrations()` controller passes `groups_locked` per invitation row

### Group Management — Figure Out Group Later Bug Fix ✅ COMPLETE

Choosing **Figure out group later** during checkout was incorrectly declining all pending invitations via `declinePendingInvitationsForRegistration()` in `OrderCompleteSubscriber.php`. Fix: only call that method when `$invitation_status === 'accepted'`. Invitations now stay pending and appear correctly on My Registrations after checkout.

### Admin — Dissolve Group ✅ COMPLETE

Admins can now dissolve a season group (e.g. a solo manager who is blocking other managers from inviting him).

- **Dissolve Group** button added to `GroupInvitationsForm.php` (season only)
- Two-step confirm: shows yellow warning with player/invitation counts before acting
- On confirm: clears all group membership fields from every registration in the group, declines all pending invitations, redirects to Roster Builder
- Cancel returns to normal form without changes

### Files Changed — June 4, 2026
- `web/modules/custom/ccsoccer/src/Controller/GroupController.php`
- `web/modules/custom/ccsoccer/src/EventSubscriber/OrderCompleteSubscriber.php`
- `web/modules/custom/ccsoccer/src/Form/GroupInvitationsForm.php`
- `web/modules/custom/ccsoccer/ccsoccer.module`
- `web/modules/custom/ccsoccer/ccsoccer.routing.yml`
- `web/modules/custom/ccsoccer/templates/ccsoccer-group-manage.html.twig`
- `web/modules/custom/ccsoccer/templates/ccsoccer-my-registrations.html.twig`

---

## Captain Upgrade Path — Decision Recorded
Discussed the path for a participant who later wants to become a captain (registered without deposit, now wants to form a team). Decision: **cancel and re-register**. Player gets a refund on cancellation, re-registers choosing "Create a new team (become captain)". Self-serve upgrade was considered (Become Captain button, standalone deposit product) but complexity-to-frequency ratio doesn't justify it (2-3 cases per tournament historically). Manual admin-mediated path also considered but cancel + re-register is cleaner for the deposit report.

---

## Season Flag Enforcement Audit — June 4, 2026

Audit of the five season status checkboxes to verify they are enforced as intended.

### ✅ Registration Visible — Correctly Implemented
`/register` page filters with `registration_visible = TRUE`. Unchecking hides the season from the public registration page.

**Minor gap:** `active` is not checked alongside `registration_visible` on `/register`. A season with `active = FALSE` but `registration_visible = TRUE` would still appear for registration. Unlikely in practice since both are usually toggled together.

### ✅ Roster Visible — Correctly Implemented
Public Teams page and individual roster pages in `ContentController` gate on `roster_visible = TRUE`. Schedule Builder auto-sets it TRUE on publish, FALSE on clear.

**Minor gap:** Direct team URLs (e.g. `/teams/123`) may bypass the flag check at the individual page level — not verified at the entity route layer.

### ✅ Schedule Visible — Correctly Implemented
Public Schedule page in `ContentController` gates on `schedule_visible = TRUE`. Schedule Builder auto-sets it on publish/clear.

**Same minor gap as above** — direct game/schedule URLs may not recheck the flag.

### ✅ Groups Locked — Now Correctly Implemented
Was previously a UI hint only. Fixed this session — now enforced server-side in `invite()`, `removeMember()`, `acceptSeasonInvitation()`, and `leaveGroup()`. Templates also hide all group modification UI when locked.

### ⚠️ Active — Partially Implemented

`active` controls admin list visibility only. It does NOT gate registration or checkout.

| Context | Checked? |
|---|---|
| Admin season lists and dropdowns | ✅ |
| Public Teams / Roster / Schedule pages | ✅ |
| `/register` page (season appears for registration) | ❌ |
| Checkout / `OrderCompleteSubscriber` | ❌ |
| My Registrations cards for players | ❌ |

**Practical risk:** If you uncheck `Active` to retire a season, players can still register for it as long as `Registration Visible` remains checked. If someone has the product in their cart when `registration_visible` is toggled off, checkout will still complete (mid-checkout race condition).

### ✅ Gaps Resolved — June 4, 2026 (morning session)

**1 & 2 — `active` and `registration_visible` now enforced at checkout**

Added server-side guards in `RegistrationController` and `OrderCompleteSubscriber`:

- `addSeasonToCart()` — blocks cart entry if `registration_visible=FALSE` or `active=FALSE`; user sees error message and is redirected to `/register`
- `addTournamentToCart()` — blocks cart entry if `registration_visible=FALSE` or `status` is `completed`/`cancelled`
- `OrderCompleteSubscriber::createSeasonRegistration()` — defense-in-depth guard after season reload; blocks registration and logs for admin review if flags were toggled mid-checkout (race condition)

**3 — Direct entity URLs for roster/schedule — already implemented, no gap**

`ContentController` already has the admin override pattern on both the Teams and Schedule pages. Users with `generate schedules` permission can pass `?season=<id>` for any season regardless of `roster_visible` / `schedule_visible` / `active`, and see a yellow admin notice banner. Normal players and unauthenticated users cannot trigger the override and see the default season instead. This was implemented in commit `7a26733`.

**4 — Resolved by 1 & 2 above.** Flags are now hard gates at checkout, not just display hints.

---

## Combined Pre-Launch Checklist

### 🚨 Immediate

1. ~~**Caleb** — Audit what `drush cim` overwrites on PROD.~~ ✅ DONE

2. **Caleb** — Team names refactor (Phase 1 complete)
   - 2a. ✅ DONE
   - 2b. `/admin/ccsoccer/team/add` pre-filter by series — still open
   - 2c. ✅ DONE
   - 2d. Phase 3: Roster builder verification
   - 2e. Add league/series columns and filter to team names taxonomy overview
   - 2f. ✅ DONE
   - 2g. Admin "team-name collision review" view — still open

---

### Before Opening Whitelist to Public

3. **Andrew** — Vanishing CAPTCHA: confirm stays visible after correct CAPTCHA + wrong password.

4. ✅ DONE **Andrew** — Create real SLO Friendly 2026 season + tournament.

5. **Caleb** — Commerce checkout end-to-end with real card on PROD (season registration + jersey-only paths).

6. **Geo-blocking** — Site is now public and receiving Russian spam bot traffic via the contact form.
   - 6a. **Replicate D7 modules (preferred first step)** — D7 used `ip2country` + `ip_ban` for country-level blocking at the Drupal application layer. D11 equivalents exist and are compatible:
     - **Smart IP** (`smart_ip`) — geolocation lookup, replaces `ip2country`
     - **Country Block** (`country_block`) — blocks by country code, replaces `ip_ban`. Config at `/admin/config/system/country-block`, enter ISO codes (e.g. `RU`). Depends on Smart IP.
     - Smart IP needs an IP2Location LITE database file (free download from ip2location.com). Upload BIN file to server, point Smart IP config at it.
     - Neither module opens new attack surface. Country Block is not covered by Drupal security advisory policy but is low risk for this use case.
     - **Try this before Cloudflare** — no DNS changes, no traffic rerouting, self-contained.
   - 6b. **Cloudflare geo-blocking (fallback)** — Andrew's original item. Requires setting up a Cloudflare account and rerouting DNS through Cloudflare proxy (was painful last time). WAF custom rules can block by country before traffic hits the server. Only pursue if Smart IP / Country Block proves insufficient.

7. **Caleb** — Remove IP whitelist from `web/.htaccess` on go-live day (both servers, skip-worktree protected).

8. **Andrew** — Done - Delete `ccsoccer-d11-migrated-200users.sql` from repo root if present.

9. ✅ DONE — Pre-launch security checklist.

10. ~~**Caleb** — Security Metrics automated testing setup.~~ ✅ DONE — test product created, PCI scan target URL working end-to-end.

11. ✅ DONE — PROD cron running every 15 min.

---

### Mid-term

12. ~~`test@ccsoccer.com` mailbox decision.~~ ✅ DONE — see June 20, 2026 session notes.

13. Andrew's local environment DB update.

14. `slofriendly` config role reference cleanup (Andrew).

15. **Caleb** — WebAuthn passkey 2.0.0-rc7 → 2.1.0-beta1. Composer advisory now resolved — do next session with full auth flow test.

16. Backup strategy verification.

17. CSP headers in report-only mode.

18. **New** — Admin view/report to surface orders flagged `ccsoccer_team_name_collision`, `ccsoccer_team_capacity_exceeded`, `ccsoccer_tournament_full`. Three flagging mechanisms now exist; none are currently surfaced anywhere for admin follow-up.

19. **New** — Confirm InMotion server's transactional email passes SPF/DKIM under DMARC `p=quarantine`. Check the next DMARC aggregate report that includes the InMotion server's source IP (not yet seen in reviewed reports).

---

### Post-Launch / First 72 Hours

20. ✅ DONE — Assign `permanent_override` role.

21. ✅ DONE — Verify credit balances against D7.

22. ✅ DONE — Check credits/registrations after April 16 dump date.

23. Remove `beta_tester` role from all users.

24. `slofriendlysoccer.com` URL forward.

25. Archive/delete D7 waivers; eventually delete `ccsoccer_site_d7_archive/`. (Note: `old.ccsoccer.com` subdomain already removed — files still present for this step.)

26. Monitor login rate, password resets, unhandled exceptions for first 72 hours.

27. Add Devel back to TEST.

---

### Development (Deferred)

28. Three-tier button methodology pass.

29. CSS consolidation — design tokens across ~33 CSS files.

30. Team handling refactor.

31. Profile picture migration — board decision pending.

32. Fix remaining `CC Soccer` → `CCSoccer` in tournament deposit + jersey notification subjects/SMS bodies.

---

## DB Quick Reference

### Production DB (live)
- DB: `n6ac4b5_d11live`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### TEST DB
- DB: `n6ac4b5_d11test`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### D7 archive DB
- DB: `n6ac4b5_ccsoccer`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### Local D11 DB
- Admin: `admin` / `TJ4XxyYGCd`

---

## Server Directory Structure
```
/home/n6ac4b5/public_html/
  ccsoccer_site/            ← PRODUCTION (ccsoccer.com)
  ccsoccer_site_d7_archive/ ← D7 archive (subdomain removed; files still on disk)
  test_ccsoccer_site/       ← TEST (test.ccsoccer.com)
  slofriendly_redirect/     ← slofriendlysoccer.com redirect
```

---

## Email Architecture

### Pipeline
```
NotificationService / OrderCompleteSubscriber
  → Symfony Mailer pipeline
    → Inline CSS + Theme wrapper (email.html.twig)
    → URL to absolute + Wrap and convert
  → Google Workspace SMTP (TEST + prod)
  → Mailpit (local DDEV only)
```

### Key Files
- `web/modules/custom/ccsoccer/src/Service/NotificationService.php`
- `web/modules/custom/ccsoccer/src/Plugin/QueueWorker/NotificationQueueWorker.php`
- `web/modules/custom/ccsoccer/src/Commands/NotificationCommands.php`
- `web/themes/custom/ccsoccer_theme/templates/email/email.html.twig`

### Notification gating
- `site_instance = 'local'` → board members only (Mailpit catches all)
- `site_instance = 'test'` → board members + beta_testers only (real emails)
- `site_instance = 'production'` → all users

### Queue Worker — SMS suppression
When `sms_body === ''` (explicitly empty), `processItem()` calls `sendEmail()` directly, bypassing `send()` which would strip HTML for SMS. This is how migration welcome email avoids SMS.

### From address — per environment (via settings.local.php override, NOT config_ignore)
- **LOCAL**: `local@ccsoccer.com` — caught by Mailpit, never delivered
- **TEST**: `test@ccsoccer.com` — Google Workspace alias, routes to same inbox as prod
- **PROD**: `ccsoccer@ccsoccer.com` — set in `system.site.yml` in config sync; no override needed

Pattern: `$config['system.site']['mail'] = 'test@ccsoccer.com';` in each environment's
`settings.local.php`. Config sync (`system.site.yml`) holds the PROD value as canonical.
No `config_ignore` entry needed — this is a `$config` override, not a config entity drift.

### SMTP config (in settings.local.php, never in git)
- Host: `smtp.gmail.com`, Port: `465`, TLS: true
- User: `ccsoccer@ccsoccer.com`
- App password: `sqygkfykzwrziota`

### reCAPTCHA Keys
- Site key: `6LchguosAAAAAC5kLFmKj0xCGdEWXNntLacANpVN`
- Secret key: `6LchguosAAAAANBJ8ikcrZvYQMxs6-FM2YHbzPEl`
- **Domain-locked to production.** Intentionally disabled on LOCAL (no working
  key for the local dev domain) — protected via `config_ignore`, see Key
  Facts / Gotchas below. TEST has a working key and reCAPTCHA enabled normally.

### Authorize.net Live Credentials (PROD only — never commit)
- API Login ID: `9Fus5B2a`
- Transaction Key: `7MA8r27R9KQrs3Kd`
- Public Client Key: `5ep4C2xSvyY4jpra4694guKsJyV6XGq2CB39SHRtrs59wHH47avwTfKM7R7xJ7hF`
- Mode: Live
- Plugin: Authorize.net Accept.js
- Configure at: `/admin/commerce/config/payment-gateways`

**Note:** payment gateway config is protected via `config_ignore` (see below) — `drush cim` will not overwrite live credentials.

### Drupal Update Notification Settings (per-environment, protected via config_ignore)
- **TEST** (`/admin/reports/updates/settings`): weekly check, email `noreply@example.invalid`, security updates only.
- **PROD**: weekly check, email `ccsoccer@ccsoccer.com`, all newer versions.
- Deliberately different per environment — TEST stays quiet, PROD surfaces everything for the update-first workflow.

---

## Key Facts / Gotchas

### config_ignore — environment-specific config that must never sync via cim/cex
`config/sync/config_ignore.settings.yml` currently protects:
```yaml
ignored_config_entities:
  - 'commerce_payment.commerce_payment_gateway.*'
  - 'update.settings'
  - 'recaptcha.settings'
  - 'captcha.captcha_point.*'
```
- **`commerce_payment.commerce_payment_gateway.*`** — PROD's live Authorize.net credentials must survive `cim`; re-enter manually on PROD if ever needed.
- **`update.settings`** — TEST and PROD intentionally have different notification email/frequency/threshold; see above.
- **`recaptcha.settings` / `captcha.captcha_point.*`** — LOCAL has no working reCAPTCHA key for its domain, so CAPTCHA is intentionally disabled there; TEST/PROD have it enabled with a working key. Added June 16, 2026 after this caused real confusion (see session notes above).

**When adding a new entry to this list:** hand-edit the file directly and `git add` it by name — do NOT run a blanket `drush cex` to capture an ignore-list change, since any other local active-config drift will get swept into the same export and risk being committed/shipped accidentally.

**After changing this file:** remember to also run `drush cim` on LOCAL (not just `cex`) — LOCAL's active config needs to actually import the updated ignore list before `cex` will stop showing the now-ignored entities as diffs. (`cex` exports active config to disk; it does not pull file changes back into active config.)

### Role config drift on LOCAL — unresolved, watch for recurrence
On June 16, 2026, `user.role.anonymous`, `user.role.board_member`, and
`user.role.tournament_director` showed as unexpectedly modified in LOCAL's
active config vs. committed config. Cause not identified — `drush cr` alone
and `drush updb` alone were both tested and neither reproduces it. Reverted via
`drush cim`. If this recurs: check `git diff` on the affected role files
immediately (before reverting) and note what was being done in the browser
right before running `cex` — we need a second data point to find the trigger.

### beta_tester role — do not delete from config/sync
Always `git checkout config/sync/user.role.beta_tester.yml` after `drush cex`.

### Google SMTP App Password
`sqygkfykzwrziota` — in `settings.local.php` on TEST and prod servers only.

### Symfony Mailer — User override
Do NOT enable the "User" override — replaces HTML with plain text.

### DB import fix for InMotion
```bash
gunzip -c dump.sql.gz | sed 's/DEFINER=[^*]*\*/\*/' | gzip > dump-clean.sql.gz
```

### Two-file CSS sync
- `web/modules/custom/ccsoccer/css/ccsoccer-base.css` (admin)
- `web/themes/custom/ccsoccer_theme/css/base.css` (public)

### composer install on servers
```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php /opt/cpanel/composer/bin/composer install --ignore-platform-req=ext-intl
```

### Migration welcome email — password reset URL
Hardcoded to `https://ccsoccer.com/user/password` in `sendMigrationWelcome()`. `Url::fromRoute()` generates `http://default/` in Drush CLI context.

### CAPTCHA admin path
`/admin/config/people/captcha` — must be logged in as `admin` user. Use private window to test. Note LOCAL intentionally has CAPTCHA disabled — see config_ignore section above.

### TournamentCancelRegistrationForm — hidden number field gotcha
Browser HTML5 validation silently blocks `ConfirmFormBase` submission when a hidden `<input type="number">` has `min` set but the value is below it. Remove `#min`/`#max` from hidden/conditionally-visible number fields and validate server-side in `validateForm()` instead.

### ConfirmFormBase + entity-typed route parameters
Do NOT put entity-typed parameters (e.g. `type: entity:tournament`) in the route path for a `ConfirmFormBase` form. Entity upcasting interferes with the form POST pipeline and causes silent submit failures. Keep the path simple (raw integer parameter only) and derive entity context from the registration itself in `buildForm()`.

### DMARC aggregate reports
Daily reports from `noreply-dmarc-support@google.com` and `dmarcreport@microsoft.com` for `ccsoccer.com` (policy is `p=quarantine`). Set up Gmail filters to skip-inbox + label both senders rather than reviewing daily. Worth a look only after a config change affecting outbound mail, or when checking whether a specific sending source (e.g. the InMotion server) passes alignment.

---

## Server Quick Reference
```bash
# SSH in
ssh ccsoccer

# TEST deploy (includes composer install)
ccsDeploy && ccsCr

# PROD deploy (includes composer install)
ccsProdDeploy && ccsProdCr

# Full deploy with DB updates
ccsDeploy && ccsUpdb && ccsCim && ccsCr
ccsProdDeploy && ccsProdUpdb && ccsProdCim && ccsProdCr

# Drush full path (TEST/PROD)
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php vendor/drush/drush/drush.php -r web [command]
```

## .htaccess — both servers
Not in git — protected via `git update-index --skip-worktree web/.htaccess`

## Git Workflow
- Always `git pull` before `git push`
- `main` is the primary branch
- `settings.local.php` is NOT in git
- Always `git checkout config/sync/user.role.beta_tester.yml` after `drush cex`
- When editing `config_ignore.settings.yml`, commit that file by name only — do not run a blanket `cex` (see Key Facts / Gotchas)
