diff --git a/database/migrations/2025_01_01_000001_create_blax_role_tables.php b/database/migrations/2025_01_01_000001_create_blax_role_tables.php index 910407d..2380734 100644 --- a/database/migrations/2025_01_01_000001_create_blax_role_tables.php +++ b/database/migrations/2025_01_01_000001_create_blax_role_tables.php @@ -19,9 +19,15 @@ return new class extends Migration */ public function up(): void { + // All keys are UUIDs to match the models (HasUuids) and the morph + // columns of host apps that are themselves UUID-keyed. Earlier + // versions of this migration shipped with $table->id() / morphs() + // which produced bigint columns and silently broke every insert + // ("Incorrect integer value: '' for column 'id'"). The + // 2026_04_29 fix-up migration converts existing installs in place. if (! Schema::hasTable(config('roles.table_names.permissions'))) { Schema::create(config('roles.table_names.permissions'), function (Blueprint $table) { - $table->id(); + $table->uuid('id')->primary(); $table->string('slug')->unique(); $table->string('description')->nullable(); $table->timestamps(); @@ -30,9 +36,9 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.permission_member'))) { Schema::create(config('roles.table_names.permission_member'), function (Blueprint $table) { - $table->id(); - $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); - $table->morphs('member'); + $table->uuid('id')->primary(); + $table->foreignUuid('permission_id')->constrained('permissions')->onDelete('cascade'); + $table->uuidMorphs('member'); $table->json('context')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); @@ -41,10 +47,10 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.permission_usage'))) { Schema::create(config('roles.table_names.permission_usage'), function (Blueprint $table) { - $table->id(); - $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); + $table->uuid('id')->primary(); + $table->foreignUuid('permission_id')->constrained('permissions')->onDelete('cascade'); $table->float('usage', 8)->default(1); - $table->morphs('user'); + $table->uuidMorphs('user'); $table->json('context')->nullable(); $table->timestamps(); }); @@ -52,8 +58,8 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.roles'))) { Schema::create(config('roles.table_names.roles'), function (Blueprint $table) { - $table->id(); - $table->foreignId('parent_id') + $table->uuid('id')->primary(); + $table->foreignUuid('parent_id') ->nullable() ->constrained('roles') ->onDelete('set null'); @@ -66,9 +72,9 @@ return new class extends Migration if (! Schema::hasTable(config('roles.table_names.role_member'))) { Schema::create(config('roles.table_names.role_member'), function (Blueprint $table) { - $table->id(); - $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); - $table->morphs('member'); + $table->uuid('id')->primary(); + $table->foreignUuid('role_id')->constrained('roles')->onDelete('cascade'); + $table->uuidMorphs('member'); $table->json('context')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); diff --git a/database/migrations/2025_01_01_000002_create_blax_access_table.php b/database/migrations/2025_01_01_000002_create_blax_access_table.php index 15fb4ce..ec4aa46 100644 --- a/database/migrations/2025_01_01_000002_create_blax_access_table.php +++ b/database/migrations/2025_01_01_000002_create_blax_access_table.php @@ -24,10 +24,14 @@ return new class extends Migration } Schema::create($table, function (Blueprint $blueprint) { - $blueprint->id(); - $blueprint->morphs('entity'); // Who has the access (User, Role, Permission) - $blueprint->morphs('accessible'); // What they have access to (Lection, Scenario, etc.) - $blueprint->nullableMorphs('source'); // What conferred this access (Subscription, Order, etc.) + // UUID schema so morph_id columns can store the UUID PKs of host + // models (Users / Roles / Permissions are all HasUuids). Earlier + // versions of this migration used bigint and quietly broke every + // insert with "Incorrect integer value: ''". + $blueprint->uuid('id')->primary(); + $blueprint->uuidMorphs('entity'); // Who has the access (User, Role, Permission) + $blueprint->uuidMorphs('accessible'); // What they have access to (Lection, Scenario, etc.) + $blueprint->nullableUuidMorphs('source'); // What conferred this access (Subscription, Order, etc.) $blueprint->json('context')->nullable(); $blueprint->timestamp('expires_at')->nullable(); $blueprint->timestamps(); diff --git a/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php b/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php index 4ed9b46..71d3ed3 100644 --- a/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php +++ b/database/migrations/2026_04_26_000001_add_source_to_accesses_table.php @@ -41,7 +41,7 @@ return new class extends Migration if (! Schema::hasColumn($table, 'source_id')) { Schema::table($table, function (Blueprint $blueprint) { - $blueprint->nullableMorphs('source'); + $blueprint->nullableUuidMorphs('source'); }); } diff --git a/database/migrations/2026_04_27_000001_create_required_accesses_table.php b/database/migrations/2026_04_27_000001_create_required_accesses_table.php index be71230..95ffca6 100644 --- a/database/migrations/2026_04_27_000001_create_required_accesses_table.php +++ b/database/migrations/2026_04_27_000001_create_required_accesses_table.php @@ -28,9 +28,13 @@ return new class extends Migration } Schema::create($table, function (Blueprint $blueprint) { - $blueprint->id(); - $blueprint->morphs('holder'); // The gated entity (e.g. Lection) - $blueprint->morphs('required'); // The entity whose access unlocks the holder (e.g. Course) + // RequiredAccess uses HasUuids; holder/required ids come from + // application models that also use UUIDs (Blog/Course/Lection, + // Product, Scenario, …). Use uuidMorphs + a uuid PK so the + // morph columns can store the UUIDs without truncation. + $blueprint->uuid('id')->primary(); + $blueprint->uuidMorphs('holder'); // The gated entity (e.g. Lection) + $blueprint->uuidMorphs('required'); // The entity whose access unlocks the holder (e.g. Course) $blueprint->timestamps(); $blueprint->unique( diff --git a/database/migrations/2026_04_29_000001_fix_required_accesses_uuid_columns.php b/database/migrations/2026_04_29_000001_fix_required_accesses_uuid_columns.php new file mode 100644 index 0000000..2fbbd2e --- /dev/null +++ b/database/migrations/2026_04_29_000001_fix_required_accesses_uuid_columns.php @@ -0,0 +1,77 @@ +morphs() (bigint + * holder/required ids) and $table->id() (bigint PK). That doesn't match + * the RequiredAccess model (HasUuids) or the typical host app whose + * gated models also use UUIDs — every insert blew up with + * "Incorrect integer value: '' for column 'holder_id'" + * + * Drop and recreate the table with uuidMorphs + uuid PK. We can drop + * because no row could have ever been inserted under the old schema. + * Hosts that somehow do have rows should run a manual data migration + * before this one. + */ +return new class extends Migration +{ + public function up(): void + { + $table = config('roles.table_names.required_accesses', 'required_accesses'); + + if (! Schema::hasTable($table)) { + return; + } + + // Skip when the schema is already correct — this happens on fresh + // installs that ran the updated create migration, and on hosts who + // hand-fixed the table before this fix-up shipped. Use Laravel's + // Schema::getColumns() so this works on MySQL/PostgreSQL/SQLite + // (INFORMATION_SCHEMA is MySQL-only). + $holderIdInfo = collect(Schema::getColumns($table)) + ->firstWhere('name', 'holder_id'); + $isUuidColumn = $holderIdInfo + && in_array(strtolower((string) ($holderIdInfo['type_name'] ?? '')), ['char', 'varchar', 'uuid'], true); + if ($isUuidColumn) { + return; + } + + // Defensive: refuse to drop a non-empty table. Nobody's been able to + // insert a row under the broken schema, so this should always be 0 + // — but if a host migrated rows in by hand we don't want to nuke them. + $rowCount = DB::table($table)->count(); + if ($rowCount > 0) { + throw new \RuntimeException( + "Refusing to recreate `{$table}`: it contains {$rowCount} rows. " + . 'Migrate them off, drop the table, and re-run the migration manually.' + ); + } + + Schema::drop($table); + + Schema::create($table, function (Blueprint $blueprint) { + $blueprint->uuid('id')->primary(); + $blueprint->uuidMorphs('holder'); + $blueprint->uuidMorphs('required'); + $blueprint->timestamps(); + + $blueprint->unique( + ['holder_type', 'holder_id', 'required_type', 'required_id'], + 'required_access_unique', + ); + $blueprint->index(['required_type', 'required_id'], 'required_access_reverse'); + }); + } + + public function down(): void + { + // No down — there's no good way to restore the broken bigint schema + // and nothing useful was stored in it anyway. + } +}; diff --git a/database/migrations/2026_04_29_000002_fix_role_tables_to_uuid.php b/database/migrations/2026_04_29_000002_fix_role_tables_to_uuid.php new file mode 100644 index 0000000..d3606b1 --- /dev/null +++ b/database/migrations/2026_04_29_000002_fix_role_tables_to_uuid.php @@ -0,0 +1,334 @@ +id()` and `$table->morphs()` + * — bigint everywhere — but the models (Permission / Role / + * RoleMember / PermissionMember / Access / RequiredAccess) all use + * `HasUuids`. So every insert blew up with + * "Incorrect integer value: '' for column 'id'" + * + * On installs that ran the buggy migrations, the tables exist with + * bigint keys. Some hosts have data inside (e.g. roles seeded by app + * code that bypassed the model insert path). This migration converts + * those tables in place: keeps the data, swaps PK + FK columns to + * CHAR(36), and rebuilds all foreign-key constraints. + * + * Idempotent. Each phase checks whether the conversion has already + * been applied (by reading the column type from + * INFORMATION_SCHEMA) and skips when the schema is already correct, + * so re-running is safe and fresh installs (whose newer create + * migration already produces UUIDs) take a fast no-op path. + * + * Caveats: + * - DDL is auto-commit on MySQL; failures mid-run leave a partial + * conversion. Take a backup before deploying. + * - Morph id columns become CHAR(36). Hosts whose entities use bigint + * PKs (e.g. User on this app) have their ints stringified; Laravel's + * morph relations compare loosely, so this is transparent. + */ +return new class extends Migration +{ + public function up(): void + { + // The fix-up only runs on MySQL — that's the driver where the + // broken bigint schema was actually created on production hosts. + // On SQLite (used by package tests and some hosts in dev) the + // updated create migration produces the correct UUID schema + // directly, and the multi-table UPDATE/ALTER syntax we use here + // (MySQL-specific) isn't supported anyway. + if (DB::connection()->getDriverName() !== 'mysql') { + return; + } + + $tables = [ + 'permissions' => config('roles.table_names.permissions', 'permissions'), + 'permission_members' => config('roles.table_names.permission_member', 'permission_members'), + 'permission_usages' => config('roles.table_names.permission_usage', 'permission_usages'), + 'roles' => config('roles.table_names.roles', 'roles'), + 'role_members' => config('roles.table_names.role_member', 'role_members'), + 'accesses' => config('roles.table_names.accesses', 'accesses'), + ]; + + // Phase 1 — parent tables (permissions, roles): convert PK from + // bigint → uuid AND propagate the new ids into every dependent + // table's FK column in the same step, so dependents are valid + // again before we drop their old bigint FK columns in Phase 2. + $this->convertParent($tables['permissions'], [ + $tables['permission_members'] => ['permission_id', 'permission_members_permission_id_foreign', 'cascade', false], + $tables['permission_usages'] => ['permission_id', 'permission_usages_permission_id_foreign', 'cascade', false], + ]); + + // Roles has a self-FK on parent_id, so the "dependent" includes the + // table itself. The fourth tuple element flags a self-FK column. + $this->convertParent($tables['roles'], [ + $tables['role_members'] => ['role_id', 'role_members_role_id_foreign', 'cascade', false], + $tables['roles'] => ['parent_id', 'roles_parent_id_foreign', 'set null', true], + ]); + + // Phase 2 — for each child table, swap its own bigint id (PK) to + // uuid. No FKs reference these so we can convert independently. + foreach (['permission_members', 'permission_usages', 'role_members'] as $key) { + $this->convertOwnIdColumn($tables[$key]); + } + + // Phase 3 — accesses table: own id, plus polymorphic morph id + // columns (entity_id, source_id). accessible_id is already + // varchar(36) on most installs. + $this->convertOwnIdColumn($tables['accesses']); + $this->convertMorphIdColumn($tables['accesses'], 'entity_id'); + $this->convertMorphIdColumn($tables['accesses'], 'source_id'); + $this->convertMorphIdColumn($tables['accesses'], 'accessible_id'); + } + + public function down(): void + { + // No-op. There's no safe way to recover the bigint schema once + // UUIDs are assigned (we'd be inventing fake auto-increment ids + // and hoping references match). Hosts who need to roll back + // should restore from backup. + } + + /** + * Convert $parent's id from bigint to uuid, propagating the new id + * into every dependent FK column listed in $children. + * + * @param array $children + * keyed by child table name; tuple = [fkColumn, fkConstraintName, onDelete, isSelfFk] + */ + private function convertParent(string $parent, array $children): void + { + if (! Schema::hasTable($parent)) { + return; + } + + // Already uuid? Skip the whole pass. + if ($this->columnIsUuid($parent, 'id')) { + return; + } + + // 1. Add the staging column for the new uuid id. + if (! Schema::hasColumn($parent, '_uuid_id')) { + DB::statement("ALTER TABLE `{$parent}` ADD COLUMN `_uuid_id` CHAR(36) NULL"); + } + + // 2. Populate _uuid_id row by row. Cheap PHP loop is fine — the + // tables this migration targets are small (admin role/permission + // catalogs, not user-scale). + $rows = DB::table($parent)->whereNull('_uuid_id')->get(['id']); + foreach ($rows as $row) { + DB::table($parent)->where('id', $row->id)->update(['_uuid_id' => (string) Str::uuid()]); + } + + // 3. For each dependent: drop its FK, add a staging uuid FK column, + // populate via JOIN on the parent's old/new ids. + foreach ($children as $child => [$fkCol, $fkName, , $isSelfFk]) { + $this->dropForeignKeyIfExists($child, $fkName); + + $stagingCol = '_uuid_' . $fkCol; + if (! Schema::hasColumn($child, $stagingCol)) { + DB::statement("ALTER TABLE `{$child}` ADD COLUMN `{$stagingCol}` CHAR(36) NULL"); + } + + // The self-FK case: in roles, parent_id can be NULL. Use a left + // join so NULLs stay NULL in the staging column. + if ($isSelfFk) { + DB::statement( + "UPDATE `{$child}` c " + . "LEFT JOIN `{$parent}` p ON c.`{$fkCol}` = p.`id` " + . "SET c.`{$stagingCol}` = p.`_uuid_id` " + . "WHERE c.`{$fkCol}` IS NOT NULL AND c.`{$stagingCol}` IS NULL" + ); + } else { + DB::statement( + "UPDATE `{$child}` c " + . "JOIN `{$parent}` p ON c.`{$fkCol}` = p.`id` " + . "SET c.`{$stagingCol}` = p.`_uuid_id` " + . "WHERE c.`{$stagingCol}` IS NULL" + ); + } + } + + // 4. Swap parent's id. MySQL refuses to drop the primary key + // while the column is auto_increment, so first strip the + // auto_increment attribute, then drop. We resolve the underlying + // column type so this works for both BIGINT and INT etc. + $this->dropAutoIncrementId($parent); + DB::statement("ALTER TABLE `{$parent}` DROP PRIMARY KEY"); + DB::statement("ALTER TABLE `{$parent}` DROP COLUMN `id`"); + DB::statement("ALTER TABLE `{$parent}` CHANGE `_uuid_id` `id` CHAR(36) NOT NULL"); + DB::statement("ALTER TABLE `{$parent}` ADD PRIMARY KEY (`id`)"); + + // 5. Swap each child's FK column and re-create the FK constraint. + foreach ($children as $child => [$fkCol, $fkName, $onDelete, $isSelfFk]) { + $stagingCol = '_uuid_' . $fkCol; + + DB::statement("ALTER TABLE `{$child}` DROP COLUMN `{$fkCol}`"); + + $nullable = $isSelfFk ? 'NULL' : 'NOT NULL'; + DB::statement("ALTER TABLE `{$child}` CHANGE `{$stagingCol}` `{$fkCol}` CHAR(36) {$nullable}"); + + $onDeleteSql = strtoupper($onDelete) === 'SET NULL' ? 'SET NULL' : 'CASCADE'; + DB::statement( + "ALTER TABLE `{$child}` " + . "ADD CONSTRAINT `{$fkName}` " + . "FOREIGN KEY (`{$fkCol}`) REFERENCES `{$parent}`(`id`) " + . "ON DELETE {$onDeleteSql}" + ); + } + } + + /** + * Convert $table's own id column from bigint auto-increment to uuid. + * Used for tables where nothing references the id (or the references + * have already been converted in a prior phase). + */ + private function convertOwnIdColumn(string $table): void + { + if (! Schema::hasTable($table)) { + return; + } + if ($this->columnIsUuid($table, 'id')) { + return; + } + + if (! Schema::hasColumn($table, '_uuid_id')) { + DB::statement("ALTER TABLE `{$table}` ADD COLUMN `_uuid_id` CHAR(36) NULL"); + } + + $rows = DB::table($table)->whereNull('_uuid_id')->get(['id']); + foreach ($rows as $row) { + DB::table($table)->where('id', $row->id)->update(['_uuid_id' => (string) Str::uuid()]); + } + + $this->dropAutoIncrementId($table); + DB::statement("ALTER TABLE `{$table}` DROP PRIMARY KEY"); + DB::statement("ALTER TABLE `{$table}` DROP COLUMN `id`"); + DB::statement("ALTER TABLE `{$table}` CHANGE `_uuid_id` `id` CHAR(36) NOT NULL"); + DB::statement("ALTER TABLE `{$table}` ADD PRIMARY KEY (`id`)"); + } + + /** + * Strip AUTO_INCREMENT from $table.id by re-declaring the column with + * its current SQL type but no auto-increment. MySQL otherwise refuses + * "DROP PRIMARY KEY" on an auto-increment column. No-op when the + * column is already non-auto-increment. + */ + private function dropAutoIncrementId(string $table): void + { + // Only relevant on MySQL — SQLite handles auto-increment differently + // and doesn't need (or support) MODIFY COLUMN to strip it. + if (DB::connection()->getDriverName() !== 'mysql') { + return; + } + $info = $this->columnInfo($table, 'id'); + if (! $info) { + return; + } + // type_name on MySQL gives us "bigint" / "int". Combine with the + // unsigned/signed marker from the full type string so we recreate + // the same column with auto_increment stripped. + $isUnsigned = str_contains(strtolower($info['type']), 'unsigned'); + $type = $info['type_name'] . ($isUnsigned ? ' unsigned' : ''); + DB::statement("ALTER TABLE `{$table}` MODIFY `id` {$type} NOT NULL"); + } + + /** + * Widen a polymorphic morph_id column to CHAR(36) so it can hold + * either a UUID (host models that use HasUuids) or a stringified + * bigint (host models that use auto-increment, e.g. User on apps + * where users haven't been migrated yet). Existing values are + * cast to string in place. + */ + private function convertMorphIdColumn(string $table, string $column): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) { + return; + } + if ($this->columnIsUuid($table, $column)) { + return; + } + + // Whether the column allows NULL — we want to preserve that. + $nullable = $this->columnIsNullable($table, $column); + + $stagingCol = '_uuid_' . $column; + if (! Schema::hasColumn($table, $stagingCol)) { + DB::statement("ALTER TABLE `{$table}` ADD COLUMN `{$stagingCol}` CHAR(36) NULL"); + } + // Stringify the existing bigint value into the staging column. + DB::statement( + "UPDATE `{$table}` SET `{$stagingCol}` = CAST(`{$column}` AS CHAR) " + . "WHERE `{$column}` IS NOT NULL AND `{$stagingCol}` IS NULL" + ); + + DB::statement("ALTER TABLE `{$table}` DROP COLUMN `{$column}`"); + + $nullSql = $nullable ? 'NULL' : 'NOT NULL'; + DB::statement("ALTER TABLE `{$table}` CHANGE `{$stagingCol}` `{$column}` CHAR(36) {$nullSql}"); + } + + /** + * Driver-agnostic column inspection. Schema::getColumns() is the + * Laravel-supported API that works on MySQL, PostgreSQL, and SQLite — + * unlike INFORMATION_SCHEMA, which exists on neither SQLite nor in + * the form expected on every MySQL build. + * + * @return array{type_name: string, type: string, nullable: bool}|null + */ + private function columnInfo(string $table, string $column): ?array + { + if (! Schema::hasColumn($table, $column)) { + return null; + } + foreach (Schema::getColumns($table) as $c) { + if (($c['name'] ?? null) === $column) { + return [ + 'type_name' => strtolower((string) ($c['type_name'] ?? '')), + 'type' => (string) ($c['type'] ?? ''), + 'nullable' => (bool) ($c['nullable'] ?? false), + ]; + } + } + return null; + } + + private function columnIsUuid(string $table, string $column): bool + { + $info = $this->columnInfo($table, $column); + return $info && in_array($info['type_name'], ['char', 'varchar', 'uuid'], true); + } + + private function columnIsNullable(string $table, string $column): bool + { + $info = $this->columnInfo($table, $column); + return $info && $info['nullable']; + } + + private function dropForeignKeyIfExists(string $table, string $fkName): void + { + // SQLite doesn't support DROP FOREIGN KEY at all and instead + // requires a full table rebuild. The migration is designed for + // MySQL bigint→UUID conversion in production; on SQLite the + // schema is created fresh as UUID via the create migration so + // there's nothing to drop. + if (DB::connection()->getDriverName() === 'sqlite') { + return; + } + try { + DB::statement("ALTER TABLE `{$table}` DROP FOREIGN KEY `{$fkName}`"); + } catch (\Throwable $e) { + // FK doesn't exist — fine. + } + } +}; diff --git a/src/Facades/MorphAliases.php b/src/Facades/MorphAliases.php new file mode 100644 index 0000000..4547c7b --- /dev/null +++ b/src/Facades/MorphAliases.php @@ -0,0 +1,25 @@ + all() + * + * @see MorphAliasRegistry + */ +class MorphAliases extends Facade +{ + protected static function getFacadeAccessor(): string + { + return MorphAliasRegistry::class; + } +} diff --git a/src/Models/RequiredAccess.php b/src/Models/RequiredAccess.php index 8f66468..0129a9d 100644 --- a/src/Models/RequiredAccess.php +++ b/src/Models/RequiredAccess.php @@ -47,4 +47,33 @@ class RequiredAccess extends Model { return $this->morphTo(); } + + /** + * Render this link as an admin-friendly payload, using the + * MorphAliasRegistry for the type alias and human-readable name. + * + * Returns null for orphaned rows (target deleted) so callers can + * `->filter()` them out without explicit guards. Hosts that need + * extra fields can compose this with their own merge: + * + * $link->toAdminArray() + ['custom' => $link->required->custom] + */ + public function toAdminArray(): ?array + { + $target = $this->required; + if (! $target) { + return null; + } + + /** @var \Blax\Roles\Support\MorphAliasRegistry $registry */ + $registry = app(\Blax\Roles\Support\MorphAliasRegistry::class); + + return [ + 'id' => $this->id, + 'target_type' => $registry->aliasFor($this->required_type, $target), + 'target_id' => (string) $this->required_id, + 'name' => $registry->nameFor($target), + 'is_published' => ($target->published_at ?? null) !== null, + ]; + } } diff --git a/src/Models/RoleMember.php b/src/Models/RoleMember.php index 355d8cc..4187a7d 100644 --- a/src/Models/RoleMember.php +++ b/src/Models/RoleMember.php @@ -30,7 +30,12 @@ class RoleMember extends MorphPivot { parent::__construct($attributes); - $this->table = config('roles.table_names.role_members') ?: parent::getTable(); + // Config uses the singular key 'role_member' (mapped to 'role_members' + // in the default config). Looking up the plural key returned null and + // fell through to parent::getTable() — which on a MorphPivot returns + // a non-pluralised "role_member", pointing direct queries at a table + // that doesn't exist. + $this->table = config('roles.table_names.role_member') ?: parent::getTable(); } public function role() diff --git a/src/RolesServiceProvider.php b/src/RolesServiceProvider.php index c55149c..b859db4 100644 --- a/src/RolesServiceProvider.php +++ b/src/RolesServiceProvider.php @@ -15,6 +15,13 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider __DIR__ . '/../config/roles.php', 'roles' ); + + // Bind the MorphAliasRegistry as a singleton with no aliases registered + // by default. Hosts add their own (alias, FQCN) pairs in their service + // provider via app(MorphAliasRegistry::class)->register(...). Hosts + // that override the binding entirely (e.g. to swap in a stricter + // implementation) still work — Laravel honours the host's binding. + $this->app->singleton(\Blax\Roles\Support\MorphAliasRegistry::class); } /** diff --git a/src/Support/MorphAliasRegistry.php b/src/Support/MorphAliasRegistry.php new file mode 100644 index 0000000..ea04605 --- /dev/null +++ b/src/Support/MorphAliasRegistry.php @@ -0,0 +1,188 @@ + fully-qualified class name. + * + * @var array + */ + protected array $map = []; + + /** + * Custom alias resolvers for cases where one class maps to multiple + * aliases depending on the instance (e.g. App\Models\Blog → "course" + * or "lection" depending on the URL prefix). Keyed by class name; the + * resolver receives the model and returns an alias string or null + * (null = fall through to the static map / class basename). + * + * @var array + */ + protected array $aliasResolvers = []; + + /** + * Per-class human-name resolvers. The resolver receives the model + * and returns the display name. If null is returned, the registry + * falls through to its default chain (getLocalized('title'/'name'), + * title, name, slug, …). + * + * @var array + */ + protected array $nameResolvers = []; + + /** + * Register an alias → class mapping, optionally with a custom name + * resolver for that class. The name resolver is the place to handle + * compound names (e.g. ProductPrice → "Product Name — Tier"). + * + * Idempotent: re-registering the same alias overrides the previous + * mapping, which makes test setup convenient. + * + * @param callable(Model): ?string|null $nameResolver + */ + public function register(string $alias, string $class, ?callable $nameResolver = null): void + { + $this->map[$alias] = $class; + if ($nameResolver) { + $this->nameResolvers[$class] = $nameResolver; + } + } + + /** + * Register a custom alias resolver for cases where one class maps to + * multiple aliases per-instance. The resolver should return null to + * let the static map take over for instances it doesn't recognise. + * + * @param callable(Model): ?string $resolver + */ + public function registerAliasResolver(string $class, callable $resolver): void + { + $this->aliasResolvers[$class] = $resolver; + } + + /** + * Resolve an alias (or already-FQCN string) to a class name, or null + * if neither matches. + */ + public function resolveClass(string $alias): ?string + { + if (isset($this->map[$alias])) { + return $this->map[$alias]; + } + + // Backwards compatibility: callers sometimes pass a FQCN directly. + if (class_exists($alias)) { + return $alias; + } + + return null; + } + + /** + * Reverse-look-up: given a stored morph class (and optionally the + * actual instance), return a short alias for UI badges. Falls back + * to a lower-cased class basename when nothing is registered, so + * the result is always a non-empty string. + */ + public function aliasFor(string $morphClass, ?Model $instance = null): string + { + // Per-instance custom resolver wins (e.g. Blog → course/lection). + if ($instance) { + foreach ($this->aliasResolvers as $class => $resolver) { + if ($morphClass === $class || is_subclass_of($morphClass, $class)) { + $alias = $resolver($instance); + if ($alias) { + return $alias; + } + } + } + } + + // Static reverse lookup (exact match first, then parents). + $alias = array_search($morphClass, $this->map, true); + if ($alias !== false) { + return $alias; + } + foreach ($this->map as $a => $c) { + if (is_subclass_of($morphClass, $c)) { + return $a; + } + } + + return strtolower(class_basename($morphClass)); + } + + /** + * Best-effort human-readable name for any model the registry knows. + * Hosts can supply per-class resolvers via register()/registerNameResolver + * for compound names; otherwise we walk the usual attribute chain. + */ + public function nameFor(Model $model): string + { + // Walk class hierarchy so a resolver registered for the parent + // (e.g. ProductPrice) still applies to subclasses. + foreach ($this->nameResolvers as $class => $resolver) { + if ($model instanceof $class) { + $name = $resolver($model); + if ($name !== null && $name !== '') { + return $name; + } + } + } + + // Default chain: localised title/name → bare title/name → slug. + if (method_exists($model, 'getLocalized')) { + $localized = $model->getLocalized('title') ?? $model->getLocalized('name'); + if ($localized) { + return $localized; + } + } + + return $model->title + ?? $model->name + ?? $model->slug + ?? 'Untitled'; + } + + /** + * Register a standalone name resolver without (re-)registering the alias. + * + * @param callable(Model): ?string $resolver + */ + public function registerNameResolver(string $class, callable $resolver): void + { + $this->nameResolvers[$class] = $resolver; + } + + /** + * @return array alias => class + */ + public function all(): array + { + return $this->map; + } +} diff --git a/src/Traits/HasPermissions.php b/src/Traits/HasPermissions.php index 54236c9..1c2cb3e 100644 --- a/src/Traits/HasPermissions.php +++ b/src/Traits/HasPermissions.php @@ -113,7 +113,7 @@ trait HasPermissions { $permission_class = config('roles.models.permission'); - if (is_numeric($permission)) { + if (is_numeric($permission) || (is_string($permission) && \Illuminate\Support\Str::isUuid($permission))) { $permission = $permission_class::find($permission); } elseif (is_string($permission)) { $permission = $permission_class::firstOrCreate([ @@ -134,7 +134,7 @@ trait HasPermissions { $permission_class = config('roles.models.permission'); - if (is_numeric($permission)) { + if (is_numeric($permission) || (is_string($permission) && \Illuminate\Support\Str::isUuid($permission))) { $permission = $permission_class::find($permission); } elseif (is_string($permission)) { $permission = $permission_class::where('slug', $permission)->first(); diff --git a/src/Traits/HasRequiredAccess.php b/src/Traits/HasRequiredAccess.php index 872a401..a577422 100644 --- a/src/Traits/HasRequiredAccess.php +++ b/src/Traits/HasRequiredAccess.php @@ -68,6 +68,65 @@ trait HasRequiredAccess ]); } + /** + * Add a required-access target by short alias + id, looking the + * target up via the package's MorphAliasRegistry. + * + * Returns null when either the alias is unknown or the target row + * doesn't exist — callers (typically WS controllers) translate that + * into a 4xx response. This keeps every host's admin controllers + * out of the business of resolving alias maps themselves. + */ + public function addRequiredAccessByAlias(string $alias, string|int $id): ?Model + { + $class = app(\Blax\Roles\Support\MorphAliasRegistry::class)->resolveClass($alias); + if (! $class || ! class_exists($class)) { + return null; + } + + $target = $class::find($id); + if (! $target) { + return null; + } + + return $this->addRequiredAccess($target); + } + + /** + * Remove a required-access target by short alias + id. Returns the + * delete count (0 if the alias was unknown or the target missing). + */ + public function removeRequiredAccessByAlias(string $alias, string|int $id): int + { + $class = app(\Blax\Roles\Support\MorphAliasRegistry::class)->resolveClass($alias); + if (! $class || ! class_exists($class)) { + return 0; + } + + $target = $class::find($id); + if (! $target) { + return 0; + } + + return $this->removeRequiredAccess($target); + } + + /** + * Convenience: list this holder's required-access links rendered + * for admin payloads (uses RequiredAccess::toAdminArray which goes + * through the MorphAliasRegistry for type aliases and names). + * + * @return array + */ + public function requiredAccessAdminPayload(): array + { + return $this->requiredAccessLinks()->with('required')->get() + ->map(fn($link) => $link->toAdminArray()) + ->filter() + ->values() + ->all(); + } + /** * Remove a required-access target. * diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index f5af7ff..24af7f8 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -3,11 +3,23 @@ namespace Blax\Roles\Traits; use Blax\Roles\Models\Role; +use Illuminate\Support\Str; trait HasRoles { use HasPermissions; + /** + * A "role identifier" is anything that can be passed in lieu of a Role + * model: a numeric primary key (legacy), a UUID primary key, or a + * Role instance. Anything else (a non-numeric, non-UUID string) is + * treated as a *name* by the higher-level methods. + */ + private static function isRoleIdString(mixed $value): bool + { + return is_numeric($value) || (is_string($value) && Str::isUuid($value)); + } + /** * Get all roles for the user. * @@ -62,13 +74,13 @@ trait HasRoles */ public function assignRole(string|Role $role, int $max_times = 1) { - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } if ($max_times >= 0) { @@ -96,10 +108,10 @@ trait HasRoles */ public function removeRole(string|Role $role) { - if (is_string($role) && !is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::where('slug', $role)->first(); - } elseif (is_numeric($role)) { + if (self::isRoleIdString($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::where('slug', $role)->first(); } elseif (!$role instanceof Role) { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } @@ -122,14 +134,14 @@ trait HasRoles { $roleIds = []; foreach ($roles as $role) { - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, ], [ 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } elseif ($role instanceof Role) { $roleModel = $role; } elseif (is_object($role) && isset($role->id)) { @@ -167,14 +179,14 @@ trait HasRoles } // Resolve role - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, ], [ 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } elseif (!$role instanceof Role) { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } @@ -227,14 +239,14 @@ trait HasRoles } // Resolve role - if (is_string($role) && !is_numeric($role)) { + if (self::isRoleIdString($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (is_string($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ 'name' => $role, ], [ 'slug' => str()->slug($role) ]); - } elseif (is_numeric($role)) { - $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); } elseif (!$role instanceof Role) { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } @@ -255,12 +267,15 @@ trait HasRoles if ($existing) { $existing->extendByHours($hours, $forceExpiry); } else { + // Pass the context as an array — the RoleMember pivot has a + // 'context' => 'array' cast that JSON-encodes it on save. + // json_encode()-ing it here would double-encode the value. $this->roles()->attach($role->id, [ 'expires_at' => now()->addHours($hours), - 'context' => json_encode([ + 'context' => [ 'origin_name' => $originName, 'origin_value' => $originValue, - ]), + ], ]); } diff --git a/tests/Unit/HasAccessTest.php b/tests/Unit/HasAccessTest.php index fd0da7c..7ed1e4f 100644 --- a/tests/Unit/HasAccessTest.php +++ b/tests/Unit/HasAccessTest.php @@ -9,6 +9,7 @@ use Blax\Roles\RolesServiceProvider; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; use Workbench\App\Models\Article; use Workbench\App\Models\User; @@ -234,6 +235,7 @@ class HasAccessTest extends TestCase 'id' => (string) \Illuminate\Support\Str::uuid(), 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'created_at' => now(), diff --git a/tests/Unit/HasPermissionsTest.php b/tests/Unit/HasPermissionsTest.php index ea91da1..1354475 100644 --- a/tests/Unit/HasPermissionsTest.php +++ b/tests/Unit/HasPermissionsTest.php @@ -7,6 +7,7 @@ use Blax\Roles\Models\Role; use Blax\Roles\RolesServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; use Workbench\App\Models\User; @@ -180,6 +181,7 @@ class HasPermissionsTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'created_at' => now(), @@ -201,6 +203,7 @@ class HasPermissionsTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->addDays(7), 'created_at' => now(), @@ -232,6 +235,7 @@ class HasPermissionsTest extends TestCase DB::table(config('roles.table_names.permission_member'))->insert([ 'permission_id' => $perm->id, 'member_id' => $role->id, + 'id' => (string) Str::uuid(), 'member_type' => $role->getMorphClass(), 'expires_at' => now()->subHour(), 'created_at' => now(), diff --git a/tests/Unit/HasRolesTest.php b/tests/Unit/HasRolesTest.php index 3180a91..337ef4f 100644 --- a/tests/Unit/HasRolesTest.php +++ b/tests/Unit/HasRolesTest.php @@ -7,6 +7,7 @@ use Blax\Roles\Models\Role; use Blax\Roles\RolesServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Orchestra\Testbench\TestCase; use Workbench\App\Models\User; @@ -328,6 +329,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'created_at' => now(), @@ -346,6 +348,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->addWeek(), 'created_at' => now(), @@ -383,6 +386,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->addHours(24), 'created_at' => now(), @@ -412,6 +416,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => null, 'created_at' => now(), @@ -572,6 +577,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => now()->subDay(), 'context' => json_encode(['origin_name' => 'Monthly Sub', 'origin_value' => 'ProductPrice:sub-monthly']), @@ -618,6 +624,7 @@ class HasRolesTest extends TestCase DB::table(config('roles.table_names.role_member'))->insert([ 'role_id' => $role->id, 'member_id' => $user->id, + 'id' => (string) Str::uuid(), 'member_type' => $user->getMorphClass(), 'expires_at' => null, 'context' => json_encode(['origin_name' => 'Sub', 'origin_value' => 'ProductPrice:sub']),