512 lines
18 KiB
Markdown
512 lines
18 KiB
Markdown
# 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:
|
|
- `<p></p>`
|
|
- `<p> </p>`
|
|
- `<p> </p>`
|
|
- `"` (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)
|