From a496679cb17c688290640b802d8d78566b232a21 Mon Sep 17 00:00:00 2001 From: Syarif Ubaidillah Date: Wed, 19 Aug 2026 06:37:08 +0700 Subject: [PATCH] feat: introduce integration mappers and synchronize Academic and Admission domains - Implement mappers for Academic (Faculty, StudyProgram, Term) and Admission (AcademicProgram, AdmissionTrack, AdmissionSchedule, FeeType) domains. - Add registry bindings in service providers for API synchronization. - Enhance models to use HasPublicUlid and clean up ULID-related traits. - Update DTOs and actions to accommodate integration features and improve mapping flexibility. - Standardize billing cycle parsing with PMB label support. --- .../Curriculum/ModifyFacultyDetails.php | 1 + .../Curriculum/RegisterStudyProgramDTO.php | 2 +- .../Integration/Mappers/FacultyDataMapper.php | 64 ++++++++++ .../Mappers/StudyProgramDataMapper.php | 82 +++++++++++++ .../Integration/Mappers/TermDataMapper.php | 72 +++++++++++ app/Domains/Academic/Models/Faculty.php | 9 +- app/Domains/Academic/Models/StudyProgram.php | 9 +- app/Domains/Academic/Models/Term.php | 9 +- .../Providers/AcademicServiceProvider.php | 18 +++ .../InitializeAdmissionTrack.php | 1 + .../InitializeAdmissionTrackDTO.php | 1 + app/Domains/Admission/Enums/BillingCycle.php | 9 ++ .../Mappers/AcademicProgramDataMapper.php | 36 +++++- .../Mappers/AdmissionScheduleDataMapper.php | 78 +++++++++++- .../Mappers/AdmissionTrackDataMapper.php | 31 ++++- .../Integration/Mappers/FeeTypeDataMapper.php | 32 +++-- .../Admission/Models/AcademicProgram.php | 9 +- .../Admission/Models/AdmissionSchedule.php | 9 +- .../Admission/Models/AdmissionTrack.php | 11 +- app/Domains/Admission/Models/FeeRate.php | 9 +- app/Domains/Admission/Models/FeeType.php | 9 +- .../Providers/AdmissionServiceProvider.php | 23 ++++ .../Actions/Api/SynchronizeWithApi.php | 44 +++++-- .../AppProvisioning/ProvisionConsumerApp.php | 2 +- .../ProvisionConsumerAppDTO.php | 5 +- app/Domains/Channel/Enums/Api/ApiType.php | 9 +- app/Domains/Channel/Enums/Client/AuthType.php | 4 +- app/Domains/Channel/Models/Client.php | 9 +- .../Support/Mapper/ApiMapperRegistry.php | 39 ++++++ app/Domains/Finance/Models/ChartOfAccount.php | 9 +- app/Domains/Finance/Models/Invoice.php | 9 +- app/Domains/Finance/Models/Payment.php | 9 +- .../Integration/Mappers/UserDataMapper.php | 9 +- app/Domains/Identity/Models/Role.php | 8 +- app/Domains/Identity/Models/User.php | 9 +- .../Integration/RunGenericImportPipeline.php | 113 ++++++++++++++---- .../Support/Integration/DataPayloadMapper.php | 11 +- .../System/Traits/Model/HasPublicUlid.php | 20 ++++ app/Domains/System/Traits/Model/HasSlugs.php | 3 +- .../DataTables/Academic/TermDataTable.php | 2 +- .../Ingestion/Excel/Identity/UserImport.php | 4 +- ..._04_23_045248_create_permission_tables.php | 1 - database/seeders/DatabaseSeeder.php | 2 - database/seeders/UserSeeder.php | 4 +- lang/en/domains/channel/messages.php | 8 ++ resources/js/plugin/sweetalert2.js | 13 +- resources/views/components/modal.blade.php | 2 +- .../profile/⚡user-settings/user-settings.php | 6 +- .../⚡api-form-modal/api-form-modal.blade.php | 52 ++++---- .../⚡api-form-modal/api-form-modal.php | 18 ++- .../client/⚡form-modal/form-modal.blade.php | 6 +- 51 files changed, 732 insertions(+), 222 deletions(-) create mode 100644 app/Domains/Academic/Integration/Mappers/FacultyDataMapper.php create mode 100644 app/Domains/Academic/Integration/Mappers/StudyProgramDataMapper.php create mode 100644 app/Domains/Academic/Integration/Mappers/TermDataMapper.php create mode 100644 app/Domains/Channel/Support/Mapper/ApiMapperRegistry.php create mode 100644 app/Domains/System/Traits/Model/HasPublicUlid.php create mode 100644 lang/en/domains/channel/messages.php diff --git a/app/Domains/Academic/Actions/Curriculum/ModifyFacultyDetails.php b/app/Domains/Academic/Actions/Curriculum/ModifyFacultyDetails.php index 8224048..373f02f 100644 --- a/app/Domains/Academic/Actions/Curriculum/ModifyFacultyDetails.php +++ b/app/Domains/Academic/Actions/Curriculum/ModifyFacultyDetails.php @@ -12,6 +12,7 @@ class ModifyFacultyDetails $faculty->update([ 'name' => $dto->name, 'code' => $dto->code, + 'status', ]); } } diff --git a/app/Domains/Academic/DTOs/Curriculum/RegisterStudyProgramDTO.php b/app/Domains/Academic/DTOs/Curriculum/RegisterStudyProgramDTO.php index 6e99503..4b85cb8 100644 --- a/app/Domains/Academic/DTOs/Curriculum/RegisterStudyProgramDTO.php +++ b/app/Domains/Academic/DTOs/Curriculum/RegisterStudyProgramDTO.php @@ -12,6 +12,6 @@ readonly class RegisterStudyProgramDTO public string $level, public LifecycleStatus $status, public int $facultyId, - public int $externalId, + public string $externalId, ) {} } diff --git a/app/Domains/Academic/Integration/Mappers/FacultyDataMapper.php b/app/Domains/Academic/Integration/Mappers/FacultyDataMapper.php new file mode 100644 index 0000000..1ec57b3 --- /dev/null +++ b/app/Domains/Academic/Integration/Mappers/FacultyDataMapper.php @@ -0,0 +1,64 @@ + $rawData['nama_fakultas'], + 'code' => $rawData['kode_fakultas'], + 'external_id' => $rawData['id_fakultas'], + ]; + } + + public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void + { + /** @var Faculty $faculty */ + $faculty = $existingModel; + if ($faculty) { + $this->modifyFacultyDetails->execute($faculty, new ModifyFacultyDetailsDTO( + name: $payload['name'], + code: $payload['code'], + )); + } else { + $this->establishFaculty->execute(dto: new EstablishFacultyDTO( + name: $payload['name'], + code: $payload['code'], + externalId: $payload['external_id'], + )); + } + } + + /** + * @return class-string + */ + public function getModelClass(): string + { + return Faculty::class; + } +} diff --git a/app/Domains/Academic/Integration/Mappers/StudyProgramDataMapper.php b/app/Domains/Academic/Integration/Mappers/StudyProgramDataMapper.php new file mode 100644 index 0000000..01f98ff --- /dev/null +++ b/app/Domains/Academic/Integration/Mappers/StudyProgramDataMapper.php @@ -0,0 +1,82 @@ + $rawData['nama_program_studi'], + 'code' => $rawData['kode_program_studi'], + 'level' => $rawData['nama_jenjang_pendidikan'], + 'faculty_id' => self::resolveFacultyId($rawData['id_fakultas']), + 'external_id' => $rawData['id_prodi'], + ]; + } + + public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void + { + /** @var StudyProgram $studyProgram */ + $studyProgram = $existingModel; + + if ($studyProgram) { + $this->adjustStudyProgramDetails->execute($studyProgram, new AdjustStudyProgramDTO( + level: $payload['level'], + status: LifecycleStatus::ACTIVE, + )); + } else { + $this->registerStudyProgram->execute(new RegisterStudyProgramDTO( + name: $payload['name'], + code: $payload['code'], + level: $payload['level'], + status: LifecycleStatus::ACTIVE, + facultyId: $payload['faculty_id'], + externalId: $payload['external_id'], + )); + } + } + + /** + * @return class-string + */ + public function getModelClass(): string + { + return StudyProgram::class; + } + + private function resolveFacultyId(string $externalId): int { + if (!isset(self::$facultyCache[$externalId])) { + self::$facultyCache[$externalId] = Faculty::where('external_id', $externalId)->first()->id; + } + + return self::$facultyCache[$externalId]; + } +} diff --git a/app/Domains/Academic/Integration/Mappers/TermDataMapper.php b/app/Domains/Academic/Integration/Mappers/TermDataMapper.php new file mode 100644 index 0000000..8999852 --- /dev/null +++ b/app/Domains/Academic/Integration/Mappers/TermDataMapper.php @@ -0,0 +1,72 @@ + $rawData['nama_semester'], + 'code' => $rawData['id_semester'], + 'year_study' => $rawData['id_tahun_ajaran'], + 'semester' => $rawData['semester'], + 'date_start' => $rawData['tanggal_mulai'], + 'date_end' => $rawData['tanggal_selesai'], + ]; + } + + public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void + { + /** @var Term $term */ + $term = $existingModel; + + if(isset($term)) { + $this->modifyTermDate->execute($term, new ModifyTermDateDTO( + startDate: CarbonImmutable::parse($payload['date_start']), + endDate: CarbonImmutable::parse($payload['date_end']), + )); + } else { + $this->initializeAcademicTerm->execute(new InitializeAcademicTermDTO( + name: $payload['name'], + code: $payload['code'], + yearStudy: $payload['year_study'], + semester: $payload['semester'], + startDate: CarbonImmutable::parse($payload['date_start']), + endDate: CarbonImmutable::parse($payload['date_end']), + )); + } + } + + /** + * @return class-string + */ + public function getModelClass(): string + { + return Term::class; + } +} diff --git a/app/Domains/Academic/Models/Faculty.php b/app/Domains/Academic/Models/Faculty.php index 779ea53..45c497f 100644 --- a/app/Domains/Academic/Models/Faculty.php +++ b/app/Domains/Academic/Models/Faculty.php @@ -4,10 +4,10 @@ namespace App\Domains\Academic\Models; use App\Domains\Academic\Policies\FacultyPolicy; use App\Domains\System\Enums\LifecycleStatus; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Academic\FacultyFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,7 +17,7 @@ use Illuminate\Database\Eloquent\Model; class Faculty extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'status' => LifecycleStatus::class, @@ -27,9 +27,4 @@ class Faculty extends Model { return FacultyFactory::new(); } - - public function uniqueIds(): array - { - return ['ulid']; - } } diff --git a/app/Domains/Academic/Models/StudyProgram.php b/app/Domains/Academic/Models/StudyProgram.php index 944f9a4..1ae182f 100644 --- a/app/Domains/Academic/Models/StudyProgram.php +++ b/app/Domains/Academic/Models/StudyProgram.php @@ -4,10 +4,10 @@ namespace App\Domains\Academic\Models; use App\Domains\Academic\Policies\StudyProgramPolicy; use App\Domains\System\Enums\LifecycleStatus; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Academic\StudyProgramFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -25,7 +25,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class StudyProgram extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'status' => LifecycleStatus::class, @@ -36,11 +36,6 @@ class StudyProgram extends Model return StudyProgramFactory::new(); } - public function uniqueIds(): array - { - return ['ulid']; - } - public function faculty(): BelongsTo { return $this->belongsTo(Faculty::class); diff --git a/app/Domains/Academic/Models/Term.php b/app/Domains/Academic/Models/Term.php index 628b355..84465ed 100644 --- a/app/Domains/Academic/Models/Term.php +++ b/app/Domains/Academic/Models/Term.php @@ -3,10 +3,10 @@ namespace App\Domains\Academic\Models; use App\Domains\Academic\Policies\TermPolicy; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Academic\TermFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -16,7 +16,7 @@ use Illuminate\Database\Eloquent\Model; class Term extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'date_start' => 'date:d/M/Y', @@ -27,9 +27,4 @@ class Term extends Model { return TermFactory::new(); } - - public function uniqueIds(): array - { - return ['ulid']; - } } diff --git a/app/Domains/Academic/Providers/AcademicServiceProvider.php b/app/Domains/Academic/Providers/AcademicServiceProvider.php index 9ddef20..9db0e07 100644 --- a/app/Domains/Academic/Providers/AcademicServiceProvider.php +++ b/app/Domains/Academic/Providers/AcademicServiceProvider.php @@ -2,6 +2,11 @@ namespace App\Domains\Academic\Providers; +use App\Domains\Academic\Integration\Mappers\FacultyDataMapper; +use App\Domains\Academic\Integration\Mappers\StudyProgramDataMapper; +use App\Domains\Academic\Integration\Mappers\TermDataMapper; +use App\Domains\Channel\Enums\Api\ApiType; +use App\Domains\Channel\Support\Mapper\ApiMapperRegistry; use App\Domains\System\Traits\Provider\RegistersDomainEvents; use Illuminate\Support\ServiceProvider; @@ -13,6 +18,19 @@ class AcademicServiceProvider extends ServiceProvider public function register(): void { + ApiMapperRegistry::register( + type: ApiType::FACULTY, + mapperClass: FacultyDataMapper::class, + ); + ApiMapperRegistry::register( + type: ApiType::STUDY_PROGRAM, + mapperClass: StudyProgramDataMapper::class, + ); + ApiMapperRegistry::register( + type: ApiType::TERM, + mapperClass: TermDataMapper::class, + ); + $this->app->register(RelationshipServiceProvider::class); } diff --git a/app/Domains/Admission/Actions/IntakeSourcing/InitializeAdmissionTrack.php b/app/Domains/Admission/Actions/IntakeSourcing/InitializeAdmissionTrack.php index 818bd11..0da37a6 100644 --- a/app/Domains/Admission/Actions/IntakeSourcing/InitializeAdmissionTrack.php +++ b/app/Domains/Admission/Actions/IntakeSourcing/InitializeAdmissionTrack.php @@ -11,6 +11,7 @@ class InitializeAdmissionTrack { AdmissionTrack::create([ 'name' => $dto->name, + 'slug' => $dto->slug, 'status' => $dto->status, ]); } diff --git a/app/Domains/Admission/DTOs/IntakeSourcing/InitializeAdmissionTrackDTO.php b/app/Domains/Admission/DTOs/IntakeSourcing/InitializeAdmissionTrackDTO.php index fbc4f46..fac3338 100644 --- a/app/Domains/Admission/DTOs/IntakeSourcing/InitializeAdmissionTrackDTO.php +++ b/app/Domains/Admission/DTOs/IntakeSourcing/InitializeAdmissionTrackDTO.php @@ -8,6 +8,7 @@ readonly class InitializeAdmissionTrackDTO { public function __construct( public string $name, + public string $slug, public LifecycleStatus $status, ) {} } diff --git a/app/Domains/Admission/Enums/BillingCycle.php b/app/Domains/Admission/Enums/BillingCycle.php index 5bc8c56..e590049 100644 --- a/app/Domains/Admission/Enums/BillingCycle.php +++ b/app/Domains/Admission/Enums/BillingCycle.php @@ -15,4 +15,13 @@ enum BillingCycle: string implements HasLabel case MONTHLY = 'monthly'; case SEMESTER = 'semester'; case ANNUAL = 'annual'; + + public static function tryFromPMB(string $label): ?self + { + return match ($label) { + 'Awal Masuk', 'Tagihan Insidentil' => self::ONCE, + 'Tagihan per Semester' => self::SEMESTER, + default => self::MONTHLY, + }; + } } diff --git a/app/Domains/Admission/Integration/Mappers/AcademicProgramDataMapper.php b/app/Domains/Admission/Integration/Mappers/AcademicProgramDataMapper.php index 0cfce5a..ba6c638 100644 --- a/app/Domains/Admission/Integration/Mappers/AcademicProgramDataMapper.php +++ b/app/Domains/Admission/Integration/Mappers/AcademicProgramDataMapper.php @@ -2,12 +2,18 @@ namespace App\Domains\Admission\Integration\Mappers; +use App\Domains\Admission\Actions\StudyModeProvisioning\DefineAcademicProgram; +use App\Domains\Admission\DTOs\StudyModeProvisioning\DefineAcademicProgramDTO; +use App\Domains\Admission\Models\AcademicProgram; +use App\Domains\System\Enums\LifecycleStatus; use App\Domains\System\Support\Integration\DataPayloadMapper; use Illuminate\Database\Eloquent\Model; class AcademicProgramDataMapper implements DataPayloadMapper { - public function __construct() + public function __construct( + protected DefineAcademicProgram $defineAcademicProgram, + ) { // Inject required Domain and Cross-Domain Actions via constructor composition } @@ -15,17 +21,37 @@ class AcademicProgramDataMapper implements DataPayloadMapper public function getLookupKey(): string { // Return the unique string key used to identify existing records (e.g., 'email') - return 'id'; + return 'code'; } public function transform(array $rawData): array { // Normalize incoming data array formats into an internal domain-safe layout - return $rawData; + return [ + 'name' => $rawData['nama_Skuliah'], + 'code' => $rawData['kode_Skuliah'], + ]; } - public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void + /** + * @param AcademicProgram|null $existingModel + */ + public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void { - // Allocate mapped payloads into strict DTOs and fire internal/external Domain Actions + if(is_null($existingModel)) { + $this->defineAcademicProgram->execute(new DefineAcademicProgramDTO( + name: $payload['name'], + code: $payload['code'], + status: LifecycleStatus::ACTIVE, + )); + } + } + + /** + * @return class-string + */ + public function getModelClass(): string + { + return AcademicProgram::class; } } diff --git a/app/Domains/Admission/Integration/Mappers/AdmissionScheduleDataMapper.php b/app/Domains/Admission/Integration/Mappers/AdmissionScheduleDataMapper.php index 76ada77..5b1b21b 100644 --- a/app/Domains/Admission/Integration/Mappers/AdmissionScheduleDataMapper.php +++ b/app/Domains/Admission/Integration/Mappers/AdmissionScheduleDataMapper.php @@ -2,30 +2,98 @@ namespace App\Domains\Admission\Integration\Mappers; +use App\Domains\Academic\Models\Term; +use App\Domains\Admission\Actions\IntakeScheduling\AdjustAdmissionScheduleDuration; +use App\Domains\Admission\Actions\IntakeScheduling\DefineAdmissionSchedule; +use App\Domains\Admission\DTOs\IntakeScheduling\AdjustAdmissionScheduleDurationDTO; +use App\Domains\Admission\DTOs\IntakeScheduling\DefineAdmissionScheduleDTO; +use App\Domains\Admission\Models\AdmissionSchedule; +use App\Domains\Admission\Models\AdmissionTrack; use App\Domains\System\Support\Integration\DataPayloadMapper; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Model; class AdmissionScheduleDataMapper implements DataPayloadMapper { - public function __construct() + private static array $cache = [ + 'admission_track' => [], + 'term' => [], + ]; + + public function __construct( + protected DefineAdmissionSchedule $defineAdmissionSchedule, + protected AdjustAdmissionScheduleDuration $adjustAdmissionScheduleDuration + ) { // Inject required Domain and Cross-Domain Actions via constructor composition } - public function getLookupKey(): string + public function getLookupKey(): array { // Return the unique string key used to identify existing records (e.g., 'email') - return 'id'; + return ['admission_track_id', 'term_id']; } public function transform(array $rawData): array { // Normalize incoming data array formats into an internal domain-safe layout - return $rawData; + return [ + 'date_start' => $rawData['tanggal_buka'], + 'date_end' => $rawData['tanggal_tutup'], + 're_registration_date_start' => $rawData['tanggal_buka_daftar_ulang'], + 're_registration_date_end' => $rawData['tanggal_tutup_daftar_ulang'], + 'admission_track_id' => $this->resolveAdmissionTrackId($rawData['slug']), + 'term_id' => $this->resolveTermId($rawData['angkatan']), + ]; } public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void { - // Allocate mapped payloads into strict DTOs and fire internal/external Domain Actions + /** @var AdmissionSchedule $admissionSchedule */ + $admissionSchedule = $existingModel; + if ($admissionSchedule) { + $this->adjustAdmissionScheduleDuration->execute($admissionSchedule, new AdjustAdmissionScheduleDurationDTO( + startData: CarbonImmutable::parse($payload['date_start']), + endData: CarbonImmutable::parse($payload['date_end']), + reRegisterStartData: CarbonImmutable::parse($payload['re_registration_date_start']), + reRegisterEndData: CarbonImmutable::parse($payload['re_registration_date_end']), + )); + } else { + ; + $this->defineAdmissionSchedule->execute(new DefineAdmissionScheduleDTO( + startData: CarbonImmutable::parse($payload['date_start']), + endData: CarbonImmutable::parse($payload['date_end']), + reRegisterStartData: CarbonImmutable::parse($payload['re_registration_date_start']), + reRegisterEndData: CarbonImmutable::parse($payload['re_registration_date_end']), + termId: $payload['term_id'], + admissionTrackId: $payload['admission_track_id'], + )); + } + } + + /** + * @return class-string + */ + public function getModelClass(): string + { + return AdmissionSchedule::class; + } + + private function resolveAdmissionTrackId(string $slug): int { + if (!isset(self::$cache['admission_track'][$slug])) { + self::$cache['admission_track'][$slug] = AdmissionTrack::where('slug', str($slug)->slug())->first()?->id; + } + + return self::$cache['admission_track'][$slug]; + } + + private function resolveTermId(int $yearStudy): int { + if (!isset(self::$cache['term'][$yearStudy])) { + self::$cache['term'][$yearStudy] = Term::where('year_study', $yearStudy) + ->where('semester', 1) + ->first()?->id ?? 0; + } + + return self::$cache['term'][$yearStudy]; } } diff --git a/app/Domains/Admission/Integration/Mappers/AdmissionTrackDataMapper.php b/app/Domains/Admission/Integration/Mappers/AdmissionTrackDataMapper.php index 6cdc101..fb7d698 100644 --- a/app/Domains/Admission/Integration/Mappers/AdmissionTrackDataMapper.php +++ b/app/Domains/Admission/Integration/Mappers/AdmissionTrackDataMapper.php @@ -2,12 +2,18 @@ namespace App\Domains\Admission\Integration\Mappers; +use App\Domains\Admission\Actions\IntakeSourcing\InitializeAdmissionTrack; +use App\Domains\Admission\DTOs\IntakeSourcing\InitializeAdmissionTrackDTO; +use App\Domains\Admission\Models\AdmissionTrack; +use App\Domains\System\Enums\LifecycleStatus; use App\Domains\System\Support\Integration\DataPayloadMapper; use Illuminate\Database\Eloquent\Model; class AdmissionTrackDataMapper implements DataPayloadMapper { - public function __construct() + public function __construct( + protected InitializeAdmissionTrack $initializeAdmissionTrack, + ) { // Inject required Domain and Cross-Domain Actions via constructor composition } @@ -15,17 +21,34 @@ class AdmissionTrackDataMapper implements DataPayloadMapper public function getLookupKey(): string { // Return the unique string key used to identify existing records (e.g., 'email') - return 'id'; + return 'slug'; } public function transform(array $rawData): array { // Normalize incoming data array formats into an internal domain-safe layout - return $rawData; + return [ + 'name' => $rawData['nama_jalur'], + 'slug' => str($rawData['slug'])->slug(), + ]; } public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void { - // Allocate mapped payloads into strict DTOs and fire internal/external Domain Actions + if (!$existingModel) { + $this->initializeAdmissionTrack->execute(new InitializeAdmissionTrackDTO( + name: $payload['name'], + slug: $payload['slug'], + status: LifecycleStatus::ACTIVE, + )); + } + } + + /** + * @return class-string + */ + public function getModelClass(): string + { + return AdmissionTrack::class; } } diff --git a/app/Domains/Admission/Integration/Mappers/FeeTypeDataMapper.php b/app/Domains/Admission/Integration/Mappers/FeeTypeDataMapper.php index a84f90d..f10764f 100644 --- a/app/Domains/Admission/Integration/Mappers/FeeTypeDataMapper.php +++ b/app/Domains/Admission/Integration/Mappers/FeeTypeDataMapper.php @@ -2,13 +2,18 @@ namespace App\Domains\Admission\Integration\Mappers; +use App\Domains\Admission\Actions\AdmissionPricing\DefineFeeType; +use App\Domains\Admission\DTOs\AdmissionPricing\FeeTypeDTO; +use App\Domains\Admission\Enums\BillingCycle; use App\Domains\Admission\Models\FeeType; use App\Domains\System\Support\Integration\DataPayloadMapper; use Illuminate\Database\Eloquent\Model; class FeeTypeDataMapper implements DataPayloadMapper { - public function __construct() + public function __construct( + protected DefineFeeType $defineFeeType + ) { // Inject required Domain and Cross-Domain Actions via constructor composition } @@ -16,23 +21,36 @@ class FeeTypeDataMapper implements DataPayloadMapper public function getLookupKey(): string { // Return the unique string key used to identify existing records (e.g., 'email') - return 'id'; + return 'name'; } public function transform(array $rawData): array { // Normalize incoming data array formats into an internal domain-safe layout return [ - 'name' => $rawData['name'], - 'billing_cycle' => $rawData['billing_cycle'], + 'name' => $rawData['nama_Jtagihan'], + 'billing_cycle' => $rawData['periodesasi_Jtagihan'], ]; } /** - * @param FeeType|null $model + * @param FeeType|null $existingModel */ - public function updateOrCreateDomainState(array $payload, ?Model $model = null): void + public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void { - // + if(!$existingModel) { + $this->defineFeeType->execute(new FeeTypeDTO( + name: $payload['name'], + billingCycle: BillingCycle::tryFromPMB($payload['billing_cycle']), + )); + } + } + + /** + * @return class-string + */ + public function getModelClass(): string + { + return FeeType::class; } } diff --git a/app/Domains/Admission/Models/AcademicProgram.php b/app/Domains/Admission/Models/AcademicProgram.php index 8f47058..0b27bfb 100644 --- a/app/Domains/Admission/Models/AcademicProgram.php +++ b/app/Domains/Admission/Models/AcademicProgram.php @@ -4,11 +4,11 @@ namespace App\Domains\Admission\Models; use App\Domains\Admission\Policies\AcademicProgramPolicy; use App\Domains\System\Enums\LifecycleStatus; +use App\Domains\System\Traits\Model\HasPublicUlid; use App\Domains\System\Traits\Model\HasSlugs; use Database\Factories\Academic\AcademicProgramFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -18,8 +18,8 @@ use Illuminate\Database\Eloquent\Model; class AcademicProgram extends Model { use HasFactory; + use HasPublicUlid; use HasSlugs; - use HasUlids; protected $casts = [ 'status' => LifecycleStatus::class, @@ -30,11 +30,6 @@ class AcademicProgram extends Model return AcademicProgramFactory::new(); } - public function uniqueIds(): array - { - return ['ulid']; - } - public function sluggable(): string { return 'name'; diff --git a/app/Domains/Admission/Models/AdmissionSchedule.php b/app/Domains/Admission/Models/AdmissionSchedule.php index b0dfd16..0e79b7f 100644 --- a/app/Domains/Admission/Models/AdmissionSchedule.php +++ b/app/Domains/Admission/Models/AdmissionSchedule.php @@ -4,10 +4,10 @@ namespace App\Domains\Admission\Models; use App\Domains\Admission\Enums\AdmissionStatus; use App\Domains\Admission\Policies\AdmissionSchedulePolicy; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Admission\AdmissionScheduleFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -26,7 +26,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class AdmissionSchedule extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'date_start' => 'date:d/M/Y', @@ -36,11 +36,6 @@ class AdmissionSchedule extends Model 'status' => AdmissionStatus::class, ]; - public function uniqueIds(): array - { - return ['ulid']; - } - protected static function newFactory(): Factory { return AdmissionScheduleFactory::new(); diff --git a/app/Domains/Admission/Models/AdmissionTrack.php b/app/Domains/Admission/Models/AdmissionTrack.php index 6dbd943..dcaa77d 100644 --- a/app/Domains/Admission/Models/AdmissionTrack.php +++ b/app/Domains/Admission/Models/AdmissionTrack.php @@ -4,32 +4,27 @@ namespace App\Domains\Admission\Models; use App\Domains\Admission\Policies\AdmissionTrackPolicy; use App\Domains\System\Enums\LifecycleStatus; +use App\Domains\System\Traits\Model\HasPublicUlid; use App\Domains\System\Traits\Model\HasSlugs; use Database\Factories\Admission\AdmissionTrackFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -#[Fillable(['name', 'status'])] +#[Fillable(['name', 'slug', 'status'])] #[UsePolicy(AdmissionTrackPolicy::class)] class AdmissionTrack extends Model { use HasFactory; + use HasPublicUlid; use HasSlugs; - use HasUlids; protected $casts = [ 'status' => LifecycleStatus::class, ]; - public function uniqueIds(): array - { - return ['ulid']; - } - protected static function newFactory(): Factory { return AdmissionTrackFactory::new(); diff --git a/app/Domains/Admission/Models/FeeRate.php b/app/Domains/Admission/Models/FeeRate.php index 4d06505..fcc8169 100644 --- a/app/Domains/Admission/Models/FeeRate.php +++ b/app/Domains/Admission/Models/FeeRate.php @@ -4,10 +4,10 @@ namespace App\Domains\Admission\Models; use App\Domains\Admission\Policies\FeeRatePolicy; use App\Domains\Finance\Casts\MoneyCurrency; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Admission\FeeRateFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -24,17 +24,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class FeeRate extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'amount' => MoneyCurrency::class, ]; - public function uniqueIds(): array - { - return ['ulid']; - } - protected static function newFactory(): Factory { return FeeRateFactory::new(); diff --git a/app/Domains/Admission/Models/FeeType.php b/app/Domains/Admission/Models/FeeType.php index 02aa20e..94ffc8d 100644 --- a/app/Domains/Admission/Models/FeeType.php +++ b/app/Domains/Admission/Models/FeeType.php @@ -4,10 +4,10 @@ namespace App\Domains\Admission\Models; use App\Domains\Admission\Enums\BillingCycle; use App\Domains\Admission\Policies\FeeTypePolicy; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Admission\FeeTypeFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,17 +17,12 @@ use Illuminate\Database\Eloquent\Model; class FeeType extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'billing_cycle' => BillingCycle::class, ]; - public function uniqueIds(): array - { - return ['ulid']; - } - protected static function newFactory(): Factory { return FeeTypeFactory::new(); diff --git a/app/Domains/Admission/Providers/AdmissionServiceProvider.php b/app/Domains/Admission/Providers/AdmissionServiceProvider.php index ed97be2..b288f93 100644 --- a/app/Domains/Admission/Providers/AdmissionServiceProvider.php +++ b/app/Domains/Admission/Providers/AdmissionServiceProvider.php @@ -2,6 +2,12 @@ namespace App\Domains\Admission\Providers; +use App\Domains\Admission\Integration\Mappers\AcademicProgramDataMapper; +use App\Domains\Admission\Integration\Mappers\AdmissionScheduleDataMapper; +use App\Domains\Admission\Integration\Mappers\AdmissionTrackDataMapper; +use App\Domains\Admission\Integration\Mappers\FeeTypeDataMapper; +use App\Domains\Channel\Enums\Api\ApiType; +use App\Domains\Channel\Support\Mapper\ApiMapperRegistry; use App\Domains\System\Traits\Provider\RegistersDomainEvents; use Illuminate\Support\ServiceProvider; @@ -13,6 +19,23 @@ class AdmissionServiceProvider extends ServiceProvider public function register(): void { + ApiMapperRegistry::register( + type: ApiType::ADMISSION_TRACK, + mapperClass: AdmissionTrackDataMapper::class, + ); + ApiMapperRegistry::register( + type: ApiType::ACADEMIC_PROGRAM, + mapperClass: AcademicProgramDataMapper::class, + ); + ApiMapperRegistry::register( + type: ApiType::ADMISSION_SCHEDULE, + mapperClass: AdmissionScheduleDataMapper::class, + ); + ApiMapperRegistry::register( + type: ApiType::FEE_TYPE, + mapperClass: FeeTypeDataMapper::class, + ); + $this->app->register(RelationshipServiceProvider::class); } diff --git a/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php b/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php index 017234d..4ae4822 100644 --- a/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php +++ b/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php @@ -4,25 +4,45 @@ namespace App\Domains\Channel\Actions\Api; use App\Domains\Channel\Enums\Api\ApiType; use App\Domains\Channel\Models\ApiEndpoint; +use App\Domains\Channel\Support\Mapper\ApiMapperRegistry; +use App\Domains\System\Actions\Integration\RunGenericImportPipeline; +use Exception; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; class SynchronizeWithApi { + /** + * @throws Exception + */ public function execute(ApiType $apiType): void { - $endpoint = ApiEndpoint::query() - ->with('client') - ->where('key', $apiType) - ->first(); + try { + $mapper = ApiMapperRegistry::resolve($apiType); + $endpoint = ApiEndpoint::query() + ->with('client') + ->where('key', $apiType) + ->first(); - $domain = $endpoint->client->domain; - $url = $endpoint->value; - $credentials = $endpoint->client->credentials; - $authType = $endpoint->client->auth_type; - $url = "https://$domain$url"; - $header = $authType->buildCredentials($credentials); + $domain = $endpoint->client->domain; + $url = $endpoint->value; + $credentials = $endpoint->client->credentials; + $authType = $endpoint->client->auth_type; + $url = "$domain$url"; + $header = $authType->buildCredentials($credentials); - $response = Http::withHeaders($header)->get($url); - dd($response); + $response = Http::withHeaders($header)->get($url); + $result = $response->json()['data']; + + + app(RunGenericImportPipeline::class)->execute( + rows: collect($result), + mapper: $mapper, + ); + } catch (Exception $e) { + Log::error('Failed to synchronize with API: '.$e->getMessage()); + throw new Exception(__('domains/channel/messages.clients.fail_to_sync', ['error' => $e->getMessage()])); + } } } diff --git a/app/Domains/Channel/Actions/AppProvisioning/ProvisionConsumerApp.php b/app/Domains/Channel/Actions/AppProvisioning/ProvisionConsumerApp.php index 754a11d..04c1155 100644 --- a/app/Domains/Channel/Actions/AppProvisioning/ProvisionConsumerApp.php +++ b/app/Domains/Channel/Actions/AppProvisioning/ProvisionConsumerApp.php @@ -14,7 +14,7 @@ class ProvisionConsumerApp 'domain' => $dto->domain, 'webhook' => $dto->webhook_url, 'auth_type' => $dto->auth_type, - 'credentials' => $dto->credentials, + 'credentials' => $dto->credentials ?? [], 'secret' => encrypt(uuid_create()), ]); } diff --git a/app/Domains/Channel/DTOs/AppProvisioning/ProvisionConsumerAppDTO.php b/app/Domains/Channel/DTOs/AppProvisioning/ProvisionConsumerAppDTO.php index eb81625..0f06ec2 100644 --- a/app/Domains/Channel/DTOs/AppProvisioning/ProvisionConsumerAppDTO.php +++ b/app/Domains/Channel/DTOs/AppProvisioning/ProvisionConsumerAppDTO.php @@ -7,14 +7,11 @@ use App\Domains\Channel\Enums\Client\AuthType; readonly class ProvisionConsumerAppDTO { - /** - * @param array $credentials - */ public function __construct( public string $name, public string $domain, public string $webhook_url, public AuthType $auth_type, - public AuthCredentials $credentials, + public ?AuthCredentials $credentials, ) {} } diff --git a/app/Domains/Channel/Enums/Api/ApiType.php b/app/Domains/Channel/Enums/Api/ApiType.php index 7febdda..28850ef 100644 --- a/app/Domains/Channel/Enums/Api/ApiType.php +++ b/app/Domains/Channel/Enums/Api/ApiType.php @@ -2,10 +2,10 @@ namespace App\Domains\Channel\Enums\Api; +use App\Domains\Channel\Support\Mapper\ApiMapperRegistry; +use App\Domains\System\Support\Integration\DataPayloadMapper; use App\UI\Enums\Concerns\InteractsWithLabels; use App\UI\Enums\Contracts\HasLabel; -use App\UI\Enums\InputType; -use App\UI\Support\Schemas\InputSchema; enum ApiType: string implements HasLabel { @@ -18,4 +18,9 @@ enum ApiType: string implements HasLabel case ADMISSION_SCHEDULE = 'admission_schedule'; case ACADEMIC_PROGRAM = 'academic_program'; case FEE_TYPE = 'fee_type'; + + public function mapper(): DataPayloadMapper + { + return ApiMapperRegistry::resolve($this); + } } diff --git a/app/Domains/Channel/Enums/Client/AuthType.php b/app/Domains/Channel/Enums/Client/AuthType.php index fdf1eae..7e8347f 100644 --- a/app/Domains/Channel/Enums/Client/AuthType.php +++ b/app/Domains/Channel/Enums/Client/AuthType.php @@ -23,10 +23,10 @@ enum AuthType: string implements HasLabel return match ($this) { self::NONE => [], self::BASIC => [ - 'Authorization' => 'Basic ' . base64_encode($credentials['username'] . ':' . $credentials['password']), + 'Authorization' => 'Basic '.base64_encode($credentials['username'].':'.$credentials['password']), ], self::BEARER => [ - 'Authorization' => 'Bearer ' . $credentials['token'] ?? '', + 'Authorization' => 'Bearer '.$credentials['token'] ?? '', ] }; } diff --git a/app/Domains/Channel/Models/Client.php b/app/Domains/Channel/Models/Client.php index e5dd9c4..64eadb8 100644 --- a/app/Domains/Channel/Models/Client.php +++ b/app/Domains/Channel/Models/Client.php @@ -4,10 +4,10 @@ namespace App\Domains\Channel\Models; use App\Domains\Channel\Enums\Client\AuthType; use App\Domains\Channel\Policies\ClientPolicy; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Channel\ClientFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,7 +17,7 @@ use Illuminate\Database\Eloquent\Model; class Client extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'secret' => 'encrypted', @@ -29,9 +29,4 @@ class Client extends Model { return ClientFactory::new(); } - - public function uniqueIds(): array - { - return ['ulid']; - } } diff --git a/app/Domains/Channel/Support/Mapper/ApiMapperRegistry.php b/app/Domains/Channel/Support/Mapper/ApiMapperRegistry.php new file mode 100644 index 0000000..249e2fb --- /dev/null +++ b/app/Domains/Channel/Support/Mapper/ApiMapperRegistry.php @@ -0,0 +1,39 @@ +> + */ + protected static array $mappers = []; + + /** + * Register mapper with type-safe using enum ApiType + */ + public static function register(ApiType $type, string $mapperClass): void + { + static::$mappers[$type->value] = $mapperClass; + } + + /** + * @param ApiType $type + * @throws InvalidArgumentException + * @return DataPayloadMapper + */ + public static function resolve(ApiType $type): DataPayloadMapper + { + $mapperClass = static::$mappers[$type->value] ?? null; + + if (! $mapperClass || ! class_exists($mapperClass)) { + throw new InvalidArgumentException("No DataMapper registered for ApiType: {$type->value}"); + } + + return app($mapperClass); + } +} diff --git a/app/Domains/Finance/Models/ChartOfAccount.php b/app/Domains/Finance/Models/ChartOfAccount.php index 806b2b2..d259283 100644 --- a/app/Domains/Finance/Models/ChartOfAccount.php +++ b/app/Domains/Finance/Models/ChartOfAccount.php @@ -5,10 +5,10 @@ namespace App\Domains\Finance\Models; use App\Domains\Finance\Enums\AccountClassification; use App\Domains\Finance\Policies\ChartOfAccountPolicy; use App\Domains\System\Enums\LifecycleStatus; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Finance\ChartOfAccountFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -24,18 +24,13 @@ use Illuminate\Database\Eloquent\Model; class ChartOfAccount extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'classification' => AccountClassification::class, 'status' => LifecycleStatus::class, ]; - public function uniqueIds(): array - { - return ['ulid']; - } - protected static function newFactory(): Factory { return ChartOfAccountFactory::new(); diff --git a/app/Domains/Finance/Models/Invoice.php b/app/Domains/Finance/Models/Invoice.php index f4ed1cf..4b37d4a 100644 --- a/app/Domains/Finance/Models/Invoice.php +++ b/app/Domains/Finance/Models/Invoice.php @@ -6,10 +6,10 @@ use App\Domains\Finance\Casts\MoneyCurrency; use App\Domains\Finance\Enums\InvoiceStatus; use App\Domains\Finance\Enums\InvoiceType; use App\Domains\Finance\Policies\InvoicePolicy; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Finance\InvoiceFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -30,7 +30,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Invoice extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'amount' => MoneyCurrency::class, @@ -38,11 +38,6 @@ class Invoice extends Model 'status' => InvoiceStatus::class, ]; - public function uniqueIds(): array - { - return ['ulid']; - } - protected static function newFactory(): Factory { return InvoiceFactory::new(); diff --git a/app/Domains/Finance/Models/Payment.php b/app/Domains/Finance/Models/Payment.php index a6d1237..a9ac3bb 100644 --- a/app/Domains/Finance/Models/Payment.php +++ b/app/Domains/Finance/Models/Payment.php @@ -6,10 +6,10 @@ use App\Domains\Finance\Casts\MoneyCurrency; use App\Domains\Finance\Enums\PaymentDirection; use App\Domains\Finance\Enums\PaymentStatus; use App\Domains\Finance\Policies\PaymentPolicy; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Finance\PaymentFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -28,7 +28,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Payment extends Model { use HasFactory; - use HasUlids; + use HasPublicUlid; protected $casts = [ 'amount' => MoneyCurrency::class, @@ -37,11 +37,6 @@ class Payment extends Model 'status' => PaymentStatus::class, ]; - public function uniqueIds(): array - { - return ['ulid']; - } - protected static function newFactory(): Factory { return PaymentFactory::new(); diff --git a/app/Domains/Identity/Integration/Mappers/UserDataMapper.php b/app/Domains/Identity/Integration/Mappers/UserDataMapper.php index df57154..af2748d 100644 --- a/app/Domains/Identity/Integration/Mappers/UserDataMapper.php +++ b/app/Domains/Identity/Integration/Mappers/UserDataMapper.php @@ -49,10 +49,10 @@ class UserDataMapper implements DataPayloadMapper * * @throws Throwable */ - public function updateOrCreateDomainState(array $payload, ?Model $model = null): void + public function updateOrCreateDomainState(array $payload, ?Model $existingModel): void { /** @var User|null $user */ - $user = $model; + $user = $existingModel ?? $this->getModelClass(); if ($user) { $this->updateUser->execute( @@ -79,4 +79,9 @@ class UserDataMapper implements DataPayloadMapper phoneNumber: $payload['phone_number'] ); } + + public function getModelClass(): string + { + return User::class; + } } diff --git a/app/Domains/Identity/Models/Role.php b/app/Domains/Identity/Models/Role.php index 6427dfa..8b91b7e 100644 --- a/app/Domains/Identity/Models/Role.php +++ b/app/Domains/Identity/Models/Role.php @@ -3,9 +3,9 @@ namespace App\Domains\Identity\Models; use App\Domains\Identity\Policies\RolePolicy; +use App\Domains\System\Traits\Model\HasPublicUlid; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use OwenIt\Auditing\Contracts\Auditable; use Spatie\Permission\Contracts\Role as RoleContract; use Spatie\Permission\Models\Role as SpatieRole; @@ -14,11 +14,5 @@ use Spatie\Permission\Models\Role as SpatieRole; #[UsePolicy(RolePolicy::class)] class Role extends SpatieRole implements Auditable, RoleContract { - use HasUlids; use \OwenIt\Auditing\Auditable; - - public function uniqueIds(): array - { - return ['ulid']; - } } diff --git a/app/Domains/Identity/Models/User.php b/app/Domains/Identity/Models/User.php index 49af524..b3e9155 100644 --- a/app/Domains/Identity/Models/User.php +++ b/app/Domains/Identity/Models/User.php @@ -7,13 +7,13 @@ use App\Domains\Identity\Notifications\ResetPasswordNotification; use App\Domains\Identity\Notifications\VerifyEmailNotification; use App\Domains\Identity\Policies\UserPolicy; use App\Domains\System\Traits\Model\HasFile; +use App\Domains\System\Traits\Model\HasPublicUlid; use Database\Factories\Identity\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\UseFactory; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -31,8 +31,8 @@ class User extends Authenticatable implements Auditable, MustVerifyEmail use HasApiTokens; use HasFactory; use HasFile; + use HasPublicUlid; use HasRoles; - use HasUlids; use Notifiable; use \OwenIt\Auditing\Auditable; @@ -68,11 +68,6 @@ class User extends Authenticatable implements Auditable, MustVerifyEmail $this->notify(new VerifyEmailNotification); } - public function uniqueIds(): array - { - return ['ulid']; - } - public function getRoleNameAttribute() { return $this->roles->first()->name ?? '-'; diff --git a/app/Domains/System/Actions/Integration/RunGenericImportPipeline.php b/app/Domains/System/Actions/Integration/RunGenericImportPipeline.php index 7081a06..6fb156a 100644 --- a/app/Domains/System/Actions/Integration/RunGenericImportPipeline.php +++ b/app/Domains/System/Actions/Integration/RunGenericImportPipeline.php @@ -3,7 +3,7 @@ namespace App\Domains\System\Actions\Integration; use App\Domains\System\Support\Integration\DataPayloadMapper; -use Illuminate\Database\Eloquent\Model; +use Exception; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -12,48 +12,109 @@ use Throwable; class RunGenericImportPipeline { /** - * @param Collection> $rows Raw spreadsheet rows from the current chunk - * @param DataPayloadMapper $mapper The specific structural mapping engine - * @param class-string $modelClass The Eloquent model class to query against + * Execute the generic import pipeline by transforming rows, fetching existing state, + * and delegating the persistence operation to the mapper. + * + * @param Collection> $rows Raw payload rows from the API or spreadsheet chunk + * @param DataPayloadMapper $mapper The domain-specific structural mapping engine + * @throws Exception */ - public function execute(Collection $rows, DataPayloadMapper $mapper, string $modelClass): void + public function execute(Collection $rows, DataPayloadMapper $mapper): void { $lookupKey = $mapper->getLookupKey(); + $modelClass = $mapper->getModelClass(); - $lookupValues = $rows->pluck($lookupKey) - ->filter() - ->map(fn ($val) => trim((string) $val)) - ->toArray(); + // 1. Transform raw payload rows into domain-aligned internal data structures + $mappedRows = $rows->map(fn ($row) => $mapper->transform($row)); - $existingRecords = $modelClass::query() - ->whereIn(column: $lookupKey, values: $lookupValues) - ->get() - ->keyBy($lookupKey); + // 2. Resolve existing domain records from the database + if (is_string($lookupKey)) { + // Single key lookup strategy + $lookupValues = $mappedRows->pluck($lookupKey) + ->filter(fn ($val) => ! is_null($val) && $val !== '') + ->map(fn ($val) => trim((string) $val)) + ->toArray(); - foreach ($rows->toArray() as $row) { - if (empty($row[$lookupKey])) { + $existingRecords = $modelClass::query() + ->whereIn(column: $lookupKey, values: $lookupValues) + ->get() + ->keyBy($lookupKey); + + } else { + // Composite key lookup strategy (Array of keys) + $query = $modelClass::query(); + + // Construct composite OR-WHERE query conditions using mapped internal keys + $query->where(function ($q) use ($mappedRows, $lookupKey) { + foreach ($mappedRows as $mappedRow) { + $q->orWhere(function ($subQ) use ($mappedRow, $lookupKey) { + foreach ($lookupKey as $col) { + $subQ->where($col, $mappedRow[$col] ?? null); + } + }); + } + }); + + // Map retrieved database models into an in-memory composite hash index + $existingRecords = $query->get()->keyBy(function ($item) use ($lookupKey) { + return $this->generateCompositeIdentifier($item->toArray(), $lookupKey); + }); + } + + // 3. Process each row and delegate execution to the domain state action + foreach ($mappedRows as $row) { + // Guard clause: Ensure all required lookup key attributes are populated + if ($this->isInvalidRow($row, $lookupKey)) { continue; } - try { - DB::transaction(function () use ($row, $lookupKey, $mapper, $existingRecords) { - $lookupValue = trim((string) $row[$lookupKey]); - - // Match against our pre-fetched in-memory cache - $matchedModel = $existingRecords->get($lookupValue); + // Derive the string identifier used to match existing records in memory + $lookupIdentifier = is_array($lookupKey) + ? $this->generateCompositeIdentifier($row, $lookupKey) + : trim((string) $row[$lookupKey]); - // Normalize data columns via the mapper specification - $normalizedPayload = $mapper->transform($row); + try { + DB::transaction(function () use ($row, $lookupIdentifier, $mapper, $existingRecords) { + $existingModel = $existingRecords->get($lookupIdentifier); - // Delegate execution to the underlying actions - $mapper->updateOrCreateDomainState(payload: $normalizedPayload, model: $matchedModel); + // Delegate execution to the underlying domain action + $mapper->updateOrCreateDomainState(payload: $row, existingModel: $existingModel); }); } catch (Throwable $e) { Log::error('Generic Import failure on Row processing: '.$e->getMessage(), [ 'row' => $row, - 'lookupKey' => $lookupKey, + 'lookupIdentifier' => $lookupIdentifier, ]); + + throw new Exception('Failed to process row: '.$e->getMessage()); + } + } + } + + /** + * Generate a deterministic string hash key for composite lookup matching. + */ + protected function generateCompositeIdentifier(array $data, array $keys): string + { + $values = array_map(fn ($k) => trim((string) ($data[$k] ?? '')), $keys); + + return implode('_', $values); + } + + /** + * Universal guard clause to validate lookup key integrity for single and composite keys. + */ + protected function isInvalidRow(array $row, string|array $lookupKey): bool + { + $keys = (array) $lookupKey; + + foreach ($keys as $key) { + $val = $row[$key] ?? null; + if (is_null($val) || $val === '') { + return true; // Skip row if any required lookup key attribute is missing } } + + return false; } } diff --git a/app/Domains/System/Support/Integration/DataPayloadMapper.php b/app/Domains/System/Support/Integration/DataPayloadMapper.php index d49fc2a..b29c829 100644 --- a/app/Domains/System/Support/Integration/DataPayloadMapper.php +++ b/app/Domains/System/Support/Integration/DataPayloadMapper.php @@ -9,15 +9,22 @@ interface DataPayloadMapper /** * Define the unique column used to locate records across entry streams. */ - public function getLookupKey(): string; + public function getLookupKey(): array|string; /** * Normalize raw incoming data structures into an internal domain-safe layout array */ public function transform(array $rawData): array; + /** + * Define model for transformation + * + * @return class-string + */ + public function getModelClass(): string; + /** * Coordinate and execute internal domain mutations using the transformed payload. */ - public function updateOrCreateDomainState(array $payload, ?Model $model = null): void; + public function updateOrCreateDomainState(array $payload, ?Model $existingModel): void; } diff --git a/app/Domains/System/Traits/Model/HasPublicUlid.php b/app/Domains/System/Traits/Model/HasPublicUlid.php new file mode 100644 index 0000000..f573b43 --- /dev/null +++ b/app/Domains/System/Traits/Model/HasPublicUlid.php @@ -0,0 +1,20 @@ +ulid = Str::ulid(); + }); + } + + public function getRouteKeyName(): string + { + return 'ulid'; + } +} diff --git a/app/Domains/System/Traits/Model/HasSlugs.php b/app/Domains/System/Traits/Model/HasSlugs.php index eb872a2..a690c19 100644 --- a/app/Domains/System/Traits/Model/HasSlugs.php +++ b/app/Domains/System/Traits/Model/HasSlugs.php @@ -14,7 +14,8 @@ trait HasSlugs public static function bootHasSlugs(): void { static::creating(function (Model $model) { - $model->slug = str($model->sluggable())->slug(); + $sluggable = $model->sluggable(); + $model->slug = str($model->$sluggable)->slug(); }); } } diff --git a/app/Http/DataTables/Academic/TermDataTable.php b/app/Http/DataTables/Academic/TermDataTable.php index 48bf008..8254432 100644 --- a/app/Http/DataTables/Academic/TermDataTable.php +++ b/app/Http/DataTables/Academic/TermDataTable.php @@ -62,7 +62,7 @@ class TermDataTable extends DataTable ->setTableId('term-table') ->columns($this->getColumns()) ->minifiedAjax() - ->orderBy(-1) + ->orderBy(1) ->layout([ 'topStart' => [ 'rowClass' => 'row gap-1', diff --git a/app/Http/Ingestion/Excel/Identity/UserImport.php b/app/Http/Ingestion/Excel/Identity/UserImport.php index caa6f08..3a2bd0d 100644 --- a/app/Http/Ingestion/Excel/Identity/UserImport.php +++ b/app/Http/Ingestion/Excel/Identity/UserImport.php @@ -3,7 +3,6 @@ namespace App\Http\Ingestion\Excel\Identity; use App\Domains\Identity\Integration\Mappers\UserDataMapper; -use App\Domains\Identity\Models\User; use App\Domains\System\Actions\Integration\RunGenericImportPipeline; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Collection; @@ -21,8 +20,7 @@ class UserImport implements ShouldQueue, ToCollection, WithChunkReading, WithHea // Excel specific processor reads a file chunk, then uses the generic integration pipeline app(RunGenericImportPipeline::class)->execute( rows: $rows, - mapper: app(UserDataMapper::class), - modelClass: User::class, + mapper: app(UserDataMapper::class) ); } diff --git a/database/migrations/2026_04_23_045248_create_permission_tables.php b/database/migrations/2026_04_23_045248_create_permission_tables.php index 8a65ddc..8986275 100644 --- a/database/migrations/2026_04_23_045248_create_permission_tables.php +++ b/database/migrations/2026_04_23_045248_create_permission_tables.php @@ -37,7 +37,6 @@ return new class extends Migration */ Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { $table->id(); // role id - $table->ulid()->unique(); 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'); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0435e41..534d80c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -7,8 +7,6 @@ use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - use WithoutModelEvents; - /** * Seed the application's database. */ diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 7a7c335..9eeaeb7 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -5,7 +5,6 @@ namespace Database\Seeders; use App\Domains\Identity\Enums\RoleType; use App\Domains\Identity\Models\Role; use App\Domains\Identity\Models\User; -use App\Domains\Identity\Scopes\HideSystemAdminRole; use Database\Factories\Account\ProfileFactory; use Illuminate\Database\Seeder; @@ -24,8 +23,7 @@ class UserSeeder extends Seeder 'email_verified_at' => now(), ]); - $systemRoles = Role::withoutGlobalScopes([HideSystemAdminRole::class]) - ->where('name', RoleType::SYSTEM_ADMIN) + $systemRoles = Role::where('name', RoleType::SYSTEM_ADMIN) ->first(); $admin->profile()->create($profile->definition()); diff --git a/lang/en/domains/channel/messages.php b/lang/en/domains/channel/messages.php new file mode 100644 index 0000000..8f63b2e --- /dev/null +++ b/lang/en/domains/channel/messages.php @@ -0,0 +1,8 @@ + [ + 'synced' => 'Clients synced successfully.', + 'fail_to_sync' => 'Clients failed to sync: :error.' + ] +]; diff --git a/resources/js/plugin/sweetalert2.js b/resources/js/plugin/sweetalert2.js index 3550da4..465ae3b 100644 --- a/resources/js/plugin/sweetalert2.js +++ b/resources/js/plugin/sweetalert2.js @@ -10,11 +10,20 @@ window.toast = function (text, type = 'success') { icon: type, text: text, toast: true, - position: 'top-end', + position: "top-end", showConfirmButton: false, timer: 3000, timerProgressBar: true, - }) + didOpen: () => { + const popup = Swal.getPopup(); + + // Pause timer when mouse enters + popup.addEventListener("mouseenter", Swal.stopTimer); + + // Resume timer when mouse leaves + popup.addEventListener("mouseleave", Swal.resumeTimer); + }, + }); } diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php index 69fe771..cbda248 100644 --- a/resources/views/components/modal.blade.php +++ b/resources/views/components/modal.blade.php @@ -37,7 +37,7 @@ x-on:hide-{{ $id }}.window="$bs.modal.instance($el).hide();" @endif > -