# CC Soccer D11 - Session Handoff
**Date:** April 17, 2026
**Branch:** `main`

---

## Current State

### Code ✅ LOCAL + TEST IN SYNC
All of Caleb's and Andrew's work is merged to main and deployed to TEST.

### Migration ✅ COMPLETE — TEST NOW ON LIVE DATA
- Fresh D7 dump taken April 16, 2026 from production D7 DB
- Migration run locally: 1,727 users, 581 credits, 5,072 registrations, 42 seasons
- Exported as `d11-beta-2026-04-16-clean.sql.gz` (DEFINER-stripped)
- Imported to `n6ac4b5_d11prod` on InMotion
- TEST site (`test.ccsoccer.com`) now points at `n6ac4b5_d11prod` — **real user data, real emails**
- All board member roles assigned (including kaimark)

### Passkeys ✅ DEPLOYED TO TEST
- `drupal/wa` 2.0.0-rc7 installed locally and on test.ccsoccer.com
- Touch ID / passkey login working
- Password login and reset still work as fallback

### Server Aliases ✅ SET UP
Bash aliases saved to `~/.bashrc` on InMotion server:
- `ccsDeploy` — cd to site dir + git pull
- `ccsCr` — drush cr
- `ccsUpdb` — drush updb -y
- `ccsCim` — drush cim -y
- Full deploy: `ccsDeploy && ccsUpdb && ccsCim && ccsCr`

### Deploy Key ✅ SET UP
SSH deploy key configured on InMotion — `ccsDeploy` no longer prompts for GitHub credentials.

---

## Session Work — April 17, 2026

### Commerce Store ✅
Created CC Soccer store via admin UI (content entity, not config — doesn't survive DB import). Order type confirmed pointing to custom checkout flow. Payment gateways confirmed sandbox + manual — intentional for TEST.

### Jersey Notification Opt-in ✅
- Added `field_jersey_notifications` boolean to user entity via `ccsoccer_update_9062`
- Added "Jersey Alerts" checkbox column to `BoardContactPreferencesForm`
- `sendJerseyPurchase()` in `NotificationService` now queries only board members with `field_jersey_notifications = TRUE` instead of all board members
- Respects existing `field_board_email` / `field_board_phone` preferences
- Tested locally via Mailpit — only opted-in members received notification
- **Action needed on TEST:** Go to Board Contact Preferences and check Jersey Alerts for appropriate members

### Completion Pane — Non-registration Orders ✅
- `CompletionPane::buildPaneForm()` now has three branches: season reg, tournament reg, no-reg (jersey-only)
- Jersey-only shows "Order complete!" / "Your order has been confirmed." and redirects to My Account
- Previously showed "You're registered!" for all order types

### Beta Tester Role ✅
- `config/sync/user.role.beta_tester.yml` added — no permissions, weight 4
- `getAllowedContacts()` in `NotificationService` now includes `beta_tester` role alongside `board_member`
- Beta users get their own transactional emails on TEST without board notification access
- At launch: remove role from users — gating disappears automatically when `site_instance = production`
- Add beta user IPs manually to `.htaccess`

### Pre-launch Checklist Updated
- Added three-tier button methodology pass to checklist

---

## Session Work — April 25, 2026

### UPDATE_WORKFLOW.md ✅
New top-level doc covering `composer install` vs `composer update`, conflict resolution, and the producer/consumer split between developers. Reference for the team to avoid simultaneous lockfile bumps.

### Admin Tables — Mobile Scroll Wrapper ✅
- `js/admin-mobile.js` wraps every admin table in a focusable `<div class="ccsoccer-table-scroll-wrapper">` at runtime
- `css/admin-mobile.css` styles the wrapper (`overflow-x: auto`, iOS momentum scrolling, keyboard focus ring, print styles)
- Library `ccsoccer/admin-mobile` attached on admin routes via `ccsoccer_page_attachments()`
- Fixes mobile portrait AND desktop-with-sidebar-open in both Claro and the public theme (board members / tournament directors lack the use admin theme permission and see admin pages in the public theme)
- Wrapper has `tabindex=0`, `role=region`, `aria-label` for keyboard/screen-reader users

### Dashboard — Current Seasons Filter ✅
- `buildSeasonsOverview()` in `AdminController.php` now filters to `end_date >= today` and sorts by `start_date ASC`
- Past seasons hidden; empty-state message differentiates "no seasons created yet" vs "all existing seasons have ended"
- Affects `/admin/ccsoccer/dashboard` and `/admin/ccsoccer/board-dashboard` (shared rendering)

### Welcome Banner Legibility ✅
- "Board Member Tools" / "Tournament Director Tools" headings now render in white on the dark welcome banner
- `.dashboard-section.welcome-section` background defined explicitly with `--color-gray-800`, text with `--color-text-inverse` so the banner looks the same in Claro and the public theme

### Credits Overview Dashboard ✅
- New `CreditsDashboardController::dashboard()` replaces the empty default `EntityListBuilder` rendering at `/admin/ccsoccer/credits`
- Route `entity.credits.collection` repointed to the new controller; menu and quick-link references unchanged (still resolve through the same route name)
- Page surfaces four stat cards (Outstanding active, Issued L30D, Used L30D, Net L30D), Recent Credit Events table (last 10 `SeasonCreditEvent` records, links to `ccsoccer.season_credits`), and Top Player Balances table (top 10 active, unexpired, links to `ccsoccer.admin_user_credits`)
- Direct DB queries with `SUM`/`GROUP BY` for stats; user-reference column resolved via `TableMapping` rather than hardcoded
- Cache: invalidated by `credits` and `season_credit_event` list cache tags + 5-minute `max-age` backstop
- Inline `<style>` block plus a piggyback on the existing `ccsoccer/season-credits` library — no new CSS file or theme hook
- Reachable from admin sidebar, Admin Dashboard quick link, and Board Dashboard quick link (all three already pointed at `entity.credits.collection`)

### Tournament Director Dashboard ✅
- `AdminController::directorDashboard()` no longer renders a "coming soon" stub
- Welcome copy updated; `buildDirectorQuickLinks()` extended with three new links: Tournament Schedule (public, `ccsoccer.tournament_schedule`), Tournament Deposits / Captain Dashboard (`ccsoccer.reports.tournament_deposits`, gated by `access tournament deposits report`), Help Center (`ccsoccer.help_center`)
- New `buildTournamentsOverview()` renders one card per active/upcoming tournament (filter `end_date >= today`, sorted by `start_date ASC`)
- Each card shows: name (link to canonical), status badge with color coding (planned / registration_open / in_progress / completed / cancelled), date range, three stats (Registrations, Teams `count / max_teams`, Captains), and action buttons: Roster, Schedule, Teams, Players, Deposits (Deposits hidden for users without the report permission)
- Empty-state distinguishes "no tournaments created yet" vs "all existing tournaments have ended"; "Add a tournament" CTA only renders for users with `administer ccsoccer` so pure directors don't see a 403 link
- CSS appended to `getDashboardStyles()` — no new files

### Help Center Accuracy Pass ✅
Six articles in `HelpCenterController.php` rewritten to match actual UI behavior — several referenced fields/buttons that do not exist:
- **Creating a Season**: Step 1 leads with recommended Clone path (Operations dropdown on the Seasons list) over Create-from-scratch. Step 2 corrected — Registration Visible checkbox is a required gate alongside the date window (the old "auto-opens" claim was wrong).
- **Roster Builder**: removed bogus Save Assignments button references (changes auto-save on drag). Notification tip rewritten — notification tells players rosters are ready, players log in to see their team. Added explanation of the three bottom-of-card stats (player count, average skill, average age).
- **Schedule Builder**: full rewrite. Added Step 1 Configure (Start/End Date, Day of Week, Time Slots with number-of-fields explanation, Bye Weeks, Reset to defaults). Documented auto-save on drag/drop. Added Snapshots section. Added Step 4 covering the Make Visible to Players button (single-click action that publishes games and flips both Schedule Visible + Roster Visible flags) and the paired Hide from Players button.
- **Managing Credits**: Issuing a Manual Credit no longer points at the Credits dashboard (no Add Credit button there). Documents two real paths: Seasons → season → Players → Credits (Admin), and Core Objects → Players → Credits (Admin). How Credits are Applied corrected — credits are not auto-applied; checkout has a Credits step where the player picks Apply / Keep / Donate.
- **Waitlist & Overrides**: added "How Reserved Spots Work" section with the Max-minus-Reserved model and a 144/10/134 worked example. Waitlist works in tandem with Reserved Spots — offered spots come from the reserved pool. Overrides also draw from the same pool, with a planning hint to size Reserved Spots accordingly.
- **Deactivating Seasons**: replaced the wrong Status field steps (no Status field exists; form has four boolean checkboxes). Documents that unchecking Active is the master switch since every public query filters on it. Added explicit fact-check that the system does NOT auto-hide a season after End Date — manual deactivation is required.

### Game Status Added to Admin Quick Links ✅
- Game Status was already a quick link on the Board Member Dashboard but missing from the Admin Dashboard. Added between Credits and Notifications in `buildQuickLinks()` so it appears on `/admin/ccsoccer` with the same icon (🎮) and description as the Board Dashboard tile.

### Games Quick Link Removed ✅
- `entity.game.collection` (`/admin/ccsoccer/games`) was the same broken-default-EntityListBuilder pattern as the old Credits page — only an Operations header, no data columns. Unlike Credits, there was no compelling cross-season use case to justify building it out: per-season schedule, schedule builder, Game Status tool, and reports already cover game workflows at the right granularity.
- Removed the "Games" tile from `buildBoardQuickLinks()` in `AdminController.php` and the `ccsoccer.games` link from `ccsoccer.links.menu.yml`. Inline comment left in `AdminController.php` explaining why.
- Route itself kept untouched so individual game canonical / edit / delete URLs continue to resolve when referenced from the schedule builder, etc.

### Help Center Card Added to Admin + Board Dashboards ✅
- `buildQuickLinks()` (Admin Dashboard `/admin/ccsoccer`) and `buildBoardQuickLinks()` (Board Member Dashboard `/admin/ccsoccer/board-dashboard`) now both include a "Help Center" card alongside Reports, matching the Tournament Director Dashboard.
- Both surface the same `ccsoccer.help_center` route (`/admin/ccsoccer/help`); access stays gated by `HelpCenterController::helpCenterAccess()`. No permission guard added in the link itself — same pattern as the Director Dashboard.

### Tournament Teams Page — Pure Alphabetical Sort ✅
- `TournamentController::teams()` was sorting by `Team.weight` first, then alphabetically as tie-breaker. The `weight` field exists for the Roster Builder's drag-drop column ordering and was bleeding through to this flat admin list — teams clustered into weight buckets, alphabetical only within each bucket, so the overall order looked random (A→W, then back to A→S).
- Replaced with a pure case-insensitive alphabetical sort (`strcasecmp` on `$team->label()`). Roster Builder's own ordering logic untouched.

### Tournament Edit/Delete Permissions Realigned ✅
- `entity.tournament.edit_form`: `administer ccsoccer` → `manage tournaments`. Previous setting was an oversight — directors could add and delete tournaments but the edit form 403'd, including the "Edit Tournament" button on the canonical view. Add and edit go through the same `ContentEntityForm` exposing identical fields, so granting edit doesn't expand any capability.
- `entity.tournament.delete_form`: `manage tournaments` → `administer ccsoccer`. Tightened as a safety rail — destroying a tournament cascades to its registrations, credit links, schedules, and snapshots. Directors can now edit everything but only admins can delete. Comments added inline in `ccsoccer.routing.yml` explaining both changes.
- Season permissions left untouched: `manage seasons` is already de facto admin-only (no other role has it) and the named permission keeps a delegation hook open for future use without code changes.

### Tournament Players Page — Search + Sticky Header Fix ✅
- `/admin/ccsoccer/tournament/{id}/players` (178 rows on the SLO Friendly) now has a client-side search box matching the Season Players page — filters on Name / Login / Email via row `data-*` attributes
- `TournamentController::players()`: added `tournament-players-search-*` markup, a `tournament-players-no-results` message, and `data-name` / `data-login` / `data-email` attributes on each row
- `js/season-players.js`: `seasonPlayersFilter` behavior selectors widened to match both `.season-players-*` and `.tournament-players-*` class sets so the single behavior services both pages (no duplicate JS)
- `css/season-players.css`: parallel `.tournament-players-search-*` / `.tournament-players-no-results` selectors added alongside the existing season ones (mirroring the doubling pattern already used for the table itself)
- **Sticky-header overlap bug fixed**: when the table sits inside `.ccsoccer-table-scroll-wrapper` (added at runtime by `admin-mobile.js`), the wrapper becomes the sticky resolution context. The existing `.toolbar-fixed` offset rules (`top: 39px` / `top: 79px`) then pushed the header DOWN into the first row instead of holding it below the page's toolbar. Added a wrapper-scoped override that pins `top: 0 !important` so the header stays at the top of the table. Affects both Season Players and Tournament Players pages.
- No pagination added — 178 rows is comfortable for client-side filter; pagination would be over-engineering at this scale.

---

## Session Work — May 6, 2026

### Removed Legacy `field_credits_balance` from User Entity ✅
The "Credits Balance" decimal field on `/user/{uid}/edit` was leftover from the D7 migration. Nothing in the codebase reads it — the source of truth for credits is the custom **Credits entity**, summed live by `CreditManagerService::getUserBalanceCents()` and surfaced through `/user/{uid}/ccsoccer-credits` (admin) and the checkout Credits pane. Players were typing arbitrary numbers into the legacy field and seeing them persist, while the real credit total stayed at $0 — confusing and a support liability.

A previous update hook (`ccsoccer_update_9061`) deleted the field on existing DBs, but the YAML still lived in `config/sync/`, so any subsequent `drush cim` (or fresh install) re-imported the field. This session cleans that up properly.

**Files changed (3-belt cleanup):**

1. **Belt #1 — Config sync (the real fix).** Removes the field from the source-of-truth YAML so `drush cim` deletes it from active config / DB on every environment going forward.
   - **Deleted:** `config/sync/field.storage.user.field_credits_balance.yml`
   - **Deleted:** `config/sync/field.field.user.user.field_credits_balance.yml`
   - **Edited:** `config/sync/core.entity_form_display.user.user.default.yml` — removed dependency line and the `field_credits_balance:` block under `content:`
   - **Edited:** `config/sync/core.entity_view_display.user.user.default.yml` — removed dependency line and `field_credits_balance: true` under `hidden:`
   - **Edited:** `config/sync/core.entity_view_display.user.user.compact.yml` — same as default view display

2. **Belt #2 — Update hook.** New `ccsoccer_update_9063()` in `web/modules/custom/ccsoccer/ccsoccer.install`. Idempotently deletes the field instance + storage via the API. Catches any environment whose active config has drifted from `config/sync` and where `cim` alone wouldn't notice.

3. **Belt #3 — Form alter.** Added `field_credits_balance` to the `$hidden_fields` list in `ccsoccer_form_user_form_alter()` (`web/modules/custom/ccsoccer/ccsoccer.module`). Once Belts #1 and #2 land, this becomes a free no-op (`isset($form[...])` returns FALSE), but it's cheap insurance against the field accidentally getting re-created by a future config import gone wrong.

**Verification after deploy:**
- `/user/{uid}/edit` no longer shows the "Credits Balance" section for any user (player or admin).
- `/user/{uid}/ccsoccer-credits` admin view still works exactly as before — separate system, untouched.
- `drush sql:query "SHOW COLUMNS FROM user__field_credits_balance"` errors with "table doesn't exist" (proves the storage was dropped).

### Deploy Instructions

**Branch / PR:** Work was committed on `fix/remove_credits_from_my_profile` (commit `b2a7027`). PR should target `main`.

**For the dev pushing the change (Andrew, this session):**
```bash
# 1. Validate locally — apply the config you committed and run the new hook
ddev drush updb -y      # ccsoccer_update_9063 runs — should report "Removed legacy field_credits_balance" or "already absent"
ddev drush cim -y       # imports the cleaned config — accept any field-deletion prompt
ddev drush cr

# 2. Visit /user/<any-uid>/edit and confirm "Credits Balance" section is gone
# 3. Push the feature branch and open a PR
git push origin fix/remove_credits_from_my_profile
# open PR → review → merge to main
```

> ⚠️ **Do NOT run `drush cex`** at any point in this workflow — not before push, not after. `cex` exports your local active config back to `config/sync/`. If you run it before `cim`, your active config still contains `field_credits_balance` and `cex` will re-create the YAML files we just deleted, silently undoing the entire change. After `cim` runs, `cex` becomes a no-op. Either way, `cex` is unnecessary here — the source-of-truth edits were made by hand directly in `config/sync/` and committed to git. `cex` is only needed when you make config changes through the Drupal admin UI (which this PR did not).
>
> **Short answer to "do I need cim/cex to upload my local changes to main?":** No. Git push is the only thing that uploads code to main. `cim` is for *applying* config to a Drupal site; `cex` is for *capturing* config from a Drupal site. They don't move anything in or out of git — only `git push` does. Run `cim` locally only as a validation step (to prove the change works on your machine before you ask reviewers to look). Skip `cex` entirely.

**For other developers (after pulling main):**
```bash
git pull
ddev drush updb -y   # runs ccsoccer_update_9063
ddev drush cim -y    # applies the config/sync deletions
ddev drush cr
```
Verify locally by visiting `/user/<any-uid>/edit` — the "Credits Balance" section should be gone.

**For production / TEST deploy (via SSH aliases on InMotion):**
```bash
ssh ccsoccer
ccsDeploy && ccsUpdb && ccsCim && ccsCr
```
That's `git pull`, `drush updb -y`, `drush cim -y`, `drush cr` — the standard deploy alias chain set up earlier in this project. No special handling required. Watch the `cim` output for any "deletion" warnings on `field_credits_balance` and accept them — that's the expected path.

**Rollback note:** If the deploy goes sideways, reverting the merge on `main` then running `ccsDeploy && ccsCim && ccsCr` restores the YAML files and re-creates the field on the next `cim`. The DB column itself can't be auto-restored, but no real data is lost since nothing was reading from it. Update hook `9063` will be marked as run; if the field needs to come back, a new hook would re-create it via the API the same way `9062` added `field_jersey_notifications`.

---

## ⚠️ ANDREW — ACTION REQUIRED: Update Your Local DB

Your local DB has the old 200-user sample with masked emails. You need to replace it with the new production-ready migration DB. **Do not re-run the migration** — just import the finished DB directly. This is much simpler than what Caleb went through.

### Step 1: Download the DB from the server
```bash
scp ccsoccer:/home/n6ac4b5/d11-beta-2026-04-16-clean.sql.gz ~/Sites/ccsoccer-d11/
```
(Assumes your SSH alias is `ccsoccer` — adjust if different)

### Step 2: Import it into DDEV
```bash
cd ~/Sites/ccsoccer-d11
ddev import-db --file=d11-beta-2026-04-16-clean.sql.gz
```
This replaces your local DB content cleanly — no site:install, no cim, no UUID drama.

### Step 3: Pull latest code
```bash
git pull
ddev drush cr
```

### Step 4: Update your local settings.local.php D7 connection
Your `settings.local.php` needs a `d7` database entry pointing at the fresh D7 dump so the migration command works if you ever need to run it. Import the D7 dump first:
```bash
ddev mysql -e "CREATE DATABASE IF NOT EXISTS n6ac4b5_ccsoccer;"
gunzip -c /path/to/d7-fresh-2026-04-16.sql.gz | ddev mysql n6ac4b5_ccsoccer
```
(The D7 dump is at `/home/n6ac4b5/d7-fresh-2026-04-16.sql.gz` on the server if you need it)

Your `settings.local.php` should already have:
```php
$databases['d7']['default'] = [
  'database' => 'n6ac4b5_ccsoccer',
  'username' => 'db',
  'password' => 'db',
  'host' => 'db',
  'driver' => 'mysql',
  'prefix' => '',
];
```
If it doesn't, add it.

### Step 5: Log in locally
Admin credentials: username `admin`, password `TJ4XxyYGCd`

---

## Andrew's Recent Features (all merged + on TEST)

### 1. Role-based Help Center
`/admin/ccsoccer/help` — documentation for board members, admins, and tournament directors. Role-gated by existing permissions. Own CSS (`help-center.css`), controller (`HelpCenterController.php`), routing, menu link, and library entry. All hardcoded content in PHP methods.

### 2. Personalized next-game banner
Shows authenticated players their next upcoming game in the site header (team, jersey color, time, field). Moved from module `hook_page_top()` to theme `preprocess_page` + Twig. Cache contexts set to `user` + `max-age = 0`.

### 3. Schedule grid current-week fix
Grid now scrolls to the current/next week on load. Fixed in both `ScheduleGridBuilder::calculateCurrentWeekIndex()` and `schedule-navigation.js`.

### 4. My Teams "registered but not assigned" fix
Players registered for a season with no team yet see "teams haven't been assigned yet, check back soon" instead of the false "not registered" message. Three-state logic in `ContentController::myTeamsPage()`.

### 5. Mobile swipe for schedule
Swipe left/right to navigate weeks on mobile. Feature branch `feature/mobile_swipe_schedule_view` merged to main.

### 6. Login form PHP warning fix
Added `'#parents' => []` to passkey help text element in `ccsoccer.module` — fixes PHP warnings on failed login attempts.

---

## Beta Testing Notes

### Password Reset Flow
- TEST now has real user data — all migrated users have randomized passwords
- Users (including board members) must use "Forgot password" to get in
- Password reset emails are gated by `site_instance = 'test'` — only board members' emails go through
- Caleb confirmed the flow works end-to-end (email went to spam — SPF/DKIM needed before launch)

### Notification Gating on TEST
When `site_instance = 'test'`, `NotificationService` dynamically builds an allowlist from all users with the `board_member` role. Only their emails/phones go through. Everyone else is silently blocked.

### What Players See
- My Account shows current season registration ✅
- No team or schedule shown (none assigned yet — correct) ✅
- Registration page shows available seasons ✅

---

## Pending Decision — Passkey RP ID
Passkeys are domain-bound. Passkeys registered on `test.ccsoccer.com` will NOT work on `ccsoccer.com` at launch unless the RP ID is set to `ccsoccer.com` now.

**Action needed:** Determine if `drupal/wa` supports a custom RP ID. If so, set it to `ccsoccer.com` before users start registering passkeys on TEST.

---

## Next Steps

### 1. Pre-launch checklist
- Set up SPF/DKIM for ccsoccer.com domain (password reset emails going to spam)
- Remove IP whitelist block from `.htaccess` on launch day
- Confirm www vs non-www canonical redirect
- Confirm HTTPS handling
- Enable CSS/JS aggregation
- reCAPTCHA setup
- Set up two alias sets in `~/.bashrc`: `ccsTest*` (pointing to `~/test_ccsoccer_site`) and `ccsProd*` (pointing to `~/public_html`) for Deploy, Cr, Updb, Cim
- Create live `settings.local.php` in `~/public_html/web/sites/default/` with live Authorize.net credentials and `site_instance = production`
- Three-tier button methodology pass (primary solid red = main CTAs, primary-soft light red = navigation actions, white/outlined = informational)

### 2. Post-launch manual actions (cannot be scripted)
- Assign `permanent_override`: Haley, Layne, Julie, Myk, Kyle
- Send password reset email to all migrated users (all have random passwords)
- Verify credit balances for known users against D7
- Check credits/registrations issued in D7 after April 16, 2026 dump date

### 3. Team handling refactor (discuss with Andrew first)
Eager team creation on season save → lazy creation when Roster Builder opens.
Do not implement without consulting Andrew — he built the roster builder.

### 4. Resolve Passkey RP ID decision (see above)

### 5. `slofriendly` role reference
During `cim` a warning appeared: `Config user.role.slofriendly not found`. There's a reference to this old role somewhere in module install hooks. Not breaking, but worth cleaning up before launch.

### 6. Top-level "CC Soccer" menu item — admin landing (discuss with Caleb)
**Open question, no code changed yet.** The top-level `ccsoccer.admin` menu link in `ccsoccer.links.menu.yml` always routes to `ccsoccer.board_dashboard` (`/admin/ccsoccer/board-dashboard`) — including for administrators. Admins have to hover the menu and select "Admin Dashboard" to reach `/admin/ccsoccer`, which has the more complete quick-link set (Leagues, Seasons, Tournaments, Players, Overrides, Waitlists, etc.).

**Proposed two-tier fix:** make the top-level menu link's route conditional on permission via `hook_menu_links_discovered_alter()` in `ccsoccer.module` — same hook the codebase already uses to conditionally toggle the public Tournament Teams / Tournament Schedule links. Roughly:

```php
if (\Drupal::currentUser()->hasPermission('administer ccsoccer')) {
  $links['ccsoccer.admin']['route_name'] = 'ccsoccer.admin';
}
```

(Real implementation should add `'cache' => ['contexts' => ['user.permissions']]` and may need to use the menu-link-manager service properly. ~10 lines.)

**Behavior after fix:**
- Admins → Admin Dashboard (`/admin/ccsoccer`)
- Board members → Board Dashboard (`/admin/ccsoccer/board-dashboard`) — unchanged
- Tournament directors → Board Dashboard if they also have `view reports` (most do, since SoccerHD4 etc. are also board members); a director-only role lands on Board too in this two-tier scheme. Director Dashboard remains reachable via the masquerade block "Director Tools" link.

**Three-tier alternative considered and rejected for now:** route directors-only to the Director Dashboard. Adds edge cases for multi-role users (board+director) without a clean "right" answer.

**Open considerations to discuss:**
- Whether to keep the "Admin Dashboard" submenu item once the top-level link goes there for admins (redundant but harmless reassurance), or drop it.
- Whether tournament-director-only users (rare today) should land on the Director Dashboard instead — three-tier vs two-tier.
- Cache implications of per-user menu link routing — needs `user.permissions` cache context to avoid serving the wrong link to logged-out users via shared render cache.

---

## DB Quick Reference

### TEST server DB (live migration data)
- DB: `n6ac4b5_d11prod`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`
- Migration counts: 1,727 users, 581 credits, 5,072 registrations, 42 seasons

### TEST site app DB (old 200-user sample — no longer in use)
- DB: `n6ac4b5_ccsoccer_test`
- User: `n6ac4b5_ccsoccer_test`
- Password: `Soc8er#Test24!`

### D7 production DB (source of truth)
- DB: `n6ac4b5_ccsoccer`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`
- Fresh dump: `/home/n6ac4b5/d7-fresh-2026-04-16.sql.gz` on server

### Local D11 DB
- Admin: `admin` / `TJ4XxyYGCd`
- D11 beta export: `d11-beta-2026-04-16-clean.sql.gz` in project root (gitignored)

---

## Migration Command Reference

**File:** `web/modules/custom/ccsoccer/src/Drush/Commands/MigrateCommands.php`

```bash
# Full migration (steps 1-4: seasons, users, credits, registrations)
ddev drush ccsoccer:migrate-d7

# Individual steps
ddev drush ccsoccer:migrate-d7 --step=seasons
ddev drush ccsoccer:migrate-d7 --step=users
ddev drush ccsoccer:migrate-d7 --step=credits
ddev drush ccsoccer:migrate-d7 --step=registrations

# Role assignments — run ONCE after migration is finalized (not in 'all')
ddev drush ccsoccer:migrate-d7 --step=roles --dry-run
ddev drush ccsoccer:migrate-d7 --step=roles
```

**Role assignments hardcoded in `assignRoles()`:**
| Username | Roles |
|---|---|
| David.farris | board_member |
| Csalvini | board_member |
| laynesmith | board_member |
| chrisraymer | board_member |
| SoccerHD4 | board_member, tournament_director |
| kaimark | board_member |
| cncross | board_member, tournament_director, administrator |
| abmeade@hotmail.com | board_member, tournament_director, administrator |

Note: `abmeade@hotmail.com` is his D11 username (email-as-username from D7), looked up by `name` field.

---

## Key Facts / Gotchas

### DB import fix for InMotion
DDEV exports views with `DEFINER=\`db\`@\`%\`` which InMotion rejects. Always use the clean version:
```bash
gunzip -c dump.sql.gz | sed 's/DEFINER=[^*]*\*/\*/' | gzip > dump-clean.sql.gz
```

### Fresh DB reset for local dev (if needed again)
If you ever need to wipe and re-run the migration locally:
```bash
ddev drush site:install --account-name=admin --account-pass=TJ4XxyYGCd -y
ddev drush config-set system.site uuid $(grep uuid config/sync/system.site.yml | awk '{print $2}') -y
ddev drush entity:delete shortcut -y
ddev drush entity:delete shortcut_set -y
ddev drush cim -y
ddev drush cr
# Then run migration
ddev drush ccsoccer:migrate-d7
ddev drush ccsoccer:migrate-d7 --step=roles
```
Note: `ddev import-db --file=dump.sql.gz` is much simpler if you have a finished DB export — skip the above entirely.

### Theme vs module rendering
Site-wide header content is rendered by the **theme** (`ccsoccer_theme_preprocess_page` + `page.html.twig`), NOT `hook_page_top()`. New header features go in the theme.

### Two-file CSS sync
Any change to base styles must be applied to both:
- `web/modules/custom/ccsoccer/css/ccsoccer-base.css` (admin/Claro pages)
- `web/themes/custom/ccsoccer_theme/css/base.css` (public pages)

### Email-as-username migration
Some D7 users had email addresses as usernames. These migrate as-is into the D11 `name` field. Their `mail` field gets real email from D7 `mail` column. Look them up by `name`, not `mail`.

### Notification gating on TEST
`NotificationService::getAllowedContacts()` queries all `board_member` role users dynamically. Board member emails/phones go through; all others are blocked. This means as of this session, board members can receive real emails on TEST (password resets confirmed working).

---

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

# Full deploy
ccsDeploy && ccsUpdb && ccsCim && ccsCr

# Individual commands
ccsDeploy   # git pull
ccsUpdb     # drush updb -y
ccsCim      # drush cim -y
ccsCr       # drush cr
```

## Test Server .htaccess (IP Whitelist)
Not in git — protected via `skip-worktree`. If ever lost:
```apache
Require ip 68.249.41.9 35.151.50.130 99.8.107.54 97.84.70.141
<IfModule mod_headers.c>
  Header set X-Robots-Tag "noindex, nofollow, noarchive"
</IfModule>
```
```bash
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
- `*.sql.gz` files are gitignored
