initial
All checks were successful
linter / quality (push) Successful in 1m37s
tests / ci (8.4) (push) Successful in 2m13s
tests / ci (8.5) (push) Successful in 1m25s

This commit is contained in:
Tim Basten
2026-03-05 11:41:39 +08:00
commit 564f78dcda
182 changed files with 21145 additions and 0 deletions

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\ApprovalWorkflow;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ApprovalStep>
*/
class ApprovalStepFactory extends Factory
{
public function definition(): array
{
return [
'workflow_id' => ApprovalWorkflow::factory(),
'order' => fake()->numberBetween(1, 5),
'name' => fake()->words(2, true),
'role' => fake()->randomElement(['travel_approver', 'administrator']),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ApprovalWorkflow>
*/
class ApprovalWorkflowFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->words(3, true),
'description' => fake()->sentence(),
'is_active' => true,
];
}
public function inactive(): static
{
return $this->state(['is_active' => false]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EmergencyContact>
*/
class EmergencyContactFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'full_name' => fake()->name(),
'phone_number' => fake()->phoneNumber(),
'relationship' => fake()->randomElement(['Spouse', 'Parent', 'Sibling', 'Friend']),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use App\Models\TravelRequest;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TravelCostCode>
*/
class TravelCostCodeFactory extends Factory
{
public function definition(): array
{
return [
'travel_request_id' => TravelRequest::factory(),
'business_unit' => strtoupper(fake()->lexify('BU???')),
'project_grant' => fake()->optional()->numerify('PG-#####'),
'account_code' => strtoupper(fake()->lexify('AC????')),
'class_code' => fake()->optional()->numerify('CC###'),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Enums\JourneyMethod;
use App\Models\TravelRequest;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TravelJourney>
*/
class TravelJourneyFactory extends Factory
{
public function definition(): array
{
return [
'travel_request_id' => TravelRequest::factory(),
'origin' => fake()->city(),
'destination' => fake()->city(),
'date' => fake()->dateTimeBetween('+1 week', '+3 months')->format('Y-m-d'),
'time' => fake()->time('H:i'),
'method' => fake()->randomElement(JourneyMethod::cases()),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use App\Enums\ApprovalStatus;
use App\Models\ApprovalStep;
use App\Models\TravelRequest;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TravelRequestApproval>
*/
class TravelRequestApprovalFactory extends Factory
{
public function definition(): array
{
return [
'travel_request_id' => TravelRequest::factory(),
'approval_step_id' => ApprovalStep::factory(),
'approver_id' => null,
'status' => ApprovalStatus::Pending,
'comments' => null,
'acted_at' => null,
];
}
public function approved(): static
{
return $this->state([
'status' => ApprovalStatus::Approved,
'approver_id' => User::factory(),
'acted_at' => now(),
]);
}
public function rejected(): static
{
return $this->state([
'status' => ApprovalStatus::Rejected,
'approver_id' => User::factory(),
'comments' => fake()->sentence(),
'acted_at' => now(),
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Database\Factories;
use App\Enums\EventType;
use App\Enums\GeneralType;
use App\Enums\TravelStatus;
use App\Models\ApprovalWorkflow;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TravelRequest>
*/
class TravelRequestFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'workflow_id' => null,
'status' => TravelStatus::Draft,
'reason_summary' => fake()->paragraph(),
'event_type' => fake()->randomElement([null, EventType::Conference, EventType::AnnualInPersonMeeting]),
'general_type' => null,
'needs_accommodation' => fake()->boolean(),
'needs_car_hire' => fake()->boolean(),
'vehicle_policy_acknowledged' => false,
'business_days' => fake()->numberBetween(1, 5),
'private_days' => fake()->numberBetween(0, 3),
'additional_notes' => fake()->optional()->sentence(),
'submitted_at' => null,
];
}
public function pending(): static
{
return $this->state([
'status' => TravelStatus::Pending,
'submitted_at' => now(),
]);
}
public function approved(): static
{
return $this->state([
'status' => TravelStatus::Approved,
'submitted_at' => now(),
]);
}
public function rejected(): static
{
return $this->state([
'status' => TravelStatus::Rejected,
'submitted_at' => now(),
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'username' => fake()->unique()->userName(),
'phone' => fake()->phoneNumber(),
'department' => fake()->randomElement(['Medicine', 'Nursing', 'Allied Health', 'Administration']),
'title' => fake()->jobTitle(),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('emergency_contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('full_name');
$table->string('phone_number');
$table->string('relationship');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('emergency_contacts');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('ldap_guid')->nullable()->unique()->after('id');
$table->string('ldap_domain')->nullable()->after('ldap_guid');
$table->string('username')->nullable()->unique()->after('ldap_domain');
$table->string('phone')->nullable()->after('email');
$table->string('department')->nullable()->after('phone');
$table->string('title')->nullable()->after('department');
$table->string('password')->nullable()->change();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['ldap_guid', 'ldap_domain', 'username', 'phone', 'department', 'title']);
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('approval_steps', function (Blueprint $table) {
$table->id();
$table->foreignId('workflow_id')->constrained('approval_workflows')->cascadeOnDelete();
$table->unsignedInteger('order');
$table->string('name');
$table->string('role');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('approval_steps');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('approval_workflows', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('approval_workflows');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('travel_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('workflow_id')->nullable()->constrained('approval_workflows')->nullOnDelete();
$table->string('status')->default('Draft');
$table->text('reason_summary');
$table->string('event_type')->nullable();
$table->string('general_type')->nullable();
$table->boolean('needs_accommodation')->default(false);
$table->boolean('needs_car_hire')->default(false);
$table->boolean('vehicle_policy_acknowledged')->default(false);
$table->unsignedInteger('business_days')->default(0);
$table->unsignedInteger('private_days')->default(0);
$table->text('additional_notes')->nullable();
$table->timestamp('submitted_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('travel_requests');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('travel_journeys', function (Blueprint $table) {
$table->id();
$table->foreignId('travel_request_id')->constrained()->cascadeOnDelete();
$table->string('origin');
$table->string('destination');
$table->date('date');
$table->time('time')->nullable();
$table->string('method');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('travel_journeys');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('travel_cost_codes', function (Blueprint $table) {
$table->id();
$table->foreignId('travel_request_id')->constrained()->cascadeOnDelete();
$table->string('business_unit')->nullable();
$table->string('project_grant')->nullable();
$table->string('account_code')->nullable();
$table->string('class_code')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('travel_cost_codes');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('travel_request_approvals', function (Blueprint $table) {
$table->id();
$table->foreignId('travel_request_id')->constrained()->cascadeOnDelete();
$table->foreignId('approval_step_id')->constrained()->cascadeOnDelete();
$table->foreignId('approver_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('status')->nullable()->default('Pending');
$table->text('comments')->nullable();
$table->timestamp('acted_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('travel_request_approvals');
}
};

View File

@@ -0,0 +1,72 @@
<?php
namespace Database\Seeders;
use App\Models\ApprovalStep;
use App\Models\ApprovalWorkflow;
use App\Models\TravelRequest;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Create roles
$this->call(RoleSeeder::class);
// Create admin user
$admin = User::factory()->create([
'name' => 'Administrator',
'email' => 'admin@example.com',
'username' => 'admin',
'password' => Hash::make('password'),
]);
$admin->assignRole('administrator');
// Create a travel approver
$approver = User::factory()->create([
'name' => 'Travel Approver',
'email' => 'approver@example.com',
'username' => 'approver',
'password' => Hash::make('password'),
]);
$approver->assignRole('travel_approver');
// Create a staff user
$staff = User::factory()->create([
'name' => 'Staff Member',
'email' => 'staff@example.com',
'username' => 'staff',
'password' => Hash::make('password'),
]);
$staff->assignRole('staff');
// Create default approval workflow with 2 steps
$workflow = ApprovalWorkflow::create([
'name' => 'Standard Travel Approval',
'description' => 'Default two-step travel approval workflow',
'is_active' => true,
]);
ApprovalStep::create([
'workflow_id' => $workflow->id,
'order' => 1,
'name' => 'Travel Approver Review',
'role' => 'travel_approver',
]);
ApprovalStep::create([
'workflow_id' => $workflow->id,
'order' => 2,
'name' => 'Administrator Final Approval',
'role' => 'administrator',
]);
// Create sample travel requests for staff
TravelRequest::factory(3)->create([
'user_id' => $staff->id,
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
class RoleSeeder extends Seeder
{
public function run(): void
{
Role::firstOrCreate(['name' => 'staff']);
Role::firstOrCreate(['name' => 'travel_approver']);
Role::firstOrCreate(['name' => 'administrator']);
}
}