From 2ce89e7c6cf690f5f6335a1acbb49f5da017df68 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 2 Dec 2025 01:20:35 +0000 Subject: [PATCH] feat: implement data migration for existing lists - Created migration-actions.ts with server actions for claiming lists - Added ClaimListsModal component for UI during registration - Integrated claim modal into RegisterForm - Created anonymous-lists.ts utilities for tracking lists in localStorage - Added migrate-lists.ts script for deployment migration - Created comprehensive MIGRATION_GUIDE.md documentation - Added migration tests with 100% pass rate - Updated package.json with db:migrate script - Added tsx as dev dependency for running migration scripts Requirements: 10.2, 10.3, 10.4, 10.5 - Users can claim existing lists on first registration - All list data is preserved during migration - All lists have an owner after migration - Migrated lists appear in user's account --- .kiro/specs/user-auth-and-sharing/tasks.md | 2 +- MIGRATION_GUIDE.md | 160 ++++++++++++++++ components/ClaimListsModal.tsx | 204 +++++++++++++++++++++ components/RegisterForm.tsx | 54 +++++- lib/__tests__/migration-actions.test.ts | 201 ++++++++++++++++++++ lib/anonymous-lists.ts | 85 +++++++++ lib/migration-actions.ts | 162 ++++++++++++++++ package.json | 2 + scripts/migrate-lists.ts | 110 +++++++++++ 9 files changed, 976 insertions(+), 4 deletions(-) create mode 100644 MIGRATION_GUIDE.md create mode 100644 components/ClaimListsModal.tsx create mode 100644 lib/__tests__/migration-actions.test.ts create mode 100644 lib/anonymous-lists.ts create mode 100644 lib/migration-actions.ts create mode 100644 scripts/migrate-lists.ts diff --git a/.kiro/specs/user-auth-and-sharing/tasks.md b/.kiro/specs/user-auth-and-sharing/tasks.md index a978f91..2e00e3a 100644 --- a/.kiro/specs/user-auth-and-sharing/tasks.md +++ b/.kiro/specs/user-auth-and-sharing/tasks.md @@ -243,7 +243,7 @@ - Update navigation to include profile and invitations links - _Requirements: 6.3_ -- [ ] 16. Implement data migration for existing lists +- [x] 16. Implement data migration for existing lists - Create migration script for existing anonymous lists - Add UI for users to claim existing lists on first registration - Ensure all lists have an owner after migration diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..95572ab --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,160 @@ +# Data Migration Guide + +This guide explains how to migrate existing anonymous lists to the new user authentication system. + +## Overview + +The Ultimate Lists App now supports user authentication and private lists. This migration ensures that: +- All existing lists are preserved (Requirement 10.3) +- All lists have an associated owner (Requirement 10.4) +- Users can claim their existing lists when they register (Requirement 10.2) +- All list data including items, metadata, and timestamps remain unchanged (Requirement 10.3) + +## Migration Strategy + +### For New Deployments + +If you're deploying the authentication system for the first time: + +1. **Run the migration script** before enabling authentication: + ```bash + npx tsx scripts/migrate-lists.ts + ``` + + This script will: + - Create a system user if it doesn't exist + - Assign any orphaned lists to the system user + - Verify all lists have owners + +2. **Deploy the application** with authentication enabled + +3. **Users can claim lists** when they register: + - Lists created before registration are tracked in localStorage + - During registration, users are prompted to claim their lists + - Lists are transferred from system user to the new user account + +### For Existing Users + +When a user registers for the first time: + +1. The app checks localStorage for any tracked list IDs +2. If lists are found, a modal appears asking the user to claim them +3. User can select which lists to claim +4. Selected lists are transferred to the user's account +5. All list data (items, metadata, timestamps) is preserved + +## Technical Details + +### Database Schema Changes + +The migration adds: +- `User` table with authentication fields +- `ownerId` field on `List` table (required) +- `Collaborator` table for list sharing +- `Invitation` table for sharing invitations + +### Migration Script + +Location: `scripts/migrate-lists.ts` + +The script: +- Creates a system user (`system@ultimatelists.app`) +- Assigns orphaned lists to the system user +- Verifies data integrity +- Provides a migration summary + +### Client-Side Tracking + +Location: `lib/anonymous-lists.ts` + +Utilities for tracking anonymous lists: +- `trackAnonymousList(listId)` - Add a list to tracking +- `getAnonymousListIds()` - Get all tracked lists +- `clearAnonymousLists()` - Clear tracking after migration +- `hasAnonymousLists()` - Check if there are lists to migrate + +### Migration Actions + +Location: `lib/migration-actions.ts` + +Server actions for migration: +- `getClaimableLists(listIds)` - Fetch lists that can be claimed +- `claimLists(listIds)` - Transfer lists to user account +- `hasExistingLists()` - Check if user has lists +- `migrateOrphanedListsToSystemUser()` - One-time migration script + +### UI Components + +Location: `components/ClaimListsModal.tsx` + +Modal component that: +- Displays claimable lists with details +- Allows users to select which lists to claim +- Handles the claim process +- Clears localStorage after successful claim + +## Testing the Migration + +### Test Scenario 1: New User with Anonymous Lists + +1. Create lists without being logged in +2. Register for an account +3. Verify the claim modal appears +4. Select lists to claim +5. Verify lists appear in the user's account +6. Verify all items and metadata are preserved + +### Test Scenario 2: New User without Anonymous Lists + +1. Register for an account without creating lists +2. Verify no claim modal appears +3. Verify user is redirected to the app + +### Test Scenario 3: Existing User + +1. Log in with an existing account +2. Verify existing lists are displayed +3. Verify all data is intact + +## Rollback Plan + +If issues occur during migration: + +1. **Database Rollback**: + ```bash + npx prisma migrate reset + ``` + +2. **Restore from Backup**: + - Restore database from pre-migration backup + - Redeploy previous version of the application + +3. **Manual Data Recovery**: + - Export lists from system user + - Manually assign to correct users + - Verify data integrity + +## Monitoring + +After migration, monitor: +- Number of orphaned lists (should be 0) +- Number of lists owned by system user +- User registration and claim rates +- Any errors in migration logs + +## Support + +If users experience issues: +1. Check browser console for errors +2. Verify localStorage contains list IDs +3. Check server logs for migration errors +4. Manually transfer lists if needed using admin tools + +## Requirements Validation + +This migration satisfies: +- ✓ Requirement 10.1: Authentication system provides migration path +- ✓ Requirement 10.2: Users can claim existing lists on registration +- ✓ Requirement 10.3: All list data is preserved during migration +- ✓ Requirement 10.4: All lists have an associated owner +- ✓ Requirement 10.5: Migrated lists appear in user's account diff --git a/components/ClaimListsModal.tsx b/components/ClaimListsModal.tsx new file mode 100644 index 0000000..3a4b4e6 --- /dev/null +++ b/components/ClaimListsModal.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useState, useEffect } from 'react' +import { claimLists, getClaimableLists, type ClaimableList } from '@/lib/migration-actions' +import Modal from './Modal' +import LoadingSpinner from './LoadingSpinner' + +interface ClaimListsModalProps { + isOpen: boolean + onClose: () => void + onComplete: () => void +} + +/** + * Modal for claiming existing anonymous lists after registration + * Requirements: 10.2, 10.5 - Migration UI for users to claim existing lists + */ +export default function ClaimListsModal({ isOpen, onClose, onComplete }: ClaimListsModalProps) { + const [claimableLists, setClaimableLists] = useState([]) + const [selectedLists, setSelectedLists] = useState>(new Set()) + const [loading, setLoading] = useState(true) + const [claiming, setClaiming] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (isOpen) { + loadClaimableLists() + } + }, [isOpen]) + + const loadClaimableLists = async () => { + try { + setLoading(true) + setError(null) + + // Get list IDs from localStorage + const storedListIds = getStoredListIds() + + if (storedListIds.length === 0) { + setClaimableLists([]) + setLoading(false) + return + } + + // Fetch lists from database + const lists = await getClaimableLists(storedListIds) + setClaimableLists(lists) + + // Select all lists by default + setSelectedLists(new Set(lists.map(l => l.id))) + } catch (err) { + console.error('Error loading claimable lists:', err) + setError('Failed to load your lists. Please try again.') + } finally { + setLoading(false) + } + } + + const getStoredListIds = (): string[] => { + try { + // Check for list IDs in localStorage + // This assumes the app previously stored list IDs locally + const stored = localStorage.getItem('anonymousListIds') + if (stored) { + return JSON.parse(stored) + } + return [] + } catch (err) { + console.error('Error reading localStorage:', err) + return [] + } + } + + const toggleListSelection = (listId: string) => { + const newSelection = new Set(selectedLists) + if (newSelection.has(listId)) { + newSelection.delete(listId) + } else { + newSelection.add(listId) + } + setSelectedLists(newSelection) + } + + const handleClaimLists = async () => { + try { + setClaiming(true) + setError(null) + + const listIds = Array.from(selectedLists) + const result = await claimLists(listIds) + + if (result.success) { + // Clear localStorage after successful claim + localStorage.removeItem('anonymousListIds') + onComplete() + } else { + setError(result.error) + } + } catch (err) { + console.error('Error claiming lists:', err) + setError('Failed to claim lists. Please try again.') + } finally { + setClaiming(false) + } + } + + const handleSkip = () => { + // User chose not to claim lists + localStorage.removeItem('anonymousListIds') + onClose() + } + + if (!isOpen) return null + + return ( + +
+ {loading ? ( +
+ +
+ ) : claimableLists.length === 0 ? ( +
+

No lists found to claim.

+ +
+ ) : ( + <> +

+ We found {claimableLists.length} list{claimableLists.length !== 1 ? 's' : ''} from your previous session. + Would you like to add {claimableLists.length !== 1 ? 'them' : 'it'} to your account? +

+ + {error && ( +
+ {error} +
+ )} + +
+ {claimableLists.map((list) => ( +
+ + ) +} diff --git a/components/RegisterForm.tsx b/components/RegisterForm.tsx index 024983e..0588f01 100644 --- a/components/RegisterForm.tsx +++ b/components/RegisterForm.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { validateEmail, validatePassword, sanitizeString } from '@/lib/validation' import { registerUser } from '@/lib/auth-actions' import { useToast } from './ToastContainer' +import ClaimListsModal from './ClaimListsModal' interface RegisterFormProps { callbackUrl?: string @@ -18,6 +19,7 @@ export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) { const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [showClaimModal, setShowClaimModal] = useState(false) const [errors, setErrors] = useState<{ name?: string email?: string @@ -154,8 +156,19 @@ export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) { } } else { showToast('Registration successful! Logging you in...', 'success') - router.push(callbackUrl) - router.refresh() + + // Check if there are lists to claim from localStorage + // Requirement 10.2 - Add UI for users to claim existing lists on first registration + const hasAnonymousLists = checkForAnonymousLists() + + if (hasAnonymousLists) { + // Show claim modal + setShowClaimModal(true) + } else { + // No lists to claim, proceed to app + router.push(callbackUrl) + router.refresh() + } } } catch (err) { console.error('Registration error:', err) @@ -165,8 +178,42 @@ export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) { } } + const checkForAnonymousLists = (): boolean => { + try { + const stored = localStorage.getItem('anonymousListIds') + if (stored) { + const listIds = JSON.parse(stored) + return Array.isArray(listIds) && listIds.length > 0 + } + return false + } catch (err) { + console.error('Error checking for anonymous lists:', err) + return false + } + } + + const handleClaimComplete = () => { + setShowClaimModal(false) + showToast('Lists claimed successfully!', 'success') + router.push(callbackUrl) + router.refresh() + } + + const handleClaimClose = () => { + setShowClaimModal(false) + router.push(callbackUrl) + router.refresh() + } + return ( -
+ <> + + +