# CC Soccer D11 - Session Handoff
**Date:** July 5, 2026
**Branch:** `fix/tournament_rules` — series rules TD access work, ready to merge to `main`

---

## Session Work — July 5, 2026 (evening)

### Anonymous checkout lockdown — step 2 DONE on LOCAL; steps 1, 3, 4 still open

Reviewed Caleb's email (his Claude discussion of lockdown options) against
`ANONYMOUS_CHECKOUT_LOCKDOWN.md`. Conclusions: the doc's 4-step plan stands as the
now-fix; Caleb's route-subscriber default-deny + automated anonymous route sweep is
a good follow-on hardening project (slot next to checklist item 17/CSP), not a
launch blocker. His risk list missed card testing (the urgent one) and the leftover
test product type; his audit ideas worth keeping: anonymous curl sweep of full route
list, `drupal/security_review` module, check JSON:API/REST not enabled, and a query
for anonymous orders by product type (quick win — confirms whether order 61 is the
only one).

#### Team pages PII check (Caleb's open question) — ✅ CLEAR
No member phone/email — or even names — reaches anonymous visitors. `/teams`
redirects anonymous to login (controller); logged in it renders first/last name
only. `/team/{team}` requires login (and is a stub). `/tournament-teams`,
`/my-teams` etc. all `_user_is_logged_in`. Public surfaces (schedule, tournament
schedule, register list) render team names/games only — no player contact info in
`ScheduleGridBuilder`. iCal feed route is `_access: TRUE` but token-validated with
`hash_equals()`. Anonymous role lacks `access user profiles`, so `/user/{uid}` is
403. Minor consistency nit (no action): `/teams` gate lives in the controller, not
the route.

#### Step 2 implemented — remove `access checkout` from anonymous
- **`config/sync/user.role.anonymous.yml`** — removed `access checkout` permission
  and the now-unneeded `commerce_checkout` module dependency.
- Imported on LOCAL (`ddev drush cim -y`), role no longer in drift.
- Validation: logged-out visitor with item in cart gets 403 at `/checkout`;
  logged-in checkout unaffected. Invite flow unaffected (invitees authenticate
  before checkout).
- This alone closes the carding/anonymous-order hole; step 1 (login pane) is the
  better-UX layer on top.

#### Step 1 finding — NOT config-only as the doc implied
The login pane's default step is `login`, but `CCSoccerCheckoutFlow::getSteps()`
defines no `login` step — flipping `step: _disabled` in the yml alone points the
pane at a nonexistent step and it won't render. Proper fix: add a `login` step
first in `getSteps()` (small code change) + pane config `step: login`,
`allow_guest_checkout: false`. Decision open: `allow_registration` true/false
(false probably fine — invitees register before checkout). Test LOCAL → TEST
per the doc's test plan before PROD.

#### Discovered: media display config drift loop (pre-existing, Caleb's July 2 work)
Three configs show `Different` even immediately after a successful `cim`:
`core.entity_view_display.media.image.default`, `…media.image.media_library`,
`…media.document.media_library`. media_library programmatically adjusts its own
view displays after save → import → re-tweak → drift again. Likely fix: `cex` just
those three files so sync matches what media_library maintains (remember
beta_tester gotcha). Flag to Caleb.

#### Deploy step 2 — run in TEST then PROD (LOCAL done)
```bash
drush cim -y   # imports anonymous role change; no cr, no updb
```
After: verify logged-out `/checkout` is 403 and a logged-in checkout completes.

---

## Session Work — July 5, 2026 (later)

### Invite email masking (finding H) — ⚠️ IMPLEMENTED ON `main`, UNCOMMITTED — needs manual test + commit

Andrew's decision: proceed with the recommended format from
`INVITE_EMAIL_MASKING_PROPOSAL.md` — first 2 chars + `***` + last 1 char + full
domain (`te***1@example.com`), with the short-local-part tiers (≤2 → `a***@`,
3–5 → `ab***@`, ≥6 → `ab***z@`).

**Confirmed: both invite pages share one code path.** Season groups
(`/my-group/5094`) and tournament teams (`/my-group/5077`) both route to
`GroupController::manage()`, attach the same `group-management.js`, and call the
same `/api/ccsoccer/user-search` endpoint — only the `?season=` vs `?tournament=`
param differs. One fix covers both.

#### What changed (4 files)
- **`src/Controller/GroupController.php`**
  - `userSearch()` now returns `masked_email` and **no raw email** for existing
    users — the real fix; nothing readable in the network tab. New-player branch
    (typed full email, uid 0) unchanged — manager typed it themselves.
  - New `maskEmail()` helper implementing the tiered format above.
  - LIKE terms now wrapped in `escapeLike()` (proposal's secondary cleanup);
    database connection added to constructor/create() DI for this.
  - `manage()` builds `invitee_display_emails` map: masked when the invitation
    has an `invitee` reference (picked from autocomplete), full when NULL
    (manager typed it).
- **`js/group-management.js`** — dropdown + selected indicator display
  `masked_email`; hidden `invitee_email` left empty for existing users (server
  resolves real address from uid in `invite()` — verified, no change needed
  there); new-player path unchanged.
- **`templates/ccsoccer-group-manage.html.twig`** — Pending Invitations table
  uses the display map. **Gap beyond the proposal, closed:** the table showed
  the full `invitee_email`, so inviting a masked user would have re-exposed the
  raw address right after sending.
- **`ccsoccer.module`** — registered `invitee_display_emails` theme variable.

#### Decisions / not changed
- Rate limit left at 30 searches/60s (proposal's tightening to 15–20 was
  optional — flag if wanted).
- Names + Registered badge still visible per proposal (accepted).
- Proposal doc status header updated to IMPLEMENTED.

#### Test before committing (LOCAL, `ddev drush cr` first — JS cache-bust)
1. Search on both a season group page and a tournament team page — emails masked
   in dropdown; network tab response contains no raw email for existing users.
2. Invite an existing user → email still sends to the real address.
3. Invite a brand-new email address → unchanged flow, full address shown.
4. Pending Invitations table: masked for autocomplete-picked, full for typed.

#### Deploy — run in EACH environment (LOCAL → TEST → PROD)
Code only; no `cim`, no `updb`.
```bash
drush cr   # rebuilds container (new DI arg) + cache-busts group-management.js
```

---

## Session Work — July 5, 2026

### Review of Caleb's June 26 – July 3 commits (`578b601`..`e8a6e92`) + fixes — ⚠️ ON BRANCH `fix/tournament_rules`, READY TO COMMIT/MERGE

Second-set-of-eyes review (Fable 5 session) of the tournament series rules page,
media module, and migration-welcome commits. Work is solid overall (idempotent
update hook 9069, charset-restricted route, proper 404s, `standalone_url: false`,
no oembed exposure). Findings below; fixes for #1, #4, #5 implemented this session.

#### Findings + resolutions

- **#1 (FIXED) — `manage tournament series rules` permission was dead code.**
  Defined in `ccsoccer.permissions.yml` but checked nowhere; all series edit routes
  required `administer ccsoccer`, so TDs could not reach any rules edit UI. Built a
  scoped editing surface (see below).
- **#2 (ACCEPTED RISK) — TD role has `use text format full_html`.** Unfiltered HTML
  = script injection capability. Andrew's decision: keep it. TD is a trusted board
  member; ~250-player league; content renders unchanged. Revisit with a filtered
  `trusted_html` format next year when rules are re-entered natively in CKEditor.
  The TD help guide includes a warning about pasting untrusted content.
- **#3 (OPEN — watch) — `filter_htmlcorrector` removed from full_html (`68d7545`).**
  Unclosed tags in rules content (pasted Google Docs HTML rendered via `|raw`) can
  break the surrounding page DOM. No safety net in the chain; the controller's
  `->processed ?? ->value` fallback would emit unfiltered text if processing failed.
  Low likelihood; fix properly when content is re-entered natively next year.
- **#4 (FIXED) — full_html image upload `max_size: '2'` = 2 bytes.** Broke every
  CKEditor inline image upload/paste (verified: 46KB paste failed). Now `'2 MB'` in
  `config/sync/editor.editor.full_html.yml`.
- **#5 (FIXED) — public rules page had `max-age 3600` but no cache tags.** Edits
  took up to an hour to appear for anonymous users. Added series entity cache tags
  in `ContentController::tournamentSeriesRules()` — saves now invalidate instantly.
- **Minor (OPEN):** `machine_name` has no uniqueness/format validation on the series
  form (bad slug would 404 via route regex; list page guards against it); inline
  `onclick="window.print()"` on rules template will need attention when CSP
  (checklist item 17) lands.

#### What was built — TD series rules editing

- **`src/Form/TournamentSeriesRulesForm.php`** (new) — edits ONLY the rules field.
  Integer route param per ConfirmFormBase gotcha. Logs each save. Warns when the
  series has no machine_name slug.
- **`src/Controller/TournamentSeriesRulesController.php`** (new) — list page at
  `/admin/ccsoccer/series-rules`: all series w/ rules status, public URL, Edit Rules
  buttons. Guards against invalid slugs (Url::fromRoute would otherwise throw).
- **`ccsoccer.routing.yml`** — two routes gated
  `_permission: 'manage tournament series rules+administer ccsoccer'` ('+' = OR).
- **`ccsoccer.links.menu.yml`** — Series Rules link under CC Soccer → Tournaments.
- **`config/sync/user.role.tournament_director.yml`** — granted the permission.
- **`HelpCenterController.php`** — new TD guide "Updating Tournament Series Rules"
  at `/admin/ccsoccer/help/tournament-series-rules` (navigation, editing, Drupal
  Media button, unpublishing, print/PDF).

#### Content setup — required ONCE PER ENVIRONMENT (content ≠ config)

Series records + rules content live in each environment's DB and do NOT deploy.
On each environment (Caleb likely did PROD already — verify the public page):
1. Admin: CC Soccer → Core Objects → Tournament Series → Add. Set name + machine
   name (e.g. `slo-friendly`).
2. Admin: edit the tournament, set its Tournament Series reference (enables the
   auto "View Tournament Rules" home page link).
3. TD or admin: CC Soccer → Tournaments → Series Rules → Edit Rules → paste/save.

#### Deploy — run in EACH environment (LOCAL → TEST → PROD)
Code + two config/sync files (TD role permission, editor max_size). No `updb`.
```bash
drush cim -y   # TD role permission + full_html image upload fix
drush cr       # new routes, menu link, form/controller classes
```
Verify after: TD sees Tournaments → Series Rules and can save; image paste works
in the rules editor; public rules page updates immediately after save.

---

## Session Work — July 3, 2026

### Security Review (Fable 5 session) — ✅ MOSTLY COMMITTED (`a96bc44`) + ⚠️ ONE STAGED FOLLOW-UP

Full security review of the custom `ccsoccer` module ahead of opening public season
registration, focused on commerce/checkout, the group_id UUID consistency, and
bot/abuse exposure. Findings + status tracked in
`CC_Soccer_Security_Review_2026_07_03.md` (summary table + dated resolution log).
Cross-referenced against the March 16 and June 13 reviews.

#### Fixed & committed

Commit `a96bc44` ("Harden checkout and group/admin actions…") covers A–F below.
J lands in the follow-up commit that also carries this SESSION_HANDOFF update.

- **A (CRITICAL) — CSRF on group action forms.** Added `_csrf_token: 'TRUE'` to the
  seven group routes (invite, nudge, delete, accept, decline, remove_member, leave)
  and restricted `accept` to POST. Converted the pending-invitations banner decline
  link (`RegistrationController::buildPendingInvitationsSection`) to a `#type => link`
  render element so it carries a valid token. The Twig forms need no change — Twig
  `path()` auto-appends the token to each form action.
  Files: `ccsoccer.routing.yml`, `src/Controller/RegistrationController.php`.

- **B (HIGH) — Checkout idempotency guard.** `OrderCompleteSubscriber::onOrderPlace`
  returns early if the order carries `ccsoccer_completion_processed`, and sets that
  flag after credits + registrations (before confirmation emails). Prevents a
  re-dispatched place transition from re-spending/donating credits or resending
  confirmations. File: `src/EventSubscriber/OrderCompleteSubscriber.php`.

- **C (HIGH) — Credit reconciliation.** `processCredits` compares credits actually
  deducted at placement against the amount discounted at checkout; on a shortfall it
  logs an error and flags the order `ccsoccer_credit_shortfall`. Same file.

- **F (MEDIUM) — Paid-but-unregistered season race.** Both season dead-end branches
  (season toggled inactive; season full) now flag the order
  `ccsoccer_season_registration_failed` (with `reason` + counts) instead of silently
  keeping the payment. Same file.

- **D (MEDIUM) — CSRF on override actions.** Added `_csrf_token: 'TRUE'` to override
  extend/nudge/revoke, and rebuilt the Active Overrides table
  (`OverrideController::buildActiveOverridesTable`, renamed from `…Markup`) as a
  render array so the action links carry a valid token. Bonus: table cells now
  auto-escape player names/labels.
  Files: `ccsoccer.routing.yml`, `src/Controller/OverrideController.php`.

- **J (INFORMATIONAL) — Permission hygiene.** (Committed with this handoff.) Added `restrict access: true` to nine
  financial/PII/mass-comm permissions (view reports, issue credits, manage
  registrations, manage waitlist, send notifications + the four report perms) and
  clarified the `view reports` description. Advisory only — adds the "grant to
  trusted roles only" warning on People → Permissions; changes no actual access.
  Confirmed these surfaces are board/TD/admin only (NOT exposed to players/public);
  the earlier "exposes every player's ledger" wording was corrected in the review.
  Files: `ccsoccer.permissions.yml` + `CC_Soccer_Security_Review_2026_07_03.md`.

#### Documented for decision / deferred (no code)

- **H (MEDIUM) — Invite autocomplete leaks member emails.**
  ✅ **RESOLVED July 5 — Andrew chose masking; implemented (see July 5 (later)
  section above). Uncommitted, needs manual test.** Any logged-in user can harvest
  member names + emails via the invite search. Recommended fix: mask the email AND
  stop sending the raw email in the response (inviting already resolves the email
  from uid, `GroupController::invite` ~L790). Andrew is leaning toward masking.
  Worth closing before the public sign-up email goes out.
- **Flagged-orders admin report** — the two new flags below join the existing
  `ccsoccer_team_name_collision` / `ccsoccer_tournament_full` /
  `ccsoccer_team_capacity_exceeded` (checklist item 18). No admin surfacing yet;
  design in `FLAGGED_ORDERS_REPORT_PROPOSAL.md`. Deferred — at ~200 players/season a
  paid-but-no-product player self-reports, and all flags log at error level to
  dblog. Interim: filter `/admin/reports/dblog` by the `ccsoccer` channel.

#### Still open (not started; recommended order)

- **E (MEDIUM)** — `JerseySelectionPane::submitPaneForm` trusts the submitted
  `variation_id`; verify it is a jersey (SKU `JERSEY-%`) before adding to cart.
- **I (LOW)** — Scrub + rotate Authorize.net **test** creds and the reCAPTCHA
  **secret** key from git (low urgency; sandbox / domain-locked; live creds already
  out of git).
- **K (LOW)** — Generic exception messages (stop returning `$e->getMessage()`), move
  the inline `onchange` in `ReportController.php:393`, escape admin notes on any
  future display.
- **G residual** — `invitee_email` fee-waiver spoof: gate the waiver on the `invitee`
  reference before team fees scale next year; plus docblock cleanup
  (`GroupInvitationsForm.php:24`, `Team.php:655-657`) now that group_id is populated.

#### New order data flags introduced this session
- `ccsoccer_credit_shortfall` — credits discounted > credits actually deducted.
- `ccsoccer_season_registration_failed` — season inactive/full at completion; payment
  collected, no registration. Both include a `detected_at` timestamp.

### ⚠️ ANDREW + CALEB — REVIEW: Anonymous checkout is possible (before public launch)

**UPDATE July 5: step 2 (remove `access checkout` from anonymous) DONE on LOCAL —
see July 5 (evening) section. Steps 1, 3, 4 still open.** Full write-up:
`ANONYMOUS_CHECKOUT_LOCKDOWN.md`.

Prompted by Caleb's observation that Wayne bought the Team Fee product while not
logged in (order 61 — see item 34). Verified root cause in `config/sync`:

- The checkout flow's **`login` pane is `_disabled`**
  (`commerce_checkout.commerce_checkout_flow.ccsoccer.yml`) — checkout never forces
  authentication.
- The **anonymous role has `access checkout` + `view commerce_product`**
  (`user.role.anonymous.yml`).

So checkout permits guests. Season/tournament/jersey are protected only *per-product*
(login-required add routes + no add-to-cart on their product pages + anonymous
registrations blocked in `OrderCompleteSubscriber`) — **not** by a global login gate.
Any product that exposes the standard add-to-cart form is anonymously buyable: the
**Team Fee** product, any **`default`**-type product, and the leftover
**`security_metrics_test_product_ty`** test product (still in config).

**Why it matters:** orphaned/anonymous orders, and — bigger — **card testing against
the live Authorize.net gateway** (bots validating stolen cards → fees, chargebacks,
merchant-account risk). This surface gets probed right when the site goes public.

**Invite flow is NOT affected** — invited players authenticate (create login) before
their cart/checkout, so locking down anonymous checkout does not break invites.

**Proposed (see doc for full plan + test steps):**
1. Re-enable the `login` checkout pane, require login / guest checkout off — primary
   fix; **needs LOCAL→TEST testing** (custom flow).
2. Remove `access checkout` from anonymous — low risk, defense-in-depth.
3. Remove the `security_metrics_test_product_ty` test product type/product — low risk.
4. Keep Team Fee unpublished; make next year's team-fee flow login-required (item 34).

**Decision needed:** Andrew + Caleb to confirm approach and who runs the TEST-gateway
verification before PROD.

#### Deploy — run in EACH environment (LOCAL → TEST → PROD)
All changes are **code only** (routes, event subscriber, controllers, permissions).
No `drush cim`, no `drush updb`.
```bash
drush cr    # rebuilds router (new _csrf_token requirements), service container,
            # and permission list (so restrict-access warnings show)
```
After deploying, quick manual checks: accept/decline an invite + leave a group still
work; override Extend/Nudge/Revoke still work; a test checkout still completes.

---

## Session Work — July 2, 2026

### Rules Page CSS + Print Styles — ⚠️ READY TO PUSH

`web/modules/custom/ccsoccer/css/content-pages.css` — added `.tournament-rules-page` styles and `@media print` rules hiding nav/header/footer/print button. Deploy with `ccsDeploy && ccsCr` (no updb or cim needed).

#### Deferred / nice-to-haves for Andrew to review:
- **OL numbered list rendering** — CKEditor5 `data-list-item-id` attributes cause columnar layout on output. Workaround: use bullet lists only for now. Root cause is likely `.breadcrumb ol` CSS bleeding + CKEditor5 list plugin behavior. Fix: scope breadcrumb CSS more tightly and investigate stripping `data-list-item-id` on render.
- **Print dialog title** — browser prints "Tournament Rules" as page header (from route `_title`). Can't be hidden via CSS (browser UI). Option: make controller set title dynamically from series name so it reads "SLO Friendly Rules" instead. Deferred.
- **Logo centering** — image embedded via media entity in a table, but `text-align:center` on `<td>` not centering it. Needs investigation. Deferred.
- **Logo resize** — logo currently full-width. Should be constrained to a reasonable size (e.g. 200–300px). Can be done via CSS on `.tournament-rules-page__body figure` or `drupal-media` wrapper.

---

### Tournament Series Rules Page — ✅ COMMITTED, READY TO DEPLOY

Added a dedicated public rules page for tournament series (e.g. SLO Friendly). Replaces the previous pattern of hand-typing a rules PDF URL into the tournament description field each year.

#### What was built:
- **`machine_name` field on `TournamentSeries`** — explicit URL slug (e.g. `slo-friendly`), set once at creation. Stable even if series name changes.
- **`rules` field on `TournamentSeries`** — `text_long` WYSIWYG (`full_html` format). Tournament director edits rules directly in Drupal.
- **Route `/tournament-series/{machine_name}/rules`** — public, renders rules only. 404s if series not found or rules empty.
- **`manage tournament series rules` permission** — scoped so tournament directors can edit rules without full series edit access.
- **Auto-link on home page tournament card** — `ContentController::homePage()` checks if tournament has a series with `machine_name` + `rules` populated and auto-renders a "View Rules" link. No manual URL pasting needed year to year.
- **`ccsoccer_update_9069`** — idempotent helper adds both fields to `tournament_series` table.

#### Files changed:
- `src/Entity/TournamentSeries.php`
- `ccsoccer.install` (Phase 31 + update hook 9069)
- `ccsoccer.module` (hook_theme registration)
- `ccsoccer.permissions.yml`
- `ccsoccer.routing.yml`
- `src/Controller/ContentController.php` (new `tournamentSeriesRules()` + updated `homePage()`)
- `templates/ccsoccer-tournament-series-rules.html.twig` (new)

#### Notes:
- The rules field stores `full_html` format. Content was pasted from Google Docs — inline styles are heavy. Next year type natively in CKEditor5 for cleaner output.
- Image in the rules (logo) renders on output but goes blank when re-editing in CKEditor5 — resolved by Media module (see below).
- Remove the hand-typed rules link from the SLO Friendly 2026 tournament description field once confirmed working on all environments.

---

### Media Module — ✅ COMMITTED, READY TO DEPLOY

Enabled `media` and `media_library` (both Drupal core). Configured Image and Document media types. Wired Drupal media button into `full_html` CKEditor5 toolbar.

#### What was done:
- `ddev drush en media media_library -y`
- Deleted Audio, Video, Remote Video media types (not needed for a soccer league site)
- Kept Image and Document types
- Added **Drupal media** button to `full_html` CKEditor5 active toolbar
- Enabled **Embed media** filter on `full_html` format (Document + Image types allowed)
- Added `full_html` access to **Tournament Director** role
- `drush cex` captured all config

#### Deploy steps — run in EACH environment (LOCAL → TEST → PROD):
```bash
drush cim -y    # imports media module enable + type config + editor config
drush cr
```
No `updb` needed — media module has no update hooks for this setup.

#### After deploying to each environment:
Upload media items (logo image, any PDFs) manually on each environment — content doesn't deploy via config.

#### Future / next year:
- Re-enter rules content natively in CKEditor5 (don't paste from Google Docs) for clean markup
- Use Media Library to insert logo image properly so it round-trips through the editor
- Consider TCPDF-based `/tournament-series/{machine_name}/rules/pdf` route for server-generated PDF download
- Print CSS for rules page (hide nav/header/footer on `window.print()`) — quick win

---

## Session Work — June 26, 2026 (later)

### Consistent player search across all surfaces — ⚠️ READY TO PUSH (branch `fix/search`)

Made every place an admin or player searches for another player behave the same way: split a multi-word query on spaces and AND the terms together, so each term must match at least one field (username, first name, last name, or email). This fixes first+last name searches that previously returned nothing because the whole string was matched against each field individually and first/last names live in separate fields.

#### Root cause
The improved invite search (commit `cd01fd7`) already split terms, but the other search surfaces still matched the full string (`%Nathan Perry%`) against each field, which can never match a record whose first name is `Nathan` and last name is `Perry`. So first-only and last-only worked, but first+last together returned 0.

#### What changed (4 files)
- **`src/Plugin/EntityReferenceSelection/UserByNameSelection.php`** — the masquerade footer autocomplete. Splits terms across `name` / `field_first_name` / `field_last_name`; falls back to default behavior on empty/whitespace input.
- **`src/Controller/PlayerAdminController.php`** — the All Players admin page (`/admin/ccsoccer/players`). Splits terms in both the result query and the count query.
- **`src/RegistrationListBuilder.php`** — `findUserIdsByName()` used by the registration list. Splits terms across name/mail/first/last.
- **`js/season-players.js`** — the shared client-side filter for the Season **and** Tournament player pages. Splits the search term so any word order matches (e.g. `Perry Nathan`), and team-name search keeps working. (The tournament page already worked for full names because `data-name` holds `First Last`; this makes reversed order consistent too.)

The player **invite** search (`GroupController::userSearch`) was already correct and was left untouched.

#### Deploy note (same stale-asset gotcha as below)
`season-players.js` is served with a cache-busting query string that only regenerates on cache rebuild. After deploying, run `drush cr` in each environment or the old JS keeps being served — same root cause as the tournament team-search finding documented in the earlier June 26 session below.

#### Unrelated note — `default.settings.php`
After pulling Caleb's `composer.lock` core bump (11.3.11 → 11.3.13) and running `composer install`, `drupal/core-composer-scaffold` re-copied `web/sites/default/default.settings.php` from the new core template. The only change is an added commented-out doc block for `media_oembed_discovery_trusted_host_patterns` — all comments, no active code, and it is the template (not the live `settings.php`). Should be committed **separately** from the search work (belongs with the core upgrade), not folded into the `fix/search` commit.

---

## Session Work — June 26, 2026

### Hotfix: Tournament `group_id` mismatch from checkout registrations — ⚠️ READY TO PUSH

A hotfix (not a feature). Fixes the disconnect where players who joined a tournament team **through checkout** showed up on the team roster but not in the captain's group — invisible in the Roster Builder yellow group and uncounted on the Group Invitations admin page, even though Manage Team and Tournament Teams views showed them.

#### Root cause
The Team entity `group_id` is a **UUID** and is the single source of truth for group membership (Roster Builder yellow grouping, Group Invitations admin page, `GroupController` / `RegistrationController` join flows). `update_9036` migrated existing data from the old deterministic format `team_<teamID>_<captainID>` to that UUID — but `OrderCompleteSubscriber` was never updated and kept writing the legacy string for every new tournament registration (create / token_accept / join branches). So checkout-registered players got a `group_id` that never matched their team UUID. Every place that compares `group_id` dropped them from the group; views that read the team `players` list still showed them, which is why it looked inconsistent.

#### What changed
- **`src/EventSubscriber/OrderCompleteSubscriber.php`** — all three join branches now route through a new `getOrCreateTeamGroupId($team)` helper that returns the team's UUID `group_id` (generating + saving one only if absent), matching the rest of the system. Legacy `team_<id>_<id>` string writes removed.
- **`ccsoccer.install`** — added idempotent update hook `ccsoccer_update_9068()` + helper `_ccsoccer_repair_legacy_team_group_ids()`. Repoints existing registrations whose `group_id` starts with `team_` to their own team's UUID and sets `invitation_status = accepted`. Leaves NULL `group_id`s untouched to preserve genuine free agents. Called from `hook_install()` per module convention. Safe to re-run (no-op after first pass).

#### Unrelated second finding (NOT a code fix)
Team search on the Tournament Players page works locally but returned 0 on PROD. This is a stale-asset issue, not a data bug: the team-aware search (`team.includes(searchTerm)` in `js/season-players.js`) shipped in commit `931b193`. PROD was serving the older aggregated JS. Resolved by `drush cr` after confirming `931b193` is deployed.

#### Deploy steps — run in EACH environment (LOCAL → TEST → PROD)
Deploy code first, then:
```bash
drush updb -y      # runs ccsoccer_update_9068 (data repair); logs repaired count
drush cr           # clears caches; rebuilds aggregated JS → fixes team search
```
Local DDEV: `ddev drush updb -y && ddev drush cr`. `updb` tracks per-environment so the repair applies exactly once each; re-running is safe.

#### Verification
- `drush updb` prints "Repaired N registration(s)…"; same count goes to watchdog/dblog.
- Roster Builder: previously-orphaned checkout players now appear in the captain's yellow group.
- Group Invitations admin page: those players now count as Accepted instead of being missing.

---

## Session Work — June 23-24, 2026

### Team-Paid Tournament Registration — ✅ COMPLETE, LIVE ON PROD

Full feature shipped to LOCAL → TEST → PROD. All three environments in sync on main.

#### What was built:
**Team Fee product type** (`team_fee`) — Commerce product type for whole-team tournament entry fees. Mirrors `tournament_registration` with `field_tournament` tie. Single variation per product, cart view display configured. Config in git; deployed via `cim`.

**`team_paid` flag on Team entity** — Boolean field, admin/TD-only (form-visible, view-hidden). Defaults FALSE. Set on Team edit form; not surfaced to captains this year. Next year the Team Fee product checkout will set it automatically. Deployed via `ccsoccer_update_9067`.

**TournamentTeamPane reshape** — Tight model: uninvited players see no join option. Invited players see per-team radio ("Join [team name]"), preselected when only one invite. Unified invite resolution: session token + invitee ref + exact email match. Session token validated against current user (security fix — stale tokens self-clear on masquerade switch). Dead code removed (getExistingTeams, attachDisabledTeams, tournament-team-select JS, elseif 'join' in processAction). Full teams omitted from invited options (radios have no per-option disabling). OrderCompleteSubscriber untouched — pane emits existing `token_accept` contract.

**TeamPaidOrderProcessor** — Line-item adjustment (not order-level) zeroes the `tournament_registration` line when `action = token_accept` AND `team.team_paid = TRUE`. Priority 200, runs before DiscountOrderProcessor (100). Dark deploy: no-op until an admin marks a team paid.

**DiscountOrderProcessor fix** — Now skips items already covered by a `team_paid_registration` adjustment. Prevents negative totals when a board member (100% discount) registers on a paid team. Discountable amount computed per-item so deposits/other items still get the board discount.

**Welcome email fix** (`user.mail.yml`) — Restored `[user:one-time-login-url]` to `register_no_approval_required` body. Also added `[site:login-url]` and clearer activation copy. The token had been dropped in commit 038a223 (May 19), leaving only a static `/register` link that contradicted the confirmation page.

#### To activate for Bakersfield / Wayne:
1. ~~Create "Team Fee — SLO Friendly 2026" product on PROD~~ ✅ DONE — [/products/team-fee-slo-friendly-2026](https://ccsoccer.com/products/team-fee-slo-friendly-2026)
2. Wayne adds to cart and pays.
3. Admin checks `team_paid` on their team at `/admin/ccsoccer/team/{id}/edit`.
4. Captain invites players by email.
5. Players register → price drops to $0 automatically.
6. Refund-not-cancel for anyone who already registered individually before flag was set.

#### Known deferred items from this feature:
- CSS: "Join [team name]" radio label not rendering bold/larger (`:has()` selector not firing or Safari cache). Cosmetic only.
- Email-match entitlement: `invitee_email` match grants waiver; Drupal doesn't re-verify email on change. Low risk at $35 this year; revisit when team fees scale next year.
- Completion-race: if a team-paid team fills between zeroed checkout and order completion, player gets free entry without being on the paid roster. Rare edge case, no fix planned yet.
- Next year: Team Fee checkout writes `team_paid` flag automatically; captain sees "team paid" badge on dashboard; `team_paid` + deposit swap at captain checkout; board approval needed for public team-fee path.

#### Branch cleanup (when feature has baked in PROD, no rush):
```bash
git push origin --delete team-paid-registration
git branch -d team-paid-registration
```

---

## Current State

### 🎉 SITE IS LIVE at ccsoccer.com
Soft-launch mode. Board members + beta testers testing. Tournament registration open for SLO Friendly 2026. Team-paid feature live and dark (no-op until a team is flagged).

### Code — LOCAL, TEST, PROD all in sync on main

---

## 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)** — Smart IP + Country Block. Smart IP needs IP2Location LITE BIN file (free, monthly update). Try before Cloudflare.
   - 6b. **Cloudflare geo-blocking (fallback)** — requires DNS rerouting through Cloudflare proxy.

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. ✅ DONE — Security Metrics test product.

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

---

### Mid-term

12. ~~`test@ccsoccer.com` mailbox / from address.~~ ✅ DONE — June 20, 2026.

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. Admin view/report to surface orders flagged `ccsoccer_team_name_collision`, `ccsoccer_team_capacity_exceeded`, `ccsoccer_tournament_full`.

19. Confirm InMotion server's transactional email passes SPF/DKIM under DMARC `p=quarantine`.

20. Module/core updates — ready to run next weekend once team-paid has baked in PROD.

---

### Post-Launch / First 72 Hours

21. ✅ DONE — Assign `permanent_override` role.

22. ✅ DONE — Verify credit balances against D7.

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

24. Remove `beta_tester` role from all users.

25. `slofriendlysoccer.com` URL forward.

26. Archive/delete D7 waivers; eventually delete `ccsoccer_site_d7_archive/`.

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

28. Add Devel back to TEST.

---

### Development (Deferred)

29. Three-tier button methodology pass.

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

31. Team handling refactor.

32. Profile picture migration — board decision pending.

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

34. `#slofriendly` `#tournament-nextyear` — Team Fee product can be purchased anonymously (not logged in). Order 61: Wayne (Bakersfield captain) bought "Team Fee — SLO Friendly 2026" as Anonymous (not verified), no user account tied to the order. Not fixed now — Caleb manually reconciling Wayne's team + disabling the product. Next year's productized team-fee flow should require login (or capture/match purchaser identity) before allowing purchase.

---

## 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

### Local invite email blocking
Non-production environments block invite emails to non-allowlisted addresses by design
(watchdog: "Email blocked to @to (not in board allowlist)"). This is correct behavior —
emails are generated but not sent. To test invite email content on LOCAL, invite a
board-member email address; it will land in Mailpit.

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

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

Override pattern: `$config['system.site']['mail'] = 'test@ccsoccer.com';` in `settings.local.php`.

### 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. LOCAL intentionally disabled — protected via `config_ignore`.

### 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`

### Drupal Update Notification Settings (per-environment, protected via config_ignore)
- **TEST**: weekly, `noreply@example.invalid`, security updates only
- **PROD**: weekly, `ccsoccer@ccsoccer.com`, all newer versions

---

## Key Facts / Gotchas

### config_ignore — environment-specific config that must never sync
```yaml
ignored_config_entities:
  - 'commerce_payment.commerce_payment_gateway.*'
  - 'update.settings'
  - 'recaptcha.settings'
  - 'captcha.captcha_point.*'
```
**When adding entries:** hand-edit the file directly, `git add` by name only — never blanket `cex`.
**After changing:** run `drush cim` on LOCAL to import the updated ignore list into active config.

### config drift check
```bash
drush config:status
```
Run before any `cex`, after deploys, and before merging feature branches. Reports `Only in sync`
(not imported), `Only in active` (drift — not exported), and `Different` (mismatch). `Different`
is the dangerous one. Edit config YAML directly in `config/sync` rather than via UI to avoid drift.

### Role config drift on LOCAL — unresolved, watch for recurrence
June 16: `user.role.anonymous`, `user.role.board_member`, `user.role.tournament_director`
showed unexpected drift. Cause unknown. Reverted via `drush cim`.

### 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)

### Commerce cart view mode
The cart's "Item" column renders the variation in its `cart` view mode (not the order item title).
New product types need `core.entity_view_display.commerce_product_variation.{type}.cart.yml`
or the cart falls back to rendering fields (showing "Price" as the label).

### 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
```

### TournamentCancelRegistrationForm — hidden number field gotcha
Remove `#min`/`#max` from hidden number fields; validate server-side instead.

### ConfirmFormBase + entity-typed route parameters
Do NOT put entity-typed parameters in route paths for `ConfirmFormBase` forms — use raw integer only.

### DMARC aggregate reports
Daily from `noreply-dmarc-support@google.com` and `dmarcreport@microsoft.com`.
Gmail filter to skip inbox + label. Check after any outbound mail config change.

### team_paid flag
Set on Team entity edit form (admin/TD only). Not visible to captains. Dark deploy — no-op
until explicitly set. Next year: Team Fee product checkout sets it automatically.
TeamPaidOrderProcessor (priority 200) applies a LINE-ITEM adjustment (not order-level) so
getSubtotalPrice() reflects the zero — prevents double-discount with DiscountOrderProcessor (100).

---

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

# 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
