# CC Soccer D7→D11 Migration Steps

**Purpose:** Step-by-step runbook for executing the D7→D11 migration.  
**Audience:** Caleb, Andrew, or anyone validating the migration.  
**Last Updated:** March 16, 2026  
**Last Tested:** February 13, 2026 (200 users, local DDEV)

---

## Prerequisites

- DDEV local environment running with D11 codebase
- D7 database dump (production or cleansed test copy)
- Git repo up to date (`git pull`)
- `drush cr` run so `MigrateCommands.php` is discovered
- D11 site has at minimum: 2 leagues created (Coed = ID 1, Mens = ID 2)
- D11 site has existing tournaments if mapping tournament registrations (SLO Friendly 2024 = ID 1, SLO Friendly 2025 = ID 2)

---

## Step 0: Import D7 Database into DDEV

DDEV's MariaDB instance can host multiple databases. We import the D7 database alongside the D11 database so the migration command can read from it directly.

### 0a. Get the D7 database dump

For testing, use the cleansed copy (`n6ac4b5_ccsoccer_test`) which has scrubbed emails and fake phone numbers. For production migration, use the real database.

```bash
# If you have a .sql dump file:
cp /path/to/d7_dump.sql ~/Sites/ccsoccer-d11/d7_dump.sql
```

### 0b. Create the D7 database in DDEV's MariaDB

```bash
# Create the database (run as root to have CREATE DATABASE privileges)
ddev mysql -uroot -e "CREATE DATABASE IF NOT EXISTS n6ac4b5_ccsoccer_test;"

# Grant the default DDEV db user access
ddev mysql -uroot -e "GRANT ALL ON n6ac4b5_ccsoccer_test.* TO 'db'@'%';"
```

### 0c. Import the D7 dump

```bash
# Import into the D7 database (not the default D11 database)
ddev mysql -uroot n6ac4b5_ccsoccer_test < d7_dump.sql
```

### 0d. Verify the import

```bash
# Quick sanity check — should see D7 tables
ddev mysql -e "USE n6ac4b5_ccsoccer_test; SHOW TABLES LIKE 'users';"

# Check user count
ddev mysql -e "SELECT COUNT(*) as user_count FROM n6ac4b5_ccsoccer_test.users WHERE uid > 0;"
```

### 0e. Configure D7 database connection in D11

Add to `web/sites/default/settings.local.php` (this file is gitignored):

```php
$databases['d7']['default'] = [
  'database' => 'n6ac4b5_ccsoccer_test',
  'username' => 'db',
  'password' => 'db',
  'host' => 'db',
  'driver' => 'mysql',
];
```

For production migration, change the database name to the production D7 database name.

---

## Step 1: Dry Run (Verify Before Migrating)

Always do a dry run first to see what will be migrated without making changes.

```bash
# Small dry run — see what 10 users would look like
ddev drush ccsoccer:migrate-d7 --dry-run --limit=10
```

**What to check in dry run output:**
- Season count and names look correct (should be ~36 for 5-year window)
- User UIDs are in the 90000+ range (no collision with D11 test users)
- Credit amounts look reasonable (in cents — 700 = $7.00)
- Registration count is non-zero for those users

---

## Step 2: Run the Migration

### Option A: Full run with limit

```bash
# Migrate 200 users (for test/demo)
ddev drush ccsoccer:migrate-d7 --limit=200
```

### Option B: Step-by-step (for debugging or partial runs)

```bash
# Run each step individually
ddev drush ccsoccer:migrate-d7 --step=seasons
ddev drush ccsoccer:migrate-d7 --step=users --limit=200
ddev drush ccsoccer:migrate-d7 --step=credits
ddev drush ccsoccer:migrate-d7 --step=registrations
```

### Option C: Full database (production)

```bash
# No limit — migrates all users matching the lookback window
ddev drush ccsoccer:migrate-d7
```

**Expected output for 200-user test run:**

| Step | Expected | Notes |
|------|----------|-------|
| Seasons | 36 created | Inactive stubs with real dates/prices |
| Users | 200 created | Most recent by D7 login timestamp |
| Credits | ~64 created | Only for migrated users |
| Registrations | ~1,600 created | All historical for migrated users |

---

## Step 3: Verify the Migration

### Quick counts

```bash
# Count entities
ddev drush eval "
echo 'Seasons: ' . count(\Drupal::entityTypeManager()->getStorage('season')->getQuery()->accessCheck(FALSE)->execute()) . PHP_EOL;
echo 'Users: ' . count(\Drupal::entityTypeManager()->getStorage('user')->getQuery()->condition('uid', 1, '>')->accessCheck(FALSE)->execute()) . PHP_EOL;
echo 'Credits: ' . count(\Drupal::entityTypeManager()->getStorage('credits')->getQuery()->accessCheck(FALSE)->execute()) . PHP_EOL;
echo 'Registrations: ' . count(\Drupal::entityTypeManager()->getStorage('ccsoccer_registration')->getQuery()->accessCheck(FALSE)->execute()) . PHP_EOL;
"
```

### Spot-check a user

```bash
# Pick a known user UID from the migration output and check their data
ddev drush eval "
\$user = \Drupal\user\Entity\User::load(91044);
if (\$user) {
  echo 'Name: ' . \$user->get('field_first_name')->value . ' ' . \$user->get('field_last_name')->value . PHP_EOL;
  echo 'Email: ' . \$user->getEmail() . PHP_EOL;
  echo 'Gender: ' . \$user->get('field_gender')->value . PHP_EOL;
  echo 'DOB: ' . \$user->get('field_dob')->value . PHP_EOL;
  echo 'Skill: ' . \$user->get('field_skill_level')->value . PHP_EOL;
  echo 'Self Score: ' . \$user->get('field_self_score')->value . PHP_EOL;
  echo 'Credits Balance: ' . \$user->get('field_credits_balance')->value . PHP_EOL;
  echo 'Phone: ' . \$user->get('field_phone')->value . PHP_EOL;
}
"
```

### Check registration history for a user

```bash
ddev drush eval "
\$regs = \Drupal::entityTypeManager()->getStorage('ccsoccer_registration')->loadByProperties(['player' => 91044]);
echo count(\$regs) . ' registrations for uid 91044:' . PHP_EOL;
foreach (\$regs as \$reg) {
  \$type = \$reg->get('registration_type')->value;
  \$season_id = \$reg->get('season')->target_id;
  \$tournament_id = \$reg->get('tournament')->target_id;
  \$ref = \$type === 'tournament' ? 'tournament ' . \$tournament_id : 'season ' . \$season_id;
  echo '  ' . \$type . ' -> ' . \$ref . ' (status: ' . \$reg->get('status')->value . ')' . PHP_EOL;
}
"
```

### Verify credit balances

```bash
ddev drush eval "
\$users = \Drupal::entityTypeManager()->getStorage('user')->getQuery()
  ->condition('field_credits_balance', 0, '>')
  ->accessCheck(FALSE)
  ->execute();
echo count(\$users) . ' users with active credit balances:' . PHP_EOL;
foreach (\Drupal\user\Entity\User::loadMultiple(\$users) as \$u) {
  echo '  uid ' . \$u->id() . ': \$' . number_format(\$u->get('field_credits_balance')->value / 100, 2) . PHP_EOL;
}
"
```

### Test login

```bash
# For test migration, all migrated users have password: CCSoccer2026!
# Log into the D11 site in a browser and try logging in as a migrated user.
# Username can be found in the migration output or via:
ddev drush eval "
\$user = \Drupal\user\Entity\User::load(91044);
echo 'Username: ' . \$user->getAccountName() . PHP_EOL;
echo 'Email: ' . \$user->getEmail() . PHP_EOL;
"
```

---

## Step 4: Clear Caches

```bash
ddev drush cr
```

This ensures registration counts, menu links, and other cached data reflect the migrated content.

---

## Resetting and Re-running (Test Only)

If you need to start fresh (e.g., something went wrong or testing changes to the migration command):

### Nuclear option: Reset all migrated data

```bash
# WARNING: This deletes ALL registrations, credits, non-admin users, and inactive seasons.
# Only use in test environments.

ddev drush eval "
\$etm = \Drupal::entityTypeManager();

// Delete all registrations
\$regs = \$etm->getStorage('ccsoccer_registration')->loadMultiple();
\$etm->getStorage('ccsoccer_registration')->delete(\$regs);
echo 'Deleted ' . count(\$regs) . ' registrations.' . PHP_EOL;

// Delete all credits
\$credits = \$etm->getStorage('credits')->loadMultiple();
\$etm->getStorage('credits')->delete(\$credits);
echo 'Deleted ' . count(\$credits) . ' credits.' . PHP_EOL;

// Delete migrated users (UID > 1000, preserving test/admin users)
\$uids = \$etm->getStorage('user')->getQuery()
  ->condition('uid', 1000, '>')
  ->accessCheck(FALSE)
  ->execute();
\$users = \Drupal\user\Entity\User::loadMultiple(\$uids);
\$etm->getStorage('user')->delete(\$users);
echo 'Deleted ' . count(\$users) . ' users.' . PHP_EOL;

// Delete inactive seasons (migration stubs)
\$sids = \$etm->getStorage('season')->getQuery()
  ->condition('active', FALSE)
  ->accessCheck(FALSE)
  ->execute();
\$seasons = \$etm->getStorage('season')->loadMultiple(\$sids);
\$etm->getStorage('season')->delete(\$seasons);
echo 'Deleted ' . count(\$seasons) . ' seasons.' . PHP_EOL;

// Reset auto-increment
\Drupal::database()->query('ALTER TABLE users AUTO_INCREMENT = 100');
echo 'Reset user auto-increment.' . PHP_EOL;
"

ddev drush cr
```

Then re-run from Step 2.

---

## Known Issues / Gotchas

### Phone numbers not migrated
D7 does NOT have a `field_data_field_phone` table. Phone numbers are stored in the `sms_user` table (from the SMS Framework module). The migration command does not currently pull from `sms_user`. The `field_phone` field will be empty for all migrated users.

**Production fix needed:** Add `LEFT JOIN sms_user su ON su.uid = u.uid` to the user migration query and map `su.number` → `field_phone`.

### Team auto-creation on season insert
The `ccsoccer_season_insert()` hook automatically creates Commerce products and teams whenever a Season entity is saved. This was fixed to skip when `active = FALSE`, but if you're running migration on a codebase that doesn't have this fix, you'll get ~684 junk team entities + 36 commerce products. Clean up with:

```bash
ddev drush scr cleanup_inactive_season_data.php
```

### Password hashes
D7 uses phpass (`$S$D...`). The test migration sets all passwords to `CCSoccer2026!` (D11 native hash). For production, we need to decide: transfer hashes with compatibility module, or force password resets.

### Registration duplicates
The migration command checks for duplicate seasons (by name) and users (by UID), but does NOT check for duplicate registrations. Running the command multiple times will create duplicate registrations. Use the reset procedure above between test runs if needed.

### Zsh escaping
If running `ddev drush eval` with inline PHP from zsh, exclamation marks (`!`) will cause `zsh: event not found` errors. Either escape them (`\!`), use single quotes, or write the PHP to a `.php` file and run with `ddev drush scr filename.php`.

---

## Migration Order (Critical)

The entities must be created in this order due to entity references:

1. **Leagues** — Must exist before Seasons (Season → League reference). These should already exist in D11.
2. **Seasons** — Must exist before Registrations (Registration → Season reference). Created as inactive stubs.
3. **Tournaments** — Must exist before tournament Registrations. Should already exist in D11 if created during development.
4. **Users** — Must exist before Credits and Registrations (both reference User).
5. **Credits** — References User. Updates `field_credits_balance` on User after creation.
6. **Registrations** — References User + Season/Tournament. Created last.

The `drush ccsoccer:migrate-d7` command handles this order automatically when run with `--step=all` (default).

---

## Production Migration Checklist

**D7 Production Dump**
- **File:** `d7-production-20260321.sql.gz` (in project root, gitignored)
- **Pulled:** March 21, 2026 (afternoon, Pacific time)
- **Post-launch check:** Verify any credits issued after this date; check for new user registrations; check waitlist changes. Spring 2026 registrations are included. Fall 2026 registrations open late June/early July in D11.

**Before migration weekend:**
- [ ] Board decision on user pruning cutoff (2/3/5-year lookback)
- [ ] Phone number extraction from `sms_user` integrated into migration command
- [ ] Password strategy decided (compatibility module vs. forced resets)
- [ ] Payment method migration tested (Authorize.net tokens)
- [ ] Full dry run on production database copy
- [ ] Communication emails drafted
- [ ] Rollback plan reviewed

**Security hardening — code fixes (COMPLETED March 16, 2026):**
- [x] Add `X-CSRF-Token` headers to all AJAX POST requests in JS files (8 files)
- [x] Add controller-level access checks to all data-modifying AJAX endpoints (8 controllers)
- [x] Replace `innerHTML` with safe DOM methods in JS files handling user-controlled data (4 files)
- [x] Add Drupal flood control rate limiting to player-facing group endpoints (3 endpoints)

**Security hardening — production deployment (do at launch):**
- [ ] Remove/rotate Authorize.net API credentials from version control; move to environment variables or `settings.local.php` config overrides (see `CC_Soccer_Security_Assessment_2026_03_16.md`)
- [ ] Set a strong `hash_salt` value in production `settings.local.php` (generate with: `php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"`)
- [ ] Disable and uninstall the Devel module for production (`drush pm:uninstall devel devel_generate`)
- [ ] Delete the SQL database dump (`ccsoccer-d11-migrated-200users.sql`) from the repository root
- [ ] Configure `trusted_host_patterns` in `settings.local.php` — do this now on test (`'^test\.ccsoccer\.com$'`), then again on production (`'^ccsoccer\.com$'`, `'^www\.ccsoccer\.com$'`)

**Migration day:**
- [ ] Put D7 site in maintenance mode
- [ ] Take final D7 database backup
- [ ] Import D7 database into production D11 environment
- [ ] Configure D7 database connection
- [ ] Run `ddev drush ccsoccer:migrate-d7 --dry-run` — verify counts
- [ ] Run `ddev drush ccsoccer:migrate-d7` — full migration
- [ ] Verify: user counts, credit balances, registration history, login test
- [ ] Run `ddev drush cr`
- [ ] Remove D7 database connection from settings (no longer needed)
- [ ] Launch D11 site
- [ ] Send migration complete email to users

**Post-migration:**
- [ ] Monitor login success rate
- [ ] Handle password reset requests
- [ ] Verify credit balances with board
- [ ] Archive D7 database (read-only reference for 6 months)

---

**Document Owner:** Caleb  
**Runbook Validated By:** (pending — Andrew to validate)
