Files

986 lines
42 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class DatabaseUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'database:update';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Apply database updates for schema changes and data migrations';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting database updates...');
// Apply all update methods
$this->renameAssociationToPlatformOrganization();
$this->addLocationNotSpecifiedCountry();
$this->addManageCallsPermission();
$this->createAdminRolesTable();
$this->createOrganizationRolesTable();
$this->addGranularPermissions();
$this->seedAdminRolePresets();
$this->updateAdministratorRolePermissions();
$this->migrateFlatAdminRoleToScoped();
$this->replaceLegacyAdminRoles();
$this->migrateHardcodedOrgRolesToOrganizationRoles();
$this->fixCoordinatorSystemRoles();
$this->addPasswordChangeLockColumns();
$this->addChangePasswordToManagerRoles();
$this->addAdminCapabilityPermissions();
$this->fixReversalTransactionTypeIcon();
$this->addPollCategory();
$this->addPollPermissions();
$this->info('Database updates completed successfully!');
return 0;
}
/**
* Rename 'Association' to 'Timebank Organization' across the database
* Updates categories table from Association/PlatformOrganization to TimebankOrganization
* Updates category_translations table with proper translations
*/
private function renameAssociationToPlatformOrganization()
{
$this->info('Renaming Association to Timebank Organization...');
try {
DB::beginTransaction();
// Update categories table: change type from Association to TimebankOrganization
$categoriesUpdated = DB::table('categories')
->where('type', 'SiteContents\\Static\\Association')
->update(['type' => 'SiteContents\\Static\\TimebankOrganization']);
if ($categoriesUpdated > 0) {
$this->info(" ✓ Updated {$categoriesUpdated} category record(s) from Association");
}
// Also update any PlatformOrganization entries to TimebankOrganization
$platformUpdated = DB::table('categories')
->where('type', 'SiteContents\\Static\\PlatformOrganization')
->update(['type' => 'SiteContents\\Static\\TimebankOrganization']);
if ($platformUpdated > 0) {
$this->info(" ✓ Updated {$platformUpdated} category record(s) from PlatformOrganization");
}
if ($categoriesUpdated === 0 && $platformUpdated === 0) {
$this->warn(' ℹ No categories found to update');
}
// Update category_translations table
// Get the category ID for TimebankOrganization
$category = DB::table('categories')
->where('type', 'SiteContents\\Static\\TimebankOrganization')
->first();
if ($category) {
$translations = [
'en' => [
'name' => 'Timebank organization page',
'slug' => 'timebank-organization-page',
],
'nl' => [
'name' => 'Timebank organisatie pagina',
'slug' => 'timebank-organisatie-pagina',
],
'de' => [
'name' => 'Timebank-Organisation Seite',
'slug' => 'timebank-organisation-seite',
],
'es' => [
'name' => 'Organización de Timebank página',
'slug' => 'organizacion-de-timebank-pagina',
],
'fr' => [
'name' => 'Organisation de Timebank page',
'slug' => 'organisation-de-timebank-page',
],
];
$translationsUpdated = 0;
foreach ($translations as $locale => $data) {
$updated = DB::table('category_translations')
->where('category_id', $category->id)
->where('locale', $locale)
->update([
'name' => $data['name'],
'slug' => $data['slug'],
]);
$translationsUpdated += $updated;
}
if ($translationsUpdated > 0) {
$this->info(" ✓ Updated {$translationsUpdated} category translation(s) (name and slug)");
} else {
$this->warn(' ℹ No category translations found to update');
}
}
DB::commit();
$this->info(' ✓ Renamed to Timebank Organization successfully');
} catch (\Exception $e) {
DB::rollBack();
$this->error(' ✗ Failed to rename: ' . $e->getMessage());
}
}
/**
* Add the "Location not specified" placeholder country (id=10, code=XX) with translations.
* Used for profiles whose Cyclos country was unmapped (code 863).
*/
private function addLocationNotSpecifiedCountry()
{
$this->info('Adding "Location not specified" placeholder country...');
try {
DB::beginTransaction();
DB::table('countries')->upsert(
[['id' => 10, 'code' => 'XX', 'flag' => '🌐', 'phonecode' => '']],
['id']
);
$locales = [
['id' => 37, 'locale' => 'en', 'name' => '~ Location not specified'],
['id' => 38, 'locale' => 'nl', 'name' => '~ Locatie niet opgegeven'],
['id' => 39, 'locale' => 'fr', 'name' => '~ Emplacement non précisé'],
['id' => 40, 'locale' => 'es', 'name' => '~ Ubicación no especificada'],
['id' => 41, 'locale' => 'de', 'name' => '~ Standort nicht angegeben'],
];
foreach ($locales as $locale) {
DB::table('country_locales')->upsert(
[['id' => $locale['id'], 'country_id' => 10, 'name' => $locale['name'], 'alias' => null, 'locale' => $locale['locale']]],
['id']
);
}
DB::commit();
$this->info(' ✓ "Location not specified" country added/updated');
} catch (\Exception $e) {
DB::rollBack();
$this->error(' ✗ Failed to add location not specified country: ' . $e->getMessage());
}
}
private function addManageCallsPermission()
{
$this->info('Adding "manage calls" permission...');
try {
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$permission = Permission::firstOrCreate(['name' => 'manage calls', 'guard_name' => 'web']);
foreach (['create calls', 'update calls', 'delete calls'] as $name) {
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
}
// Give permission to existing site-editor and admin roles
foreach (['site-editor', 'admin'] as $roleName) {
$role = Role::findByName($roleName, 'web');
if ($role && !$role->hasPermissionTo('manage calls')) {
$role->givePermissionTo($permission);
$this->info(" ✓ Gave 'manage calls' to role '{$roleName}'");
}
}
// Give permission to all scoped Admin\{id}\site-editor roles
$scopedRoles = Role::where('name', 'like', 'Admin\\%\\site-editor')->get();
foreach ($scopedRoles as $scopedRole) {
if (!$scopedRole->hasPermissionTo('manage calls')) {
$scopedRole->givePermissionTo($permission);
$this->info(" ✓ Gave 'manage calls' to scoped role '{$scopedRole->name}'");
}
}
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->info(' ✓ "manage calls" permission added/updated');
} catch (\Exception $e) {
$this->error(' ✗ Failed to add manage calls permission: ' . $e->getMessage());
}
}
private function addGranularPermissions()
{
$this->info('Adding granular permissions (view/publish/unpublish/send)...');
try {
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$newPermissions = [
// view permissions for all resources
'view posts', 'view tags', 'view categories', 'view calls',
'view mailings', 'view users', 'view user_projects',
'view organizations', 'view banks', 'view admins', 'view accounts',
'view profiles', 'view permissions', 'view roles',
// publish/unpublish calls
'publish calls', 'unpublish calls',
// publish/unpublish posts
'publish posts', 'unpublish posts',
// send mailings (irreversible dispatch)
'send mailings',
// additional granular actions already in seeder but possibly missing
'create mailings', 'update mailings', 'delete mailings',
'create calls', 'update calls', 'delete calls',
'create posts', 'update posts', 'delete posts',
'create tags', 'update tags', 'delete tags',
'create categories', 'update categories', 'delete categories',
'create users', 'update users', 'delete users',
'create user_projects', 'update user_projects', 'delete user_projects',
'create organizations', 'update organizations', 'delete organizations',
'create banks', 'update banks', 'delete banks',
'create admins', 'update admins', 'delete admins',
'create accounts', 'update accounts', 'delete accounts',
'create profiles', 'update profiles', 'delete profiles',
'create permissions', 'update permissions', 'delete permissions',
'create roles', 'update roles', 'delete roles',
'manage maintenance',
'view search-metrics', 'view application-log', 'view inactive-profiles-log', 'view search-reindex-log',
'view stats',
// Org/bank capability permissions
'org pay', 'org transactions', 'org reports', 'org stats', 'org contacts',
'org calls', 'org messenger', 'org edit-profile', 'org settings', 'org change-password',
// Admin profile capability permissions
'admin reports', 'admin contacts', 'admin messenger', 'admin settings', 'admin change-password',
];
$created = 0;
foreach ($newPermissions as $name) {
$permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
if ($permission->wasRecentlyCreated) {
$created++;
}
}
// Give all permissions to the flat admin and super-admin Spatie roles
foreach (['admin', 'super-admin'] as $roleName) {
$role = Role::where('name', $roleName)->where('guard_name', 'web')->first();
if ($role) {
$role->givePermissionTo(Permission::where('guard_name', 'web')->get());
}
}
// Give view+create+update posts/tags/categories/calls to flat site-editor role
$siteEditorRole = Role::where('name', 'site-editor')->where('guard_name', 'web')->first();
if ($siteEditorRole) {
$siteEditorPerms = [
'view posts', 'create posts', 'update posts', 'publish posts', 'unpublish posts', 'delete posts', 'manage posts',
'view tags', 'create tags', 'update tags', 'delete tags', 'manage tags',
'view categories', 'create categories', 'update categories', 'delete categories', 'manage categories',
'view calls', 'create calls', 'update calls', 'publish calls', 'unpublish calls', 'delete calls', 'manage calls',
];
foreach ($siteEditorPerms as $permName) {
$perm = Permission::where('name', $permName)->where('guard_name', 'web')->first();
if ($perm && !$siteEditorRole->hasPermissionTo($permName)) {
$siteEditorRole->givePermissionTo($perm);
}
}
}
// Sync all scoped Admin\{id}\{role} Spatie roles from AdminRole definitions
if (\Schema::hasTable('admin_roles')) {
$adminRoles = \App\Models\AdminRole::all();
$allSpatieRoles = Role::where('guard_name', 'web')->get();
foreach ($adminRoles as $adminRole) {
$suffix = '\\' . $adminRole->name;
$spatieRoles = $allSpatieRoles->filter(fn($r) => str_ends_with($r->name, $suffix));
foreach ($spatieRoles as $spatieRole) {
$spatieRole->syncPermissions($adminRole->permissions ?? []);
}
}
}
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->info(" ✓ Added {$created} new permission(s), synced scoped roles");
} catch (\Exception $e) {
$this->error(' ✗ Failed to add granular permissions: ' . $e->getMessage());
}
}
private function updateAdministratorRolePermissions()
{
$this->info('Updating administrator role with full granular permissions...');
if (!\Schema::hasTable('admin_roles')) {
$this->warn(' ℹ admin_roles table not found, skipping');
return;
}
// Check if we have UPDATE privilege on admin_roles
try {
DB::statement('UPDATE admin_roles SET updated_at = updated_at WHERE id = 0');
} catch (\Exception $e) {
$this->warn(' ℹ Insufficient privileges to update admin_roles. Run manually:');
$this->warn(' sudo mysql -e "GRANT UPDATE, DELETE ON ' . config('database.connections.mysql.database') . '.admin_roles TO \''. config('database.connections.mysql.username') . '\'@\'localhost\'; FLUSH PRIVILEGES;"');
$this->warn(' Then re-run: php artisan database:update');
return;
}
$allPermissions = [
// Posts
'view posts', 'create posts', 'update posts', 'publish posts', 'unpublish posts', 'delete posts', 'manage posts',
// Tags
'view tags', 'create tags', 'update tags', 'delete tags', 'manage tags',
// Categories
'view categories', 'create categories', 'update categories', 'delete categories', 'manage categories',
// Calls
'view calls', 'create calls', 'update calls', 'publish calls', 'unpublish calls', 'delete calls', 'manage calls',
// Mailings
'view mailings', 'create mailings', 'update mailings', 'send mailings', 'delete mailings', 'manage mailings',
// Users
'view users', 'create users', 'update users', 'delete users', 'manage users',
// User projects
'view user_projects', 'create user_projects', 'update user_projects', 'delete user_projects', 'manage user_projects',
// Organizations
'view organizations', 'create organizations', 'update organizations', 'delete organizations', 'manage organizations',
// Banks
'view banks', 'create banks', 'update banks', 'delete banks', 'manage banks',
// Admins
'view admins', 'create admins', 'update admins', 'delete admins', 'manage admins',
// Accounts
'view accounts', 'create accounts', 'update accounts', 'delete accounts', 'manage accounts',
// Profiles
'view profiles', 'create profiles', 'update profiles', 'delete profiles', 'manage profiles',
// Permissions
'view permissions', 'create permissions', 'update permissions', 'delete permissions', 'manage permissions',
// Roles
'view roles', 'create roles', 'update roles', 'delete roles', 'manage roles',
// Maintenance & admin tools
'manage maintenance',
'view search-metrics', 'view application-log', 'view inactive-profiles-log', 'view search-reindex-log',
'view stats',
// Admin profile capabilities
'admin reports', 'admin contacts', 'admin messenger', 'admin settings', 'admin change-password',
];
try {
$adminRole = \App\Models\AdminRole::where('name', 'administrator')->first();
if (!$adminRole) {
$this->warn(' ℹ administrator AdminRole not found, skipping');
return;
}
$adminRole->permissions = $allPermissions;
$adminRole->save();
// Sync all scoped Admin\{id}\administrator Spatie roles
$spatieRoles = Role::where('guard_name', 'web')->get()
->filter(fn($r) => str_ends_with($r->name, '\\administrator'));
foreach ($spatieRoles as $spatieRole) {
$spatieRole->syncPermissions($allPermissions);
$this->info(" ✓ Synced permissions for scoped role '{$spatieRole->name}'");
}
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->info(' ✓ administrator AdminRole permissions updated (' . count($allPermissions) . ' permissions)');
} catch (\Exception $e) {
$this->error(' ✗ Failed to update administrator role: ' . $e->getMessage());
}
}
/**
* Replace the legacy roles Admin\{id}\admin by the default role from admin_roles (see admin-roles:migrate).
* Runs after migrateFlatAdminRoleToScoped(), so its administrator roles already exist.
*/
private function replaceLegacyAdminRoles()
{
$this->info('Replacing legacy Admin\\{id}\\admin roles...');
try {
$this->call('admin-roles:migrate');
} catch (\Throwable $e) {
$this->warn(' ℹ Skipped: ' . $e->getMessage() . ' (run php artisan admin-roles:migrate --dry-run)');
}
}
private function migrateFlatAdminRoleToScoped()
{
$this->info('Migrating flat admin role to scoped roles...');
try {
$flatAdminRole = Role::where('name', 'admin')->where('guard_name', 'web')->first();
if (!$flatAdminRole) {
$this->info(' ℹ No flat admin role found, skipping');
return;
}
// Find all users with the flat admin role
$userIds = DB::table('model_has_roles')
->where('role_id', $flatAdminRole->id)
->where('model_type', 'App\Models\User')
->pluck('model_id');
if ($userIds->isEmpty()) {
$this->info(' ℹ No users with flat admin role, skipping');
return;
}
$administratorAdminRole = \App\Models\AdminRole::where('name', 'administrator')->first();
if (!$administratorAdminRole) {
$this->warn(' ℹ administrator AdminRole not found, skipping');
return;
}
$migrated = 0;
foreach ($userIds as $userId) {
$user = \App\Models\User::find($userId);
if (!$user) continue;
// Find all admin profiles linked to this user
$adminProfiles = \App\Models\Admin::whereHas('users', fn($q) => $q->where('users.id', $userId))->get();
// Without a link nothing is granted: guessing would give administrator on every admin profile
if ($adminProfiles->isEmpty()) {
$this->warn(" ℹ User {$user->name} (id:{$userId}) has the flat admin role but no admin profile link: no role assigned");
continue;
}
foreach ($adminProfiles as $admin) {
$scopedRoleName = 'Admin\\' . $admin->id . '\\administrator';
// Get or create the scoped Spatie role
$scopedRole = Role::firstOrCreate(
['name' => $scopedRoleName, 'guard_name' => 'web'],
);
// Sync permissions from AdminRole definition
$scopedRole->syncPermissions($administratorAdminRole->permissions ?? []);
// Assign to user if not already assigned
$exists = DB::table('model_has_roles')
->where('role_id', $scopedRole->id)
->where('model_id', $userId)
->where('model_type', 'App\Models\User')
->exists();
if (!$exists) {
DB::table('model_has_roles')->insert([
'role_id' => $scopedRole->id,
'model_id' => $userId,
'model_type' => 'App\Models\User',
]);
$this->info(" ✓ Assigned {$scopedRoleName} to user {$user->name} (id:{$userId})");
$migrated++;
} else {
$this->info(" ℹ User {$user->name} already has {$scopedRoleName}");
}
}
}
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->info(" ✓ Migrated {$migrated} scoped role assignment(s)");
} catch (\Exception $e) {
$this->error(' ✗ Failed to migrate flat admin role: ' . $e->getMessage());
}
}
private function seedAdminRolePresets()
{
$this->info('Seeding admin role presets...');
if (!\Schema::hasTable('admin_roles')) {
$this->warn(' ℹ admin_roles table not found, skipping');
return;
}
$presets = [
[
'name' => 'content-reviewer',
'label' => 'Content Reviewer',
'permissions' => ['view posts', 'view tags', 'view categories', 'view calls'],
'is_system' => false,
],
[
'name' => 'content-editor',
'label' => 'Content Editor',
'permissions' => [
'view posts', 'create posts', 'update posts',
'view tags', 'create tags', 'update tags',
'view categories', 'create categories', 'update categories',
'view calls', 'create calls', 'update calls',
],
'is_system' => false,
],
[
'name' => 'content-publisher',
'label' => 'Content Publisher',
'permissions' => [
'view posts', 'create posts', 'update posts', 'publish posts', 'unpublish posts',
'view tags', 'create tags', 'update tags',
'view categories', 'create categories', 'update categories',
'view calls', 'create calls', 'update calls', 'publish calls', 'unpublish calls',
],
'is_system' => false,
],
[
'name' => 'mailing-editor',
'label' => 'Mailing Editor',
'permissions' => ['view mailings', 'create mailings', 'update mailings'],
'is_system' => false,
],
];
$seeded = 0;
foreach ($presets as $preset) {
$exists = DB::table('admin_roles')->where('name', $preset['name'])->exists();
if (!$exists) {
DB::table('admin_roles')->insert([
'name' => $preset['name'],
'label' => $preset['label'],
'permissions' => json_encode($preset['permissions']),
'is_system' => $preset['is_system'],
'created_at' => now(),
'updated_at' => now(),
]);
$seeded++;
$this->info(" ✓ Seeded role preset '{$preset['name']}'");
}
}
if ($seeded === 0) {
$this->warn(' ℹ All role presets already exist, skipping');
}
}
private function createOrganizationRolesTable()
{
$this->info('Creating organization_roles table...');
try {
if (\Schema::hasTable('organization_roles')) {
$this->warn(' ℹ organization_roles table already exists, skipping');
return;
}
\Schema::create('organization_roles', function (\Illuminate\Database\Schema\Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('label');
$table->json('permissions');
$table->boolean('is_system')->default(false);
$table->timestamps();
});
$managerPermissions = ['org pay', 'org transactions', 'org reports', 'org contacts', 'org calls', 'org messenger', 'org edit-profile', 'org settings', 'org change-password'];
$coordinatorPermissions = ['org transactions', 'org reports', 'org contacts', 'org calls', 'org messenger', 'org edit-profile', 'org settings'];
DB::table('organization_roles')->insert([
[
'name' => 'organization-manager',
'label' => 'Organization Manager',
'permissions' => json_encode($managerPermissions),
'is_system' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'organization-coordinator',
'label' => 'Organization Coordinator',
'permissions' => json_encode($coordinatorPermissions),
'is_system' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'bank-manager',
'label' => 'Bank Manager',
'permissions' => json_encode($managerPermissions),
'is_system' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'bank-coordinator',
'label' => 'Bank Coordinator',
'permissions' => json_encode($coordinatorPermissions),
'is_system' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->info(' ✓ organization_roles table created and seeded');
} catch (\Exception $e) {
$this->error(' ✗ Failed to create organization_roles table: ' . $e->getMessage());
}
}
private function addChangePasswordToManagerRoles()
{
$this->info('Adding org change-password to manager roles...');
try {
if (!\Schema::hasTable('organization_roles')) {
$this->warn(' ℹ organization_roles table not found, skipping');
return;
}
$managers = \App\Models\OrganizationRole::whereIn('name', ['organization-manager', 'bank-manager'])->get();
$updated = 0;
foreach ($managers as $role) {
$permissions = $role->permissions ?? [];
if (!in_array('org change-password', $permissions)) {
$permissions[] = 'org change-password';
$role->permissions = $permissions;
$role->save();
$this->info(" ✓ Added org change-password to '{$role->name}'");
$updated++;
}
}
if ($updated === 0) {
$this->warn(' ℹ Manager roles already have org change-password, skipping');
}
// Re-sync scoped Spatie roles so the permission takes effect
if ($updated > 0) {
$allSpatieRoles = \Spatie\Permission\Models\Role::where('guard_name', 'web')->get();
foreach ($managers->fresh() as $orgRole) {
$suffix = '\\' . $orgRole->name;
$spatieRoles = $allSpatieRoles->filter(fn($r) => str_ends_with($r->name, $suffix));
foreach ($spatieRoles as $spatieRole) {
$spatieRole->syncPermissions($orgRole->permissions ?? []);
$this->info(" ✓ Synced '{$spatieRole->name}'");
}
}
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
}
} catch (\Exception $e) {
$this->error(' ✗ Failed to add org change-password to manager roles: ' . $e->getMessage());
}
}
private function fixCoordinatorSystemRoles()
{
$this->info('Fixing coordinator roles is_system flag...');
try {
if (!\Schema::hasTable('organization_roles')) {
$this->warn(' ℹ organization_roles table not found, skipping');
return;
}
$updated = DB::table('organization_roles')
->whereIn('name', ['organization-coordinator', 'bank-coordinator'])
->where('is_system', true)
->update(['is_system' => false]);
if ($updated > 0) {
$this->info(" ✓ Set {$updated} coordinator role(s) to is_system=false");
} else {
$this->warn(' ℹ Coordinator roles already correct, skipping');
}
} catch (\Exception $e) {
$this->error(' ✗ Failed to fix coordinator roles: ' . $e->getMessage());
}
}
private function addPasswordChangeLockColumns()
{
$this->info('Adding password_change_disabled column...');
try {
foreach (['users', 'admins', 'banks'] as $tableName) {
if (\Schema::hasColumn($tableName, 'password_change_disabled')) {
$this->warn(" ℹ {$tableName}.password_change_disabled already exists, skipping");
continue;
}
\Schema::table($tableName, function (\Illuminate\Database\Schema\Blueprint $table) {
$table->boolean('password_change_disabled')->default(false)->after('password');
});
$this->info(" ✓ Added {$tableName}.password_change_disabled");
}
} catch (\Exception $e) {
$this->error(' ✗ Failed to add password_change_disabled: ' . $e->getMessage());
}
}
private function migrateHardcodedOrgRolesToOrganizationRoles()
{
$this->info('Syncing scoped org/bank Spatie roles from OrganizationRole definitions...');
if (!\Schema::hasTable('organization_roles')) {
$this->warn(' ℹ organization_roles table not found, skipping');
return;
}
try {
$orgRoles = \App\Models\OrganizationRole::all();
$allSpatieRoles = Role::where('guard_name', 'web')->get();
$synced = 0;
foreach ($orgRoles as $orgRole) {
$suffix = '\\' . $orgRole->name;
$spatieRoles = $allSpatieRoles->filter(fn($r) => str_ends_with($r->name, $suffix));
foreach ($spatieRoles as $spatieRole) {
$spatieRole->syncPermissions($orgRole->permissions ?? []);
$this->info(" ✓ Synced '{$spatieRole->name}'");
$synced++;
}
}
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->info(" ✓ Synced {$synced} scoped org/bank role(s)");
} catch (\Exception $e) {
$this->error(' ✗ Failed to sync org/bank roles: ' . $e->getMessage());
}
}
private function createAdminRolesTable()
{
$this->info('Creating admin_roles table...');
try {
if (\Schema::hasTable('admin_roles')) {
$this->warn(' ℹ admin_roles table already exists, skipping');
return;
}
\Schema::create('admin_roles', function (\Illuminate\Database\Schema\Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('label');
$table->json('permissions');
$table->boolean('is_system')->default(false);
$table->timestamps();
});
DB::table('admin_roles')->insert([
[
'name' => 'administrator',
'label' => 'Administrator',
'permissions' => json_encode([
'manage posts', 'manage tags', 'manage categories',
'manage mailings', 'manage calls', 'manage users',
'manage user_projects', 'manage organizations', 'manage banks',
'manage admins', 'manage accounts',
'view profiles', 'create profiles', 'update profiles', 'delete profiles', 'manage profiles',
'view permissions', 'create permissions', 'update permissions', 'delete permissions', 'manage permissions',
'view roles', 'create roles', 'update roles', 'delete roles', 'manage roles',
'manage maintenance',
'view search-metrics', 'view application-log', 'view inactive-profiles-log', 'view search-reindex-log',
'view stats',
]),
'is_system' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'site-editor',
'label' => 'Site Editor',
'permissions' => json_encode([
'manage posts', 'manage tags', 'manage categories', 'manage calls',
]),
'is_system' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->info(' ✓ admin_roles table created and seeded');
} catch (\Exception $e) {
$this->error(' ✗ Failed to create admin_roles table: ' . $e->getMessage());
}
}
/**
* Poll permissions (view / create / update / export / delete / manage polls), separate from posts.
* 'manage polls' goes to every admin role type (admin_roles) and to every existing scoped
* admin role, including older ones without an admin_roles row. Safe to run again.
*/
private function addPollPermissions()
{
$this->info('Adding poll permissions...');
$permissions = ['view polls', 'create polls', 'update polls', 'export polls', 'delete polls', 'manage polls'];
try {
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$created = 0;
foreach ($permissions as $name) {
if (Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web'])->wasRecentlyCreated) {
$created++;
}
}
$types = 0;
foreach (\App\Models\AdminRole::all() as $adminRole) {
$current = $adminRole->permissions ?? [];
if (!in_array('manage polls', $current, true)) {
$adminRole->permissions = array_values(array_merge($current, ['manage polls']));
$adminRole->save();
$types++;
}
}
// Added to the scoped roles rather than re-synced, so their other permissions stay as they are
$roles = 0;
Role::where('guard_name', 'web')->where('name', 'like', 'Admin\\\\%')->get()
->each(function (Role $role) use (&$roles) {
if (!$role->hasPermissionTo('manage polls')) {
$role->givePermissionTo('manage polls');
$roles++;
}
});
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->info(" ✓ Added {$created} poll permission(s); 'manage polls' given to {$types} admin role type(s) and {$roles} admin role(s)");
} catch (\Exception $e) {
$this->error(' ✗ Failed to add poll permissions: ' . $e->getMessage());
}
}
private function addAdminCapabilityPermissions()
{
$this->info('Adding admin profile capability permissions...');
$caps = ['admin reports', 'admin contacts', 'admin messenger', 'admin settings', 'admin change-password'];
try {
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$created = 0;
foreach ($caps as $name) {
$permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
if ($permission->wasRecentlyCreated) {
$created++;
}
}
// Grant all admin caps to the administrator AdminRole and sync its scoped Spatie roles
$adminRole = \App\Models\AdminRole::where('name', 'administrator')->first();
if ($adminRole) {
$current = $adminRole->permissions ?? [];
$merged = array_values(array_unique(array_merge($current, $caps)));
$adminRole->permissions = $merged;
$adminRole->save();
$suffix = '\\administrator';
Role::where('guard_name', 'web')->get()
->filter(fn($r) => str_ends_with($r->name, $suffix))
->each(fn($r) => $r->syncPermissions($adminRole->permissions));
$this->info(' ✓ Synced admin capabilities to administrator role');
}
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->info(" ✓ Added {$created} new admin capability permission(s)");
} catch (\Exception $e) {
$this->error(' ✗ Failed to add admin capability permissions: ' . $e->getMessage());
}
}
/**
* Fix the Reversal transaction type's icon.
*
* The type was originally seeded with icon='receipt-refund'; it was
* later changed to icon='arrow-path' (rendered mirrored via a CSS
* transform at the two blade render sites) but transaction_types is
* immutable for the app's restricted DB user, so any environment that
* already inserted the row via the earlier migration needs this
* corrected via an elevated-privilege command like this one.
*/
private function fixReversalTransactionTypeIcon()
{
$this->info('Fixing Reversal transaction type icon...');
try {
$updated = DB::table('transaction_types')
->where('id', 8)
->where('name', 'Reversal')
->where('icon', '!=', 'arrow-path')
->update(['icon' => 'arrow-path']);
if ($updated > 0) {
$this->info(' ✓ Updated Reversal transaction type icon to arrow-path');
} else {
$this->warn(' ℹ Reversal transaction type icon already correct (or type not found), skipping');
}
} catch (\Exception $e) {
$this->error(' ✗ Failed to fix Reversal transaction type icon: ' . $e->getMessage());
}
}
/**
* Add the "Poll" post category (type App\Models\Poll, not bound to a location:
* a poll's own targeting decides who sees it).
*/
private function addPollCategory()
{
$this->info('Adding "Poll" post category...');
try {
DB::beginTransaction();
$category = \App\Models\Category::firstOrCreate(
['type' => \App\Models\Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]
);
$translations = [
'en' => ['name' => 'Poll', 'slug' => 'poll'],
'nl' => ['name' => 'Peiling', 'slug' => 'peiling'],
'de' => ['name' => 'Umfrage', 'slug' => 'umfrage'],
'es' => ['name' => 'Encuesta', 'slug' => 'encuesta'],
'fr' => ['name' => 'Sondage', 'slug' => 'sondage'],
];
foreach ($translations as $locale => $translation) {
\App\Models\CategoryTranslation::updateOrCreate(
['category_id' => $category->id, 'locale' => $locale],
$translation
);
}
DB::commit();
$this->info(" ✓ \"Poll\" category added/updated (id {$category->id})");
} catch (\Exception $e) {
DB::rollBack();
$this->error(' ✗ Failed to add poll category: ' . $e->getMessage());
}
}
}