DONE: use full name for date in letter, include password in email after membership is done, block ic_number to exclude - (#9)
Build Docker Image / build-backend (push) Successful in 44s
Build Docker Image / build-frontend (push) Successful in 18s

Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-07-08 12:15:41 +08:00
parent 18c0d268b1
commit ebd3caecc6
23 changed files with 352 additions and 41 deletions
+2
View File
@@ -54,6 +54,7 @@ class User extends Authenticatable
'member_type',
'public_profile_token',
'join_date',
'leave_date',
'birth_date',
'birth_place',
'onboarding_completed_at',
@@ -136,6 +137,7 @@ class User extends Authenticatable
'member_type' => 'string',
'public_profile_token_expires_at' => 'datetime',
'join_date' => 'date',
'leave_date' => 'date',
'birth_date' => 'date',
'birth_place' => 'string',
'onboarding_completed_at' => 'datetime',
@@ -38,6 +38,7 @@ class UserResource extends JsonResource
'member_type' => $this->member_type,
'public_profile_token' => $this->public_profile_token,
'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place,
'onboarding_completed_at' => $this->onboarding_completed_at,
@@ -19,6 +19,7 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
public function __construct(
public MembershipApplication $application,
public Document $resultLetter,
public ?string $plainPassword = null,
) {}
/**
@@ -40,6 +41,8 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
data: [
'application' => $this->application,
'applicant' => $applicant,
'plainPassword' => $this->plainPassword,
'loginUrl' => rtrim(config('user.frontend_url'), '/'),
],
)->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
@@ -9,6 +9,15 @@ Permohonan keahlian anda (**{{ $application->application_number }}**) **dilulusk
Surat keputusan rasmi dilampirkan dalam e-mel ini.
@if ($plainPassword)
Akaun portal anda telah didaftarkan. Maklumat log masuk adalah seperti berikut:
- **E-mel:** {{ $applicant->email }}
- **Kata laluan:** {{ $plainPassword }}
Sila log masuk di [{{ $loginUrl }}]({{ $loginUrl }}) dan tukar kata laluan anda selepas log masuk kali pertama.
@endif
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
@include('emails.partials.footer')
@@ -274,15 +274,18 @@ class MembershipApplicationService
}
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$plainPassword = null;
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]);
$member = $this->createMemberFromApplication($application);
$application->update(['user_id' => $member['user']->id]);
$plainPassword = $member['plainPassword'];
$application->refresh();
}
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
$this->sendResultNotification($application, $resultLetter);
$this->sendResultNotification($application, $resultLetter, $plainPassword);
$application->update([
'status' => ApplicationStatus::Completed,
@@ -438,7 +441,7 @@ class MembershipApplicationService
'application' => $application,
'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference,
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d M Y'),
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d F Y'),
'isPassed' => $isPassed,
'letterSubject' => $isPassed
? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
@@ -447,7 +450,10 @@ class MembershipApplicationService
];
}
protected function createMemberFromApplication(MembershipApplication $application): User
/**
* @return array{user: User, plainPassword: string}
*/
protected function createMemberFromApplication(MembershipApplication $application): array
{
$application->loadMissing(['applicant', 'heirs']);
$applicant = $application->applicant;
@@ -458,10 +464,12 @@ class MembershipApplicationService
]);
}
$plainPassword = Str::password(16);
$user = User::create([
'name' => $applicant->name,
'email' => $applicant->email,
'password' => Hash::make(Str::password(16)),
'password' => Hash::make($plainPassword),
'ic_number' => $applicant->ic_number,
'phone_number' => $applicant->phone_number,
'position' => $applicant->current_position,
@@ -507,7 +515,10 @@ class MembershipApplicationService
app(EmailVerificationOtpService::class)->send($user);
return $user;
return [
'user' => $user,
'plainPassword' => $plainPassword,
];
}
public function lookupMemberByIcNumber(string $icNumber): ?User
@@ -532,12 +543,15 @@ class MembershipApplicationService
->notify(new MembershipApplicationSubmittedNotification($application));
}
protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void
{
protected function sendResultNotification(
MembershipApplication $application,
Document $resultLetter,
?string $plainPassword = null,
): void {
$application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application, $resultLetter)
? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword)
: new MembershipApplicationFailedNotification($application, $resultLetter);
Notification::route('mail', $application->applicant->email)->notify($notification);
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->date('leave_date')->nullable()->after('join_date');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('leave_date');
});
}
};
@@ -44,7 +44,7 @@ class UserController extends BaseCrudController
$validated['uuid'] = Str::uuid();
$validated['password'] = Hash::make('suteraselamanya');
return $validated;
return $this->applyLeaveDateForStatus($validated);
}
/**
@@ -58,6 +58,25 @@ class UserController extends BaseCrudController
unset($validated['email']);
}
return $this->applyLeaveDateForStatus($validated, $user);
}
/**
* Set or clear leave_date based on membership status transitions.
*/
protected function applyLeaveDateForStatus(array $validated, ?User $user = null): array
{
$newStatus = $validated['status'] ?? $user?->status;
$currentStatus = $user?->status;
if ($newStatus === 'inactive') {
if ($currentStatus !== 'inactive') {
$validated['leave_date'] = $validated['leave_date'] ?? now()->toDateString();
}
} elseif (in_array($newStatus, ['active', 'pending'], true)) {
$validated['leave_date'] = null;
}
return $validated;
}
@@ -171,13 +190,27 @@ class UserController extends BaseCrudController
$this->authorize('viewAny', $this->modelClass);
try {
$request->validate([
'join_date_from' => 'nullable|date',
'join_date_to' => 'nullable|date',
'leave_date_from' => 'nullable|date',
'leave_date_to' => 'nullable|date',
]);
$perPage = min((int) $request->get('per_page', 10), 500);
$dateFilters = array_filter([
'join_date_from' => $request->get('join_date_from'),
'join_date_to' => $request->get('join_date_to'),
'leave_date_from' => $request->get('leave_date_from'),
'leave_date_to' => $request->get('leave_date_to'),
]);
$items = $this->userService->getPaginatedList(
$perPage,
$request->get('search', ''),
$request->get('status', ''),
$request->get('sort_by', 'id'),
$request->get('sort_order', 'asc')
$request->get('sort_order', 'asc'),
$dateFilters
);
return response()->json([
@@ -44,6 +44,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer',
'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255',
];
@@ -74,6 +75,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer',
'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255',
];
@@ -100,6 +102,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer',
'member_type' => 'required|string|max:255',
'join_date' => 'required|date',
'leave_date' => 'nullable|date',
'birth_date' => 'required|date',
'birth_place' => 'required|string|max:255',
];
@@ -136,6 +139,7 @@ class UserRequest extends FormRequest
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
'join_date.required' => 'Tarikh join diperlukan.',
'join_date.date' => 'Tarikh join tidak sah.',
'leave_date.date' => 'Tarikh berhenti tidak sah.',
'birth_date.required' => 'Tarikh lahir diperlukan.',
'birth_date.date' => 'Tarikh lahir tidak sah.',
'birth_place.required' => 'Tempat lahir diperlukan.',
@@ -20,7 +20,8 @@ interface UserRepositoryInterface
string $search = '',
string $status = '',
string $sortBy = 'name',
string $sortOrder = 'asc'
string $sortOrder = 'asc',
array $dateFilters = []
);
/**
@@ -26,6 +26,25 @@ class UserRepository implements UserRepositoryInterface
});
}
private function applyDateRangeFilters($query, array $dateFilters): void
{
if (! empty($dateFilters['join_date_from'])) {
$query->whereDate('join_date', '>=', $dateFilters['join_date_from']);
}
if (! empty($dateFilters['join_date_to'])) {
$query->whereDate('join_date', '<=', $dateFilters['join_date_to']);
}
if (! empty($dateFilters['leave_date_from'])) {
$query->whereDate('leave_date', '>=', $dateFilters['leave_date_from']);
}
if (! empty($dateFilters['leave_date_to'])) {
$query->whereDate('leave_date', '<=', $dateFilters['leave_date_to']);
}
}
/**
* Get all Users with pagination and search
*/
@@ -46,7 +65,8 @@ class UserRepository implements UserRepositoryInterface
string $search = '',
string $status = '',
string $sortBy = 'name',
string $sortOrder = 'asc'
string $sortOrder = 'asc',
array $dateFilters = []
) {
$allowedSortColumns = [
'id',
@@ -55,6 +75,8 @@ class UserRepository implements UserRepositoryInterface
'position',
'status',
'member_number',
'join_date',
'leave_date',
'created_at',
'deleted_at',
];
@@ -73,6 +95,8 @@ class UserRepository implements UserRepositoryInterface
$query->where('status', $status);
}
$this->applyDateRangeFilters($query, $dateFilters);
return $query->paginate($perPage);
}
@@ -93,6 +117,8 @@ class UserRepository implements UserRepositoryInterface
'position',
'status',
'member_number',
'join_date',
'leave_date',
'created_at',
'deleted_at',
];
+4 -2
View File
@@ -21,14 +21,16 @@ class UserService
string $search,
string $status,
string $sortBy,
string $sortOrder
string $sortOrder,
array $dateFilters = []
): LengthAwarePaginator {
return $this->repository->getAllWithRelationsPaginated(
$perPage,
$search,
$status,
$sortBy,
$sortOrder
$sortOrder,
$dateFilters
);
}
@@ -25,6 +25,7 @@ class UserListResource extends JsonResource
'member_number' => $this->member_number,
'member_type' => $this->member_type,
'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place,
'created_at' => $this->created_at,