Browse Source

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.
master
Syarif Ubaidillah 2 weeks ago
parent
commit
a496679cb1
  1. 1
      app/Domains/Academic/Actions/Curriculum/ModifyFacultyDetails.php
  2. 2
      app/Domains/Academic/DTOs/Curriculum/RegisterStudyProgramDTO.php
  3. 64
      app/Domains/Academic/Integration/Mappers/FacultyDataMapper.php
  4. 82
      app/Domains/Academic/Integration/Mappers/StudyProgramDataMapper.php
  5. 72
      app/Domains/Academic/Integration/Mappers/TermDataMapper.php
  6. 9
      app/Domains/Academic/Models/Faculty.php
  7. 9
      app/Domains/Academic/Models/StudyProgram.php
  8. 9
      app/Domains/Academic/Models/Term.php
  9. 18
      app/Domains/Academic/Providers/AcademicServiceProvider.php
  10. 1
      app/Domains/Admission/Actions/IntakeSourcing/InitializeAdmissionTrack.php
  11. 1
      app/Domains/Admission/DTOs/IntakeSourcing/InitializeAdmissionTrackDTO.php
  12. 9
      app/Domains/Admission/Enums/BillingCycle.php
  13. 34
      app/Domains/Admission/Integration/Mappers/AcademicProgramDataMapper.php
  14. 78
      app/Domains/Admission/Integration/Mappers/AdmissionScheduleDataMapper.php
  15. 31
      app/Domains/Admission/Integration/Mappers/AdmissionTrackDataMapper.php
  16. 32
      app/Domains/Admission/Integration/Mappers/FeeTypeDataMapper.php
  17. 9
      app/Domains/Admission/Models/AcademicProgram.php
  18. 9
      app/Domains/Admission/Models/AdmissionSchedule.php
  19. 11
      app/Domains/Admission/Models/AdmissionTrack.php
  20. 9
      app/Domains/Admission/Models/FeeRate.php
  21. 9
      app/Domains/Admission/Models/FeeType.php
  22. 23
      app/Domains/Admission/Providers/AdmissionServiceProvider.php
  23. 24
      app/Domains/Channel/Actions/Api/SynchronizeWithApi.php
  24. 2
      app/Domains/Channel/Actions/AppProvisioning/ProvisionConsumerApp.php
  25. 5
      app/Domains/Channel/DTOs/AppProvisioning/ProvisionConsumerAppDTO.php
  26. 9
      app/Domains/Channel/Enums/Api/ApiType.php
  27. 4
      app/Domains/Channel/Enums/Client/AuthType.php
  28. 9
      app/Domains/Channel/Models/Client.php
  29. 39
      app/Domains/Channel/Support/Mapper/ApiMapperRegistry.php
  30. 9
      app/Domains/Finance/Models/ChartOfAccount.php
  31. 9
      app/Domains/Finance/Models/Invoice.php
  32. 9
      app/Domains/Finance/Models/Payment.php
  33. 9
      app/Domains/Identity/Integration/Mappers/UserDataMapper.php
  34. 8
      app/Domains/Identity/Models/Role.php
  35. 9
      app/Domains/Identity/Models/User.php
  36. 101
      app/Domains/System/Actions/Integration/RunGenericImportPipeline.php
  37. 11
      app/Domains/System/Support/Integration/DataPayloadMapper.php
  38. 20
      app/Domains/System/Traits/Model/HasPublicUlid.php
  39. 3
      app/Domains/System/Traits/Model/HasSlugs.php
  40. 2
      app/Http/DataTables/Academic/TermDataTable.php
  41. 4
      app/Http/Ingestion/Excel/Identity/UserImport.php
  42. 1
      database/migrations/2026_04_23_045248_create_permission_tables.php
  43. 2
      database/seeders/DatabaseSeeder.php
  44. 4
      database/seeders/UserSeeder.php
  45. 8
      lang/en/domains/channel/messages.php
  46. 13
      resources/js/plugin/sweetalert2.js
  47. 2
      resources/views/components/modal.blade.php
  48. 6
      resources/views/pages/channel/client/⚡api-form-modal/api-form-modal.blade.php
  49. 16
      resources/views/pages/channel/client/⚡api-form-modal/api-form-modal.php
  50. 6
      resources/views/pages/channel/client/⚡form-modal/form-modal.blade.php

1
app/Domains/Academic/Actions/Curriculum/ModifyFacultyDetails.php

@ -12,6 +12,7 @@ class ModifyFacultyDetails
$faculty->update([
'name' => $dto->name,
'code' => $dto->code,
'status',
]);
}
}

2
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,
) {}
}

64
app/Domains/Academic/Integration/Mappers/FacultyDataMapper.php

@ -0,0 +1,64 @@
<?php
namespace App\Domains\Academic\Integration\Mappers;
use App\Domains\Academic\Actions\Curriculum\EstablishFaculty;
use App\Domains\Academic\Actions\Curriculum\ModifyFacultyDetails;
use App\Domains\Academic\DTOs\Curriculum\EstablishFacultyDTO;
use App\Domains\Academic\DTOs\Curriculum\ModifyFacultyDetailsDTO;
use App\Domains\Academic\Models\Faculty;
use App\Domains\System\Support\Integration\DataPayloadMapper;
use Illuminate\Database\Eloquent\Model;
class FacultyDataMapper implements DataPayloadMapper
{
public function __construct(
protected EstablishFaculty $establishFaculty,
protected ModifyFacultyDetails $modifyFacultyDetails,
)
{
// Inject required Domain and Cross-Domain Actions via constructor composition
}
public function getLookupKey(): string
{
// Return the unique string key used to identify existing records (e.g., 'email')
return 'external_id';
}
public function transform(array $rawData): array
{
// Normalize incoming data array formats into an internal domain-safe layout
return [
'name' => $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;
}
}

82
app/Domains/Academic/Integration/Mappers/StudyProgramDataMapper.php

@ -0,0 +1,82 @@
<?php
namespace App\Domains\Academic\Integration\Mappers;
use App\Domains\Academic\Actions\Curriculum\AdjustStudyProgramDetails;
use App\Domains\Academic\Actions\Curriculum\RegisterStudyProgram;
use App\Domains\Academic\DTOs\Curriculum\AdjustStudyProgramDTO;
use App\Domains\Academic\DTOs\Curriculum\RegisterStudyProgramDTO;
use App\Domains\Academic\Models\Faculty;
use App\Domains\Academic\Models\StudyProgram;
use App\Domains\System\Enums\LifecycleStatus;
use App\Domains\System\Support\Integration\DataPayloadMapper;
use Illuminate\Database\Eloquent\Model;
class StudyProgramDataMapper implements DataPayloadMapper
{
private static array $facultyCache = [];
public function __construct(
protected RegisterStudyProgram $registerStudyProgram,
protected AdjustStudyProgramDetails $adjustStudyProgramDetails,
)
{
// Inject required Domain and Cross-Domain Actions via constructor composition
}
public function getLookupKey(): string
{
// Return the unique string key used to identify existing records (e.g., 'email')
return 'external_id';
}
public function transform(array $rawData): array
{
// Normalize incoming data array formats into an internal domain-safe layout
return [
'name' => $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];
}
}

72
app/Domains/Academic/Integration/Mappers/TermDataMapper.php

@ -0,0 +1,72 @@
<?php
namespace App\Domains\Academic\Integration\Mappers;
use App\Domains\Academic\Actions\Calendar\InitializeAcademicTerm;
use App\Domains\Academic\Actions\Calendar\ModifyTermDate;
use App\Domains\Academic\DTOs\Calendar\InitializeAcademicTermDTO;
use App\Domains\Academic\DTOs\Calendar\ModifyTermDateDTO;
use App\Domains\Academic\Models\Term;
use App\Domains\System\Support\Integration\DataPayloadMapper;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
class TermDataMapper implements DataPayloadMapper
{
public function __construct(
protected InitializeAcademicTerm $initializeAcademicTerm,
protected ModifyTermDate $modifyTermDate,
)
{
// Inject required Domain and Cross-Domain Actions via constructor composition
}
public function getLookupKey(): string
{
// Return the unique string key used to identify existing records (e.g., 'email')
return 'code';
}
public function transform(array $rawData): array
{
// Normalize incoming data array formats into an internal domain-safe layout
return [
'name' => $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;
}
}

9
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'];
}
}

9
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);

9
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'];
}
}

18
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);
}

1
app/Domains/Admission/Actions/IntakeSourcing/InitializeAdmissionTrack.php

@ -11,6 +11,7 @@ class InitializeAdmissionTrack
{
AdmissionTrack::create([
'name' => $dto->name,
'slug' => $dto->slug,
'status' => $dto->status,
]);
}

1
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,
) {}
}

9
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,
};
}
}

34
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'],
];
}
/**
* @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;
}
}

78
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];
}
}

31
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;
}
}

32
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;
}
}

9
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';

9
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();

11
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();

9
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();

9
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();

23
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);
}

24
app/Domains/Channel/Actions/Api/SynchronizeWithApi.php

@ -4,12 +4,22 @@ 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
{
try {
$mapper = ApiMapperRegistry::resolve($apiType);
$endpoint = ApiEndpoint::query()
->with('client')
->where('key', $apiType)
@ -19,10 +29,20 @@ class SynchronizeWithApi
$url = $endpoint->value;
$credentials = $endpoint->client->credentials;
$authType = $endpoint->client->auth_type;
$url = "https://$domain$url";
$url = "$domain$url";
$header = $authType->buildCredentials($credentials);
$response = Http::withHeaders($header)->get($url);
dd($response);
$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()]));
}
}
}

2
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()),
]);
}

5
app/Domains/Channel/DTOs/AppProvisioning/ProvisionConsumerAppDTO.php

@ -7,14 +7,11 @@ use App\Domains\Channel\Enums\Client\AuthType;
readonly class ProvisionConsumerAppDTO
{
/**
* @param array<string, string> $credentials
*/
public function __construct(
public string $name,
public string $domain,
public string $webhook_url,
public AuthType $auth_type,
public AuthCredentials $credentials,
public ?AuthCredentials $credentials,
) {}
}

9
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);
}
}

4
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'] ?? '',
]
};
}

9
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'];
}
}

39
app/Domains/Channel/Support/Mapper/ApiMapperRegistry.php

@ -0,0 +1,39 @@
<?php
namespace App\Domains\Channel\Support\Mapper;
use App\Domains\Channel\Enums\Api\ApiType;
use App\Domains\System\Support\Integration\DataPayloadMapper;
use InvalidArgumentException;
class ApiMapperRegistry
{
/**
* @var array<string, class-string<DataPayloadMapper>>
*/
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);
}
}

9
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();

9
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();

9
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();

9
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;
}
}

8
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'];
}
}

9
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 ?? '-';

101
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,16 +12,26 @@ use Throwable;
class RunGenericImportPipeline
{
/**
* @param Collection<int, array<string, mixed>> $rows Raw spreadsheet rows from the current chunk
* @param DataPayloadMapper $mapper The specific structural mapping engine
* @param class-string<Model> $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<int, array<string, mixed>> $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()
// 1. Transform raw payload rows into domain-aligned internal data structures
$mappedRows = $rows->map(fn ($row) => $mapper->transform($row));
// 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();
@ -30,30 +40,81 @@ class RunGenericImportPipeline
->get()
->keyBy($lookupKey);
foreach ($rows->toArray() as $row) {
if (empty($row[$lookupKey])) {
continue;
} 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);
}
});
}
});
try {
DB::transaction(function () use ($row, $lookupKey, $mapper, $existingRecords) {
$lookupValue = trim((string) $row[$lookupKey]);
// 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);
});
}
// Match against our pre-fetched in-memory cache
$matchedModel = $existingRecords->get($lookupValue);
// 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;
}
// Normalize data columns via the mapper specification
$normalizedPayload = $mapper->transform($row);
// Derive the string identifier used to match existing records in memory
$lookupIdentifier = is_array($lookupKey)
? $this->generateCompositeIdentifier($row, $lookupKey)
: trim((string) $row[$lookupKey]);
// Delegate execution to the underlying actions
$mapper->updateOrCreateDomainState(payload: $normalizedPayload, model: $matchedModel);
try {
DB::transaction(function () use ($row, $lookupIdentifier, $mapper, $existingRecords) {
$existingModel = $existingRecords->get($lookupIdentifier);
// 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;
}
}

11
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;
}

20
app/Domains/System/Traits/Model/HasPublicUlid.php

@ -0,0 +1,20 @@
<?php
namespace App\Domains\System\Traits\Model;
use Illuminate\Support\Str;
trait HasPublicUlid
{
public static function bootHasPublicUlid(): void
{
static::creating(function ($model) {
$model->ulid = Str::ulid();
});
}
public function getRouteKeyName(): string
{
return 'ulid';
}
}

3
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();
});
}
}

2
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',

4
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)
);
}

1
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');

2
database/seeders/DatabaseSeeder.php

@ -7,8 +7,6 @@ use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/

4
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());

8
lang/en/domains/channel/messages.php

@ -0,0 +1,8 @@
<?php
return [
'clients' => [
'synced' => 'Clients synced successfully.',
'fail_to_sync' => 'Clients failed to sync: :error.'
]
];

13
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);
},
});
}

2
resources/views/components/modal.blade.php

@ -37,7 +37,7 @@
x-on:hide-{{ $id }}.window="$bs.modal.instance($el).hide();"
@endif
>
<div class="modal-dialog modal-dialog-scrollable modal-fullscreen-md-down {{ $size }}">
<div class="modal-dialog modal-dialog-scrollable modal-fullscreen-md-down {{ $size }}" role="document">
@if ($form)
<form
@if ($attributes->has('x-on:submit')) x-on:submit="{{ $attributes->get('x-on:submit') }}" @endif

6
resources/views/pages/channel/client/⚡api-form-modal/api-form-modal.blade.php

@ -1,7 +1,8 @@
<x-modal id="api-form-modal" :title="$this->title" size="modal-lg" wire:submit="save" wire:loading form livewire>
<x-modal id="api-form-modal" :title="$this->title" size="modal-fullscreen" wire:submit="save" wire:loading form livewire>
<div class="d-flex flex-column gap-3">
<div class="row g-3">
@foreach($this->endpoints as $key => $endpoint)
<div class="col-sm-12 col-md-4">
<x-card :subtitle="$endpoint['label']">
<x-slot:actions>
<x-button type="button" theme="danger" wire:click="runSynchronization('{{ $key }}')" icon="tabler-player-play" />
@ -28,6 +29,7 @@
</div>
</div>
</x-card>
</div>
@endforeach
</div>

16
resources/views/pages/channel/client/⚡api-form-modal/api-form-modal.php

@ -8,6 +8,7 @@ use App\Domains\Channel\Models\ApiEndpoint;
use App\Domains\Channel\Models\Client;
use App\Livewire\Concerns\WithModal;
use App\Livewire\Concerns\WithToast;
use http\Exception;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
@ -92,13 +93,14 @@ new class extends Component
$this->validate();
$clients = $this->clients->pluck('id', 'ulid');
$endpointToSave = array_map(function ($item) use ($clients) {
return new EndpointUrlDTO(
$endpointToSave = [];
foreach ($this->field as $key => $item) {
$endpointToSave[$key] = new EndpointUrlDTO(
clientId: $clients[$item['client_id']],
endpointUrl: $item['value'],
field: ApiType::from($item['type'])
field: ApiType::tryFrom($item['type'])
);
}, $this->field);
}
$assign->execute($endpointToSave);
@ -109,6 +111,12 @@ new class extends Component
public function runSynchronization(ApiType $type, SynchronizeWithApi $api): void
{
try {
$api->execute($type);
$this->success(__('domains/channel/messages.clients.synced'));
} catch (\Exception $e) {
$this->error($e->getMessage());
}
}
};

6
resources/views/pages/channel/client/⚡form-modal/form-modal.blade.php

@ -40,11 +40,11 @@
wire:model="form.credentials.password"
:label="__('domains/channel/field.client.credentials.password')"
/>
@elseif ($form->auth_type == AuthType::TOKEN)
@elseif ($form->auth_type == AuthType::BEARER)
<x-form.input
name="form.credentials.token"
name="form.credentials.bearer"
wire:model="form.credentials.token"
:label="__('domains/channel/field.client.credentials.token')"
:label="__('domains/channel/field.client.credentials.bearer')"
/>
@endif
</div>

Loading…
Cancel
Save