chore: initial Laravel 13 project setup for eCert MBIP
- Laravel 13.9 + PHP 8.5 + MySQL - Bootstrap 5.3 + jQuery 3.7 + Chart.js (replacing Alpine/Tailwind) - Packages: intervention/image, dompdf, simple-qrcode, league/csv, laravel/breeze, laravel/boost - 17 database migrations: users, programs, qr_codes, participants, attendances, certificates, questionnaires, email_logs, audit_logs - 13 Eloquent models with full relationships - Admin layout (Bootstrap 5 sidebar) + public layout (mobile-first) - Rate limiters: checkin (60/min), certificate (30/min) - Admin seeder: admin@mbip.gov.my - Storage directories + symlink configured Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
34
app/Models/Attendance.php
Normal file
34
app/Models/Attendance.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Attendance extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'program_id', 'participant_id', 'program_participant_id',
|
||||
'attendance_source', 'attendance_session',
|
||||
'checked_in_at', 'checked_in_ip', 'user_agent', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['checked_in_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
|
||||
public function participant()
|
||||
{
|
||||
return $this->belongsTo(Participant::class);
|
||||
}
|
||||
|
||||
public function programParticipant()
|
||||
{
|
||||
return $this->belongsTo(ProgramParticipant::class);
|
||||
}
|
||||
}
|
||||
31
app/Models/AuditLog.php
Normal file
31
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'action', 'auditable_type', 'auditable_id',
|
||||
'old_values', 'new_values', 'ip_address', 'user_agent',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'old_values' => 'array',
|
||||
'new_values' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function auditable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
67
app/Models/Certificate.php
Normal file
67
app/Models/Certificate.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Certificate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid', 'program_id', 'participant_id', 'certificate_template_id',
|
||||
'certificate_no', 'file_path', 'token', 'status', 'error_message',
|
||||
'generated_at', 'emailed_at', 'downloaded_at', 'download_count',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'generated_at' => 'datetime',
|
||||
'emailed_at' => 'datetime',
|
||||
'downloaded_at' => 'datetime',
|
||||
'download_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(function ($model) {
|
||||
$model->uuid ??= (string) Str::uuid();
|
||||
$model->token ??= Str::random(48);
|
||||
});
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
|
||||
public function participant()
|
||||
{
|
||||
return $this->belongsTo(Participant::class);
|
||||
}
|
||||
|
||||
public function template()
|
||||
{
|
||||
return $this->belongsTo(CertificateTemplate::class, 'certificate_template_id');
|
||||
}
|
||||
|
||||
public function emailLogs()
|
||||
{
|
||||
return $this->hasMany(EmailLog::class);
|
||||
}
|
||||
|
||||
public function isGenerated(): bool
|
||||
{
|
||||
return $this->status === 'generated' || $this->status === 'emailed' || $this->status === 'downloaded';
|
||||
}
|
||||
|
||||
public function recordDownload(): void
|
||||
{
|
||||
$this->increment('download_count');
|
||||
if (! $this->downloaded_at) {
|
||||
$this->update(['downloaded_at' => now(), 'status' => 'downloaded']);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
app/Models/CertificateTemplate.php
Normal file
40
app/Models/CertificateTemplate.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CertificateTemplate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'program_id', 'original_filename', 'image_path', 'config_json', 'is_active', 'uploaded_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'config_json' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
|
||||
public function uploader()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
public function certificates()
|
||||
{
|
||||
return $this->hasMany(Certificate::class);
|
||||
}
|
||||
|
||||
public function getFieldConfig(string $field): ?array
|
||||
{
|
||||
return $this->config_json['fields'][$field] ?? null;
|
||||
}
|
||||
}
|
||||
33
app/Models/EmailLog.php
Normal file
33
app/Models/EmailLog.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EmailLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'program_id', 'participant_id', 'certificate_id',
|
||||
'recipient_email', 'subject', 'email_type', 'status', 'error_message', 'sent_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['sent_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
|
||||
public function participant()
|
||||
{
|
||||
return $this->belongsTo(Participant::class);
|
||||
}
|
||||
|
||||
public function certificate()
|
||||
{
|
||||
return $this->belongsTo(Certificate::class);
|
||||
}
|
||||
}
|
||||
62
app/Models/Participant.php
Normal file
62
app/Models/Participant.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Participant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'name', 'no_kp', 'email', 'phone', 'agency', 'participant_type',
|
||||
];
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(fn($model) => $model->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
public function programs()
|
||||
{
|
||||
return $this->belongsToMany(Program::class, 'program_participants')
|
||||
->withPivot(['registration_source', 'is_pre_registered', 'pre_registered_session', 'status', 'registered_at'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function programParticipants()
|
||||
{
|
||||
return $this->hasMany(ProgramParticipant::class);
|
||||
}
|
||||
|
||||
public function attendances()
|
||||
{
|
||||
return $this->hasMany(Attendance::class);
|
||||
}
|
||||
|
||||
public function certificates()
|
||||
{
|
||||
return $this->hasMany(Certificate::class);
|
||||
}
|
||||
|
||||
public function questionnaireResponses()
|
||||
{
|
||||
return $this->hasMany(QuestionnaireResponse::class);
|
||||
}
|
||||
|
||||
public function attendanceForProgram(int $programId): ?Attendance
|
||||
{
|
||||
return $this->attendances()->where('program_id', $programId)->first();
|
||||
}
|
||||
|
||||
public function hasAnsweredQuestionnaire(int $programId, int $questionnaireSetId): bool
|
||||
{
|
||||
return $this->questionnaireResponses()
|
||||
->where('program_id', $programId)
|
||||
->where('questionnaire_set_id', $questionnaireSetId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
122
app/Models/Program.php
Normal file
122
app/Models/Program.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Program extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'title', 'description', 'organizer', 'location',
|
||||
'start_date', 'end_date',
|
||||
'checkin_start_at', 'checkin_end_at',
|
||||
'ecert_download_start_at', 'ecert_download_end_at',
|
||||
'status', 'allow_walk_in',
|
||||
'default_staff_session', 'default_external_session',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'checkin_start_at' => 'datetime',
|
||||
'checkin_end_at' => 'datetime',
|
||||
'ecert_download_start_at' => 'datetime',
|
||||
'ecert_download_end_at' => 'datetime',
|
||||
'allow_walk_in' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(fn($model) => $model->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function qrCode()
|
||||
{
|
||||
return $this->hasOne(ProgramQrCode::class)->where('is_active', true)->latestOfMany();
|
||||
}
|
||||
|
||||
public function qrCodes()
|
||||
{
|
||||
return $this->hasMany(ProgramQrCode::class);
|
||||
}
|
||||
|
||||
public function participants()
|
||||
{
|
||||
return $this->belongsToMany(Participant::class, 'program_participants')
|
||||
->withPivot(['registration_source', 'is_pre_registered', 'pre_registered_session', 'status', 'registered_at'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function programParticipants()
|
||||
{
|
||||
return $this->hasMany(ProgramParticipant::class);
|
||||
}
|
||||
|
||||
public function attendances()
|
||||
{
|
||||
return $this->hasMany(Attendance::class);
|
||||
}
|
||||
|
||||
public function certificateTemplate()
|
||||
{
|
||||
return $this->hasOne(CertificateTemplate::class)->where('is_active', true)->latestOfMany();
|
||||
}
|
||||
|
||||
public function certificateTemplates()
|
||||
{
|
||||
return $this->hasMany(CertificateTemplate::class);
|
||||
}
|
||||
|
||||
public function certificates()
|
||||
{
|
||||
return $this->hasMany(Certificate::class);
|
||||
}
|
||||
|
||||
public function questionnaire()
|
||||
{
|
||||
return $this->hasOne(ProgramQuestionnaire::class);
|
||||
}
|
||||
|
||||
public function questionnaireSets()
|
||||
{
|
||||
return $this->belongsToMany(QuestionnaireSet::class, 'program_questionnaires')
|
||||
->withPivot(['is_confirmed', 'confirmed_at', 'confirmed_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function isCheckinOpen(): bool
|
||||
{
|
||||
$now = now();
|
||||
return $this->status === 'published'
|
||||
&& $this->checkin_start_at
|
||||
&& $now->between($this->checkin_start_at, $this->checkin_end_at ?? $now->addYear());
|
||||
}
|
||||
|
||||
public function isDownloadOpen(): bool
|
||||
{
|
||||
$now = now();
|
||||
return $this->status === 'published'
|
||||
&& $this->ecert_download_start_at
|
||||
&& $now->gte($this->ecert_download_start_at)
|
||||
&& (! $this->ecert_download_end_at || $now->lte($this->ecert_download_end_at));
|
||||
}
|
||||
}
|
||||
37
app/Models/ProgramParticipant.php
Normal file
37
app/Models/ProgramParticipant.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProgramParticipant extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'program_id', 'participant_id',
|
||||
'registration_source', 'is_pre_registered', 'pre_registered_session',
|
||||
'status', 'registered_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_pre_registered' => 'boolean',
|
||||
'registered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
|
||||
public function participant()
|
||||
{
|
||||
return $this->belongsTo(Participant::class);
|
||||
}
|
||||
|
||||
public function attendance()
|
||||
{
|
||||
return $this->hasOne(Attendance::class);
|
||||
}
|
||||
}
|
||||
27
app/Models/ProgramQrCode.php
Normal file
27
app/Models/ProgramQrCode.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProgramQrCode extends Model
|
||||
{
|
||||
protected $fillable = ['program_id', 'token', 'qr_image_path', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(fn($model) => $model->token ??= Str::random(48));
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
}
|
||||
35
app/Models/ProgramQuestionnaire.php
Normal file
35
app/Models/ProgramQuestionnaire.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProgramQuestionnaire extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'program_id', 'questionnaire_set_id', 'is_confirmed', 'confirmed_at', 'confirmed_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_confirmed' => 'boolean',
|
||||
'confirmed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
|
||||
public function questionnaireSet()
|
||||
{
|
||||
return $this->belongsTo(QuestionnaireSet::class);
|
||||
}
|
||||
|
||||
public function confirmedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'confirmed_by');
|
||||
}
|
||||
}
|
||||
27
app/Models/QuestionnaireAnswer.php
Normal file
27
app/Models/QuestionnaireAnswer.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QuestionnaireAnswer extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'questionnaire_response_id', 'questionnaire_question_id', 'answer_value',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['answer_value' => 'array'];
|
||||
}
|
||||
|
||||
public function response()
|
||||
{
|
||||
return $this->belongsTo(QuestionnaireResponse::class, 'questionnaire_response_id');
|
||||
}
|
||||
|
||||
public function question()
|
||||
{
|
||||
return $this->belongsTo(QuestionnaireQuestion::class, 'questionnaire_question_id');
|
||||
}
|
||||
}
|
||||
32
app/Models/QuestionnaireQuestion.php
Normal file
32
app/Models/QuestionnaireQuestion.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QuestionnaireQuestion extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'questionnaire_set_id', 'question_text', 'question_type',
|
||||
'options_json', 'is_required', 'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'options_json' => 'array',
|
||||
'is_required' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function questionnaireSet()
|
||||
{
|
||||
return $this->belongsTo(QuestionnaireSet::class);
|
||||
}
|
||||
|
||||
public function answers()
|
||||
{
|
||||
return $this->hasMany(QuestionnaireAnswer::class);
|
||||
}
|
||||
}
|
||||
38
app/Models/QuestionnaireResponse.php
Normal file
38
app/Models/QuestionnaireResponse.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QuestionnaireResponse extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'program_id', 'participant_id', 'questionnaire_set_id',
|
||||
'submitted_at', 'ip_address', 'user_agent',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['submitted_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function program()
|
||||
{
|
||||
return $this->belongsTo(Program::class);
|
||||
}
|
||||
|
||||
public function participant()
|
||||
{
|
||||
return $this->belongsTo(Participant::class);
|
||||
}
|
||||
|
||||
public function questionnaireSet()
|
||||
{
|
||||
return $this->belongsTo(QuestionnaireSet::class);
|
||||
}
|
||||
|
||||
public function answers()
|
||||
{
|
||||
return $this->hasMany(QuestionnaireAnswer::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/QuestionnaireSet.php
Normal file
40
app/Models/QuestionnaireSet.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class QuestionnaireSet extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['title', 'description', 'status', 'created_by'];
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function questions()
|
||||
{
|
||||
return $this->hasMany(QuestionnaireQuestion::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function programs()
|
||||
{
|
||||
return $this->belongsToMany(Program::class, 'program_questionnaires')
|
||||
->withPivot(['is_confirmed', 'confirmed_at', 'confirmed_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function programQuestionnaires()
|
||||
{
|
||||
return $this->hasMany(ProgramQuestionnaire::class);
|
||||
}
|
||||
|
||||
public function responses()
|
||||
{
|
||||
return $this->hasMany(QuestionnaireResponse::class);
|
||||
}
|
||||
}
|
||||
36
app/Models/User.php
Normal file
36
app/Models/User.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = ['name', 'email', 'password', 'is_admin'];
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function programs()
|
||||
{
|
||||
return $this->hasMany(Program::class, 'created_by');
|
||||
}
|
||||
|
||||
public function auditLogs()
|
||||
{
|
||||
return $this->hasMany(AuditLog::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user