diff --git a/.env.development b/.env.development index b5c5094..74b36c6 100644 --- a/.env.development +++ b/.env.development @@ -95,4 +95,7 @@ API_ALLOWED_USER_AGENTS= ENABLE_API_KEY_AUTH=true API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae -API_LOG_KEY_USAGE=true \ No newline at end of file +API_LOG_KEY_USAGE=true + +PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 +FRONTEND_URL=https://mykopkb.koppkb.com \ No newline at end of file diff --git a/.env.production b/.env.production index 8cfae54..80a9c56 100644 --- a/.env.production +++ b/.env.production @@ -102,4 +102,7 @@ API_ALLOWED_USER_AGENTS= ENABLE_API_KEY_AUTH=true API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae -API_LOG_KEY_USAGE=true \ No newline at end of file +API_LOG_KEY_USAGE=true + +PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 +FRONTEND_URL=https://mykopkb.koppkb.com \ No newline at end of file diff --git a/Modules/ActivityLog/Http/Controllers/ActivityLogController.php b/Modules/ActivityLog/Http/Controllers/ActivityLogController.php index 0a1e8d7..fdbbe69 100644 --- a/Modules/ActivityLog/Http/Controllers/ActivityLogController.php +++ b/Modules/ActivityLog/Http/Controllers/ActivityLogController.php @@ -3,17 +3,22 @@ namespace Modules\ActivityLog\Http\Controllers; use App\Http\Controllers\Controller; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Spatie\Activitylog\Models\Activity; use App\Services\ActivityLogger; class ActivityLogController extends Controller { + use AuthorizesRequests; + /** * Display a listing of the resource. */ public function index(Request $request) { + $this->authorize('viewAny', Activity::class); + // ActivityLogger::logView(new Activity, 'Viewed activity logs'); $perPage = $request->get('per_page', 10); @@ -27,8 +32,10 @@ class ActivityLogController extends Controller $q->where('description', 'ILIKE', "%{$search}%") ->orWhere('subject_type', 'ILIKE', "%{$search}%") ->orWhere('event', 'ILIKE', "%{$search}%") - ->orWhere('causer.name', 'ILIKE', "%{$search}%") - ->orWhere('causer.email', 'ILIKE', "%{$search}%"); + ->orWhereHas('causer', function ($causerQuery) use ($search) { + $causerQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); }); } diff --git a/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php b/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php index d1fad58..69101d9 100644 --- a/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php +++ b/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php @@ -3,7 +3,10 @@ namespace Modules\ActivityLog\Providers; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; +use Modules\ActivityLog\Policies\ActivityLogPolicy; +use Spatie\Activitylog\Models\Activity; use Nwidart\Modules\Traits\PathNamespace; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -21,6 +24,8 @@ class ActivityLogServiceProvider extends ServiceProvider */ public function boot(): void { + Gate::policy(Activity::class, ActivityLogPolicy::class); + $this->registerCommands(); $this->registerCommandSchedules(); $this->registerTranslations(); diff --git a/Modules/Auth/Entities/User.php b/Modules/Auth/Entities/User.php index 3c094b5..a20c45f 100644 --- a/Modules/Auth/Entities/User.php +++ b/Modules/Auth/Entities/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Str; use Laravel\Sanctum\HasApiTokens; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -51,9 +52,11 @@ class User extends Authenticatable 'marriage_status', 'member_number', 'member_type', + 'public_profile_token', 'join_date', 'birth_date', 'birth_place', + 'onboarding_completed_at', ]; /** @@ -67,8 +70,55 @@ class User extends Authenticatable 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at', + 'public_profile_token', ]; + protected static function booted(): void + { + static::creating(function (User $user) { + $expiryDays = self::publicProfileTokenTtlDays(); + + if (empty($user->public_profile_token)) { + $user->public_profile_token = Str::random(48); + } + + if (empty($user->public_profile_token_expires_at)) { + $user->public_profile_token_expires_at = now()->addDays($expiryDays); + } + }); + } + + public static function publicProfileTokenTtlDays(): int + { + return (int) config('user.public_profile_token_ttl_days', 90); + } + + public function isPublicProfileTokenExpired(): bool + { + return $this->public_profile_token_expires_at !== null + && $this->public_profile_token_expires_at->isPast(); + } + + public function ensurePublicProfileToken(): string + { + $expiryDays = self::publicProfileTokenTtlDays(); + + if (blank($this->public_profile_token) || $this->isPublicProfileTokenExpired()) { + $this->forceFill([ + 'public_profile_token' => Str::random(48), + 'public_profile_token_expires_at' => now()->addDays($expiryDays), + ])->save(); + + return $this->public_profile_token; + } + + $this->forceFill([ + 'public_profile_token_expires_at' => now()->addDays($expiryDays), + ])->save(); + + return $this->public_profile_token; + } + /** * Get the attributes that should be cast. * @@ -84,9 +134,11 @@ class User extends Authenticatable 'marriage_status' => 'string', 'member_number' => 'integer', 'member_type' => 'string', + 'public_profile_token_expires_at' => 'datetime', 'join_date' => 'date', 'birth_date' => 'date', 'birth_place' => 'string', + 'onboarding_completed_at' => 'datetime', ]; } diff --git a/Modules/Auth/Http/Controllers/SessionController.php b/Modules/Auth/Http/Controllers/SessionController.php index 34a0652..cfcde33 100644 --- a/Modules/Auth/Http/Controllers/SessionController.php +++ b/Modules/Auth/Http/Controllers/SessionController.php @@ -24,6 +24,7 @@ class SessionController extends Controller ], 401); } + $user->ensurePublicProfileToken(); $user->load(['roles.permissions']); return response()->json([ diff --git a/Modules/Auth/Services/AuthSessionService.php b/Modules/Auth/Services/AuthSessionService.php index bca4178..8cd803c 100644 --- a/Modules/Auth/Services/AuthSessionService.php +++ b/Modules/Auth/Services/AuthSessionService.php @@ -12,6 +12,8 @@ class AuthSessionService { public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse { + $user->ensurePublicProfileToken(); + if (config('app.env') !== 'local') { $user->tokens()->delete(); } diff --git a/Modules/Auth/Transformers/UserResource.php b/Modules/Auth/Transformers/UserResource.php index 98518ef..b3cc612 100644 --- a/Modules/Auth/Transformers/UserResource.php +++ b/Modules/Auth/Transformers/UserResource.php @@ -36,9 +36,11 @@ class UserResource extends JsonResource 'marriage_status' => $this->marriage_status, 'member_number' => $this->member_number, 'member_type' => $this->member_type, + 'public_profile_token' => $this->public_profile_token, 'join_date' => $this->join_date, 'birth_date' => $this->birth_date, 'birth_place' => $this->birth_place, + 'onboarding_completed_at' => $this->onboarding_completed_at, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, 'deleted_at' => $this->deleted_at, diff --git a/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php b/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php index ba3f09a..36018fc 100644 --- a/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php +++ b/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php @@ -2,19 +2,28 @@ namespace Modules\MembershipApplication\Emails; +use App\Models\Document; +use App\Notifications\Concerns\BuildsMailMessage; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Storage; use Modules\MembershipApplication\Entities\MembershipApplication; -class MembershipApplicationFailedNotification extends Notification +class MembershipApplicationFailedNotification extends Notification implements ShouldQueue { + use BuildsMailMessage; use Queueable; public function __construct( - protected MembershipApplication $application + public MembershipApplication $application, + public Document $resultLetter, ) {} + /** + * @return array + */ public function via(object $notifiable): array { return ['mail']; @@ -23,13 +32,21 @@ class MembershipApplicationFailedNotification extends Notification public function toMail(object $notifiable): MailMessage { $this->application->loadMissing('applicant'); + $applicant = $this->application->applicant; - return (new MailMessage) - ->subject('Keputusan Permohonan Keahlian') - ->markdown('membershipapplication::emails.failed', [ - 'name' => $this->application->applicant->name, - 'applicationNumber' => $this->application->application_number, - 'logoPath' => public_path('images/logo-kopkb.svg'), - ]); + return $this->mailMessage( + subject: 'Keputusan Permohonan Keahlian KOPKB - TIDAK LULUS', + view: 'membershipapplication::emails.failed', + data: [ + 'application' => $this->application, + 'applicant' => $applicant, + ], + )->attach( + Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path), + [ + 'as' => $this->resultLetter->name, + 'mime' => $this->resultLetter->mime_type, + ] + ); } } diff --git a/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php b/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php index e9ccbd8..c07ec92 100644 --- a/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php +++ b/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php @@ -2,19 +2,28 @@ namespace Modules\MembershipApplication\Emails; +use App\Models\Document; +use App\Notifications\Concerns\BuildsMailMessage; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Storage; use Modules\MembershipApplication\Entities\MembershipApplication; -class MembershipApplicationPassedNotification extends Notification +class MembershipApplicationPassedNotification extends Notification implements ShouldQueue { + use BuildsMailMessage; use Queueable; public function __construct( - protected MembershipApplication $application + public MembershipApplication $application, + public Document $resultLetter, ) {} + /** + * @return array + */ public function via(object $notifiable): array { return ['mail']; @@ -23,13 +32,21 @@ class MembershipApplicationPassedNotification extends Notification public function toMail(object $notifiable): MailMessage { $this->application->loadMissing('applicant'); + $applicant = $this->application->applicant; - return (new MailMessage) - ->subject('Permohonan Keahlian Diluluskan') - ->markdown('membershipapplication::emails.passed', [ - 'name' => $this->application->applicant->name, - 'applicationNumber' => $this->application->application_number, - 'logoPath' => public_path('images/logo-kopkb.svg'), - ]); + return $this->mailMessage( + subject: 'Keputusan Permohonan Keahlian KOPKB - LULUS', + view: 'membershipapplication::emails.passed', + data: [ + 'application' => $this->application, + 'applicant' => $applicant, + ], + )->attach( + Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path), + [ + 'as' => $this->resultLetter->name, + 'mime' => $this->resultLetter->mime_type, + ] + ); } } diff --git a/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php b/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php index 4b77632..97d5f68 100644 --- a/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php +++ b/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php @@ -2,6 +2,7 @@ namespace Modules\MembershipApplication\Emails; +use App\Notifications\Concerns\BuildsMailMessage; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -9,6 +10,7 @@ use Modules\MembershipApplication\Entities\MembershipApplication; class MembershipApplicationSubmittedNotification extends Notification { + use BuildsMailMessage; use Queueable; public function __construct( @@ -24,9 +26,10 @@ class MembershipApplicationSubmittedNotification extends Notification { $this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']); - return (new MailMessage) - ->subject('Pengesahan Permohonan Keahlian') - ->markdown('membershipapplication::emails.submitted', [ + return $this->mailMessage( + subject: 'Pengesahan Permohonan Keahlian', + view: 'membershipapplication::emails.submitted', + data: [ 'application' => $this->application, 'applicant' => $this->application->applicant, 'heirs' => $this->application->heirs, @@ -37,7 +40,7 @@ class MembershipApplicationSubmittedNotification extends Notification 'salary_slip' => 'Slip Gaji', 'employer_letter' => 'Surat Pengesahan Majikan', ], - 'logoPath' => public_path('images/logo-kopkb.svg'), - ]); + ], + ); } } diff --git a/Modules/MembershipApplication/Entities/MembershipApplication.php b/Modules/MembershipApplication/Entities/MembershipApplication.php index 9464541..15a7ff0 100644 --- a/Modules/MembershipApplication/Entities/MembershipApplication.php +++ b/Modules/MembershipApplication/Entities/MembershipApplication.php @@ -18,6 +18,8 @@ class MembershipApplication extends Model public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter'; + public const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment'; + public const MINIMUM_SHARE_CAPITAL = 500; public const SHARE_INSTALLMENT_MONTHS = 6; diff --git a/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php b/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php index 006de7b..7da1e09 100644 --- a/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php +++ b/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php @@ -17,6 +17,7 @@ use Modules\MembershipApplication\Enums\ManagementDecision; use Modules\MembershipApplication\Http\Requests\AssignReferencesRequest; use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest; use Modules\MembershipApplication\Http\Requests\BoardReviewRequest; +use Modules\MembershipApplication\Http\Requests\CompleteMembershipApplicationRequest; use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest; use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest; use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest; @@ -180,18 +181,19 @@ class MembershipApplicationController extends Controller ]); } - public function complete(Request $request, MembershipApplication $membershipApplication): JsonResponse + public function complete(CompleteMembershipApplicationRequest $request, MembershipApplication $membershipApplication): JsonResponse { $this->authorize('complete', $membershipApplication); $application = $this->membershipApplicationService->complete( $membershipApplication, - $request->user() + $request->user(), + $request->validated('board_meeting_reference'), ); $message = $application->board_result === BoardResult::Pass->value - ? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.' - : 'Permohonan selesai. E-mel keputusan dihantar.'; + ? 'Permohonan selesai. Surat keputusan dijana, akaun ahli dicipta dan e-mel dihantar.' + : 'Permohonan selesai. Surat keputusan dijana dan e-mel dihantar.'; return response()->json([ 'success' => true, @@ -206,7 +208,8 @@ class MembershipApplicationController extends Controller $result = $this->membershipApplicationService->completeBatch( $request->validated('application_ids'), - $request->user() + $request->user(), + $request->validated('board_meeting_reference'), ); $succeededCount = count($result['succeeded']); @@ -245,6 +248,22 @@ class MembershipApplicationController extends Controller return $this->documentService->downloadDocument($document->id); } + public function deleteDocument(MembershipApplication $membershipApplication, string $documentId): JsonResponse + { + $this->authorize('update', $membershipApplication); + + $application = $this->membershipApplicationService->deleteDocument( + $membershipApplication, + $documentId, + ); + + return response()->json([ + 'success' => true, + 'message' => 'Lampiran pentadbir berjaya dipadam.', + 'data' => new MembershipApplicationResource($application), + ]); + } + public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse { $this->authorize('generateResultLetter', $membershipApplication); diff --git a/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php b/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php index deb9c19..0a4c3ae 100644 --- a/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php +++ b/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php @@ -19,6 +19,7 @@ class BatchCompleteRequest extends FormRequest return [ 'application_ids' => 'required|array|min:1|max:100', 'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id', + 'board_meeting_reference' => 'required|string|max:255', ]; } @@ -33,6 +34,8 @@ class BatchCompleteRequest extends FormRequest 'application_ids.max' => 'Maksimum 100 permohonan setiap batch.', 'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.', 'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.', + 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', + 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', ]; } } diff --git a/Modules/MembershipApplication/Http/Requests/CompleteMembershipApplicationRequest.php b/Modules/MembershipApplication/Http/Requests/CompleteMembershipApplicationRequest.php new file mode 100644 index 0000000..971bfde --- /dev/null +++ b/Modules/MembershipApplication/Http/Requests/CompleteMembershipApplicationRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'board_meeting_reference' => 'required|string|max:255', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', + 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', + ]; + } +} diff --git a/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php b/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php index 8027dc1..f15abed 100644 --- a/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php +++ b/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php @@ -48,7 +48,7 @@ class StoreMembershipApplicationRequest extends FormRequest 'documents.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240', 'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', - 'documents.employer_letter' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', + 'documents.employer_letter' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', ]; } @@ -89,6 +89,8 @@ class StoreMembershipApplicationRequest extends FormRequest 'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.', 'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.', + 'documents.employer_letter.required' => 'Surat pengesahan majikan diperlukan.', + 'documents.employer_letter.mimes' => 'Surat pengesahan majikan mestilah PDF, JPG, JPEG atau PNG.', ]; } } diff --git a/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php b/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php index 542495f..47198a4 100644 --- a/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php +++ b/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php @@ -4,6 +4,7 @@ namespace Modules\MembershipApplication\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Modules\MembershipApplication\Entities\MembershipApplication; class UploadMembershipApplicationDocumentRequest extends FormRequest { @@ -18,7 +19,13 @@ class UploadMembershipApplicationDocumentRequest extends FormRequest public function rules(): array { return [ - 'type' => ['required', Rule::in(['ic_copy', 'photo', 'salary_slip', 'employer_letter'])], + 'type' => ['required', Rule::in([ + 'ic_copy', + 'photo', + 'salary_slip', + 'employer_letter', + MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE, + ])], 'file' => [ 'required', 'file', diff --git a/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php b/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php index 679ef22..06fccd4 100644 --- a/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php +++ b/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php @@ -23,6 +23,7 @@ class MembershipApplicationRepository implements MembershipApplicationRepository return MembershipApplication::with([ 'applicant', 'heirs', + 'user:id,member_number', 'references.member:id,name,ic_number', 'documents', 'reviews.reviewer:id,name', diff --git a/Modules/MembershipApplication/Resources/views/emails/failed.blade.php b/Modules/MembershipApplication/Resources/views/emails/failed.blade.php index 62b70a8..2982892 100644 --- a/Modules/MembershipApplication/Resources/views/emails/failed.blade.php +++ b/Modules/MembershipApplication/Resources/views/emails/failed.blade.php @@ -1,18 +1,15 @@ @component('mail::message') -@if (! empty($logoPath) && file_exists($logoPath)) -
- {{ config('app.name') }} -
-@endif +@include('emails.partials.header') # Keputusan Permohonan Keahlian -Assalamualaikum **{{ $name }}**, +Assalamualaikum **{{ $applicant->name }}**, -Permohonan keahlian anda (**{{ $applicationNumber }}**) **tidak diluluskan** pada peringkat lembaga. +Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan** pada peringkat lembaga. + +Surat keputusan rasmi dilampirkan dalam e-mel ini. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. -Terima kasih,
-{{ config('app.name') }} +@include('emails.partials.footer') @endcomponent diff --git a/Modules/MembershipApplication/Resources/views/emails/passed.blade.php b/Modules/MembershipApplication/Resources/views/emails/passed.blade.php index 3c962c2..b9752cd 100644 --- a/Modules/MembershipApplication/Resources/views/emails/passed.blade.php +++ b/Modules/MembershipApplication/Resources/views/emails/passed.blade.php @@ -1,18 +1,15 @@ @component('mail::message') -@if (! empty($logoPath) && file_exists($logoPath)) -
- {{ config('app.name') }} -
-@endif +@include('emails.partials.header') # Keputusan Permohonan Keahlian -Assalamualaikum **{{ $name }}**, +Assalamualaikum **{{ $applicant->name }}**, -Permohonan keahlian anda (**{{ $applicationNumber }}**) **diluluskan** pada peringkat lembaga. +Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan** pada peringkat lembaga. + +Surat keputusan rasmi dilampirkan dalam e-mel ini. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. -Terima kasih,
-{{ config('app.name') }} +@include('emails.partials.footer') @endcomponent diff --git a/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php b/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php index 7a75b9e..5782b9b 100644 --- a/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php +++ b/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php @@ -1,9 +1,5 @@ @component('mail::message') -@if (! empty($logoPath) && file_exists($logoPath)) -
- {{ config('app.name') }} -
-@endif +@include('emails.partials.header') # Pengesahan Permohonan Keahlian @@ -74,6 +70,5 @@ Tiada dokumen. Anda akan menerima emel apabila keputusan permohonan tersedia. -Terima kasih,
-{{ config('app.name') }} +@include('emails.partials.footer') @endcomponent diff --git a/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php b/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php index cdfc055..21c1fea 100644 --- a/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php +++ b/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php @@ -22,7 +22,7 @@

2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi - Permodalan Kelantan Berhad (KoPKB) telah {{ $boardResultLabel }} dalam {{ $boardMeetingReference }}. + Permodalan Kelantan Berhad (KoPKB) telah {{ $boardResultLabel }} dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah {{ $memberNumber }}@endif.

@if ($isPassed) @@ -66,8 +66,12 @@ @section('letter-signature')
Dengan hormatnya,
KOPERASI PERMODALAN KELANTAN BERHAD
-
-
MUHAMMAD NAFIS BIN ZAINUDDIN
+
+ @if (! empty($signatureUrl)) + + @endif +
+
MUHAMMAD NAFIS BIN ZAINUDIN
Pengurus Besar
b/p Setiausaha
@endsection diff --git a/Modules/MembershipApplication/Routes/api.php b/Modules/MembershipApplication/Routes/api.php index 5259d36..7c5e34c 100644 --- a/Modules/MembershipApplication/Routes/api.php +++ b/Modules/MembershipApplication/Routes/api.php @@ -38,6 +38,7 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct // Membership application upload document routes Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document'); + Route::delete('membership-applications/{membershipApplication}/documents/{documentId}', [MembershipApplicationController::class, 'deleteDocument'])->name('membership-application.delete-document'); // download document routes Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document'); diff --git a/Modules/MembershipApplication/Services/MembershipApplicationService.php b/Modules/MembershipApplication/Services/MembershipApplicationService.php index 10c6284..887bc7f 100644 --- a/Modules/MembershipApplication/Services/MembershipApplicationService.php +++ b/Modules/MembershipApplication/Services/MembershipApplicationService.php @@ -2,6 +2,7 @@ namespace Modules\MembershipApplication\Services; +use App\Models\Document; use App\Services\DocumentService; use App\Services\LetterService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -137,15 +138,38 @@ class MembershipApplicationService $this->documentService->validateFile($file); - $application->documentsOfType($type)->get()->each( - fn ($document) => $this->documentService->deleteDocument($document->id) - ); + if ($type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) { + $application->documentsOfType($type)->get()->each( + fn ($document) => $this->documentService->deleteDocument($document->id) + ); + } $this->documentService->uploadDocument($application, $file, $type); return $this->repository->findByIdWithRelations($application->id); } + public function deleteDocument(MembershipApplication $application, string $documentId): MembershipApplication + { + if ($application->status === ApplicationStatus::Completed) { + throw ValidationException::withMessages([ + 'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'], + ]); + } + + $document = $application->documents()->findOrFail($documentId); + + if ($document->type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) { + throw ValidationException::withMessages([ + 'document' => ['Hanya lampiran pentadbir boleh dipadam.'], + ]); + } + + $this->documentService->deleteDocument($document->id); + + return $this->repository->findByIdWithRelations($application->id); + } + public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication { if ($application->status !== ApplicationStatus::Submitted) { throw ValidationException::withMessages([ @@ -230,8 +254,11 @@ class MembershipApplicationService }); } - public function complete(MembershipApplication $application, User $processedBy): MembershipApplication - { + public function complete( + MembershipApplication $application, + User $processedBy, + string $boardMeetingReference, + ): MembershipApplication { if ($application->status !== ApplicationStatus::PendingNotification) { throw ValidationException::withMessages([ 'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'], @@ -244,13 +271,16 @@ class MembershipApplicationService ]); } - return DB::transaction(function () use ($application) { + return DB::transaction(function () use ($application, $boardMeetingReference) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { $user = $this->createMemberFromApplication($application); $application->update(['user_id' => $user->id]); + $application->refresh(); } - $this->sendResultNotification($application); + $resultLetter = $this->storeResultLetter($application, $boardMeetingReference); + + $this->sendResultNotification($application, $resultLetter); $application->update([ 'status' => ApplicationStatus::Completed, @@ -268,7 +298,7 @@ class MembershipApplicationService * failed: list * } */ - public function completeBatch(array $applicationIds, User $processedBy): array + public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array { $succeeded = []; $failed = []; @@ -287,7 +317,7 @@ class MembershipApplicationService } try { - $succeeded[] = $this->complete($application, $processedBy); + $succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference); } catch (ValidationException $e) { $failed[] = [ 'id' => $id, @@ -317,40 +347,47 @@ class MembershipApplicationService } return DB::transaction(function () use ($application, $boardMeetingReference) { - $lockedApplication = MembershipApplication::query() - ->whereKey($application->id) - ->lockForUpdate() - ->firstOrFail(); - - if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) { - throw ValidationException::withMessages([ - 'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'], - ]); - } - - $application = $this->repository->findByIdWithRelations($lockedApplication->id); - - if (! $application || ! $application->applicant) { - throw ValidationException::withMessages([ - 'application' => ['Maklumat pemohon tidak lengkap.'], - ]); - } - - $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference); - $pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData); - $fileName = 'surat-keputusan-'.$application->application_number.'.pdf'; - - $this->documentService->storeGeneratedDocument( - $application, - $pdf, - $fileName, - MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE, - ); + $this->storeResultLetter($application, $boardMeetingReference); return $this->repository->findByIdWithRelations($application->id); }); } + protected function storeResultLetter( + MembershipApplication $application, + string $boardMeetingReference, + ): Document { + $lockedApplication = MembershipApplication::query() + ->whereKey($application->id) + ->lockForUpdate() + ->firstOrFail(); + + if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) { + throw ValidationException::withMessages([ + 'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'], + ]); + } + + $application = $this->repository->findByIdWithRelations($lockedApplication->id); + + if (! $application || ! $application->applicant) { + throw ValidationException::withMessages([ + 'application' => ['Maklumat pemohon tidak lengkap.'], + ]); + } + + $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference); + $pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData); + $fileName = 'surat-keputusan-'.$application->application_number.'.pdf'; + + return $this->documentService->storeGeneratedDocument( + $application, + $pdf, + $fileName, + MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE, + ); + } + /** * @return array */ @@ -378,8 +415,9 @@ class MembershipApplicationService ): array { $applicant = $application->applicant; $isPassed = $application->board_result === BoardResult::Pass->value; + $stockMonthlyContribution = (float) $applicant->stock_monthly_contribution; $monthlyShareInstallment = number_format( - MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS, + $stockMonthlyContribution < 84 ? 84 : $stockMonthlyContribution, 2, '.', '', @@ -396,10 +434,11 @@ class MembershipApplicationService ? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD' : 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD', 'monthlyShareInstallment' => $monthlyShareInstallment, - 'stockMonthlyContribution' => number_format((float) $applicant->stock_monthly_contribution, 2, '.', ''), + 'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''), 'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''), 'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS, 'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''), + 'memberNumber' => $isPassed ? $application->user?->member_number : null, ]; } @@ -488,20 +527,30 @@ class MembershipApplicationService ->notify(new MembershipApplicationSubmittedNotification($application)); } - protected function sendResultNotification(MembershipApplication $application): void + protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void { $application->loadMissing('applicant'); $notification = $application->board_result === BoardResult::Pass->value - ? new MembershipApplicationPassedNotification($application) - : new MembershipApplicationFailedNotification($application); + ? new MembershipApplicationPassedNotification($application, $resultLetter) + : new MembershipApplicationFailedNotification($application, $resultLetter); Notification::route('mail', $application->applicant->email)->notify($notification); } protected function generateMemberNumber(): int { - return ((int) User::max('member_number')) + 1; + $latestUser = User::query() + ->whereNotNull('member_number') + ->orderByDesc('member_number') + ->lockForUpdate() + ->first(); + + if ($latestUser === null && DB::getDriverName() === 'pgsql') { + DB::selectOne('SELECT pg_advisory_xact_lock(?) AS locked', [742891035]); + } + + return ((int) ($latestUser?->member_number ?? 0)) + 1; } /** diff --git a/Modules/User/Config/config.php b/Modules/User/Config/config.php index 566de5a..6484b21 100644 --- a/Modules/User/Config/config.php +++ b/Modules/User/Config/config.php @@ -2,4 +2,7 @@ return [ 'name' => 'User', + 'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90), + 'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'), + 'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo.svg')), ]; diff --git a/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php b/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php new file mode 100644 index 0000000..51eee6d --- /dev/null +++ b/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php @@ -0,0 +1,34 @@ +string('public_profile_token', 64)->nullable()->unique()->after('member_type'); + }); + + User::withTrashed() + ->whereNull('public_profile_token') + ->cursor() + ->each(function (User $user) { + $user->forceFill([ + 'public_profile_token' => Str::random(48), + ])->saveQuietly(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['public_profile_token']); + $table->dropColumn('public_profile_token'); + }); + } +}; diff --git a/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php b/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php new file mode 100644 index 0000000..cd5ccc4 --- /dev/null +++ b/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php @@ -0,0 +1,35 @@ +timestamp('public_profile_token_expires_at')->nullable()->after('public_profile_token'); + }); + + $expiryDays = (int) config('user.public_profile_token_ttl_days', 90); + + User::withTrashed() + ->whereNotNull('public_profile_token') + ->whereNull('public_profile_token_expires_at') + ->cursor() + ->each(function (User $user) use ($expiryDays) { + $user->forceFill([ + 'public_profile_token_expires_at' => now()->addDays($expiryDays), + ])->saveQuietly(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('public_profile_token_expires_at'); + }); + } +}; diff --git a/Modules/User/Database/Migrations/2026_07_06_100000_add_onboarding_completed_at_to_users_table.php b/Modules/User/Database/Migrations/2026_07_06_100000_add_onboarding_completed_at_to_users_table.php new file mode 100644 index 0000000..9ef1af9 --- /dev/null +++ b/Modules/User/Database/Migrations/2026_07_06_100000_add_onboarding_completed_at_to_users_table.php @@ -0,0 +1,22 @@ +timestamp('onboarding_completed_at')->nullable()->after('birth_place'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('onboarding_completed_at'); + }); + } +}; diff --git a/Modules/User/Http/Controllers/MemberDigitalCardController.php b/Modules/User/Http/Controllers/MemberDigitalCardController.php new file mode 100644 index 0000000..c0dcabc --- /dev/null +++ b/Modules/User/Http/Controllers/MemberDigitalCardController.php @@ -0,0 +1,61 @@ +validate([ + 'side' => 'sometimes|in:depan,belakang', + ]); + + $user = $request->user(); + $side = $validated['side'] ?? 'depan'; + + if ($user->status !== 'active') { + return response()->json([ + 'success' => false, + 'message' => 'Kad digital hanya tersedia untuk anggota aktif.', + 'code' => 'digital_card_inactive', + 'data' => null, + ], 403); + } + + try { + $png = $this->memberDigitalCardService->renderPng($user, $side); + $memberNumber = $user->member_number ?: 'anggota'; + $filename = sprintf('kad-digital-%s-%s.png', $memberNumber, $side); + + return response($png, Response::HTTP_OK, [ + 'Content-Type' => 'image/png', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + 'Cache-Control' => 'no-store, private', + ]); + } catch (\Throwable $exception) { + Log::error('Failed to render member digital card.', [ + 'user_id' => $user->id, + 'side' => $side, + 'message' => $exception->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Gagal menjana kad digital. Sila cuba lagi.', + 'code' => 'digital_card_render_failed', + 'data' => null, + ], 500); + } + } +} diff --git a/Modules/User/Http/Controllers/PublicMemberProfileController.php b/Modules/User/Http/Controllers/PublicMemberProfileController.php new file mode 100644 index 0000000..c25357e --- /dev/null +++ b/Modules/User/Http/Controllers/PublicMemberProfileController.php @@ -0,0 +1,37 @@ +publicMemberProfileService->findMemberByToken($token); + + if (! $member) { + return response()->json([ + 'success' => false, + 'message' => 'Anggota tidak dijumpai atau tidak sah.', + 'code' => 'public_profile_not_found', + 'data' => null, + ], 404); + } + + $member = $this->publicMemberProfileService->loadVerifiableRelations($member); + + return response()->json([ + 'success' => true, + 'message' => 'Anggota disahkan.', + 'data' => new PublicMemberProfileResource($member), + ]); + } +} diff --git a/Modules/User/Http/Controllers/UserController.php b/Modules/User/Http/Controllers/UserController.php index 2526ff6..3e3ffb3 100644 --- a/Modules/User/Http/Controllers/UserController.php +++ b/Modules/User/Http/Controllers/UserController.php @@ -127,6 +127,17 @@ class UserController extends BaseCrudController ]); } + public function completeOnboarding(Request $request): JsonResponse + { + $user = $this->userService->completeOnboarding($request->user()); + + return response()->json([ + 'success' => true, + 'data' => new UserResource($user), + 'message' => 'Onboarding completed.', + ]); + } + public function updatePassword(Request $request): JsonResponse { $user = $request->user(); diff --git a/Modules/User/Providers/UserServiceProvider.php b/Modules/User/Providers/UserServiceProvider.php index 9c28f9e..e9f7dba 100644 --- a/Modules/User/Providers/UserServiceProvider.php +++ b/Modules/User/Providers/UserServiceProvider.php @@ -5,6 +5,8 @@ namespace Modules\User\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Cache\RateLimiting\Limit; use Modules\Auth\Entities\User; use Modules\User\Policies\UserPolicy; use Modules\User\Entities\Address; @@ -34,6 +36,7 @@ class UserServiceProvider extends ServiceProvider */ public function boot(): void { + $this->registerRateLimiters(); $this->registerCommands(); $this->registerCommandSchedules(); $this->registerTranslations(); @@ -96,6 +99,13 @@ class UserServiceProvider extends ServiceProvider Gate::policy(Heir::class, HeirPolicy::class); } + protected function registerRateLimiters(): void + { + RateLimiter::for('public-member-profile', function ($request) { + return Limit::perMinute(60)->by($request->ip()); + }); + } + /** * Register commands in the format of Command::class */ diff --git a/Modules/User/Resources/Views/digital-card/back.blade.php b/Modules/User/Resources/Views/digital-card/back.blade.php new file mode 100644 index 0000000..fd0f376 --- /dev/null +++ b/Modules/User/Resources/Views/digital-card/back.blade.php @@ -0,0 +1,166 @@ + + + + + + + +
+
+
+ +
+
+ @if ($logoDataUri) + + @endif +
Belakang ยท Kod QR
+
+ +
+
+ Kod QR profil anggota +
+ +
+

Imbas untuk sahkan profil anggota MyKOPKB.

+ +
+
No. Anggota
+
{{ $memberNumber }}
+
+
+
+ + +
+
+ + diff --git a/Modules/User/Resources/Views/digital-card/front.blade.php b/Modules/User/Resources/Views/digital-card/front.blade.php new file mode 100644 index 0000000..9352c56 --- /dev/null +++ b/Modules/User/Resources/Views/digital-card/front.blade.php @@ -0,0 +1,214 @@ + + + + + + + +
+
+
+ +
+ @if ($avatarDataUri) + {{ $memberName }} + @else +
{{ $avatarInitials }}
+ @endif +
+ +
+
+ @if ($logoDataUri) + + @endif +
Kad Digital
+
+ +
+
+
No. Anggota
+
{{ $memberNumber }}
+
+ +
+
Unit
+
{{ $companyName }}
+
+
+ + +
+
+ + diff --git a/Modules/User/Routes/api.php b/Modules/User/Routes/api.php index 28c0793..c4fc38b 100644 --- a/Modules/User/Routes/api.php +++ b/Modules/User/Routes/api.php @@ -8,6 +8,14 @@ use Modules\User\Http\Controllers\EmploymentController; use Modules\User\Http\Controllers\BankController; use Modules\User\Http\Controllers\BankDetailController; use Modules\User\Http\Controllers\HeirController; +use Modules\User\Http\Controllers\MemberDigitalCardController; +use Modules\User\Http\Controllers\PublicMemberProfileController; + +Route::prefix('v1/public')->group(function () { + Route::middleware('throttle:public-member-profile') + ->get('members/{token}', [PublicMemberProfileController::class, 'show']) + ->name('user.public.member-profile'); +}); Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () { Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index'); @@ -27,7 +35,10 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct // User profile management routes Route::post('/profile', [UserController::class, 'updateProfile']); + Route::post('/profile/onboarding/complete', [UserController::class, 'completeOnboarding']); Route::put('/profile/password', [UserController::class, 'updatePassword']); + Route::get('/profile/digital-card', [MemberDigitalCardController::class, 'download']) + ->name('profile.digital-card.download'); // Impersonation routes Route::get('/impersonate/take/{id}', [ImpersonateController::class, 'take'])->name('impersonate'); diff --git a/Modules/User/Services/MemberDigitalCardService.php b/Modules/User/Services/MemberDigitalCardService.php new file mode 100644 index 0000000..de68afd --- /dev/null +++ b/Modules/User/Services/MemberDigitalCardService.php @@ -0,0 +1,141 @@ +prepareViewData($user))->render(); + + return $this->makeBrowsershot($html)->screenshot(); + } + + /** + * @return array + */ + protected function prepareViewData(User $user): array + { + $user = app(PublicMemberProfileService::class)->loadVerifiableRelations($user); + $user->ensurePublicProfileToken(); + + $currentEmployment = $user->employments->firstWhere('is_current', true) + ?? $user->employments->first(); + + $logoPath = config('user.digital_card_logo_path'); + $profileUrl = rtrim(config('user.frontend_url'), '/').'/v/'.$user->public_profile_token; + + return [ + 'memberNumber' => $this->displayCardValue($user->member_number), + 'memberName' => $user->name ?: '-', + 'memberType' => $this->displayCardValue($user->member_type), + 'companyName' => $this->displayCardValue($currentEmployment?->company_name), + 'avatarDataUri' => $this->resolveStorageDataUri($user->image_url), + 'avatarInitials' => $this->avatarInitials($user->name), + 'logoDataUri' => $this->resolveFileDataUri($logoPath), + 'profileUrl' => $profileUrl, + 'qrDataUri' => $this->generateQrDataUri($profileUrl), + ]; + } + + protected function makeBrowsershot(string $html): Browsershot + { + $browsershot = Browsershot::html($html) + ->setNodeModulePath(config('browsershot.node_module_path')) + ->timeout(config('browsershot.timeout')) + ->windowSize(self::CARD_WIDTH, self::CARD_HEIGHT) + ->deviceScaleFactor(2) + ->showBackground(); + + if ($nodeBinary = config('browsershot.node_binary')) { + $browsershot->setNodeBinary($nodeBinary); + } + + if ($npmBinary = config('browsershot.npm_binary')) { + $browsershot->setNpmBinary($npmBinary); + } + + if ($chromePath = config('browsershot.chrome_path')) { + $browsershot->setChromePath($chromePath); + } + + if (config('browsershot.no_sandbox')) { + $browsershot->noSandbox(); + } + + return $browsershot; + } + + protected function generateQrDataUri(string $content): string + { + $renderer = new ImageRenderer( + new RendererStyle(220, 1), + new SvgImageBackEnd + ); + $writer = new Writer($renderer); + $svg = $writer->writeString($content); + + return 'data:image/svg+xml;base64,'.base64_encode($svg); + } + + protected function resolveFileDataUri(?string $path): ?string + { + if (empty($path) || ! file_exists($path)) { + return null; + } + + $mime = mime_content_type($path) ?: 'image/svg+xml'; + + return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path)); + } + + protected function resolveStorageDataUri(?string $path): ?string + { + if (blank($path)) { + return null; + } + + $fullPath = Storage::disk('public')->path($path); + + return $this->resolveFileDataUri($fullPath); + } + + protected function avatarInitials(?string $name): string + { + $trimmed = trim((string) $name); + + if ($trimmed === '') { + return '--'; + } + + return strtoupper(mb_substr($trimmed, 0, 2)); + } + + protected function displayCardValue(mixed $value): string + { + if ($value === null || $value === '') { + return '-'; + } + + $trimmed = trim((string) $value); + + return $trimmed !== '' ? $trimmed : '-'; + } +} diff --git a/Modules/User/Services/PublicMemberProfileService.php b/Modules/User/Services/PublicMemberProfileService.php new file mode 100644 index 0000000..2a0c468 --- /dev/null +++ b/Modules/User/Services/PublicMemberProfileService.php @@ -0,0 +1,25 @@ +where('public_profile_token', $token) + ->where('status', 'active') + ->first(); + } + + public function loadVerifiableRelations(User $member): User + { + $member->load(['employments' => function ($query) { + $query->orderByDesc('is_current')->orderByDesc('start_date'); + }]); + + return $member; + } +} diff --git a/Modules/User/Services/UserService.php b/Modules/User/Services/UserService.php index 90f20fb..0033e64 100644 --- a/Modules/User/Services/UserService.php +++ b/Modules/User/Services/UserService.php @@ -121,6 +121,17 @@ class UserService /** * @return array{}|array{error: string} */ + public function completeOnboarding(User $user): User + { + if ($user->onboarding_completed_at === null) { + $user->update(['onboarding_completed_at' => now()]); + } + + $user->load(['roles.permissions']); + + return $user; + } + public function updatePassword(User $user, string $currentPassword, string $newPassword): array { if (! Hash::check($currentPassword, $user->password)) { diff --git a/Modules/User/Transformers/PublicMemberProfileResource.php b/Modules/User/Transformers/PublicMemberProfileResource.php new file mode 100644 index 0000000..283f9d3 --- /dev/null +++ b/Modules/User/Transformers/PublicMemberProfileResource.php @@ -0,0 +1,26 @@ +employments->firstWhere('is_current', true) + ?? $this->employments->first(); + + return [ + 'name' => $this->name, + 'member_number' => $this->member_number, + 'member_type' => $this->member_type, + 'status' => $this->status, + 'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null, + 'company_name' => $currentEmployment?->company_name, + 'verified_at' => now()->toIso8601String(), + ]; + } +} diff --git a/app/Services/LetterService.php b/app/Services/LetterService.php index ba27182..a7f9c81 100644 --- a/app/Services/LetterService.php +++ b/app/Services/LetterService.php @@ -56,11 +56,14 @@ class LetterService public function prepareViewData(array $overrides = []): array { $logoPath = $overrides['logoPath'] ?? config('mail.logo_path'); + $signaturePath = $overrides['signaturePath'] ?? config('letter.signature_path'); return array_merge([ 'letterLayout' => LetterLayout::resolve(), 'logoPath' => $logoPath, - 'logoUrl' => $this->resolveLogoDataUri($logoPath), + 'logoUrl' => $this->resolveImageDataUri($logoPath), + 'signaturePath' => $signaturePath, + 'signatureUrl' => $this->resolveImageDataUri($signaturePath), 'organizationAddress' => $overrides['organizationAddress'] ?? null, 'organizationContact' => $overrides['organizationContact'] ?? null, ], $overrides); @@ -97,14 +100,14 @@ class LetterService return $browsershot; } - protected function resolveLogoDataUri(?string $logoPath): ?string + protected function resolveImageDataUri(?string $path): ?string { - if (empty($logoPath) || ! file_exists($logoPath)) { + if (empty($path) || ! file_exists($path)) { return null; } - $mime = mime_content_type($logoPath) ?: 'image/svg+xml'; + $mime = mime_content_type($path) ?: 'image/svg+xml'; - return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($logoPath)); + return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path)); } } diff --git a/config/letter.php b/config/letter.php index dad463b..4b232b4 100644 --- a/config/letter.php +++ b/config/letter.php @@ -82,4 +82,12 @@ return [ 'black' => '#000000', ], + /* + |-------------------------------------------------------------------------- + | Assets + |-------------------------------------------------------------------------- + */ + + 'signature_path' => resource_path('assets/signatures/signature.svg'), + ]; diff --git a/config/mail.php b/config/mail.php index c875aaf..3b53d7f 100644 --- a/config/mail.php +++ b/config/mail.php @@ -124,6 +124,6 @@ return [ | */ - 'logo_path' => env('MAIL_LOGO_PATH', public_path('images/logo/logo-kopkb-letterhead.svg')), + 'logo_path' => public_path('images/logo/logo.svg'), ]; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php index e8ec6ec..425e705 100644 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -12,7 +12,7 @@ return new class extends Migration public function up(): void { Schema::create('jobs', function (Blueprint $table) { - $table->uuid('id')->primary(); + $table->id(); $table->string('queue')->index(); $table->longText('payload'); $table->unsignedTinyInteger('attempts'); @@ -22,7 +22,7 @@ return new class extends Migration }); Schema::create('job_batches', function (Blueprint $table) { - $table->uuid('id')->primary(); + $table->string('id')->primary(); $table->string('name'); $table->integer('total_jobs'); $table->integer('pending_jobs'); @@ -35,7 +35,7 @@ return new class extends Migration }); Schema::create('failed_jobs', function (Blueprint $table) { - $table->uuid('id')->primary(); + $table->id(); $table->string('uuid')->unique(); $table->text('connection'); $table->text('queue'); diff --git a/public/images/logo/logo-kopkb.svg b/public/images/logo/logo-kopkb.svg deleted file mode 100644 index ef88ccb..0000000 --- a/public/images/logo/logo-kopkb.svg +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/logo/logo.svg b/public/images/logo/logo.svg new file mode 100644 index 0000000..5498014 --- /dev/null +++ b/public/images/logo/logo.svg @@ -0,0 +1,649 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/assets/signatures/signature.svg b/resources/assets/signatures/signature.svg new file mode 100644 index 0000000..aa1cf66 --- /dev/null +++ b/resources/assets/signatures/signature.svg @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/resources/views/pdf/layouts/letter.blade.php b/resources/views/pdf/layouts/letter.blade.php index e649a3a..35f34c6 100644 --- a/resources/views/pdf/layouts/letter.blade.php +++ b/resources/views/pdf/layouts/letter.blade.php @@ -239,6 +239,12 @@ height: var(--letter-signature-space-height); } + .signature-space-img { + height: var(--letter-signature-space-height); + width: auto; + display: block; + } + .signatory-name { font-weight: bold; text-transform: uppercase; diff --git a/resources/views/pdf/preview-approved-membership.blade.php b/resources/views/pdf/preview-approved-membership.blade.php index fd2819b..620b66f 100644 --- a/resources/views/pdf/preview-approved-membership.blade.php +++ b/resources/views/pdf/preview-approved-membership.blade.php @@ -27,19 +27,19 @@

2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi - Permodalan Kelantan Berhad(KoPKB) telah {{ membership_applications.board_result }} dalam {{ User manual fills before generate }} + Permodalan Kelantan Berhad(KoPKB) telah dalam

3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-

  • Saham
    • -
    • Potongan sebanyak RM 84.00 {{(6 months auto calculate)}}
    • -
    • Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima
    • +
    • Potongan sebanyak RM 84.00
    • +
    • Potongan sebanyak RM sehingga Syer Maksima
  • Yuran
    • -
    • Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan
    • +
    • Potongan sebanyak RM setiap bulan
  • Fi Masuk
    • @@ -65,7 +65,11 @@ @section('letter-signature')
      Dengan hormatnya,
      KOPERASI PERMODALAN KELANTAN BERHAD
      -
      +
      + @if (! empty($signatureUrl)) + + @endif +
      MUHAMMAD NAFIS BIN ZAINUDDIN
      Pengurus Besar
      b/p Setiausaha