# Data Migration Procedures This guide provides step-by-step procedures for migrating data from Changemaker Lite V1 to V2, including export scripts, transformation logic, import procedures, and validation steps. ## Overview V2 data migration involves: 1. **Export** - Extract data from V1 NocoDB tables 2. **Transform** - Convert V1 schema to V2 Prisma models 3. **Import** - Load transformed data into V2 PostgreSQL 4. **Validate** - Verify data integrity and completeness !!! danger "Production Migration Warning" ALWAYS perform a test migration on a staging environment before production. Data loss is possible if scripts contain errors. ## Prerequisites Before beginning data migration: - [ ] **V1 backup completed** (PostgreSQL dump + uploads) - [ ] **V2 environment running** (`docker compose up -d v2-postgres redis api`) - [ ] **Prisma migrations applied** (`npx prisma migrate deploy`) - [ ] **Node.js 20+ installed** (for transformation scripts) - [ ] **Sufficient disk space** (3x current database size recommended) - [ ] **Network access** (V1 NocoDB API, V2 database) ## Data Mapping ### V1 Tables → V2 Prisma Models | V1 NocoDB Table | V2 Prisma Model | Notes | |-----------------|-----------------|-------| | `influence_users` | `User` | Merge with `login` table | | `login` | `User` | Merge with `influence_users` | | `campaigns` | `Campaign` | Add `createdByUserId` relation | | `representatives` | `Representative` | Direct migration | | `responses` | `RepresentativeResponse` | Add verification fields | | `response_upvotes` | `ResponseUpvote` | Add IP dedup field | | `postal_code_cache` | `PostalCodeCache` | Direct migration | | `locations` | `Location` | Split address, add geocoding fields | | `shifts` | `Shift` | Extract signups to `ShiftSignup` | | `shift_signups` | `ShiftSignup` | Add status enum | | `cuts` | `Cut` | Parse GeoJSON coordinates | | (none) | `RefreshToken` | New in V2 (generated on first login) | | (none) | `SiteSettings` | New in V2 (seed with defaults) | | (none) | `MapSettings` | New in V2 (seed with defaults) | ### Field Mapping Tables #### Users | V1 Field (`influence_users`) | V1 Field (`login`) | V2 Field | Transformation | |------------------------------|---------------------|----------|----------------| | `Id` | `Id` | - | Discard (V2 uses CUID) | | `Email` | `Email` | `email` | Merge by email, enforce unique | | `Password` | `Password` | `password` | Bcrypt hash (direct copy) | | - | `Name` | `name` | From `login.Name` | | - | - | `phone` | NULL (not in V1) | | `Role` | - | `role` | Map: 'admin'→'SUPER_ADMIN', 'user'→'USER' | | - | - | `status` | Default: 'ACTIVE' | | - | - | `createdVia` | Default: 'STANDARD' | | - | - | `expiresAt` | NULL | | - | - | `emailVerified` | Default: false | | `Created` | `Created` | `createdAt` | ISO 8601 timestamp | | - | - | `updatedAt` | Use `createdAt` or current time | **Merge Logic**: ```javascript // Pseudocode const mergeUsers = (influenceUsers, loginUsers) => { const merged = new Map(); // Add all login users first (has name field) loginUsers.forEach(user => { merged.set(user.Email.toLowerCase(), { email: user.Email, password: user.Password, name: user.Name, role: 'USER', // Default, may be overridden createdAt: user.Created || new Date() }); }); // Override with influence_users (has role field) influenceUsers.forEach(user => { const existing = merged.get(user.Email.toLowerCase()); if (existing) { existing.role = mapRole(user.Role); } else { merged.set(user.Email.toLowerCase(), { email: user.Email, password: user.Password, name: null, role: mapRole(user.Role), createdAt: user.Created || new Date() }); } }); return Array.from(merged.values()); }; const mapRole = (v1Role) => { const roleMap = { 'admin': 'SUPER_ADMIN', 'moderator': 'INFLUENCE_ADMIN', 'user': 'USER' }; return roleMap[v1Role] || 'USER'; }; ``` #### Campaigns | V1 Field | V2 Field | Transformation | |----------|----------|----------------| | `Id` | - | Discard (use CUID) | | `Title` | `title` | Direct copy | | `Description` | `description` | Direct copy | | `Slug` | `slug` | Direct copy | | `IsActive` | `active` | Boolean conversion | | - | `highlighted` | Default: false | | `TargetLevel` | `targetLevel` | Direct copy or NULL | | `TargetPosition` | `targetPosition` | Direct copy or NULL | | - | `targetName` | NULL (not in V1) | | - | `targetEmail` | NULL | | - | `targetPostalCode` | NULL | | - | `customSubject` | NULL | | - | `customBody` | NULL | | - | `responseWallEnabled` | Default: true | | `Created` | `createdAt` | ISO 8601 timestamp | | - | `updatedAt` | Use `createdAt` | | - | `createdByUserId` | **Requires user lookup** | **CreatedBy Mapping**: ```javascript // V1 campaigns may not have createdBy field // Options: // 1. Assign all to first SUPER_ADMIN user // 2. Use separate mapping table if V1 tracked creators // 3. Create placeholder "System" user const assignCreator = async (campaign) => { // Find first SUPER_ADMIN user const admin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }); if (!admin) { throw new Error('No SUPER_ADMIN user found. Create admin user first.'); } return admin.id; }; ``` #### Locations | V1 Field | V2 Field | Transformation | |----------|----------|----------------| | `Id` | - | Discard (use CUID) | | `Address` | `address`, `city`, `province`, `postalCode` | **Parse address string** | | - | `addressLine2` | NULL | | - | `country` | Default: 'Canada' | | `Latitude` | `latitude` | Float conversion | | `Longitude` | `longitude` | Float conversion | | - | `geocoded` | `latitude != NULL && longitude != NULL` | | - | `geocodedAt` | Use `createdAt` if geocoded | | - | `geocodeProvider` | 'Legacy V1' or NULL | | - | `geocodeQuality` | NULL (unknown) | | `SupportLevel` | `supportLevel` | Map string to enum | | `Notes` | `notes` | Direct copy | | - | `contactName` | NULL | | - | `contactPhone` | NULL | | - | `contactEmail` | NULL | | - | `cutId` | NULL (assign later if needed) | | `Created` | `createdAt` | ISO 8601 timestamp | | - | `updatedAt` | Use `createdAt` | | - | `createdByUserId` | First MAP_ADMIN or SUPER_ADMIN | **Address Parsing**: ```javascript // V1 stored full address as single string // V2 requires structured fields const parseAddress = (addressString) => { // Example V1 address: "123 Main St, Toronto, ON M5V 1A1" // Basic parsing (may need refinement for edge cases) const parts = addressString.split(',').map(s => s.trim()); if (parts.length === 1) { // Only street address return { address: parts[0], city: null, province: null, postalCode: null }; } // Extract postal code (last part if matches pattern) const postalRegex = /^[A-Z]\d[A-Z]\s?\d[A-Z]\d$/i; let postalCode = null; let province = null; let city = null; if (parts.length >= 3) { const lastPart = parts[parts.length - 1]; const postalMatch = lastPart.match(/([A-Z]\d[A-Z]\s?\d[A-Z]\d)/i); if (postalMatch) { postalCode = postalMatch[1].replace(/\s/, '').toUpperCase(); // Province usually before postal code const provincePart = lastPart.replace(postalMatch[0], '').trim(); if (provincePart) { province = provincePart; } else if (parts.length >= 4) { province = parts[parts.length - 2]; } } // City is second-to-last or third-to-last if (parts.length >= 4 && province) { city = parts[parts.length - 3]; } else if (parts.length >= 3) { city = parts[parts.length - 2]; } } return { address: parts[0], city: city || null, province: province || null, postalCode: postalCode || null }; }; // Example usage: parseAddress("123 Main St, Toronto, ON M5V 1A1"); // → { address: "123 Main St", city: "Toronto", province: "ON", postalCode: "M5V1A1" } ``` **SupportLevel Enum Mapping**: ```javascript const mapSupportLevel = (v1Level) => { // V1 used inconsistent strings const levelMap = { 'strong support': 'STRONG_SUPPORT', 'support': 'SUPPORT', 'undecided': 'UNDECIDED', 'oppose': 'OPPOSED', 'strong oppose': 'STRONG_OPPOSED', 'unknown': 'UNKNOWN', 'not home': 'NOT_HOME', 'moved': 'MOVED', 'deceased': 'DECEASED', '': 'UNKNOWN' }; return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN'; }; ``` ## Export V1 Data ### Option 1: NocoDB API Export **Script**: `scripts/export-v1-nocodb.js` ```javascript #!/usr/bin/env node const axios = require('axios'); const fs = require('fs').promises; const path = require('path'); const NOCODB_URL = process.env.V1_NOCODB_URL || 'http://localhost:8080'; const NOCODB_TOKEN = process.env.V1_NOCODB_TOKEN; const OUTPUT_DIR = process.env.OUTPUT_DIR || './v1-export'; const tables = [ 'influence_users', 'login', 'campaigns', 'representatives', 'responses', 'response_upvotes', 'postal_code_cache', 'locations', 'shifts', 'shift_signups', 'cuts' ]; const exportTable = async (tableName) => { console.log(`Exporting ${tableName}...`); let allRecords = []; let offset = 0; const limit = 100; let hasMore = true; while (hasMore) { const response = await axios.get( `${NOCODB_URL}/api/v1/db/data/v1/${tableName}`, { headers: { 'xc-token': NOCODB_TOKEN }, params: { limit, offset } } ); const records = response.data.list || []; allRecords = allRecords.concat(records); console.log(` Fetched ${records.length} records (total: ${allRecords.length})`); if (records.length < limit) { hasMore = false; } else { offset += limit; } } await fs.writeFile( path.join(OUTPUT_DIR, `${tableName}.json`), JSON.stringify(allRecords, null, 2) ); console.log(`✓ Exported ${allRecords.length} records from ${tableName}`); return allRecords.length; }; const main = async () => { await fs.mkdir(OUTPUT_DIR, { recursive: true }); const counts = {}; for (const table of tables) { try { counts[table] = await exportTable(table); } catch (error) { console.error(`✗ Failed to export ${table}:`, error.message); counts[table] = 0; } } // Write summary await fs.writeFile( path.join(OUTPUT_DIR, 'export-summary.json'), JSON.stringify({ exportedAt: new Date(), counts }, null, 2) ); console.log('\nExport Summary:'); console.table(counts); }; main().catch(console.error); ``` **Usage**: ```bash cd /home/bunker-admin/changemaker.lite mkdir -p v1-export # Export from running V1 instance V1_NOCODB_URL=http://localhost:8080 \ V1_NOCODB_TOKEN=your-token \ OUTPUT_DIR=./v1-export \ node scripts/export-v1-nocodb.js ``` ### Option 2: PostgreSQL Direct Export If you have direct access to V1 PostgreSQL database: ```bash # Export each table as CSV docker compose -f docker-compose.v1.yml exec v1-postgres \ psql -U nocodb -d nocodb -c "\COPY influence_users TO STDOUT CSV HEADER" > v1-export/influence_users.csv docker compose -f docker-compose.v1.yml exec v1-postgres \ psql -U nocodb -d nocodb -c "\COPY login TO STDOUT CSV HEADER" > v1-export/login.csv docker compose -f docker-compose.v1.yml exec v1-postgres \ psql -U nocodb -d nocodb -c "\COPY campaigns TO STDOUT CSV HEADER" > v1-export/campaigns.csv # Repeat for all tables... ``` ### Backup File Uploads ```bash # V1 uploads directory tar -czf v1-uploads-backup.tar.gz ./uploads/ # Verify archive tar -tzf v1-uploads-backup.tar.gz | head -20 ``` ## Transform Data ### User Transformation **Script**: `scripts/transform-users.js` ```javascript #!/usr/bin/env node const fs = require('fs').promises; const path = require('path'); const INPUT_DIR = process.env.INPUT_DIR || './v1-export'; const OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import'; const mapRole = (v1Role) => { const roleMap = { 'admin': 'SUPER_ADMIN', 'moderator': 'INFLUENCE_ADMIN', 'user': 'USER' }; return roleMap[v1Role] || 'USER'; }; const transformUsers = async () => { const influenceUsers = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'influence_users.json'), 'utf-8') ); const loginUsers = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'login.json'), 'utf-8') ); const merged = new Map(); // Add login users (has name field) loginUsers.forEach(user => { merged.set(user.Email.toLowerCase(), { email: user.Email, password: user.Password, name: user.Name || null, role: 'USER', status: 'ACTIVE', createdVia: 'STANDARD', emailVerified: false, createdAt: user.Created || new Date().toISOString(), updatedAt: user.Created || new Date().toISOString() }); }); // Override with influence_users (has role field) influenceUsers.forEach(user => { const existing = merged.get(user.Email.toLowerCase()); if (existing) { existing.role = mapRole(user.Role); } else { merged.set(user.Email.toLowerCase(), { email: user.Email, password: user.Password, name: null, role: mapRole(user.Role), status: 'ACTIVE', createdVia: 'STANDARD', emailVerified: false, createdAt: user.Created || new Date().toISOString(), updatedAt: user.Created || new Date().toISOString() }); } }); const users = Array.from(merged.values()); await fs.writeFile( path.join(OUTPUT_DIR, 'users.json'), JSON.stringify(users, null, 2) ); console.log(`✓ Transformed ${users.length} users`); console.log(` influence_users: ${influenceUsers.length}`); console.log(` login: ${loginUsers.length}`); console.log(` merged: ${users.length}`); return users; }; const main = async () => { await fs.mkdir(OUTPUT_DIR, { recursive: true }); await transformUsers(); }; main().catch(console.error); ``` ### Campaign Transformation **Script**: `scripts/transform-campaigns.js` ```javascript #!/usr/bin/env node const fs = require('fs').promises; const path = require('path'); const INPUT_DIR = process.env.INPUT_DIR || './v1-export'; const OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import'; const transformCampaigns = async () => { const v1Campaigns = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8') ); // Note: createdByUserId must be populated after users are imported // This transformation creates placeholder field const campaigns = v1Campaigns.map(campaign => ({ title: campaign.Title, description: campaign.Description || null, slug: campaign.Slug, active: Boolean(campaign.IsActive), highlighted: false, targetLevel: campaign.TargetLevel || null, targetPosition: campaign.TargetPosition || null, targetName: null, targetEmail: null, targetPostalCode: null, customSubject: null, customBody: null, responseWallEnabled: true, createdAt: campaign.Created || new Date().toISOString(), updatedAt: campaign.Created || new Date().toISOString(), _v1Id: campaign.Id // Keep for reference in import script })); await fs.writeFile( path.join(OUTPUT_DIR, 'campaigns.json'), JSON.stringify(campaigns, null, 2) ); console.log(`✓ Transformed ${campaigns.length} campaigns`); return campaigns; }; const main = async () => { await fs.mkdir(OUTPUT_DIR, { recursive: true }); await transformCampaigns(); }; main().catch(console.error); ``` ### Location Transformation **Script**: `scripts/transform-locations.js` ```javascript #!/usr/bin/env node const fs = require('fs').promises; const path = require('path'); const INPUT_DIR = process.env.INPUT_DIR || './v1-export'; const OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import'; const parseAddress = (addressString) => { if (!addressString) { return { address: '', city: null, province: null, postalCode: null }; } const parts = addressString.split(',').map(s => s.trim()); if (parts.length === 1) { return { address: parts[0], city: null, province: null, postalCode: null }; } const postalRegex = /([A-Z]\d[A-Z]\s?\d[A-Z]\d)/i; let postalCode = null; let province = null; let city = null; if (parts.length >= 3) { const lastPart = parts[parts.length - 1]; const postalMatch = lastPart.match(postalRegex); if (postalMatch) { postalCode = postalMatch[1].replace(/\s/, '').toUpperCase(); const provincePart = lastPart.replace(postalMatch[0], '').trim(); if (provincePart) { province = provincePart; } else if (parts.length >= 4) { province = parts[parts.length - 2]; } } if (parts.length >= 4 && province) { city = parts[parts.length - 3]; } else if (parts.length >= 3) { city = parts[parts.length - 2]; } } return { address: parts[0], city: city || null, province: province || null, postalCode: postalCode || null }; }; const mapSupportLevel = (v1Level) => { const levelMap = { 'strong support': 'STRONG_SUPPORT', 'support': 'SUPPORT', 'undecided': 'UNDECIDED', 'oppose': 'OPPOSED', 'strong oppose': 'STRONG_OPPOSED', 'unknown': 'UNKNOWN', 'not home': 'NOT_HOME', 'moved': 'MOVED', 'deceased': 'DECEASED', '': 'UNKNOWN' }; return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN'; }; const transformLocations = async () => { const v1Locations = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8') ); const locations = v1Locations.map(loc => { const { address, city, province, postalCode } = parseAddress(loc.Address); const hasCoordinates = loc.Latitude != null && loc.Longitude != null; return { ...parseAddress(loc.Address), country: 'Canada', latitude: loc.Latitude ? parseFloat(loc.Latitude) : null, longitude: loc.Longitude ? parseFloat(loc.Longitude) : null, geocoded: hasCoordinates, geocodedAt: hasCoordinates ? (loc.Created || new Date().toISOString()) : null, geocodeProvider: hasCoordinates ? 'Legacy V1' : null, geocodeQuality: null, supportLevel: mapSupportLevel(loc.SupportLevel), notes: loc.Notes || null, contactName: null, contactPhone: null, contactEmail: null, createdAt: loc.Created || new Date().toISOString(), updatedAt: loc.Created || new Date().toISOString(), _v1Id: loc.Id }; }); await fs.writeFile( path.join(OUTPUT_DIR, 'locations.json'), JSON.stringify(locations, null, 2) ); console.log(`✓ Transformed ${locations.length} locations`); const geocodedCount = locations.filter(l => l.geocoded).length; console.log(` Geocoded: ${geocodedCount} (${(geocodedCount/locations.length*100).toFixed(1)}%)`); return locations; }; const main = async () => { await fs.mkdir(OUTPUT_DIR, { recursive: true }); await transformLocations(); }; main().catch(console.error); ``` ## Import V2 Data ### Import Script **Script**: `scripts/import-v2-data.js` ```javascript #!/usr/bin/env node const { PrismaClient } = require('@prisma/client'); const fs = require('fs').promises; const path = require('path'); const prisma = new PrismaClient(); const INPUT_DIR = process.env.INPUT_DIR || './v2-import'; const importUsers = async () => { const users = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'users.json'), 'utf-8') ); console.log(`Importing ${users.length} users...`); const created = []; for (const user of users) { try { const newUser = await prisma.user.create({ data: user }); created.push(newUser); } catch (error) { if (error.code === 'P2002') { console.warn(` ⚠ User ${user.email} already exists, skipping`); } else { console.error(` ✗ Failed to import user ${user.email}:`, error.message); } } } console.log(`✓ Imported ${created.length}/${users.length} users`); return created; }; const importCampaigns = async () => { const campaigns = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8') ); // Find first SUPER_ADMIN user const admin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }); if (!admin) { throw new Error('No SUPER_ADMIN user found. Import users first.'); } console.log(`Importing ${campaigns.length} campaigns (creator: ${admin.email})...`); const created = []; for (const campaign of campaigns) { try { const { _v1Id, ...data } = campaign; const newCampaign = await prisma.campaign.create({ data: { ...data, createdByUserId: admin.id } }); created.push(newCampaign); } catch (error) { if (error.code === 'P2002') { console.warn(` ⚠ Campaign ${campaign.slug} already exists, skipping`); } else { console.error(` ✗ Failed to import campaign ${campaign.title}:`, error.message); } } } console.log(`✓ Imported ${created.length}/${campaigns.length} campaigns`); return created; }; const importLocations = async () => { const locations = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8') ); // Find first MAP_ADMIN or SUPER_ADMIN user const admin = await prisma.user.findFirst({ where: { OR: [{ role: 'MAP_ADMIN' }, { role: 'SUPER_ADMIN' }] } }); if (!admin) { throw new Error('No MAP_ADMIN or SUPER_ADMIN user found. Import users first.'); } console.log(`Importing ${locations.length} locations (creator: ${admin.email})...`); const created = []; for (const location of locations) { try { const { _v1Id, ...data } = location; const newLocation = await prisma.location.create({ data: { ...data, createdByUserId: admin.id } }); created.push(newLocation); } catch (error) { console.error(` ✗ Failed to import location ${location.address}:`, error.message); } } console.log(`✓ Imported ${created.length}/${locations.length} locations`); return created; }; const main = async () => { try { console.log('Starting V2 data import...\n'); await importUsers(); console.log(); await importCampaigns(); console.log(); await importLocations(); console.log(); console.log('✓ Import complete!'); } catch (error) { console.error('Import failed:', error); process.exit(1); } finally { await prisma.$disconnect(); } }; main(); ``` **Usage**: ```bash cd /home/bunker-admin/changemaker.lite # Ensure V2 database is running and migrated docker compose up -d v2-postgres docker compose exec api npx prisma migrate deploy # Run import INPUT_DIR=./v2-import node scripts/import-v2-data.js ``` ## Validate Migration ### Validation Script **Script**: `scripts/validate-migration.js` ```javascript #!/usr/bin/env node const { PrismaClient } = require('@prisma/client'); const fs = require('fs').promises; const path = require('path'); const prisma = new PrismaClient(); const V1_EXPORT_DIR = './v1-export'; const validateCounts = async () => { console.log('Validating record counts...\n'); const v1Summary = JSON.parse( await fs.readFile(path.join(V1_EXPORT_DIR, 'export-summary.json'), 'utf-8') ); const v2Counts = { users: await prisma.user.count(), campaigns: await prisma.campaign.count(), locations: await prisma.location.count(), shifts: await prisma.shift.count(), representatives: await prisma.representative.count() }; const comparison = [ { Table: 'Users', V1: v1Summary.counts.influence_users + v1Summary.counts.login, V2: v2Counts.users, Match: '≈' // Approximate due to deduplication }, { Table: 'Campaigns', V1: v1Summary.counts.campaigns, V2: v2Counts.campaigns, Match: v1Summary.counts.campaigns === v2Counts.campaigns ? '✓' : '✗' }, { Table: 'Locations', V1: v1Summary.counts.locations, V2: v2Counts.locations, Match: v1Summary.counts.locations === v2Counts.locations ? '✓' : '✗' }, { Table: 'Shifts', V1: v1Summary.counts.shifts, V2: v2Counts.shifts, Match: v1Summary.counts.shifts === v2Counts.shifts ? '✓' : '✗' }, { Table: 'Representatives', V1: v1Summary.counts.representatives, V2: v2Counts.representatives, Match: v1Summary.counts.representatives === v2Counts.representatives ? '✓' : '✗' } ]; console.table(comparison); }; const validateSampleData = async () => { console.log('\nValidating sample data integrity...\n'); // Check first user const firstUser = await prisma.user.findFirst({ orderBy: { createdAt: 'asc' } }); console.log('First User:', { email: firstUser.email, role: firstUser.role, hasPassword: firstUser.password?.startsWith('$2b$') ? 'Yes (bcrypt)' : 'No' }); // Check first campaign const firstCampaign = await prisma.campaign.findFirst({ include: { createdBy: { select: { email: true } } }, orderBy: { createdAt: 'asc' } }); console.log('First Campaign:', { title: firstCampaign.title, slug: firstCampaign.slug, creator: firstCampaign.createdBy.email }); // Check first location const firstLocation = await prisma.location.findFirst({ orderBy: { createdAt: 'asc' } }); console.log('First Location:', { address: firstLocation.address, city: firstLocation.city, geocoded: firstLocation.geocoded, supportLevel: firstLocation.supportLevel }); // Geocoding statistics const totalLocations = await prisma.location.count(); const geocodedLocations = await prisma.location.count({ where: { geocoded: true } }); console.log('\nGeocoding Stats:', { total: totalLocations, geocoded: geocodedLocations, percentage: `${(geocodedLocations / totalLocations * 100).toFixed(1)}%` }); }; const main = async () => { try { await validateCounts(); await validateSampleData(); console.log('\n✓ Validation complete'); } catch (error) { console.error('Validation failed:', error); process.exit(1); } finally { await prisma.$disconnect(); } }; main(); ``` ## Special Cases ### Handling Duplicate Emails During user merge, you may encounter duplicate emails: ```javascript // Option 1: Keep first occurrence, log duplicates const handleDuplicates = (users) => { const seen = new Set(); const duplicates = []; const unique = users.filter(user => { if (seen.has(user.email.toLowerCase())) { duplicates.push(user); return false; } seen.add(user.email.toLowerCase()); return true; }); if (duplicates.length > 0) { console.warn(`Found ${duplicates.length} duplicate emails:`); duplicates.forEach(d => console.warn(` - ${d.email}`)); } return unique; }; // Option 2: Append suffix to duplicates const handleDuplicatesWithSuffix = (users) => { const counts = new Map(); return users.map(user => { const email = user.email.toLowerCase(); const count = counts.get(email) || 0; counts.set(email, count + 1); if (count > 0) { const [local, domain] = email.split('@'); return { ...user, email: `${local}+v1dup${count}@${domain}` }; } return user; }); }; ``` ### Migrating Representative Cache Representative cache can be rebuilt from Represent API, but to preserve it: ```javascript const transformRepresentatives = async () => { const v1Reps = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'representatives.json'), 'utf-8') ); const reps = v1Reps.map(rep => ({ name: rep.Name, email: rep.Email, district: rep.District, party: rep.Party, level: rep.Level, photoUrl: rep.PhotoUrl || null, postalCodes: rep.PostalCodes ? JSON.parse(rep.PostalCodes) : [], createdAt: rep.Created || new Date().toISOString(), updatedAt: rep.Updated || new Date().toISOString() })); await fs.writeFile( path.join(OUTPUT_DIR, 'representatives.json'), JSON.stringify(reps, null, 2) ); return reps; }; ``` ### Migrating Shift Signups V1 may have embedded signups; V2 uses separate `ShiftSignup` table: ```javascript const transformShiftSignups = async () => { const v1Shifts = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'shifts.json'), 'utf-8') ); const signups = []; v1Shifts.forEach(shift => { if (shift.Signups && Array.isArray(shift.Signups)) { shift.Signups.forEach(signup => { signups.push({ shiftId: shift.Id, // V1 ID, will need mapping in import userId: signup.UserId, // V1 ID, will need mapping status: 'CONFIRMED', notes: signup.Notes || null, confirmedAt: signup.CreatedAt || new Date().toISOString(), createdAt: signup.CreatedAt || new Date().toISOString() }); }); } }); await fs.writeFile( path.join(OUTPUT_DIR, 'shift-signups.json'), JSON.stringify(signups, null, 2) ); return signups; }; // Import with ID mapping const importShiftSignups = async (idMappings) => { const signups = JSON.parse( await fs.readFile(path.join(INPUT_DIR, 'shift-signups.json'), 'utf-8') ); for (const signup of signups) { const v2ShiftId = idMappings.shifts[signup.shiftId]; const v2UserId = idMappings.users[signup.userId]; if (!v2ShiftId || !v2UserId) { console.warn(`Skipping signup: shift ${signup.shiftId} or user ${signup.userId} not found`); continue; } await prisma.shiftSignup.create({ data: { shiftId: v2ShiftId, userId: v2UserId, status: signup.status, notes: signup.notes, confirmedAt: signup.confirmedAt, createdAt: signup.createdAt } }); } }; ``` ## Testing Migration ### Pre-Production Test Migration Before production migration, perform full test on staging: ```bash # 1. Clone production V1 data to staging ./scripts/backup.sh scp backups/latest.tar.gz staging-server:/tmp/ # 2. Restore V1 on staging ssh staging-server cd /opt/changemaker-lite tar -xzf /tmp/latest.tar.gz -C ./ docker compose -f docker-compose.v1.yml up -d # 3. Export V1 data docker compose -f docker-compose.v1.yml exec influence-app node /app/scripts/export-data.js # 4. Set up V2 on staging git checkout v2 docker compose up -d v2-postgres redis docker compose exec api npx prisma migrate deploy # 5. Transform and import node scripts/transform-users.js node scripts/transform-campaigns.js node scripts/transform-locations.js node scripts/import-v2-data.js # 6. Validate node scripts/validate-migration.js # 7. Test critical workflows ./scripts/test-v2-workflows.sh ``` ### Test Critical Workflows **Script**: `scripts/test-v2-workflows.sh` ```bash #!/bin/bash set -e API_URL="http://localhost:4000" ADMIN_TOKEN="" echo "Testing V2 Critical Workflows" echo "==============================" # 1. Admin Login echo -n "1. Admin login... " LOGIN_RESPONSE=$(curl -s -X POST "$API_URL/api/auth/login" \ -H "Content-Type: application/json" \ -d '{"email":"admin@example.com","password":"Admin123!"}') ADMIN_TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.data.accessToken') if [ "$ADMIN_TOKEN" != "null" ] && [ -n "$ADMIN_TOKEN" ]; then echo "✓" else echo "✗ Failed" exit 1 fi # 2. List Campaigns echo -n "2. List campaigns... " CAMPAIGNS=$(curl -s "$API_URL/api/influence/campaigns" \ -H "Authorization: Bearer $ADMIN_TOKEN") CAMPAIGN_COUNT=$(echo $CAMPAIGNS | jq '.data | length') echo "✓ ($CAMPAIGN_COUNT campaigns)" # 3. Representative Lookup echo -n "3. Representative lookup (M5V 1A1)... " REPS=$(curl -s -X POST "$API_URL/api/influence/representatives/lookup" \ -H "Content-Type: application/json" \ -d '{"postalCode":"M5V1A1"}') REP_COUNT=$(echo $REPS | jq '.data | length') echo "✓ ($REP_COUNT representatives)" # 4. List Locations echo -n "4. List locations... " LOCATIONS=$(curl -s "$API_URL/api/map/locations" \ -H "Authorization: Bearer $ADMIN_TOKEN") LOCATION_COUNT=$(echo $LOCATIONS | jq '.data | length') echo "✓ ($LOCATION_COUNT locations)" # 5. Send Test Email echo -n "5. Queue test email... " EMAIL_RESPONSE=$(curl -s -X POST "$API_URL/api/influence/campaign-emails/send-email" \ -H "Content-Type: application/json" \ -d '{ "campaignId":"'$(echo $CAMPAIGNS | jq -r '.data[0].id')'", "postalCode":"M5V1A1", "senderName":"Test User", "senderEmail":"test@example.com" }') if echo $EMAIL_RESPONSE | jq -e '.success' > /dev/null; then echo "✓" else echo "✗ Failed" fi echo echo "All critical workflows passed ✓" ``` ## Production Migration ### Step-by-Step Procedure #### Phase 1: Preparation (1-2 days before) 1. **Announce Downtime Window** ``` Subject: Scheduled Maintenance - System Upgrade We will be performing a major system upgrade on [DATE] at [TIME]. Expected downtime: 15-30 minutes What to expect: - All users will be logged out - You will need to re-login after the upgrade - Your data and passwords remain unchanged Please save any unsaved work before [TIME]. ``` 2. **Backup V1** ```bash ./scripts/backup.sh --include-uploads # Verify backup tar -tzf backups/changemaker-v1-$(date +%Y%m%d).tar.gz | head -20 ``` 3. **Test V2 on Staging** (use procedure above) #### Phase 2: Export (T-60min) 4. **Enable V1 Read-Only Mode** ```bash # Stop V1 write services docker compose -f docker-compose.v1.yml stop influence-app map-app # Keep database running for export ``` 5. **Export V1 Data** ```bash V1_NOCODB_URL=http://localhost:8080 \ V1_NOCODB_TOKEN=$(cat .env | grep NOCODB_API_TOKEN | cut -d= -f2) \ node scripts/export-v1-nocodb.js # Verify export ls -lh v1-export/ ``` #### Phase 3: Transform (T-30min) 6. **Transform Data** ```bash node scripts/transform-users.js node scripts/transform-campaigns.js node scripts/transform-locations.js node scripts/transform-shifts.js # Verify transformed data ls -lh v2-import/ ``` #### Phase 4: Import (T-15min) 7. **Stop V1 Completely** ```bash docker compose -f docker-compose.v1.yml down ``` 8. **Start V2 Database** ```bash docker compose up -d v2-postgres redis docker compose exec api npx prisma migrate deploy ``` 9. **Import Data** ```bash node scripts/import-v2-data.js | tee migration.log ``` 10. **Validate Import** ```bash node scripts/validate-migration.js ``` #### Phase 5: Launch V2 (T+0min) 11. **Start All V2 Services** ```bash docker compose up -d # Wait for health checks sleep 30 # Verify all healthy docker compose ps ``` 12. **Smoke Test** ```bash ./scripts/test-v2-workflows.sh ``` 13. **Update DNS/Tunnel** - Pangolin: Update endpoint in admin - Cloudflare: Update tunnel configuration - Manual DNS: Update A/CNAME records #### Phase 6: Monitor (T+15min to T+24hr) 14. **Watch Logs** ```bash docker compose logs -f api admin ``` 15. **Monitor Metrics** - Open Grafana: http://localhost:3001 - Check API Performance dashboard - Watch for error spikes 16. **Test User Logins** - Admin login - Regular user login - Temp user creation (shift signup) 17. **Announce Migration Complete** ``` Subject: System Upgrade Complete Our system upgrade is complete! You can now log in at: https://app.cmlite.org Your username and password remain unchanged. New features available: - [List new V2 features] If you experience any issues, please contact support@cmlite.org. ``` ## Rollback Procedures If migration fails, follow these steps: ### Emergency Rollback (T+0 to T+2hr) ```bash # 1. Stop V2 services docker compose down # 2. Restore V1 services docker compose -f docker-compose.v1.yml up -d # 3. Restore V1 database from backup (if modified) docker compose -f docker-compose.v1.yml exec -T v1-postgres \ psql -U nocodb nocodb < backups/v1-postgres-backup.sql # 4. Verify V1 operational curl -I http://localhost:3333/health # 5. Revert DNS/tunnel # 6. Announce rollback echo "Migration has been rolled back. V1 is operational." | \ mail -s "Migration Rollback" admin@cmlite.org ``` ### Post-Rollback Analysis 1. **Review Migration Logs** ```bash cat migration.log | grep ERROR ``` 2. **Identify Root Cause** - Data transformation errors? - Database constraint violations? - Application bugs? 3. **Fix Issues on Staging** - Update transformation scripts - Test again on staging - Validate thoroughly 4. **Reschedule Migration** - New downtime window - Communicate lessons learned ## Troubleshooting ### Issue: Prisma Unique Constraint Violation **Error**: `P2002: Unique constraint failed on the constraint: unique_email` **Cause**: Duplicate emails in merged user data. **Solution**: ```javascript // Before import, deduplicate const users = JSON.parse(await fs.readFile('v2-import/users.json', 'utf-8')); const unique = handleDuplicates(users); await fs.writeFile('v2-import/users.json', JSON.stringify(unique, null, 2)); ``` ### Issue: Foreign Key Constraint Violation **Error**: `P2003: Foreign key constraint failed on the field: createdByUserId` **Cause**: Campaign references user that doesn't exist (import order). **Solution**: Always import in order: 1. Users first 2. Campaigns (references users) 3. Locations (references users) 4. Shifts, responses, etc. ### Issue: Bcrypt Hashes Not Working **Symptoms**: Users can't login after migration despite correct password. **Cause**: Password field truncated or corrupted. **Diagnosis**: ```sql -- Check password hash format SELECT email, LEFT(password, 10), LENGTH(password) FROM "User" LIMIT 5; -- Should be: "$2b$10...", length 60 ``` **Solution**: ```bash # Re-import users, ensure password field is text type # Or batch reset passwords: docker compose exec api node scripts/reset-all-passwords.js ``` ## Related Documentation - [Migration Overview](index.md) - Migration planning guide - [Breaking Changes](breaking-changes.md) - V1→V2 differences - [API Changes](api-changes.md) - Endpoint mapping - [Feature Parity](feature-parity.md) - Feature comparison ## Next Steps After successful migration: 1. **Configure V2 Settings** - [Site Settings](../user-guides/admin-guide.md#site-settings) - [Map Settings](../user-guides/admin-guide.md#map-settings) - [Email Configuration](../deployment/email.md) 2. **Train Administrators** - [Admin Guide](../user-guides/admin-guide.md) - [Campaign Management](../features/influence/campaigns.md) - [Volunteer Canvassing](../features/map/canvassing.md) 3. **Enable New Features** - [Landing Page Builder](../features/pages/page-builder.md) - [Email Templates](../features/email-templates/template-system.md) - [Media Library](../features/media/video-library.md) 4. **Set Up Monitoring** - [Observability Guide](../features/observability/monitoring.md) - [Backup Procedures](../deployment/backup-restore.md) !!! success "Migration Complete" Congratulations on completing your V2 migration! Welcome to the modern Changemaker Lite platform.