# Cyclos Database Import Reference This document describes how the legacy Cyclos database is imported into the Laravel Timebank application during the `php artisan db:seed` process. ## Overview The Cyclos import is a production-grade data migration system that transfers members, accounts, transactions, profile data, and images from a Cyclos timebank database into the new Laravel-based system. The migration preserves historical data integrity while transforming it to fit the new polymorphic multi-profile architecture. ## Migration Flow The import process is orchestrated through `database/seeders/DatabaseSeeder.php` and consists of several stages, each triggered by user confirmation prompts: ``` db:seed ├── Database refresh & base seeders ├── [Optional] migrate:cyclos ← Main migration │ ├── [Optional] migrate:cyclos-profiles ← Profile data │ │ ├── profiles:clean-about ← Cleanup │ │ └── profiles:clean-cyclos_skills ← Cleanup │ └── [Optional] migrate:cyclos-gift-accounts ← Account consolidation └── [Optional] Elasticsearch re-indexing ``` ## Source Database Configuration The source Cyclos database name is prompted during the migration process. The database must be imported into MySQL and accessible from the application. **Setup Steps:** 1. Export the Cyclos database as a SQL dump 2. Place the dump file in the application root 3. Import into MySQL: `mysql -u root -p < cyclos_dump.sql` 4. Run the migration and enter the database name when prompted **Input Validation:** - If you enter the database name with a `.sql` extension (e.g., `timebank_2025_09_19.sql`), it will be automatically stripped - The command verifies the database exists before proceeding; if not found, it displays available databases to help identify typos During the `db:seed` process, the source database name entered in `migrate:cyclos` is cached and automatically reused by `migrate:cyclos-profiles`, so you only need to enter it once. The destination database is read from the environment: ```php $destinationDb = env('DB_DATABASE'); ``` --- ## Stage 1: Main Migration (`migrate:cyclos`) **File:** `app/Console/Commands/MigrateCyclosCommand.php` This is the primary migration command that handles members, images, accounts, and transactions. ### Member Group Mapping Cyclos organizes members into groups. The migration maps these groups to Laravel model types: | Cyclos Group ID | Group Name | Laravel Model | Status | |-----------------|------------|---------------|--------| | 5 | Active Users | `User` | Active | | 6 | Inactive Users | `User` | `inactive_at` set | | 8 | Removed Users | `User` | `deleted_at` set | | 13 | Local Banks (Level I) | `Bank` | `level = 1` | | 14 | Organizations | `Organization` | Active | | 15 | Projects (Level II) | `Bank` | `level = 2` | | 17 | Local Admins | *Not migrated* | - | | 18 | TEST: Projects | `Organization` | Active | | 22 | TEST: Users | `User` | Active | | 27 | Inactive Projects | `Organization` | `inactive_at` set | ### Member Field Mapping Each member type maps Cyclos fields to Laravel columns: | Cyclos Field | Laravel Column | Notes | |--------------|----------------|-------| | `members.id` | `cyclos_id` | Foreign key reference | | `members.name` | `full_name` | Display name | | `members.email` | `email` | - | | `members.member_activation_date` | `email_verified_at` | Converted via `FROM_UNIXTIME()` | | `members.creation_date` | `created_at` | Converted via `FROM_UNIXTIME()` | | `users.username` | `name` | Profile URL slug | | `users.password` | `password` | Preserved hash (may need reset) | | `users.salt` | `cyclos_salt` | For potential password compatibility | | `users.last_login` | `last_login_at` | Converted via `FROM_UNIXTIME()` | | `group_history_logs.start_date` | `inactive_at` / `deleted_at` | For inactive/removed members | ### Member Migration SQL Pattern Each member type uses a similar INSERT pattern with ON DUPLICATE KEY UPDATE for idempotency: ```sql INSERT INTO {destination}.users (cyclos_id, full_name, email, ...) SELECT m.id AS cyclos_id, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, ... FROM {source}.members m JOIN {source}.users u ON m.id = u.id WHERE m.group_id = {group_id} ON DUPLICATE KEY UPDATE ... ``` ### Removed User Handling Removed users (group_id = 8) receive anonymized data: ```sql CONCAT('Removed Cyclos user', m.id) AS full_name, CONCAT(m.id, '@removed.mail') AS email, CONCAT('Removed user ', m.id) AS name, 0 as limit_min, 0 as limit_max ``` ### Profile Images Migration Images are stored as binary data in Cyclos. The migration: 1. Selects the image with the lowest `order_number` for each member (primary profile photo) 2. Extracts binary data from `{source}.images.image` 3. Saves to `storage/app/public/profile-photos/{type}_{uniqid}.jpg` 4. Updates the profile's `profile_photo_path` column 5. Sets default avatar path for profiles without images ```php Storage::disk('public')->put($filename, $image->image); ``` ### Account Migration Cyclos uses account types identified by `type_id`. These map to the polymorphic accounts table: | Cyclos Type ID | Account Type | Owner Types | Notes | |----------------|--------------|-------------|-------| | 1 | Debit | Bank (id=1) | System settlement account | | 2 | Community | Bank (id=1) | Community pool | | 5 | Work | User, Organization, Bank | Primary transaction account | | 6 | Gift | User, Organization, Bank | Temporary, consolidated later | | 7 | Project | User, Organization, Bank | Project-specific transactions | Account limits are pulled from white-label configuration: ```php $userLimitMin = timebank_config('accounts.user.limit_min'); $userLimitMax = timebank_config('accounts.user.limit_max'); ``` ### Account Migration SQL Pattern ```sql INSERT INTO {destination}.accounts (name, accountable_type, accountable_id, cyclos_id, ...) SELECT '{accountName}' AS name, 'App\\Models\\User' AS accountable_type, u.id AS accountable_id, a.id AS cyclos_id, ... FROM {source}.accounts a JOIN {destination}.users u ON a.member_id = u.cyclos_id WHERE a.type_id = {type_id} ``` ### Transaction Migration **Critical Transformation:** Cyclos stores amounts as decimal hours (e.g. `0.8667` = 52 minutes). Laravel stores amounts as integer minutes. ```sql ROUND(t.amount * 60) AS amount ``` **Known limitation:** Rounding to integer minutes per transaction accumulates small errors across accounts with many transactions. For example, an account with 502 transactions may end up with a balance ±11 minutes off from Cyclos. The `verify:cyclos-migration` command reports these discrepancies. A per-account correction mechanism is planned to address this. Transaction mapping: | Cyclos Field | Laravel Column | Notes | |--------------|----------------|-------| | `transfers.from_account_id` | `from_account_id` | Via `accounts.cyclos_id` join | | `transfers.to_account_id` | `to_account_id` | Via `accounts.cyclos_id` join | | `transfers.amount` | `amount` | Multiplied by 60, rounded to integer minutes | | `transfers.description` | `description` | - | | `transfers.type_id` | `transaction_type_id` | Direct mapping | | - | `transaction_status_id` | Always `1` (completed) | | `transfers.date` | `created_at` | Converted via `FROM_UNIXTIME()` | ### Validation Checks After migration, three inline integrity checks are performed: 1. **Member Count:** Total Cyclos members (excluding group 17) must equal Users + Organizations + Banks 2. **Transaction Count:** Total Cyclos transfers must equal Laravel transactions 3. **Balance Integrity:** Net balance across all accounts must equal zero For a full verification after the complete seed process, run: ```bash php artisan verify:cyclos-migration ``` This checks member counts, transaction counts, per-account-type balances, gift account cleanup, and deleted profile cleanup. See `references/CYCLOS_MIGRATION_DISCREPANCIES.md` for the latest results. ```php // Balance check using window functions $totalNetBalance = DB::query() ->fromSub($accountBalances, 'account_balances') ->selectRaw('SUM(net_balance) as total_net_balance') ->first(); if ($totalNetBalance->total_net_balance != 0) { $this->error("OUT OF BALANCE AMOUNT: {$totalNetBalance->total_net_balance}"); } ``` ### Laravel-Love Registration After data migration, the command registers all profiles with the Laravel-Love package for reaction tracking: ```php Artisan::call('love:register-reacters', ['--model' => 'App\Models\User']); Artisan::call('love:register-reactants', ['--model' => 'App\Models\User']); // ... for Organization and Bank as well Artisan::call('love:add-reactions-to-transactions'); ``` --- ## Stage 2: Profile Data Migration (`migrate:cyclos-profiles`) **File:** `app/Console/Commands/MigrateCyclosProfilesCommand.php` This command migrates custom profile fields and preferences from Cyclos. ### Custom Field Mapping Cyclos stores custom fields in `custom_field_values` with `field_id` identifiers: | Field ID | Cyclos Field | Laravel Column | Model Types | Notes | |----------|--------------|----------------|-------------|-------| | 1 | Birthday | `date_of_birth` | User | Date format: `DD/MM/YYYY` → `YYYY-MM-DD` | | 7 | Phone | `phone` | All | Truncated to 20 characters | | 10 | Website | `website` | All | - | | 13 | Skills | `cyclos_skills` | User, Organization | Truncated to 495 chars + "..." | | 17 | About | `about` | User | - | | 28 | General Newsletter | `message_settings.general_newsletter` | All | 790=No, 791=Yes | | 29 | Local Newsletter | `message_settings.local_newsletter` | All | 792=No, 793=Yes | | 35 | Motivation | `motivation` | All | - | | 36 | Country | `locations.country_id` | All | Via code mapping | | 38 | City | `locations.city_id` | All | Via code mapping | ### Location Code Mapping Cyclos uses `possible_value_id` for location selection. These map to Laravel location IDs: **Countries:** ```php $countryCodeMap = [ 860 => 2, // BE (Belgium) 861 => 7, // PT (Portugal) 862 => 1, // NL (Netherlands) 863 => null, // Other/not set ]; ``` **Cities:** ```php $cityCodeMap = [ 864 => 188, // Amsterdam 865 => 200, // Haarlem 866 => 316, // Leiden 867 => 305, // The Hague 868 => 300, // Delft 869 => 331, // Rotterdam 870 => 272, // Utrecht 881 => 345, // Brussels ]; ``` After creating location records, the migration calls `$location->syncAllLocationData()` to populate related fields (divisions, etc.) from the parent city/country. ### Newsletter Preferences Newsletter preferences migrate to the polymorphic `message_settings` table: ```php $model->message_settings()->updateOrCreate( ['message_settingable_id' => $model->id, 'message_settingable_type' => $modelClass], ['general_newsletter' => $value] ); ``` ### HTML Tag Stripping After migration, HTML tags are stripped from text fields: ```php $cleaned = strip_tags($record->cyclos_skills); $cleaned = strip_tags($record->about); ``` --- ## Stage 3: Cleanup Commands ### `profiles:clean-about` **File:** `app/Console/Commands/CleanCyclosProfiles.php` Removes empty paragraph markup from about fields: - `
` - `` - `
` - `"` (single double-quote character) Sets these to `null` for clean display. ### `profiles:clean-cyclos_skills` **File:** `app/Console/Commands/CleanCyclosSkills.php` Removes trailing pipe symbols from skills fields. Cyclos used pipe-delimited skill lists that sometimes had trailing pipes: ```php $cleaned = preg_replace('/(\s*\|\s*)+$/', '', $original); ``` --- ## Stage 4: Gift Account Consolidation (`migrate:cyclos-gift-accounts`) **File:** `app/Console/Commands/MigrateCyclosGiftAccounts.php` Cyclos had separate "gift" accounts (type_id = 6) that received welcome bonuses or gift transactions. This command consolidates gift account balances into primary work accounts and marks all gift accounts as inactive. ### How Gift Accounts Are Created During Stage 1 (`migrate:cyclos`), gift accounts are imported from Cyclos for all profile types: 1. **All gift accounts are imported** from Cyclos `accounts` table where `type_id = 6` 2. They are created with `name = 'gift'` and associated with Users, Organizations, or Banks 3. All related transactions are also imported, preserving the original balances 4. At this stage, gift accounts are **active** (no `inactive_at` set) ### Consolidation Process The `migrate:cyclos-gift-accounts` command then processes these accounts: 1. **Find all gift accounts** - Queries all accounts where `name = 'gift'` 2. **For each gift account, check the balance:** - If balance is **zero or negative**: Skip (nothing to transfer) - If balance is **positive**: Transfer the full balance to the owner's primary (non-gift) account 3. **Transfer positive balances:** ```php $transfer = new Transaction(); $transfer->from_account_id = $fromAccount->id; // Gift account $transfer->to_account_id = $toAccount->id; // Primary work account $transfer->amount = $balance; $transfer->description = "Migration of balance from gift account (ID: {$fromAccount->id})"; $transfer->transaction_type_id = 6; // Migration type $transfer->save(); ``` 4. **Mark ALL gift accounts as inactive** - After the loop completes, **all** gift accounts are marked inactive, regardless of whether they had a positive balance or not: ```php $giftAccountIds = $giftAccounts->pluck('id'); Account::whereIn('id', $giftAccountIds)->update(['inactive_at' => now()]); ``` ### Important Notes - **All gift accounts become inactive** after this command runs - not just those with positive balances - Accounts with zero/negative balance are skipped for the transfer step but still marked inactive - The original transactions remain in the gift account history (for audit purposes) - The migration transaction creates a clear record of when and how balances were moved - If no destination account is found for an owner, the gift account is logged as a warning but still marked inactive --- ## Database Safety Measures ### Transaction Wrapping Every migration step is wrapped in database transactions: ```php DB::beginTransaction(); try { // Migration logic DB::commit(); } catch (\Exception $e) { DB::rollBack(); $this->error('Migration failed: ' . $e->getMessage()); } ``` ### Job Queue Suppression During migration, job dispatching is disabled to prevent side effects: ```php \Queue::fake(); ``` ### Idempotency Member migrations use `ON DUPLICATE KEY UPDATE` to allow re-running without data duplication. --- ## Password Handling Cyclos password hashes are preserved in the `password` column with the salt stored in `cyclos_salt`. However, Laravel uses a different hashing mechanism (bcrypt by default), so: - Users may need to use "Forgot Password" to set a new Laravel-compatible password - Alternatively, a custom hash driver could be implemented to verify Cyclos passwords --- ## Running the Migration ### Prerequisites 1. Export the Cyclos database as a SQL dump 2. Import the dump into MySQL: ```bash mysql -u root -p < cyclos_dump.sql ``` 3. Note the database name you used during import ### Full Fresh Migration Use `seed.sh` for a complete fresh setup — it handles dropping tables, running migrations, maintenance mode, immutability restrictions, and prompts for the Cyclos DB: ```bash ./seed.sh ``` If running the seeder manually: ```bash php artisan db:seed ``` You will be prompted through the following steps: 1. **Seed base data?** - Transaction types, permissions, countries, etc. 2. **Seed example tags?** - Optional tag data 3. **Migrate cyclos database?** - If yes: - You will be prompted to enter the source Cyclos database name - The database name is cached for use by subsequent commands 4. **Migrate cyclos profile data?** - Uses cached database name automatically 5. **Migrate cyclos gift accounts?** - Consolidates gift balances 6. **Assign Dev-Admin role?** - Optional admin assignment 7. **Re-create Elasticsearch index?** - Rebuilds search indices ### Individual Commands When running commands individually (outside of `db:seed`), each command will prompt for the source database name if not cached: ```bash # Main migration - prompts for database name and caches it php artisan migrate:cyclos # Profile data - uses cached name or prompts if cache expired php artisan migrate:cyclos-profiles # Cleanup commands (no database prompt needed) php artisan profiles:clean-about php artisan profiles:clean-cyclos_skills # Gift account consolidation (no database prompt needed) php artisan migrate:cyclos-gift-accounts ``` ### Database Name Caching The source database name is cached for 1 hour after being entered in `migrate:cyclos`. This allows `migrate:cyclos-profiles` to automatically use the same database without re-prompting during the `db:seed` process. ```php // Set by migrate:cyclos cache()->put('cyclos_migration_source_db', $sourceDb, now()->addHours(1)); // Retrieved by migrate:cyclos-profiles $sourceDb = cache()->get('cyclos_migration_source_db'); ``` --- ## Key Files Reference | File | Purpose | |------|---------| | `database/seeders/DatabaseSeeder.php` | Orchestrates the full seeding process | | `app/Console/Commands/MigrateCyclosCommand.php` | Members, images, accounts, transactions | | `app/Console/Commands/MigrateCyclosProfilesCommand.php` | Custom fields, locations, newsletters | | `app/Console/Commands/MigrateCyclosGiftAccounts.php` | Gift account consolidation | | `app/Console/Commands/CleanCyclosProfiles.php` | About field cleanup | | `app/Console/Commands/CleanCyclosSkills.php` | Skills field cleanup | --- ## Post-Migration Considerations 1. **Password Resets:** Advise users to reset passwords via the forgot password flow 2. **Profile Photos:** Verify images are accessible at `/storage/profile-photos/` 3. **Balance Verification:** Run `php artisan verify:cyclos-migration` — all 20 checks should pass. See `references/CYCLOS_MIGRATION_DISCREPANCIES.md` for explanation of known discrepancies. 4. **Search Indexing:** Ensure Elasticsearch indices are rebuilt after migration 5. **Gift Accounts:** Verified automatically by `verify:cyclos-migration` (section 4)