add school year and security fix
API CI/CD / Validate (composer + pint) (push) Successful in 3m14s
API CI/CD / Test (PHPUnit) (push) Failing after 3m28s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 56s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-06 00:55:01 -04:00
parent 99782c2a3c
commit 39228168c8
40 changed files with 20370 additions and 70 deletions
BIN
View File
Binary file not shown.
@@ -0,0 +1,162 @@
<?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
{
if (! Schema::hasTable('school_years')) {
Schema::create('school_years', function (Blueprint $table): void {
$table->id();
$table->string('name', 50)->unique();
$table->date('start_date');
$table->date('end_date');
$table->string('status', 20)->default('draft')->index();
$table->boolean('is_current')->default(false)->index();
$table->timestamp('closed_at')->nullable();
$table->unsignedBigInteger('closed_by')->nullable();
$table->timestamp('reopened_at')->nullable();
$table->unsignedBigInteger('reopened_by')->nullable();
$table->timestamp('archived_at')->nullable();
$table->unsignedBigInteger('archived_by')->nullable();
$table->timestamps();
});
} else {
Schema::table('school_years', function (Blueprint $table): void {
if (! Schema::hasColumn('school_years', 'reopened_at')) {
$table->timestamp('reopened_at')->nullable()->after('closed_by');
}
if (! Schema::hasColumn('school_years', 'reopened_by')) {
$table->unsignedBigInteger('reopened_by')->nullable()->after('reopened_at');
}
if (! Schema::hasColumn('school_years', 'archived_at')) {
$table->timestamp('archived_at')->nullable()->after('reopened_by');
}
if (! Schema::hasColumn('school_years', 'archived_by')) {
$table->unsignedBigInteger('archived_by')->nullable()->after('archived_at');
}
});
}
if (! Schema::hasTable('student_enrollments')) {
Schema::create('student_enrollments', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('student_id');
$table->unsignedBigInteger('school_year_id');
$table->unsignedBigInteger('grade_id');
$table->unsignedBigInteger('class_id')->nullable();
$table->string('status', 30)->default('enrolled')->index();
$table->unsignedBigInteger('promoted_from_enrollment_id')->nullable();
$table->timestamps();
$table->unique(['student_id', 'school_year_id'], 'uniq_student_year');
$table->index('school_year_id', 'idx_enrollment_school_year');
$table->index('student_id', 'idx_enrollment_student');
});
}
if (! Schema::hasTable('parent_accounts')) {
Schema::create('parent_accounts', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('parent_id');
$table->string('school_year', 50)->nullable();
$table->unsignedBigInteger('school_year_id')->nullable();
$table->decimal('opening_balance', 12, 2)->default(0);
$table->decimal('current_balance', 12, 2)->default(0);
$table->timestamps();
$table->unique(['parent_id', 'school_year'], 'uniq_parent_school_year_name');
$table->index('parent_id', 'idx_parent_account_parent');
$table->index('school_year_id', 'idx_parent_account_school_year_id');
});
} else {
Schema::table('parent_accounts', function (Blueprint $table): void {
if (! Schema::hasColumn('parent_accounts', 'school_year_id')) {
$table->unsignedBigInteger('school_year_id')->nullable()->after('school_year');
}
});
}
if (! Schema::hasTable('parent_balance_transfers')) {
Schema::create('parent_balance_transfers', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('parent_id');
$table->string('from_school_year', 50)->nullable();
$table->string('to_school_year', 50)->nullable();
$table->unsignedBigInteger('from_school_year_id')->nullable();
$table->unsignedBigInteger('to_school_year_id')->nullable();
$table->decimal('amount', 12, 2);
$table->string('status', 30)->default('transferred');
$table->json('source_summary_json')->nullable();
$table->json('source_summary')->nullable();
$table->unsignedBigInteger('new_invoice_id')->nullable();
$table->unsignedBigInteger('old_balance_invoice_id')->nullable();
$table->timestamp('reversed_at')->nullable();
$table->unsignedBigInteger('reversed_by')->nullable();
$table->text('reversal_reason')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
$table->unique(['parent_id', 'from_school_year', 'to_school_year'], 'uniq_parent_year_transfer_name');
$table->index('parent_id', 'idx_balance_transfer_parent');
$table->index('from_school_year_id', 'idx_balance_transfer_from_year_id');
$table->index('to_school_year_id', 'idx_balance_transfer_to_year_id');
});
} else {
Schema::table('parent_balance_transfers', function (Blueprint $table): void {
if (! Schema::hasColumn('parent_balance_transfers', 'from_school_year_id')) {
$table->unsignedBigInteger('from_school_year_id')->nullable()->after('to_school_year');
}
if (! Schema::hasColumn('parent_balance_transfers', 'to_school_year_id')) {
$table->unsignedBigInteger('to_school_year_id')->nullable()->after('from_school_year_id');
}
if (! Schema::hasColumn('parent_balance_transfers', 'source_summary')) {
$table->json('source_summary')->nullable()->after('source_summary_json');
}
if (! Schema::hasColumn('parent_balance_transfers', 'old_balance_invoice_id')) {
$table->unsignedBigInteger('old_balance_invoice_id')->nullable()->after('new_invoice_id');
}
if (! Schema::hasColumn('parent_balance_transfers', 'reversed_at')) {
$table->timestamp('reversed_at')->nullable();
}
if (! Schema::hasColumn('parent_balance_transfers', 'reversed_by')) {
$table->unsignedBigInteger('reversed_by')->nullable();
}
if (! Schema::hasColumn('parent_balance_transfers', 'reversal_reason')) {
$table->text('reversal_reason')->nullable();
}
});
}
if (Schema::hasTable('invoices')) {
Schema::table('invoices', function (Blueprint $table): void {
if (! Schema::hasColumn('invoices', 'school_year_id')) {
$table->unsignedBigInteger('school_year_id')->nullable()->after('school_year');
}
if (! Schema::hasColumn('invoices', 'parent_id')) {
$table->unsignedBigInteger('parent_id')->nullable();
}
if (! Schema::hasColumn('invoices', 'student_id')) {
$table->unsignedBigInteger('student_id')->nullable()->after('parent_id');
}
if (! Schema::hasColumn('invoices', 'invoice_type')) {
$table->string('invoice_type', 50)->nullable()->after('invoice_number');
}
if (! Schema::hasColumn('invoices', 'balance_transfer_id')) {
$table->unsignedBigInteger('balance_transfer_id')->nullable()->after('invoice_type');
}
if (! Schema::hasColumn('invoices', 'locked_at')) {
$table->timestamp('locked_at')->nullable();
}
if (! Schema::hasColumn('invoices', 'locked_by')) {
$table->unsignedBigInteger('locked_by')->nullable();
}
});
}
}
public function down(): void
{
Schema::dropIfExists('student_enrollments');
}
};
@@ -0,0 +1,151 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** @var list<string> */
private array $permissions = [
'school_year.view',
'school_year.view_closed',
'school_year.create',
'school_year.close',
'school_year.reopen',
'school_year.archive',
'school_year.manage',
'school_year.select',
'student_enrollment.view',
'student_enrollment.manage',
'student_enrollment.promote',
'student_enrollment.transition',
'finance.view',
'finance.manage',
'finance.invoice.manage',
'finance.payment.manage',
'finance.balance_transfer.view',
'finance.balance_transfer.run',
'finance.balance_transfer.reverse',
'finance.report.view',
'reports.view',
'reports.export',
'audit.view',
];
public function up(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
foreach ($this->permissions as $permission) {
DB::table('permissions')->updateOrInsert(
['name' => $permission],
$this->filterColumns('permissions', [
'description' => $this->describe($permission),
'created_at' => now(),
'updated_at' => now(),
])
);
}
$this->grantByRoleNames(['super admin', 'super_admin', 'administrator', 'admin'], $this->permissions);
$this->grantByRoleNames(['director', 'principal'], [
'school_year.view',
'school_year.view_closed',
'school_year.select',
'school_year.close',
'student_enrollment.view',
'student_enrollment.promote',
'finance.balance_transfer.view',
'reports.view',
]);
$this->grantByRoleNames(['finance admin', 'finance_admin', 'finance'], [
'school_year.view',
'school_year.select',
'finance.view',
'finance.manage',
'finance.invoice.manage',
'finance.payment.manage',
'finance.balance_transfer.view',
'finance.balance_transfer.run',
'finance.balance_transfer.reverse',
'finance.report.view',
'reports.view',
'reports.export',
]);
$this->grantByRoleNames(['teacher'], [
'school_year.view',
'school_year.view_closed',
'school_year.select',
'student_enrollment.view',
]);
$this->grantByRoleNames(['parent'], [
'school_year.view',
'school_year.select',
'finance.view',
]);
}
public function down(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
DB::table('permissions')->whereIn('name', $this->permissions)->delete();
}
private function grantByRoleNames(array $roleNames, array $permissions): void
{
if (! Schema::hasTable('roles') || ! Schema::hasTable('role_permissions')) {
return;
}
$roleIds = DB::table('roles')
->whereIn(DB::raw('LOWER(name)'), array_map('strtolower', $roleNames))
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
if ($roleIds === []) {
return;
}
$permissionRows = DB::table('permissions')
->whereIn('name', $permissions)
->pluck('id', 'name');
foreach ($roleIds as $roleId) {
foreach ($permissionRows as $permissionId) {
DB::table('role_permissions')->updateOrInsert(
['role_id' => $roleId, 'permission_id' => (int) $permissionId],
$this->filterColumns('role_permissions', [
'can_create' => true,
'can_read' => true,
'can_update' => true,
'can_delete' => true,
'can_manage' => true,
'created_at' => now(),
'updated_at' => now(),
])
);
}
}
}
private function describe(string $permission): string
{
return 'Allows '.$permission.' for the school-year closure workflow.';
}
private function filterColumns(string $table, array $values): array
{
return array_filter(
$values,
static fn (string $column): bool => Schema::hasColumn($table, $column),
ARRAY_FILTER_USE_KEY
);
}
};