diff --git a/.env b/.env index f3148d7..4d847d3 100644 --- a/.env +++ b/.env @@ -77,3 +77,5 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +REGISTRATION_SHOW_VERIFICATION_CODE=true diff --git a/.env.example b/.env.example index 7f39047..0421a1f 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,6 @@ MINIO_USE_PATH_STYLE_ENDPOINT=true MINIO_CA_BUNDLE= VITE_APP_NAME="${APP_NAME}" + +# Sementara aktifkan selama channel pengiriman kode registrasi belum tersedia. +REGISTRATION_SHOW_VERIFICATION_CODE=true diff --git a/app/Http/Controllers/Access/RoleApplicationController.php b/app/Http/Controllers/Access/RoleApplicationController.php new file mode 100644 index 0000000..b1ce0be --- /dev/null +++ b/app/Http/Controllers/Access/RoleApplicationController.php @@ -0,0 +1,65 @@ +success($this->service->mine($request->user()), 'Daftar pengajuan berhasil diambil'); + } + + public function store(StoreRoleApplicationRequest $request) + { + return $this->created($this->service->submit($request->validated(), $request->user()), 'Pengajuan berhasil dikirim'); + } + + public function reviewList(Request $request, string $type) + { + $filters = $request->validate(['search' => ['nullable', 'string', 'max:100'], 'status' => ['nullable', 'in:pending,approved,rejected'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]); + + return $this->success($this->service->reviewList($type, $filters, $request->user(), $this->tenantId($request)), 'Daftar pengajuan berhasil diambil'); + } + + public function tenantMenuGroupOptions(Request $request) + { + return $this->success( + $this->service->tenantMenuGroupOptions($request->user()), + 'Pilihan Group Menu tenant berhasil diambil' + ); + } + + public function staffMenuGroupOptions(Request $request) + { + return $this->success( + $this->service->staffMenuGroupOptions($request->user(), $this->tenantId($request)), + 'Pilihan Group Menu staff berhasil diambil' + ); + } + + public function review(ReviewRoleApplicationRequest $request, RoleApplication $application) + { + return $this->updated($this->service->review($application, $request->validated(), $request->user(), $this->tenantId($request)), 'Pengajuan berhasil ditinjau'); + } + + public function linkCustomer(Request $request) + { + $data = $request->validate(['customer_id' => ['required', 'integer', 'exists:customers,id'], 'user_id' => ['required', 'integer', 'exists:users,id'], 'relationship' => ['nullable', 'in:owner,family,staff'], 'is_primary' => ['nullable', 'boolean']]); + + return $this->created($this->service->linkCustomer($data, $request->user(), (int) $this->tenantId($request)), 'User berhasil dihubungkan dengan customer'); + } + + private function tenantId(Request $request): ?int + { + return $request->attributes->get('tenant_id'); + } +} diff --git a/app/Http/Controllers/Menu/MenuController.php b/app/Http/Controllers/Menu/MenuController.php index 55e3bd2..cbc8126 100644 --- a/app/Http/Controllers/Menu/MenuController.php +++ b/app/Http/Controllers/Menu/MenuController.php @@ -13,14 +13,16 @@ class MenuController extends ApiController { $user = auth()->user(); $tenantId = request()->attributes->get('tenant_id'); + $tenantRole = $user?->tenantRole($tenantId); if (! $user) { return $this->error('Unauthenticated', 401); } - if ($user->isMasterAdmin()) { + if ($user->isMasterAdmin() || $user->is_platform_staff) { $menus = MenuList::query() ->where('is_active', true) + ->when($user->is_platform_staff && ! $user->isMasterAdmin(), fn ($q) => $q->whereIn('slug', ['role-applications', 'role-applications-tenants'])) ->orderBy('sort_order') ->orderBy('id') ->get() @@ -108,10 +110,22 @@ class MenuController extends ApiController $tree = $this->buildMenuTree($menus); + if (! $user->isMasterAdmin() && ! $user->is_platform_staff && ! in_array($tenantRole, ['staff', 'tenant_owner'], true)) { + $restrictedSlugs = ['voucher-hotspot-agents', 'voucher-hotspot-login-page']; + $tree = $this->removeRestrictedMenus($tree, $restrictedSlugs); + } + if (! $user->isMasterAdmin() && ! $user->is_platform_staff) { + $tree = $this->removeRestrictedMenus($tree, ['role-applications-tenants']); + } + if (! $user->isTenantOwner($tenantId)) { + $tree = $this->removeRestrictedMenus($tree, ['role-applications-staff']); + } + return $this->success( [ 'is_master' => (bool) $user->is_master, 'access_level' => $user->access_level->value, + 'tenant_role' => $tenantRole, 'tenant_id' => $tenantId, 'menus' => $tree, ], @@ -143,4 +157,16 @@ class MenuController extends ApiController }) ->all(); } + + private function removeRestrictedMenus(array $menus, array $restrictedSlugs): array + { + return collect($menus) + ->reject(fn ($menu) => in_array($menu['slug'], $restrictedSlugs, true)) + ->map(function ($menu) use ($restrictedSlugs) { + $menu['children'] = $this->removeRestrictedMenus($menu['children'] ?? [], $restrictedSlugs); + + return $menu; + }) + ->values()->all(); + } } diff --git a/app/Http/Controllers/Notification/NotificationController.php b/app/Http/Controllers/Notification/NotificationController.php index 9a57991..4d12ef6 100644 --- a/app/Http/Controllers/Notification/NotificationController.php +++ b/app/Http/Controllers/Notification/NotificationController.php @@ -9,7 +9,9 @@ use App\Http\Requests\Notification\StoreCustomerContactRequest; use App\Http\Requests\Notification\StoreNotificationBroadcastRequest; use App\Http\Requests\Notification\StoreNotificationChannelRequest; use App\Http\Requests\Notification\StoreNotificationTemplateRequest; +use App\Http\Requests\Notification\StoreSystemNotificationRequest; use App\Http\Resources\Notification\NotificationDeliveryResource; +use App\Http\Resources\Notification\SystemNotificationCampaignResource; use App\Models\Customer; use App\Models\CustomerContact; use App\Models\NotificationBroadcast; @@ -17,11 +19,12 @@ use App\Models\NotificationChannel; use App\Models\NotificationEvent; use App\Models\NotificationTemplate; use App\Services\Notification\NotificationService; +use App\Services\Notification\WhapiIdService; use Illuminate\Http\Request; class NotificationController extends ApiController { - public function __construct(protected NotificationService $service) {} + public function __construct(protected NotificationService $service, protected WhapiIdService $whapi) {} public function events() { @@ -33,9 +36,42 @@ class NotificationController extends ApiController return $this->success($this->service->channels($this->channel($request), $request->user(), $this->tenantId($request)), 'Konfigurasi channel berhasil diambil'); } + public function systemCampaigns(IndexNotificationRequest $request) + { + return $this->successPaginated( + $this->service->systemCampaigns($request->validated(), $request->user(), $this->tenantId($request)), + SystemNotificationCampaignResource::class, + 'Riwayat notifikasi System berhasil diambil' + ); + } + + public function storeSystemCampaign(StoreSystemNotificationRequest $request) + { + return $this->created( + $this->service->createSystemCampaign($request->validated(), $request->user(), $this->tenantId($request)), + 'Notifikasi System masuk antrean' + ); + } + + public function systemAudienceOptions(Request $request) + { + $data = $request->validate([ + 'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'], + 'search' => ['nullable', 'string', 'max:100'], + ]); + + return $this->success( + $this->service->systemAudienceOptions( + $request->user(), $this->tenantId($request), $data['tenant_id'] ?? null, $data['search'] ?? null + ), + 'Pilihan penerima notifikasi berhasil diambil' + ); + } + public function storeChannel(StoreNotificationChannelRequest $request) { abort_unless($request->validated('channel') === $this->channel($request), 422); + $this->authorizeChannelMutation($request); return $this->created($this->service->saveChannel($request->validated(), $request->user(), $this->tenantId($request)), 'Konfigurasi channel berhasil dibuat'); } @@ -44,6 +80,7 @@ class NotificationController extends ApiController { $this->authorizeTenant($request, $notificationChannel->tenant_id); abort_unless($notificationChannel->channel === $this->channel($request), 404); + $this->authorizeChannelMutation($request); return $this->updated($this->service->saveChannel($request->validated(), $request->user(), $this->tenantId($request), $notificationChannel), 'Konfigurasi channel berhasil diperbarui'); } @@ -52,10 +89,56 @@ class NotificationController extends ApiController { $this->authorizeTenant($request, $notificationChannel->tenant_id); abort_unless($notificationChannel->channel === $this->channel($request), 404); + $this->authorizeChannelMutation($request); return $this->success($this->service->testChannel($notificationChannel), 'Pengujian channel selesai'); } + public function destroyChannel(Request $request, NotificationChannel $notificationChannel) + { + $this->authorizeTenant($request, $notificationChannel->tenant_id); + abort_unless($notificationChannel->channel === $this->channel($request), 404); + $this->authorizeChannelMutation($request); + $this->service->deleteChannel($notificationChannel); + + return $this->deleted('Konfigurasi channel berhasil dinonaktifkan atau dihapus'); + } + + public function whapiState(Request $request, NotificationChannel $notificationChannel) + { + $this->authorizeWhapiAccess($request, $notificationChannel); + + return $this->success($this->whapi->state($notificationChannel), 'Status perangkat WHAPI berhasil diambil'); + } + + public function whapiStart(Request $request, NotificationChannel $notificationChannel) + { + $this->authorizeWhapiAccess($request, $notificationChannel); + + return $this->success($this->whapi->start($notificationChannel), 'Layanan WHAPI berhasil dijalankan'); + } + + public function whapiQr(Request $request, NotificationChannel $notificationChannel) + { + $this->authorizeWhapiAccess($request, $notificationChannel); + + return $this->success($this->whapi->qr($notificationChannel), 'QR WHAPI berhasil diambil'); + } + + public function whapiTestMessage(Request $request, NotificationChannel $notificationChannel) + { + $this->authorizeWhapiAccess($request, $notificationChannel); + $data = $request->validate([ + 'phone' => ['required', 'string', 'max:25', 'regex:/^\+?[0-9][0-9\s\-()]{7,24}$/'], + 'message' => ['required', 'string', 'max:4000'], + ]); + + return $this->success( + $this->whapi->send($notificationChannel, $data['phone'], $data['message']), + 'Pesan uji WHAPI berhasil dikirim' + ); + } + public function templates(Request $request) { return $this->success($this->service->templates($this->channel($request), $request->user(), $this->tenantId($request)), 'Daftar template berhasil diambil'); @@ -185,8 +268,24 @@ class NotificationController extends ApiController return $request->attributes->get('tenant_id'); } - private function authorizeTenant(Request $request, int $tenantId): void + private function authorizeTenant(Request $request, ?int $tenantId): void { - abort_unless($request->user()->isMasterAdmin() || $tenantId === $this->tenantId($request), 403); + abort_unless($request->user()->isMasterAdmin() + || ($tenantId !== null && $tenantId === $this->tenantId($request)), 403); + } + + private function authorizeChannelMutation(Request $request): void + { + if ($this->channel($request) === 'whatsapp_unofficial') { + abort_unless($request->user()->isMasterAdmin(), 403, 'WhatsApp Unofficial hanya dapat dikonfigurasi oleh Master Admin.'); + } + } + + private function authorizeWhapiAccess(Request $request, NotificationChannel $channel): void + { + abort_unless($channel->channel === 'whatsapp_unofficial' && $channel->provider === 'whapi_id', 404); + abort_unless($request->user()->isMasterAdmin() + || ((int) $channel->tenant_id === (int) $this->tenantId($request) + && $request->user()->isTenantOwner($this->tenantId($request))), 403); } } diff --git a/app/Http/Controllers/Voucher/VoucherController.php b/app/Http/Controllers/Voucher/VoucherController.php new file mode 100644 index 0000000..dbd35aa --- /dev/null +++ b/app/Http/Controllers/Voucher/VoucherController.php @@ -0,0 +1,77 @@ +successPaginated($this->service->sales($request->validated(), $request->user(), $this->tenantId($request)), VoucherSaleResource::class, 'Daftar penjualan voucher berhasil diambil'); + } + + public function storeSale(StoreVoucherSaleRequest $request) + { + return $this->created(new VoucherSaleResource($this->service->createSale($request->validated(), $request->user(), $this->tenantId($request))), 'Voucher berhasil dibuat'); + } + + public function showSale(Request $request, VoucherSale $sale) + { + return $this->success(new VoucherSaleResource($this->service->saleDetail($sale, $request->user(), $this->tenantId($request))), 'Detail voucher berhasil diambil'); + } + + public function agents(IndexVoucherRequest $request) + { + return $this->successPaginated($this->service->agents($request->validated(), $request->user(), $this->tenantId($request)), VoucherAgentResource::class, 'Daftar agen voucher berhasil diambil'); + } + + public function storeAgent(StoreVoucherAgentRequest $request) + { + return $this->created(new VoucherAgentResource($this->service->saveAgent($request->validated(), $request->user(), $this->tenantId($request))), 'Agen voucher berhasil dibuat'); + } + + public function updateAgent(StoreVoucherAgentRequest $request, VoucherAgent $agent) + { + return $this->updated(new VoucherAgentResource($this->service->saveAgent($request->validated(), $request->user(), $this->tenantId($request), $agent)), 'Agen voucher berhasil diperbarui'); + } + + public function destroyAgent(Request $request, VoucherAgent $agent) + { + $this->service->saveAgent([...$agent->toArray(), 'status' => 'inactive'], $request->user(), $this->tenantId($request), $agent); + + return $this->deleted('Agen voucher berhasil dinonaktifkan'); + } + + public function options(Request $request) + { + return $this->success($this->service->options((string) $request->route('voucher_scope'), $request->user(), $this->tenantId($request)), 'Opsi voucher berhasil diambil'); + } + + public function loginPage(Request $request) + { + return $this->success($this->service->loginPage($request->user(), $this->tenantId($request)), 'Setting LoginPage berhasil diambil'); + } + + public function saveLoginPage(SaveVoucherLoginPageRequest $request) + { + return $this->updated($this->service->saveLoginPage($request->validated(), $request->user(), $this->tenantId($request)), 'Setting LoginPage berhasil disimpan'); + } + + private function tenantId(Request $request): ?int + { + return $request->attributes->get('tenant_id'); + } +} diff --git a/app/Http/Middleware/EnsureAccessLevel.php b/app/Http/Middleware/EnsureAccessLevel.php index ef8f674..13b3c52 100644 --- a/app/Http/Middleware/EnsureAccessLevel.php +++ b/app/Http/Middleware/EnsureAccessLevel.php @@ -12,7 +12,10 @@ class EnsureAccessLevel { $user = $request->user(); - if (! $user || (! $user->isMasterAdmin() && ! in_array($user->access_level->value, $levels, true))) { + $tenantId = $request->attributes->get('tenant_id'); + $effectiveLevel = $tenantId ? $user?->tenantRole($tenantId) : $user?->access_level?->value; + + if (! $user || (! $user->isMasterAdmin() && ! in_array($effectiveLevel, $levels, true))) { return response()->json([ 'success' => false, 'code' => 403, diff --git a/app/Http/Requests/Access/ReviewRoleApplicationRequest.php b/app/Http/Requests/Access/ReviewRoleApplicationRequest.php new file mode 100644 index 0000000..c9a5e0d --- /dev/null +++ b/app/Http/Requests/Access/ReviewRoleApplicationRequest.php @@ -0,0 +1,35 @@ +route('application'); + $requiresMenuGroup = $this->input('decision') === 'approved' + && in_array($application?->application_type, ['tenant', 'staff'], true); + + return [ + 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'review_notes' => ['nullable', 'string', 'max:2000'], + 'menu_group_id' => [ + Rule::requiredIf($requiresMenuGroup), + 'nullable', + 'integer', + Rule::exists('menu_groups', 'id')->where('is_active', true), + ], + 'payment_type' => ['nullable', Rule::in(['prepaid', 'postpaid'])], + 'price_mode' => ['nullable', Rule::in(['discount', 'commission'])], + 'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], 'commission_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'credit_limit' => ['nullable', 'numeric', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/Access/StoreRoleApplicationRequest.php b/app/Http/Requests/Access/StoreRoleApplicationRequest.php new file mode 100644 index 0000000..0482b6b --- /dev/null +++ b/app/Http/Requests/Access/StoreRoleApplicationRequest.php @@ -0,0 +1,26 @@ + ['required', Rule::in(['tenant', 'agent', 'staff'])], + 'tenant_id' => ['required_if:application_type,agent,staff', 'nullable', 'integer', 'exists:tenants,id'], + 'tenant_name' => ['required_if:application_type,tenant', 'nullable', 'string', 'max:150'], + 'tenant_code' => ['nullable', 'string', 'max:50'], 'phone' => ['nullable', 'string', 'max:30'], + 'email' => ['nullable', 'email', 'max:150'], 'address' => ['nullable', 'string', 'max:2000'], + 'reason' => ['required', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Notification/IndexNotificationRequest.php b/app/Http/Requests/Notification/IndexNotificationRequest.php index d8ed040..851f9e2 100644 --- a/app/Http/Requests/Notification/IndexNotificationRequest.php +++ b/app/Http/Requests/Notification/IndexNotificationRequest.php @@ -17,6 +17,7 @@ class IndexNotificationRequest extends FormRequest 'search' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'string', 'max:30'], 'unread' => ['nullable', 'boolean'], + 'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'], 'page' => ['nullable', 'integer', 'min:1'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], ]; diff --git a/app/Http/Requests/Notification/StoreSystemNotificationRequest.php b/app/Http/Requests/Notification/StoreSystemNotificationRequest.php new file mode 100644 index 0000000..556f416 --- /dev/null +++ b/app/Http/Requests/Notification/StoreSystemNotificationRequest.php @@ -0,0 +1,32 @@ + ['required', 'string', 'max:255'], + 'body' => ['required', 'string', 'max:10000'], + 'action_url' => ['nullable', 'string', 'max:2000'], + 'icon' => ['nullable', 'string', 'max:100'], + 'target_scope' => ['required', Rule::in(['all_users', 'tenant', 'access_level', 'selected_users'])], + 'tenant_id' => [Rule::requiredIf($this->input('target_scope') === 'tenant' && $this->user()?->isMasterAdmin()), 'nullable', 'integer', 'exists:tenants,id'], + 'statuses' => ['nullable', 'array'], + 'statuses.*' => ['string', Rule::in(['active', 'inactive', 'suspended'])], + 'access_levels' => ['required_if:target_scope,access_level', 'nullable', 'array', 'min:1'], + 'access_levels.*' => ['string', Rule::in(['customer', 'staff', 'tenant_owner', 'master_admin'])], + 'user_ids' => ['required_if:target_scope,selected_users', 'nullable', 'array', 'min:1', 'max:1000'], + 'user_ids.*' => ['integer', 'exists:users,id'], + ]; + } +} diff --git a/app/Http/Requests/User/StoreUserRequest.php b/app/Http/Requests/User/StoreUserRequest.php index a7a5602..2594612 100644 --- a/app/Http/Requests/User/StoreUserRequest.php +++ b/app/Http/Requests/User/StoreUserRequest.php @@ -22,6 +22,7 @@ class StoreUserRequest extends FormRequest 'whatsapp_number' => ['nullable', 'string', 'max:20'], 'password' => ['required', 'string', 'min:8'], 'is_master' => ['nullable', 'boolean'], + 'is_platform_staff' => ['nullable', 'boolean'], 'access_level' => ['nullable', Rule::enum(UserAccessLevel::class)], 'status' => ['required', Rule::in(['active', 'inactive', 'suspended'])], @@ -56,6 +57,7 @@ class StoreUserRequest extends FormRequest 'user_tenants' => ['nullable', 'array'], 'user_tenants.*.tenant_id' => ['required_with:user_tenants', 'integer', 'exists:tenants,id'], + 'user_tenants.*.role' => ['nullable', Rule::in(['customer', 'staff', 'tenant_owner'])], 'user_tenants.*.is_default' => ['nullable', 'boolean'], 'user_menu_groups' => ['nullable', 'array'], diff --git a/app/Http/Requests/User/UpdateUserRequest.php b/app/Http/Requests/User/UpdateUserRequest.php index 06ad2a9..fbfe3b7 100644 --- a/app/Http/Requests/User/UpdateUserRequest.php +++ b/app/Http/Requests/User/UpdateUserRequest.php @@ -35,6 +35,7 @@ class UpdateUserRequest extends FormRequest 'whatsapp_number' => ['nullable', 'string', 'max:20'], 'password' => ['nullable', 'string', 'min:8'], 'is_master' => ['nullable', 'boolean'], + 'is_platform_staff' => ['nullable', 'boolean'], 'access_level' => ['sometimes', 'required', Rule::enum(UserAccessLevel::class)], 'status' => ['sometimes', 'required', Rule::in(['active', 'inactive', 'suspended'])], @@ -69,6 +70,7 @@ class UpdateUserRequest extends FormRequest 'user_tenants' => ['nullable', 'array'], 'user_tenants.*.tenant_id' => ['required_with:user_tenants', 'integer', 'exists:tenants,id'], + 'user_tenants.*.role' => ['nullable', Rule::in(['customer', 'staff', 'tenant_owner'])], 'user_tenants.*.is_default' => ['nullable', 'boolean'], 'user_menu_groups' => ['nullable', 'array'], diff --git a/app/Http/Requests/Voucher/IndexVoucherRequest.php b/app/Http/Requests/Voucher/IndexVoucherRequest.php new file mode 100644 index 0000000..331ce7a --- /dev/null +++ b/app/Http/Requests/Voucher/IndexVoucherRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'string', 'max:100'], 'payment_status' => ['nullable', 'string', 'max:30'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], 'page' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Voucher/SaveVoucherLoginPageRequest.php b/app/Http/Requests/Voucher/SaveVoucherLoginPageRequest.php new file mode 100644 index 0000000..34b1347 --- /dev/null +++ b/app/Http/Requests/Voucher/SaveVoucherLoginPageRequest.php @@ -0,0 +1,24 @@ + ['required', 'boolean'], 'title' => ['required', 'string', 'max:150'], + 'theme_color' => ['required', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'allowed_package_profile_ids' => ['nullable', 'array'], + 'allowed_package_profile_ids.*' => ['integer', 'exists:nas_package_profiles,id'], + 'settings' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Voucher/StoreVoucherAgentRequest.php b/app/Http/Requests/Voucher/StoreVoucherAgentRequest.php new file mode 100644 index 0000000..2952d3a --- /dev/null +++ b/app/Http/Requests/Voucher/StoreVoucherAgentRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'integer', 'exists:tenants,id'], 'user_id' => ['required', 'integer', 'exists:users,id'], + 'agent_code' => ['nullable', 'string', 'max:50'], 'payment_type' => ['required', Rule::in(['prepaid', 'postpaid'])], + 'price_mode' => ['required', Rule::in(['discount', 'commission'])], + 'discount_percent' => ['required', 'numeric', 'min:0', 'max:100'], + 'commission_percent' => ['required', 'numeric', 'min:0', 'max:100'], + 'credit_limit' => ['required', 'numeric', 'min:0'], 'status' => ['required', Rule::in(['active', 'inactive', 'suspended'])], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Voucher/StoreVoucherSaleRequest.php b/app/Http/Requests/Voucher/StoreVoucherSaleRequest.php new file mode 100644 index 0000000..4799c61 --- /dev/null +++ b/app/Http/Requests/Voucher/StoreVoucherSaleRequest.php @@ -0,0 +1,22 @@ + ['required', 'integer', 'exists:nas_package_profiles,id'], + 'nas_mikrotik_id' => ['nullable', 'integer', 'exists:nas_mikrotiks,id'], + 'quantity' => ['required', 'integer', 'min:1', 'max:100'], 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Resources/Notification/SystemNotificationCampaignResource.php b/app/Http/Resources/Notification/SystemNotificationCampaignResource.php new file mode 100644 index 0000000..b46d2fa --- /dev/null +++ b/app/Http/Resources/Notification/SystemNotificationCampaignResource.php @@ -0,0 +1,22 @@ + $this->id, 'uuid' => $this->uuid, 'tenant_id' => $this->tenant_id, + 'title' => $this->title, 'body' => $this->body, 'action_url' => $this->action_url, + 'icon' => $this->icon, 'audience' => $this->audience, 'status' => $this->status, + 'estimated_recipients' => $this->estimated_recipients, + 'processed_recipients' => $this->processed_recipients, + 'tenant' => $this->whenLoaded('tenant'), 'creator' => $this->whenLoaded('creator'), + 'completed_at' => $this->completed_at, 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/User/UserResource.php b/app/Http/Resources/User/UserResource.php index cf4031d..b0d2a70 100644 --- a/app/Http/Resources/User/UserResource.php +++ b/app/Http/Resources/User/UserResource.php @@ -55,6 +55,7 @@ class UserResource extends JsonResource 'id' => $item->id, 'user_id' => $item->user_id, 'tenant_id' => $item->tenant_id, + 'role' => $item->role, 'is_default' => (bool) $item->is_default, 'created_at' => $item->created_at, 'updated_at' => $item->updated_at, diff --git a/app/Http/Resources/Voucher/VoucherAgentResource.php b/app/Http/Resources/Voucher/VoucherAgentResource.php new file mode 100644 index 0000000..d890409 --- /dev/null +++ b/app/Http/Resources/Voucher/VoucherAgentResource.php @@ -0,0 +1,21 @@ + $this->id, 'uuid' => $this->uuid, 'tenant_id' => $this->tenant_id, + 'user_id' => $this->user_id, 'user' => $this->whenLoaded('user'), 'agent_code' => $this->agent_code, + 'payment_type' => $this->payment_type, 'price_mode' => $this->price_mode, + 'discount_percent' => $this->discount_percent, 'commission_percent' => $this->commission_percent, + 'credit_limit' => $this->credit_limit, 'outstanding_balance' => $this->outstanding_balance, + 'status' => $this->status, 'notes' => $this->notes, 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/Voucher/VoucherSaleResource.php b/app/Http/Resources/Voucher/VoucherSaleResource.php new file mode 100644 index 0000000..f918ef1 --- /dev/null +++ b/app/Http/Resources/Voucher/VoucherSaleResource.php @@ -0,0 +1,25 @@ + $this->id, 'uuid' => $this->uuid, 'sale_number' => $this->sale_number, + 'agent' => $this->whenLoaded('agent'), 'package_profile' => $this->whenLoaded('packageProfile'), + 'creator' => $this->whenLoaded('creator'), 'quantity' => $this->quantity, 'unit_price' => $this->unit_price, + 'discount_amount' => $this->discount_amount, 'total_amount' => $this->total_amount, + 'payment_status' => $this->payment_status, 'status' => $this->status, 'created_at' => $this->created_at, + 'vouchers' => $this->whenLoaded('vouchers', fn () => $this->vouchers->map(fn ($voucher) => [ + 'id' => $voucher->id, 'uuid' => $voucher->uuid, 'username' => $voucher->username, + 'password' => $voucher->access_password, 'status' => $voucher->status, 'sync_status' => $voucher->sync_status, + 'mikrotik' => $voucher->mikrotik, 'valid_until' => $voucher->valid_until, + ])), + ]; + } +} diff --git a/app/Jobs/ProcessSystemNotificationCampaign.php b/app/Jobs/ProcessSystemNotificationCampaign.php new file mode 100644 index 0000000..5fc7078 --- /dev/null +++ b/app/Jobs/ProcessSystemNotificationCampaign.php @@ -0,0 +1,61 @@ +campaignId); + if (! $campaign || $campaign->status === 'completed') { + return; + } + + $campaign->update(['status' => 'processing']); + $query = $this->audienceQuery($campaign); + $campaign->update(['estimated_recipients' => (clone $query)->count()]); + + $query->select('users.id')->chunkById(500, function ($users) use ($campaign) { + $now = now(); + DB::table('user_notifications')->insertOrIgnore($users->map(fn ($user) => [ + 'uuid' => Str::uuid(), + 'tenant_id' => $campaign->tenant_id, + 'user_id' => $user->id, + 'system_notification_campaign_id' => $campaign->id, + 'title' => $campaign->title, + 'body' => $campaign->body, + 'action_url' => $campaign->action_url, + 'icon' => $campaign->icon, + 'created_at' => $now, + 'updated_at' => $now, + ])->all()); + $campaign->increment('processed_recipients', $users->count()); + }); + $campaign->update(['status' => 'completed', 'completed_at' => now()]); + } + + private function audienceQuery(SystemNotificationCampaign $campaign) + { + $audience = $campaign->audience; + + return User::query() + ->whereNull('deleted_at') + ->when($audience['statuses'] ?? null, fn ($q, $values) => $q->whereIn('status', $values)) + ->when(! $campaign->tenant_id && ($audience['access_levels'] ?? null), fn ($q) => $q->whereIn('access_level', $audience['access_levels'])) + ->when($audience['user_ids'] ?? null, fn ($q, $values) => $q->whereIn('id', $values)) + ->when($campaign->tenant_id, fn ($q, $tenantId) => $q->whereHas('userTenants', fn ($x) => $x + ->where('tenant_id', $tenantId) + ->when($audience['access_levels'] ?? null, fn ($roleQuery, $roles) => $roleQuery->whereIn('role', $roles)))); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 8e5dab3..f6e64ba 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -95,4 +95,9 @@ class Customer extends Model { return $this->hasMany(CustomerContact::class); } + + public function linkedUsers() + { + return $this->belongsToMany(User::class, 'customer_user_links')->withPivot(['tenant_id', 'relationship', 'is_primary'])->withTimestamps(); + } } diff --git a/app/Models/CustomerUserLink.php b/app/Models/CustomerUserLink.php new file mode 100644 index 0000000..9793cad --- /dev/null +++ b/app/Models/CustomerUserLink.php @@ -0,0 +1,30 @@ + 'boolean']; + } + + public function customer() + { + return $this->belongsTo(Customer::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/HotspotVoucher.php b/app/Models/HotspotVoucher.php new file mode 100644 index 0000000..23e2032 --- /dev/null +++ b/app/Models/HotspotVoucher.php @@ -0,0 +1,35 @@ + 'encrypted', 'profile_snapshot' => 'array', 'valid_until' => 'datetime', + 'activated_at' => 'datetime', 'used_at' => 'datetime', 'synced_at' => 'datetime', + ]; + } + + public function sale() + { + return $this->belongsTo(VoucherSale::class, 'voucher_sale_id'); + } + + public function packageProfile() + { + return $this->belongsTo(NasPackageProfile::class, 'nas_package_profile_id'); + } + + public function mikrotik() + { + return $this->belongsTo(NasMikrotik::class, 'nas_mikrotik_id'); + } +} diff --git a/app/Models/NotificationChannel.php b/app/Models/NotificationChannel.php index b86326f..6bdf7d6 100644 --- a/app/Models/NotificationChannel.php +++ b/app/Models/NotificationChannel.php @@ -30,4 +30,9 @@ class NotificationChannel extends Model { return $this->hasMany(NotificationTemplate::class); } + + public function deliveries() + { + return $this->hasMany(NotificationDelivery::class); + } } diff --git a/app/Models/RoleApplication.php b/app/Models/RoleApplication.php new file mode 100644 index 0000000..888d51b --- /dev/null +++ b/app/Models/RoleApplication.php @@ -0,0 +1,30 @@ + 'array', 'submitted_at' => 'datetime', 'reviewed_at' => 'datetime']; + } + + public function applicant() + { + return $this->belongsTo(User::class, 'applicant_user_id'); + } + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function reviewer() + { + return $this->belongsTo(User::class, 'reviewed_by'); + } +} diff --git a/app/Models/SystemNotificationCampaign.php b/app/Models/SystemNotificationCampaign.php new file mode 100644 index 0000000..9429f79 --- /dev/null +++ b/app/Models/SystemNotificationCampaign.php @@ -0,0 +1,30 @@ + 'array', 'completed_at' => 'datetime']; + } + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function notifications() + { + return $this->hasMany(UserNotification::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index be8b78a..451a5fe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -36,6 +36,7 @@ class User extends Authenticatable 'profile_photo_file_id', 'password', 'is_master', + 'is_platform_staff', 'access_level', 'status', 'verification_channel', @@ -66,6 +67,7 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'password' => 'hashed', 'is_master' => 'boolean', + 'is_platform_staff' => 'boolean', 'access_level' => UserAccessLevel::class, 'verification_code_expires_at' => 'datetime', 'verified_at' => 'datetime', @@ -112,14 +114,45 @@ class User extends Authenticatable return $this->morphMany(Wallet::class, 'owner'); } + public function voucherAgents() + { + return $this->hasMany(VoucherAgent::class); + } + + public function roleApplications() + { + return $this->hasMany(RoleApplication::class, 'applicant_user_id'); + } + + public function linkedCustomers() + { + return $this->belongsToMany(Customer::class, 'customer_user_links')->withPivot(['tenant_id', 'relationship', 'is_primary'])->withTimestamps(); + } + public function isMasterAdmin(): bool { return $this->is_master || $this->access_level === UserAccessLevel::MASTER_ADMIN; } - public function isTenantOwner(): bool + public function isTenantOwner(?int $tenantId = null): bool { - return $this->access_level === UserAccessLevel::TENANT_OWNER; + if ($this->access_level !== UserAccessLevel::TENANT_OWNER) { + return false; + } + + return $this->userTenants() + ->where('role', 'tenant_owner') + ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) + ->exists(); + } + + public function tenantRole(?int $tenantId): ?string + { + if (! $tenantId) { + return null; + } + + return $this->userTenants()->where('tenant_id', $tenantId)->value('role'); } } diff --git a/app/Models/UserNotification.php b/app/Models/UserNotification.php index 76cc895..ec22cde 100644 --- a/app/Models/UserNotification.php +++ b/app/Models/UserNotification.php @@ -13,5 +13,13 @@ class UserNotification extends Model return ['read_at' => 'datetime']; } - public function user() { return $this->belongsTo(User::class); } + public function user() + { + return $this->belongsTo(User::class); + } + + public function campaign() + { + return $this->belongsTo(SystemNotificationCampaign::class, 'system_notification_campaign_id'); + } } diff --git a/app/Models/UserTenant.php b/app/Models/UserTenant.php index 3295a68..3663644 100644 --- a/app/Models/UserTenant.php +++ b/app/Models/UserTenant.php @@ -11,6 +11,7 @@ class UserTenant extends Model protected $fillable = [ 'user_id', 'tenant_id', + 'role', 'is_default', ]; diff --git a/app/Models/VoucherAgent.php b/app/Models/VoucherAgent.php new file mode 100644 index 0000000..021ea00 --- /dev/null +++ b/app/Models/VoucherAgent.php @@ -0,0 +1,33 @@ + 'decimal:2', 'commission_percent' => 'decimal:2', + 'credit_limit' => 'decimal:2', 'outstanding_balance' => 'decimal:2', + ]; + } + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function sales() + { + return $this->hasMany(VoucherSale::class); + } +} diff --git a/app/Models/VoucherLoginPageSetting.php b/app/Models/VoucherLoginPageSetting.php new file mode 100644 index 0000000..c87bbdf --- /dev/null +++ b/app/Models/VoucherLoginPageSetting.php @@ -0,0 +1,28 @@ + 'boolean', 'allowed_package_profile_ids' => 'array', + 'settings' => 'array', 'enabled_at' => 'datetime', + ]; + } + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function gatewayAccount() + { + return $this->belongsTo(PaymentGatewayAccount::class, 'payment_gateway_account_id'); + } +} diff --git a/app/Models/VoucherSale.php b/app/Models/VoucherSale.php new file mode 100644 index 0000000..ed83ff3 --- /dev/null +++ b/app/Models/VoucherSale.php @@ -0,0 +1,43 @@ + 'decimal:2', 'discount_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', 'pricing_snapshot' => 'array', + ]; + } + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function agent() + { + return $this->belongsTo(VoucherAgent::class, 'voucher_agent_id'); + } + + public function packageProfile() + { + return $this->belongsTo(NasPackageProfile::class, 'nas_package_profile_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function vouchers() + { + return $this->hasMany(HotspotVoucher::class); + } +} diff --git a/app/Models/Wallet.php b/app/Models/Wallet.php index 788bb81..db87523 100644 --- a/app/Models/Wallet.php +++ b/app/Models/Wallet.php @@ -18,6 +18,11 @@ class Wallet extends Model return $this->morphTo(); } + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + public function ledgerEntries() { return $this->hasMany(WalletLedgerEntry::class); diff --git a/app/Services/Access/AccessService.php b/app/Services/Access/AccessService.php index b845cfa..83f6156 100644 --- a/app/Services/Access/AccessService.php +++ b/app/Services/Access/AccessService.php @@ -66,7 +66,7 @@ class AccessService return true; } - if (! $actor->isTenantOwner() || ! $tenantId || $target->isMasterAdmin()) { + if (! $actor->isTenantOwner($tenantId) || ! $tenantId || $target->isMasterAdmin()) { return false; } diff --git a/app/Services/Access/RoleApplicationService.php b/app/Services/Access/RoleApplicationService.php new file mode 100644 index 0000000..a801de6 --- /dev/null +++ b/app/Services/Access/RoleApplicationService.php @@ -0,0 +1,288 @@ +roleApplications()->with(['tenant:id,tenant_code,tenant_name', 'reviewer:id,name'])->latest('id')->get(); + } + + public function submit(array $data, User $user): RoleApplication + { + $type = $data['application_type']; + if (RoleApplication::where('applicant_user_id', $user->id)->where('application_type', $type)->where('status', 'pending')->exists()) { + throw ValidationException::withMessages(['application_type' => 'Masih ada pengajuan sejenis yang menunggu persetujuan.']); + } + if ($type === 'tenant' && $user->userTenants()->where('role', 'tenant_owner')->exists()) { + throw ValidationException::withMessages(['application_type' => 'Satu user hanya boleh menjadi Tenant Owner pada satu tenant.']); + } + if ($type === 'agent' && VoucherAgent::where('user_id', $user->id)->where('tenant_id', $data['tenant_id'])->where('status', 'active')->exists()) { + throw ValidationException::withMessages(['tenant_id' => 'User sudah menjadi agen aktif pada tenant tersebut.']); + } + if ($type === 'staff' && $user->userTenants()->where('tenant_id', $data['tenant_id'])->where('role', 'tenant_owner')->exists()) { + throw ValidationException::withMessages(['tenant_id' => 'Tenant Owner tidak dapat mengajukan diri sebagai staff pada tenant miliknya.']); + } + + return RoleApplication::create([ + 'uuid' => Str::uuid(), 'applicant_user_id' => $user->id, + 'tenant_id' => in_array($type, ['agent', 'staff'], true) ? $data['tenant_id'] : null, 'application_type' => $type, + 'status' => 'pending', 'payload' => collect($data)->except(['application_type', 'tenant_id'])->all(), + 'submitted_at' => now(), + ])->load('tenant:id,tenant_code,tenant_name'); + } + + public function reviewList(string $type, array $filters, User $reviewer, ?int $tenantId) + { + $this->authorizeReviewer($type, $reviewer, $tenantId); + + return RoleApplication::with(['applicant:id,name,username,email,whatsapp_number', 'tenant:id,tenant_code,tenant_name', 'reviewer:id,name']) + ->where('application_type', $type) + ->when(in_array($type, ['agent', 'staff'], true) && ! $reviewer->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) + ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) + ->when($filters['search'] ?? null, fn ($q, $search) => $q->whereHas('applicant', fn ($x) => $x->where('name', 'ilike', "%{$search}%")->orWhere('email', 'ilike', "%{$search}%"))) + ->latest('id')->paginate($filters['per_page'] ?? 10); + } + + public function tenantMenuGroupOptions(User $reviewer): array + { + $this->authorizeReviewer('tenant', $reviewer, null); + + return MenuGroup::query() + ->whereNull('tenant_id') + ->where('is_system', true) + ->where('is_active', true) + ->withCount('menus') + ->orderBy('name') + ->get(['id', 'name', 'description']) + ->map(fn (MenuGroup $group) => [ + 'id' => $group->id, + 'name' => $group->name, + 'description' => $group->description, + 'menus_count' => $group->menus_count, + ])->all(); + } + + public function staffMenuGroupOptions(User $reviewer, ?int $tenantId): array + { + $this->authorizeReviewer('staff', $reviewer, $tenantId); + + $assignedGroupIds = UserMenuGroup::query() + ->where('user_id', $reviewer->id) + ->where('tenant_id', $tenantId) + ->pluck('menu_group_id'); + + return MenuGroup::query() + ->where('is_active', true) + ->where(function ($query) use ($tenantId, $assignedGroupIds) { + $query->where('tenant_id', $tenantId) + ->orWhereIn('id', $assignedGroupIds); + }) + ->withCount('menus') + ->orderBy('name') + ->get(['id', 'name', 'description', 'tenant_id']) + ->map(fn (MenuGroup $group) => [ + 'id' => $group->id, + 'name' => $group->name, + 'description' => $group->description, + 'menus_count' => $group->menus_count, + 'is_tenant_group' => (int) $group->tenant_id === (int) $tenantId, + ])->all(); + } + + public function review(RoleApplication $application, array $data, User $reviewer, ?int $tenantId): RoleApplication + { + $this->authorizeReviewer($application->application_type, $reviewer, $tenantId, $application->tenant_id); + if ($application->status !== 'pending') { + throw ValidationException::withMessages(['application' => 'Pengajuan ini sudah ditinjau.']); + } + + return DB::transaction(function () use ($application, $data, $reviewer) { + if ($data['decision'] === 'approved') { + if ($application->application_type === 'agent' && empty($data['payment_type'])) { + throw ValidationException::withMessages(['payment_type' => 'Kategori pembayaran agen wajib dipilih.']); + } + match ($application->application_type) { + 'tenant' => $this->approveTenant($application, (int) $data['menu_group_id']), + 'staff' => $this->approveStaff($application, (int) $data['menu_group_id'], $reviewer), + default => $this->approveAgent($application, $data), + }; + } + $application->update([ + 'status' => $data['decision'], 'reviewed_by' => $reviewer->id, + 'review_notes' => $data['review_notes'] ?? null, 'reviewed_at' => now(), + ]); + + return $application->fresh(['applicant:id,name,username,email', 'tenant:id,tenant_code,tenant_name', 'reviewer:id,name']); + }); + } + + public function linkCustomer(array $data, User $reviewer, int $tenantId): CustomerUserLink + { + abort_unless($reviewer->isMasterAdmin() || in_array($reviewer->tenantRole($tenantId), ['tenant_owner', 'staff'], true), 403); + $customer = Customer::whereKey($data['customer_id'])->where('tenant_id', $tenantId)->firstOrFail(); + $link = CustomerUserLink::updateOrCreate(['customer_id' => $customer->id, 'user_id' => $data['user_id']], [ + 'tenant_id' => $tenantId, 'linked_by' => $reviewer->id, + 'relationship' => $data['relationship'] ?? 'owner', 'is_primary' => $data['is_primary'] ?? true, + ]); + if (! $customer->user_id && ($data['is_primary'] ?? true)) { + $customer->update(['user_id' => $data['user_id']]); + } + + return $link->load(['customer:id,tenant_id,customer_code,name', 'user:id,name,username,email']); + } + + private function approveTenant(RoleApplication $application, int $templateGroupId): void + { + $payload = $application->payload; + $group = MenuGroup::query() + ->whereNull('tenant_id') + ->where('is_system', true) + ->where('is_active', true) + ->findOrFail($templateGroupId); + $tenant = Tenant::create([ + 'tenant_code' => $this->uniqueTenantCode($payload['tenant_code'] ?? null), 'tenant_name' => $payload['tenant_name'], + 'phone' => $payload['phone'] ?? null, 'email' => $payload['email'] ?? null, + 'address' => $payload['address'] ?? null, 'status' => 'active', + ]); + $application->tenant_id = $tenant->id; + if (UserTenant::where('user_id', $application->applicant_user_id)->where('role', 'tenant_owner')->exists()) { + throw ValidationException::withMessages([ + 'application' => 'User sudah menjadi Tenant Owner pada tenant lain.', + ]); + } + UserTenant::where('user_id', $application->applicant_user_id)->update(['is_default' => false]); + UserTenant::updateOrCreate( + ['user_id' => $application->applicant_user_id, 'tenant_id' => $tenant->id], + ['role' => 'tenant_owner', 'is_default' => true] + ); + $application->applicant()->update(['access_level' => 'tenant_owner']); + + UserMenuGroup::create([ + 'user_id' => $application->applicant_user_id, + 'tenant_id' => $tenant->id, + 'menu_group_id' => $group->id, + ]); + + $application->payload = array_merge($payload, [ + 'approved_menu_group_id' => $group->id, + 'approved_menu_group_name' => $group->name, + ]); + } + + private function approveStaff(RoleApplication $application, int $menuGroupId, User $reviewer): void + { + $availableGroupIds = collect($this->staffMenuGroupOptions($reviewer, $application->tenant_id))->pluck('id'); + if (! $availableGroupIds->contains($menuGroupId)) { + throw ValidationException::withMessages([ + 'menu_group_id' => 'Group Menu tidak tersedia pada tenant aktif.', + ]); + } + + $existingTenantRole = UserTenant::query() + ->where('user_id', $application->applicant_user_id) + ->where('tenant_id', $application->tenant_id) + ->value('role'); + if ($existingTenantRole === 'tenant_owner') { + throw ValidationException::withMessages([ + 'application' => 'Tenant Owner tidak dapat diubah menjadi staff pada tenant miliknya.', + ]); + } + + UserTenant::updateOrCreate( + ['user_id' => $application->applicant_user_id, 'tenant_id' => $application->tenant_id], + ['role' => 'staff', 'is_default' => ! UserTenant::where('user_id', $application->applicant_user_id)->exists()] + ); + if (! $application->applicant->userTenants()->where('role', 'tenant_owner')->exists()) { + $application->applicant()->update(['access_level' => 'staff']); + } + UserMenuGroup::where('user_id', $application->applicant_user_id) + ->where('tenant_id', $application->tenant_id) + ->delete(); + UserMenuGroup::create([ + 'user_id' => $application->applicant_user_id, + 'tenant_id' => $application->tenant_id, + 'menu_group_id' => $menuGroupId, + ]); + + $group = MenuGroup::findOrFail($menuGroupId); + $application->payload = array_merge($application->payload, [ + 'approved_menu_group_id' => $group->id, + 'approved_menu_group_name' => $group->name, + ]); + } + + private function approveAgent(RoleApplication $application, array $data): void + { + UserTenant::updateOrCreate(['user_id' => $application->applicant_user_id, 'tenant_id' => $application->tenant_id], [ + 'is_default' => ! UserTenant::where('user_id', $application->applicant_user_id)->exists(), + ]); + VoucherAgent::updateOrCreate(['tenant_id' => $application->tenant_id, 'user_id' => $application->applicant_user_id], [ + 'uuid' => Str::uuid(), 'agent_code' => 'AGT-'.$application->tenant_id.'-'.Str::upper(Str::random(6)), + 'payment_type' => $data['payment_type'], 'price_mode' => $data['price_mode'] ?? 'discount', + 'discount_percent' => $data['discount_percent'] ?? 0, 'commission_percent' => $data['commission_percent'] ?? 0, + 'credit_limit' => $data['payment_type'] === 'postpaid' ? ($data['credit_limit'] ?? 0) : 0, + 'status' => 'active', 'notes' => $data['review_notes'] ?? null, + ]); + $this->assignDefaultGroup($application->applicant, Tenant::findOrFail($application->tenant_id), 'agent'); + } + + private function assignDefaultGroup(User $user, Tenant $tenant, string $role): void + { + $name = $role === 'tenant_owner' ? 'Tenant Owner Default' : 'Agen Voucher Default'; + $group = MenuGroup::firstOrCreate(['tenant_id' => $tenant->id, 'name' => $name], [ + 'description' => 'Dibuat otomatis dari approval pengajuan.', 'is_system' => true, 'is_active' => true, + ]); + $slugs = $role === 'tenant_owner' + ? MenuList::where('is_active', true)->whereNotIn('slug', ['tenants', 'role-applications-tenants'])->pluck('id') + : MenuList::whereIn('slug', ['voucher-hotspot', 'voucher-hotspot-sales', 'wallet'])->pluck('id'); + $group->menus()->syncWithoutDetaching($slugs->mapWithKeys(fn ($id) => [$id => [ + 'can_view' => true, 'can_create' => true, 'can_update' => $role === 'tenant_owner', + 'can_delete' => $role === 'tenant_owner', 'can_approve' => $role === 'tenant_owner', 'can_export' => true, + ]])->all()); + UserMenuGroup::firstOrCreate(['user_id' => $user->id, 'tenant_id' => $tenant->id, 'menu_group_id' => $group->id]); + } + + private function authorizeReviewer(string $type, User $reviewer, ?int $tenantId, ?int $applicationTenantId = null): void + { + if ($type === 'tenant') { + abort_unless($reviewer->isMasterAdmin() || $reviewer->is_platform_staff, 403); + } + if ($type === 'agent' && ! $reviewer->isMasterAdmin()) { + abort_unless($tenantId && (! $applicationTenantId || $tenantId === $applicationTenantId) + && in_array($reviewer->tenantRole($tenantId), ['tenant_owner', 'staff'], true), 403); + } + if ($type === 'staff') { + abort_unless($tenantId + && (! $applicationTenantId || $tenantId === $applicationTenantId) + && $reviewer->isTenantOwner($tenantId), 403); + } + } + + private function uniqueTenantCode(?string $requested): string + { + $base = Str::upper(Str::slug($requested ?: 'TNT-'.Str::random(6), '')); + $code = $base; + $counter = 1; + while (Tenant::where('tenant_code', $code)->exists()) { + $code = $base.'-'.$counter++; + } + + return $code; + } +} diff --git a/app/Services/Auth/AuthService.php b/app/Services/Auth/AuthService.php index 8404231..0ef0654 100644 --- a/app/Services/Auth/AuthService.php +++ b/app/Services/Auth/AuthService.php @@ -19,6 +19,8 @@ class AuthService 'whatsapp_number' => $data['whatsapp_number'], 'password' => Hash::make($data['password']), 'is_master' => false, + 'is_platform_staff' => false, + 'access_level' => 'customer', 'status' => 'inactive', 'verification_channel' => $data['verification_channel'], 'verification_target' => $data['verification_target'], @@ -32,7 +34,8 @@ class AuthService 'verification' => [ 'channel' => $user->verification_channel, 'target' => $user->verification_target, - 'code' => $code, + 'code' => config('registration.show_verification_code') ? $code : null, + 'code_visible' => (bool) config('registration.show_verification_code'), 'expires_at' => $user->verification_code_expires_at, ], ]; @@ -45,7 +48,7 @@ class AuthService ->where('verification_target', $data['verification_target']) ->first(); - if (!$user) { + if (! $user) { throw ValidationException::withMessages([ 'verification_target' => 'Target verifikasi tidak ditemukan.', ]); @@ -83,7 +86,7 @@ class AuthService ->where('verification_target', $data['verification_target']) ->first(); - if (!$user) { + if (! $user) { throw ValidationException::withMessages([ 'verification_target' => 'Target verifikasi tidak ditemukan.', ]); @@ -105,7 +108,8 @@ class AuthService return [ 'channel' => $user->verification_channel, 'target' => $user->verification_target, - 'code' => $code, + 'code' => config('registration.show_verification_code') ? $code : null, + 'code_visible' => (bool) config('registration.show_verification_code'), 'expires_at' => $user->verification_code_expires_at, ]; } diff --git a/app/Services/Customer/CustomerService.php b/app/Services/Customer/CustomerService.php index 0ff7d2b..bdaf16d 100644 --- a/app/Services/Customer/CustomerService.php +++ b/app/Services/Customer/CustomerService.php @@ -100,6 +100,18 @@ class CustomerService 'registration', ['action_url' => "/customers/orders/{$customer->id}/detail"], ); + User::query() + ->whereHas('userTenants', fn ($query) => $query + ->where('tenant_id', $customer->tenant_id) + ->where('role', 'tenant_owner')) + ->each(fn (User $owner) => $this->notifications->emit( + 'customer.registration', + $owner, + $customer, + ['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []], + 'registration', + ['action_url' => "/customers/orders/{$customer->id}/detail", 'icon' => 'cil-user-follow'], + )); return $customer->load(self::DETAIL_RELATIONS); } diff --git a/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php b/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php index 64fcda8..c091d59 100644 --- a/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php +++ b/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php @@ -4,6 +4,7 @@ namespace App\Services\Notification\Adapters; use App\Models\NotificationDelivery; use App\Services\Notification\Contracts\NotificationChannelAdapter; +use App\Services\Notification\WhapiIdService; use Illuminate\Support\Facades\Http; use Illuminate\Validation\ValidationException; @@ -12,6 +13,11 @@ class WhatsAppUnofficialAdapter implements NotificationChannelAdapter public function send(NotificationDelivery $delivery): array { $config = $delivery->channelConfig; + if ($config?->provider === 'whapi_id') { + $response = app(WhapiIdService::class)->send($config, $delivery->destination, $delivery->rendered_body); + + return ['provider_message_id' => data_get($response, 'results.id_message'), 'response' => $response]; + } $credentials = $config?->credentials ?? []; $settings = $config?->settings ?? []; $url = $settings['send_url'] ?? null; diff --git a/app/Services/Notification/NotificationManager.php b/app/Services/Notification/NotificationManager.php index d3a3d2b..b8a42be 100644 --- a/app/Services/Notification/NotificationManager.php +++ b/app/Services/Notification/NotificationManager.php @@ -40,12 +40,17 @@ class NotificationManager } return DB::transaction(function () use ($event, $recipient, $subject, $payload, $occurrenceKey, $context, $tenantId) { + $recipientOccurrenceKey = Str::limit( + $occurrenceKey.'|'.$recipient->getMorphClass().'|'.$recipient->getKey(), + 255, + '' + ); $occurrence = NotificationEventOccurrence::firstOrCreate([ 'tenant_id' => $tenantId, 'notification_event_id' => $event->id, 'subjectable_type' => $subject->getMorphClass(), 'subjectable_id' => $subject->getKey(), - 'occurrence_key' => $occurrenceKey, + 'occurrence_key' => $recipientOccurrenceKey, ]); if (! $occurrence->wasRecentlyCreated) { return null; @@ -57,6 +62,20 @@ class NotificationManager ->where('enabled', true) ->orderBy('priority') ->get(); + $systemConfigured = NotificationPreference::where('tenant_id', $tenantId) + ->where('notification_event_id', $event->id) + ->where('channel', 'system') + ->exists(); + if (! $systemConfigured && in_array('system', $event->available_channels ?? [], true)) { + $preferences->prepend(new NotificationPreference([ + 'tenant_id' => $tenantId, + 'notification_event_id' => $event->id, + 'channel' => 'system', + 'enabled' => true, + 'priority' => 1, + 'send_delay_minutes' => 0, + ])); + } if ($preferences->isEmpty()) { $occurrence->update(['processed_at' => now()]); diff --git a/app/Services/Notification/NotificationService.php b/app/Services/Notification/NotificationService.php index a3f54dd..bc94a6f 100644 --- a/app/Services/Notification/NotificationService.php +++ b/app/Services/Notification/NotificationService.php @@ -4,6 +4,7 @@ namespace App\Services\Notification; use App\Jobs\ProcessBroadcast; use App\Jobs\ProcessNotificationDelivery; +use App\Jobs\ProcessSystemNotificationCampaign; use App\Models\Customer; use App\Models\CustomerContact; use App\Models\NotificationBroadcast; @@ -14,6 +15,7 @@ use App\Models\NotificationEvent; use App\Models\NotificationMessage; use App\Models\NotificationPreference; use App\Models\NotificationTemplate; +use App\Models\SystemNotificationCampaign; use App\Models\TopologyLink; use App\Models\User; use App\Models\UserNotification; @@ -32,29 +34,126 @@ class NotificationService return NotificationChannel::with('tenant:id,tenant_code,tenant_name') ->where('channel', $channel) ->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) - ->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) + ->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where( + fn ($scope) => $scope->where('tenant_id', $tenantId)->orWhereNull('tenant_id') + )) ->orderByDesc('is_default')->orderBy('name')->get() ->each(fn ($item) => $item->setAttribute('credentials_configured', ! empty($item->getRawOriginal('credentials')))); } + public function systemCampaigns(array $filters, User $actor, ?int $tenantId) + { + return SystemNotificationCampaign::with(['tenant:id,tenant_code,tenant_name', 'creator:id,name']) + ->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) + ->when($actor->isMasterAdmin() && ! empty($filters['tenant_id']), fn ($q) => $q->where('tenant_id', $filters['tenant_id'])) + ->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status'])) + ->latest('id')->paginate($filters['per_page'] ?? 10); + } + + public function createSystemCampaign(array $data, User $actor, ?int $tenantId): SystemNotificationCampaign + { + $effectiveTenantId = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? null) : $tenantId; + if (! $actor->isMasterAdmin() && ! $effectiveTenantId) { + throw ValidationException::withMessages(['tenant_id' => 'Tenant aktif wajib tersedia.']); + } + if (! $actor->isMasterAdmin() && ($data['target_scope'] ?? null) === 'all_users') { + throw ValidationException::withMessages(['target_scope' => 'Hanya Master Admin yang dapat mengirim ke semua user.']); + } + if (($data['target_scope'] ?? null) === 'selected_users' && empty($data['user_ids'])) { + throw ValidationException::withMessages(['user_ids' => 'Pilih minimal satu user penerima.']); + } + if ($effectiveTenantId && ! empty($data['user_ids'])) { + $validCount = User::whereIn('id', $data['user_ids']) + ->whereHas('userTenants', fn ($q) => $q->where('tenant_id', $effectiveTenantId))->count(); + if ($validCount !== count(array_unique($data['user_ids']))) { + throw ValidationException::withMessages(['user_ids' => 'Terdapat user di luar tenant aktif.']); + } + } + + $campaign = SystemNotificationCampaign::create([ + 'uuid' => Str::uuid(), + 'tenant_id' => $effectiveTenantId, + 'created_by' => $actor->id, + 'title' => $data['title'], + 'body' => $data['body'], + 'action_url' => $data['action_url'] ?? null, + 'icon' => $data['icon'] ?? 'cil-bell', + 'audience' => [ + 'target_scope' => $data['target_scope'], + 'statuses' => $data['statuses'] ?? [], + 'access_levels' => $data['access_levels'] ?? [], + 'user_ids' => $data['user_ids'] ?? [], + ], + ]); + ProcessSystemNotificationCampaign::dispatch($campaign->id)->onQueue('system-notifications'); + + return $campaign; + } + + public function systemAudienceOptions(User $actor, ?int $tenantId, ?int $requestedTenantId, ?string $search) + { + $effectiveTenantId = $actor->isMasterAdmin() ? $requestedTenantId : $tenantId; + + return User::query() + ->when($effectiveTenantId, fn ($q) => $q->whereHas('userTenants', fn ($x) => $x->where('tenant_id', $effectiveTenantId))) + ->when($search, fn ($q, $term) => $q->where(fn ($x) => $x + ->where('name', 'ILIKE', "%{$term}%") + ->orWhere('username', 'ILIKE', "%{$term}%") + ->orWhere('email', 'ILIKE', "%{$term}%"))) + ->with(['userTenants' => fn ($q) => $q->when($effectiveTenantId, fn ($x) => $x->where('tenant_id', $effectiveTenantId))]) + ->orderBy('name')->limit(50)->get(['id', 'name', 'username', 'email', 'access_level', 'status']) + ->map(fn (User $user) => [ + ...$user->only(['id', 'name', 'username', 'email', 'status']), + 'access_level' => $user->access_level->value, + 'tenant_role' => $user->userTenants->first()?->role, + ]); + } + public function saveChannel(array $data, User $actor, ?int $tenantId, ?NotificationChannel $channel = null): NotificationChannel { - $effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $channel?->tenant_id ?? $tenantId) : $tenantId; - if (! $effectiveTenant) { + $effectiveTenant = $actor->isMasterAdmin() + ? (array_key_exists('tenant_id', $data) ? $data['tenant_id'] : ($channel ? $channel->tenant_id : $tenantId)) + : $tenantId; + $isGlobalWhapi = $actor->isMasterAdmin() + && ($data['channel'] ?? $channel?->channel) === 'whatsapp_unofficial' + && ! $effectiveTenant; + if (! $effectiveTenant && ! $isGlobalWhapi) { throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']); } - if ($channel && (int) $channel->tenant_id !== (int) $effectiveTenant) { + if ($channel && $channel->tenant_id !== null && (int) $channel->tenant_id !== (int) $effectiveTenant) { throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi bukan milik tenant tersebut.']); } + if ($channel && $channel->tenant_id === null && ! $isGlobalWhapi) { + throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi global hanya dapat digunakan untuk WHAPI Master Admin.']); + } $credentials = array_filter($data['credentials'] ?? [], fn ($value) => $value !== '' && $value !== null); if ($channel && $credentials) { $credentials = [...($channel->credentials ?? []), ...$credentials]; } if (($data['is_default'] ?? false) === true) { - NotificationChannel::where('tenant_id', $effectiveTenant)->where('channel', $data['channel']) + NotificationChannel::where('channel', $data['channel']) + ->when($effectiveTenant, fn ($q) => $q->where('tenant_id', $effectiveTenant), fn ($q) => $q->whereNull('tenant_id')) ->when($channel, fn ($q) => $q->whereKeyNot($channel->id)) ->update(['is_default' => false]); } + if ($data['channel'] === 'whatsapp_unofficial') { + $data['provider'] = 'whapi_id'; + $apiKey = $credentials['api_key'] ?? data_get($channel?->credentials, 'api_key'); + $baseUrl = data_get($data, 'settings.base_url', data_get($channel?->settings, 'base_url')); + if (! $apiKey || ! $baseUrl) { + throw ValidationException::withMessages([ + 'credentials' => 'API Key perangkat dan Base URL WHAPI wajib diisi.', + ]); + } + if ($isGlobalWhapi && NotificationChannel::whereNull('tenant_id') + ->where('channel', 'whatsapp_unofficial') + ->when($channel, fn ($q) => $q->whereKeyNot($channel->id)) + ->exists()) { + throw ValidationException::withMessages([ + 'channel' => 'Konfigurasi WHAPI global sudah tersedia. Silakan perbarui konfigurasi tersebut.', + ]); + } + } $payload = [ ...$data, 'tenant_id' => $effectiveTenant, @@ -72,6 +171,16 @@ class NotificationService ->setAttribute('credentials_configured', ! empty($channel->getRawOriginal('credentials'))); } + public function deleteChannel(NotificationChannel $channel): void + { + if ($channel->templates()->exists() || $channel->deliveries()->exists()) { + $channel->update(['enabled' => false, 'is_default' => false]); + + return; + } + $channel->delete(); + } + public function templates(string $channel, User $actor, ?int $tenantId) { return NotificationTemplate::with(['event:id,code,name,category,variables', 'channelConfig:id,name']) @@ -170,7 +279,9 @@ class NotificationService 'system' => 'Penyimpanan notifikasi aplikasi siap.', 'telegram' => data_get(Http::timeout(15)->get('https://api.telegram.org/bot'.$credentials['bot_token'].'/getMe')->throw()->json(), 'result.username', 'Bot Telegram terhubung.'), 'whatsapp_official' => data_get(Http::withToken($credentials['access_token'])->timeout(15)->get(rtrim($settings['base_url'] ?? 'https://graph.facebook.com/v22.0', '/').'/'.$credentials['phone_number_id'])->throw()->json(), 'display_phone_number', 'WhatsApp Official terhubung.'), - 'whatsapp_unofficial' => data_get(Http::withHeaders(isset($credentials['api_key']) ? [($settings['api_key_header'] ?? 'X-API-Key') => $credentials['api_key']] : [])->timeout(15)->get($settings['health_url'])->throw()->json(), 'status', 'Gateway terhubung.'), + 'whatsapp_unofficial' => $channel->provider === 'whapi_id' + ? data_get(app(WhapiIdService::class)->state($channel), 'results.state', 'WHAPI terhubung.') + : data_get(Http::withHeaders(isset($credentials['api_key']) ? [($settings['api_key_header'] ?? 'X-API-Key') => $credentials['api_key']] : [])->timeout(15)->get($settings['health_url'])->throw()->json(), 'status', 'Gateway terhubung.'), 'email' => ! empty($settings['host']) && ! empty($settings['from_address']) ? 'Konfigurasi SMTP lengkap.' : throw new \RuntimeException('Host dan alamat pengirim SMTP wajib diisi.'), }; $channel->update(['last_health_status' => 'healthy', 'last_health_message' => (string) $message, 'last_health_check_at' => now()]); diff --git a/app/Services/Notification/WhapiIdService.php b/app/Services/Notification/WhapiIdService.php new file mode 100644 index 0000000..a925de7 --- /dev/null +++ b/app/Services/Notification/WhapiIdService.php @@ -0,0 +1,93 @@ +get($channel, '/api/getState'); + } + + public function start(NotificationChannel $channel): array + { + return $this->get($channel, '/api/serviceStart'); + } + + public function qr(NotificationChannel $channel): array + { + return $this->get($channel, '/api/getQR'); + } + + public function send(NotificationChannel $channel, string $phone, string $message): array + { + try { + return $this->client($channel)->asForm()->post('/api/sendMessage', [ + ...$this->auth($channel), 'phone' => $this->normalizePhone($phone), + 'message' => $message, 'forward_type' => 0, 'ephemeral' => 0, + ])->throw()->json(); + } catch (RequestException|ConnectionException $exception) { + $this->providerError($exception); + } + } + + private function client(NotificationChannel $channel): PendingRequest + { + $baseUrl = rtrim((string) data_get($channel->settings, 'base_url'), '/'); + if (! $baseUrl || ! preg_match('#^https?://#i', $baseUrl)) { + throw ValidationException::withMessages(['base_url' => 'Base URL WHAPI wajib menggunakan HTTP atau HTTPS.']); + } + $baseUrl = preg_replace('#/api$#i', '', $baseUrl); + + return Http::baseUrl($baseUrl)->acceptJson()->timeout(30)->retry(2, 500); + } + + private function get(NotificationChannel $channel, string $path): array + { + try { + return $this->client($channel)->get($path, $this->auth($channel))->throw()->json(); + } catch (RequestException|ConnectionException $exception) { + $this->providerError($exception); + } + } + + private function providerError(RequestException|ConnectionException $exception): never + { + $status = $exception instanceof RequestException ? $exception->response->status() : null; + $providerMessage = $exception instanceof RequestException + ? data_get($exception->response->json(), 'results.message') + ?? data_get($exception->response->json(), 'message') + : null; + throw ValidationException::withMessages([ + 'whapi' => $providerMessage + ?: ($status ? "WHAPI merespons HTTP {$status}. Periksa Base URL dan endpoint gateway." : 'Server WHAPI tidak dapat dihubungi.'), + ]); + } + + private function auth(NotificationChannel $channel): array + { + $apiKey = data_get($channel->credentials, 'api_key'); + if (! $apiKey) { + throw ValidationException::withMessages(['api_key' => 'API Key perangkat WHAPI belum diatur.']); + } + + return ['apiKey' => $apiKey]; + } + + private function normalizePhone(string $phone): string + { + $phone = preg_replace('/\D+/', '', $phone); + if (str_starts_with($phone, '0')) { + $phone = '62'.substr($phone, 1); + } + + return $phone; + } +} diff --git a/app/Services/User/UserService.php b/app/Services/User/UserService.php index 91685f1..031b57a 100644 --- a/app/Services/User/UserService.php +++ b/app/Services/User/UserService.php @@ -65,6 +65,7 @@ class UserService 'profile_photo_file_id', 'password', 'is_master', + 'is_platform_staff', 'access_level', 'status', ]); @@ -109,6 +110,7 @@ class UserService 'whatsapp_number', 'profile_photo_file_id', 'is_master', + 'is_platform_staff', 'access_level', 'status', ]); @@ -176,9 +178,12 @@ class UserService protected function syncTenants(User $user, array $rows): void { + $existingRoles = $user->userTenants()->pluck('role', 'tenant_id'); $normalized = collect($rows) ->map(fn ($row) => [ 'tenant_id' => $row['tenant_id'] ?? null, + 'role' => $row['role'] ?? $existingRoles->get($row['tenant_id'] ?? null) + ?? ($user->access_level === UserAccessLevel::STAFF ? 'staff' : 'customer'), 'is_default' => (bool) ($row['is_default'] ?? false), ]) ->filter(fn ($row) => ! empty($row['tenant_id'])) @@ -186,6 +191,12 @@ class UserService ->values() ->all(); + if (collect($normalized)->where('role', 'tenant_owner')->count() > 1) { + throw ValidationException::withMessages([ + 'user_tenants' => 'Satu user hanya boleh menjadi Tenant Owner pada satu tenant.', + ]); + } + $defaultSet = false; foreach ($normalized as $index => $row) { if ($row['is_default'] && ! $defaultSet) { @@ -243,6 +254,13 @@ class UserService return; } + if ($payload['is_platform_staff'] ?? false) { + throw ValidationException::withMessages([ + 'is_platform_staff' => 'Hanya Master Admin yang dapat memberikan akses staff platform.', + ]); + } + unset($payload['is_platform_staff']); + if ($target?->isMasterAdmin()) { throw ValidationException::withMessages([ 'user' => 'Master Admin hanya dapat diubah oleh Master Admin.', @@ -275,6 +293,11 @@ class UserService $key => 'Assignment hanya boleh dibuat untuk tenant yang sedang aktif.', ]); } + if ($key === 'user_tenants' && ($row['role'] ?? null) === 'tenant_owner') { + throw ValidationException::withMessages([ + $key => 'Tenant Owner tidak dapat memberikan role Tenant Owner.', + ]); + } } } } diff --git a/app/Services/Voucher/VoucherService.php b/app/Services/Voucher/VoucherService.php new file mode 100644 index 0000000..375dbce --- /dev/null +++ b/app/Services/Voucher/VoucherService.php @@ -0,0 +1,241 @@ +where('tenant_id', $tenantId) + ->when($actor->access_level === UserAccessLevel::CUSTOMER, fn ($q) => $q->whereHas('agent', fn ($x) => $x->where('user_id', $actor->id))) + ->when($filters['search'] ?? null, fn ($q, $search) => $q->where('sale_number', 'ilike', "%{$search}%")) + ->when($filters['payment_status'] ?? null, fn ($q, $value) => $q->where('payment_status', $value)) + ->latest('id')->paginate($filters['per_page'] ?? 10); + } + + public function saleDetail(VoucherSale $sale, User $actor, ?int $tenantId): VoucherSale + { + $this->authorizeTenant($sale->tenant_id, $tenantId, $actor); + if ($actor->access_level === UserAccessLevel::CUSTOMER && $sale->agent?->user_id !== $actor->id) { + abort(403); + } + + $sale->load(['agent.user:id,name,username', 'packageProfile', 'vouchers.mikrotik:id,name']); + $sale->vouchers->each(fn (HotspotVoucher $voucher) => $voucher->setAttribute('access_password', $voucher->password)); + + return $sale; + } + + public function createSale(array $data, User $actor, ?int $tenantId): VoucherSale + { + if (! $tenantId) { + throw ValidationException::withMessages(['tenant_id' => 'Tenant aktif wajib tersedia.']); + } + $package = NasPackageProfile::whereKey($data['nas_package_profile_id'])->where('tenant_id', $tenantId) + ->where('service_type', 'hotspot')->where('status', 'active')->firstOrFail(); + if (! empty($data['nas_mikrotik_id']) && ! NasMikrotik::whereKey($data['nas_mikrotik_id'])->where('tenant_id', $tenantId)->exists()) { + throw ValidationException::withMessages(['nas_mikrotik_id' => 'Mikrotik tidak valid untuk tenant aktif.']); + } + + return DB::transaction(function () use ($data, $actor, $tenantId, $package) { + $agent = $actor->access_level === UserAccessLevel::CUSTOMER + ? VoucherAgent::with('user')->where('tenant_id', $tenantId)->where('user_id', $actor->id)->where('status', 'active')->lockForUpdate()->first() + : null; + if ($actor->access_level === UserAccessLevel::CUSTOMER && ! $agent) { + throw ValidationException::withMessages(['agent' => 'Akun belum terdaftar sebagai agen voucher aktif.']); + } + + $quantity = (int) $data['quantity']; + $gross = (float) $package->price * $quantity; + $benefitPercent = $agent?->price_mode === 'commission' + ? (float) $agent->commission_percent + : (float) ($agent?->discount_percent ?? 0); + $discount = round($gross * $benefitPercent / 100, 2); + $total = $gross - $discount; + $saleUuid = (string) Str::uuid(); + + if ($agent?->payment_type === 'prepaid') { + $this->wallets->chargeUser($actor, $tenantId, $total, "voucher-sale:{$saleUuid}", "Pembelian {$quantity} voucher {$package->name}"); + } + if ($agent?->payment_type === 'postpaid') { + if ((float) $agent->outstanding_balance + $total > (float) $agent->credit_limit) { + throw ValidationException::withMessages(['credit_limit' => 'Limit kredit agen tidak mencukupi.']); + } + $agent->increment('outstanding_balance', $total); + } + + $sale = VoucherSale::create([ + 'uuid' => $saleUuid, 'sale_number' => $this->nextSaleNumber($tenantId), 'tenant_id' => $tenantId, + 'voucher_agent_id' => $agent?->id, 'nas_package_profile_id' => $package->id, + 'created_by' => $actor->id, 'source' => 'dashboard', 'quantity' => $quantity, + 'unit_price' => $package->price, 'discount_amount' => $discount, 'total_amount' => $total, + 'payment_status' => $agent?->payment_type === 'postpaid' ? 'credit' : 'paid', 'status' => 'completed', + 'pricing_snapshot' => [ + 'agent_type' => $agent?->payment_type, 'price_mode' => $agent?->price_mode, + 'discount_percent' => $agent?->discount_percent, 'commission_percent' => $agent?->commission_percent, + 'agent_benefit_amount' => $discount, + ], + 'notes' => $data['notes'] ?? null, + ]); + + for ($index = 0; $index < $quantity; $index++) { + $username = $this->uniqueVoucherCode($tenantId); + $password = Str::upper(Str::random(6)); + $voucher = HotspotVoucher::create([ + 'uuid' => Str::uuid(), 'tenant_id' => $tenantId, 'voucher_sale_id' => $sale->id, + 'nas_package_profile_id' => $package->id, 'nas_mikrotik_id' => $data['nas_mikrotik_id'] ?? null, + 'username' => $username, 'password' => $password, 'status' => 'generated', 'sync_status' => 'pending', + 'profile_snapshot' => ['name' => $package->name, 'external_profile_name' => $package->external_profile_name, 'session_timeout' => $package->session_timeout], + ]); + $voucher->setAttribute('access_password', $password); + } + + return $this->saleDetail($sale, $actor, $tenantId); + }); + } + + public function agents(array $filters, User $actor, ?int $tenantId) + { + return VoucherAgent::with(['user:id,name,username,email,whatsapp_number']) + ->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) + ->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) + ->when($filters['search'] ?? null, fn ($q, $search) => $q->where(fn ($x) => $x->where('agent_code', 'ilike', "%{$search}%")->orWhereHas('user', fn ($u) => $u->where('name', 'ilike', "%{$search}%")))) + ->latest('id')->paginate($filters['per_page'] ?? 10); + } + + public function saveAgent(array $data, User $actor, ?int $tenantId, ?VoucherAgent $agent = null): VoucherAgent + { + $effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId ?? $agent?->tenant_id) : $tenantId; + if (! $effectiveTenant) { + throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']); + } + if ($agent) { + $this->authorizeTenant($agent->tenant_id, $effectiveTenant, $actor); + } + $belongs = DB::table('user_tenants')->where('tenant_id', $effectiveTenant)->where('user_id', $data['user_id'])->exists(); + if (! $belongs) { + throw ValidationException::withMessages(['user_id' => 'User tidak terdaftar pada tenant tersebut.']); + } + + $payload = [...$data, 'tenant_id' => $effectiveTenant, 'uuid' => $agent?->uuid ?? Str::uuid()]; + $payload['agent_code'] = filled($data['agent_code'] ?? null) ? Str::upper($data['agent_code']) : $this->nextAgentCode($effectiveTenant); + if ($data['payment_type'] === 'prepaid') { + $payload['credit_limit'] = 0; + } + $model = $agent ?: new VoucherAgent; + $model->fill($payload)->save(); + + return $model->load('user:id,name,username,email,whatsapp_number'); + } + + public function options(string $scope, User $actor, ?int $tenantId): array + { + $data = []; + if (in_array($scope, ['sales', 'login-page'], true)) { + $data['packages'] = NasPackageProfile::where('tenant_id', $tenantId)->where('service_type', 'hotspot')->where('status', 'active')->orderBy('name')->get(); + } + if ($scope === 'sales') { + $data['mikrotiks'] = NasMikrotik::where('tenant_id', $tenantId)->where('status', 'active')->orderBy('name')->get(['id', 'name', 'connection_type']); + } + if ($scope === 'agents') { + $data['users'] = User::whereHas('userTenants', fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->get(['id', 'name', 'username', 'email']); + } + + return $data; + } + + public function loginPage(User $actor, ?int $tenantId): array + { + if (! $tenantId) { + throw ValidationException::withMessages(['tenant_id' => 'Tenant aktif wajib tersedia.']); + } + $setting = VoucherLoginPageSetting::firstOrCreate(['tenant_id' => $tenantId], [ + 'uuid' => Str::uuid(), 'public_key' => Str::random(64), 'enabled' => false, + ]); + + return ['setting' => $setting->load('gatewayAccount.provider:id,code,name'), 'embed_script' => $this->embedScript($setting), 'dummy_sales' => $setting->enabled ? $this->dummySales() : []]; + } + + public function saveLoginPage(array $data, User $actor, ?int $tenantId): array + { + $setting = VoucherLoginPageSetting::firstOrCreate(['tenant_id' => $tenantId], ['uuid' => Str::uuid(), 'public_key' => Str::random(64)]); + $requestedPackages = collect($data['allowed_package_profile_ids'] ?? [])->map(fn ($id) => (int) $id)->unique(); + $validPackageCount = NasPackageProfile::where('tenant_id', $tenantId)->where('service_type', 'hotspot')->whereIn('id', $requestedPackages)->count(); + if ($validPackageCount !== $requestedPackages->count()) { + throw ValidationException::withMessages(['allowed_package_profile_ids' => 'Terdapat profile paket yang bukan milik tenant aktif.']); + } + if ($data['enabled']) { + $paymentSetting = TenantPaymentSetting::where('tenant_id', $tenantId)->whereIn('payment_mode', ['tenant_gateway', 'platform_gateway'])->first(); + $accountId = $paymentSetting?->payment_mode === 'tenant_gateway' ? $paymentSetting?->tenant_gateway_account_id : $paymentSetting?->platform_gateway_account_id; + $gateway = PaymentGatewayAccount::whereKey($accountId)->where('enabled', true)->first(); + if (! $gateway) { + throw ValidationException::withMessages(['enabled' => 'LoginPage Voucher hanya dapat diaktifkan jika tenant memiliki payment gateway aktif.']); + } + $data['payment_gateway_account_id'] = $gateway->id; + } else { + $data['payment_gateway_account_id'] = null; + } + $setting->update([...$data, 'enabled_at' => $data['enabled'] ? ($setting->enabled_at ?? now()) : null]); + + return $this->loginPage($actor, $tenantId); + } + + private function embedScript(VoucherLoginPageSetting $setting): string + { + return '
'."\n".''; + } + + private function dummySales(): array + { + return collect(range(1, 5))->map(fn ($i) => [ + 'id' => $i, 'reference' => 'DEMO-'.now()->format('Ymd').'-'.str_pad((string) $i, 3, '0', STR_PAD_LEFT), + 'package' => ['Hotspot 2 Jam', 'Hotspot Harian', 'Hotspot Mingguan'][$i % 3], + 'buyer' => 'Customer Demo '.$i, 'amount' => 5000 * $i, + 'status' => $i === 4 ? 'pending' : 'paid', 'created_at' => now()->subMinutes($i * 17)->toIso8601String(), + ])->all(); + } + + private function nextSaleNumber(int $tenantId): string + { + return 'VCR-'.$tenantId.'-'.now()->format('YmdHis').'-'.Str::upper(Str::random(4)); + } + + private function nextAgentCode(int $tenantId): string + { + return 'AGT-'.$tenantId.'-'.str_pad((string) (VoucherAgent::where('tenant_id', $tenantId)->count() + 1), 4, '0', STR_PAD_LEFT); + } + + private function uniqueVoucherCode(int $tenantId): string + { + do { + $code = 'V'.Str::upper(Str::random(9)); + } while (HotspotVoucher::where('tenant_id', $tenantId)->where('username', $code)->exists()); + + return $code; + } + + private function authorizeTenant(int $modelTenantId, ?int $tenantId, User $actor): void + { + if (! $actor->isMasterAdmin()) { + abort_unless($tenantId && $modelTenantId === $tenantId, 403); + } + } +} diff --git a/app/Services/Wallet/WalletService.php b/app/Services/Wallet/WalletService.php index 38a2f2f..373ac9b 100644 --- a/app/Services/Wallet/WalletService.php +++ b/app/Services/Wallet/WalletService.php @@ -3,7 +3,9 @@ namespace App\Services\Wallet; use App\Models\Tenant; +use App\Models\TenantPaymentSetting; use App\Models\User; +use App\Models\VoucherAgent; use App\Models\Wallet; use App\Models\WalletLedgerEntry; use App\Models\WalletTransaction; @@ -15,14 +17,60 @@ use Illuminate\Validation\ValidationException; class WalletService { + public function chargeUser(User $user, int $tenantId, float $amount, string $idempotencyKey, string $description): WalletTransaction + { + return DB::transaction(function () use ($user, $tenantId, $amount, $idempotencyKey, $description) { + $existing = WalletTransaction::where('idempotency_key', $idempotencyKey)->first(); + if ($existing) { + return $existing; + } + + $wallet = Wallet::lockForUpdate()->find($this->walletFor($user, $tenantId, 'deposit')->id); + if ((float) $wallet->available_balance < $amount) { + throw ValidationException::withMessages(['wallet' => 'Saldo deposit agen pada tenant ini tidak mencukupi.']); + } + + $transaction = WalletTransaction::create([ + 'uuid' => Str::uuid(), 'transaction_number' => 'WLT-'.now()->format('YmdHis').'-'.Str::upper(Str::random(6)), + 'type' => 'voucher_purchase', 'status' => 'processing', 'initiated_by' => $user->id, + 'amount' => $amount, 'fee' => 0, 'idempotency_key' => $idempotencyKey, 'description' => $description, + ]); + $this->post($wallet, $transaction, 'debit', $amount); + $transaction->update(['status' => 'completed', 'completed_at' => now()]); + + return $transaction; + }); + } + public function wallets(User $user, ?int $tenantId) { - $wallets = collect([$this->walletFor($user, 'user')]); - if ($tenantId && ($user->isMasterAdmin() || $user->isTenantOwner())) { - $wallets->push($this->walletFor(Tenant::findOrFail($tenantId), 'tenant')); + if (! $tenantId) { + return collect(); } - return $wallets->values(); + $balances = collect([$this->balanceData($this->walletFor($user, $tenantId, 'deposit'), 'deposit', 'Saldo Deposit')]); + $usesGlobalGateway = TenantPaymentSetting::where('tenant_id', $tenantId) + ->where('payment_mode', 'platform_gateway')->whereNotNull('platform_gateway_account_id')->exists(); + if ($usesGlobalGateway && ($user->isMasterAdmin() || $user->isTenantOwner($tenantId))) { + $balances->push($this->balanceData($this->walletFor(Tenant::findOrFail($tenantId), $tenantId, 'tenant_settlement'), 'tenant_settlement', 'Saldo Settlement Tenant')); + } + + $agent = VoucherAgent::where('tenant_id', $tenantId)->where('user_id', $user->id) + ->where('payment_type', 'postpaid')->where('status', 'active')->first(); + if ($agent) { + $balances->push([ + 'id' => "credit-{$agent->id}", 'uuid' => $agent->uuid, 'tenant_id' => $tenantId, + 'wallet_type' => 'voucher_credit', 'balance_kind' => 'credit', 'label' => 'Saldo Kredit Voucher', + 'credit_limit' => $agent->credit_limit, 'used_credit' => $agent->outstanding_balance, + 'remaining_credit' => number_format(max(0, (float) $agent->credit_limit - (float) $agent->outstanding_balance), 2, '.', ''), + 'available_balance' => number_format(max(0, (float) $agent->credit_limit - (float) $agent->outstanding_balance), 2, '.', ''), + 'pending_balance' => '0.00', 'locked_balance' => '0.00', 'currency' => 'IDR', + 'status' => $agent->status, 'ledger_available' => false, 'can_withdraw' => false, 'can_transfer' => false, + 'usage_note' => 'Khusus untuk pembelian voucher hotspot pada tenant ini.', + ]); + } + + return $balances->values(); } public function ledger(Wallet $wallet, User $actor, ?int $tenantId, array $filters) @@ -37,6 +85,9 @@ class WalletService { $this->authorizeWallet($source, $actor, $tenantId); $this->authorizeWallet($destination, $actor, $tenantId); + if ((int) $source->tenant_id !== (int) $destination->tenant_id || (int) $source->tenant_id !== (int) $tenantId) { + throw ValidationException::withMessages(['destination_wallet_id' => 'Saldo tidak dapat dipindahkan antar tenant.']); + } if ($source->is($destination)) { throw ValidationException::withMessages(['destination_wallet_id' => 'Dompet tujuan harus berbeda.']); } @@ -63,6 +114,9 @@ class WalletService public function requestWithdrawal(Wallet $wallet, array $data, User $actor, ?int $tenantId): WalletWithdrawal { $this->authorizeWallet($wallet, $actor, $tenantId); + if ($wallet->wallet_type !== 'tenant_settlement') { + throw ValidationException::withMessages(['wallet' => 'Saldo deposit user hanya dapat digunakan untuk transaksi layanan dan tidak dapat ditarik tunai.']); + } return DB::transaction(function () use ($wallet, $data, $actor) { $locked = Wallet::lockForUpdate()->findOrFail($wallet->id); @@ -82,14 +136,25 @@ class WalletService }); } - private function walletFor(Model $owner, string $type): Wallet + private function walletFor(Model $owner, int $tenantId, string $type): Wallet { return Wallet::firstOrCreate( - ['owner_type' => $owner->getMorphClass(), 'owner_id' => $owner->getKey(), 'wallet_type' => $type, 'currency' => 'IDR'], + ['owner_type' => $owner->getMorphClass(), 'owner_id' => $owner->getKey(), 'tenant_id' => $tenantId, 'wallet_type' => $type, 'currency' => 'IDR'], ['uuid' => Str::uuid(), 'status' => 'active'] ); } + private function balanceData(Wallet $wallet, string $kind, string $label): array + { + return [ + ...$wallet->toArray(), 'balance_kind' => $kind, 'label' => $label, 'ledger_available' => true, + 'can_withdraw' => $kind === 'tenant_settlement', 'can_transfer' => false, + 'usage_note' => $kind === 'deposit' + ? 'Dapat digunakan untuk transaksi layanan pada tenant ini.' + : 'Dana tenant dari transaksi melalui payment gateway global.', + ]; + } + private function newTransaction(string $type, float $amount, User $actor, string $description, float $fee = 0): WalletTransaction { return WalletTransaction::create([ @@ -116,8 +181,9 @@ class WalletService if ($actor->isMasterAdmin()) { return; } - $allowed = ($wallet->owner_type === $actor->getMorphClass() && (int) $wallet->owner_id === (int) $actor->id) - || ($actor->isTenantOwner() && $wallet->owner_type === (new Tenant)->getMorphClass() && (int) $wallet->owner_id === (int) $tenantId); - abort_unless($allowed, 403, 'Dompet tidak dapat diakses.'); + $sameTenant = $tenantId && (int) $wallet->tenant_id === (int) $tenantId; + $allowed = $sameTenant && (($wallet->owner_type === $actor->getMorphClass() && (int) $wallet->owner_id === (int) $actor->id) + || ($actor->isTenantOwner($tenantId) && $wallet->owner_type === (new Tenant)->getMorphClass() && (int) $wallet->owner_id === (int) $tenantId)); + abort_unless($allowed, 403, 'Saldo tidak dapat diakses.'); } } diff --git a/config/registration.php b/config/registration.php new file mode 100644 index 0000000..5adabf1 --- /dev/null +++ b/config/registration.php @@ -0,0 +1,15 @@ + env('REGISTRATION_SHOW_VERIFICATION_CODE', false), +]; diff --git a/database/migrations/2026_07_31_090000_create_hotspot_voucher_tables.php b/database/migrations/2026_07_31_090000_create_hotspot_voucher_tables.php new file mode 100644 index 0000000..a32cee6 --- /dev/null +++ b/database/migrations/2026_07_31_090000_create_hotspot_voucher_tables.php @@ -0,0 +1,100 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->restrictOnDelete(); + $table->string('agent_code'); + $table->string('payment_type', 20); + $table->string('price_mode', 20)->default('discount'); + $table->decimal('discount_percent', 5, 2)->default(0); + $table->decimal('commission_percent', 5, 2)->default(0); + $table->decimal('credit_limit', 18, 2)->default(0); + $table->decimal('outstanding_balance', 18, 2)->default(0); + $table->string('status', 20)->default('active'); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'user_id']); + $table->unique(['tenant_id', 'agent_code']); + $table->index(['tenant_id', 'payment_type', 'status']); + }); + + Schema::create('voucher_sales', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('sale_number')->unique(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('voucher_agent_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('nas_package_profile_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('source', 30)->default('dashboard'); + $table->unsignedInteger('quantity'); + $table->decimal('unit_price', 18, 2); + $table->decimal('discount_amount', 18, 2)->default(0); + $table->decimal('total_amount', 18, 2); + $table->string('payment_status', 30)->default('paid'); + $table->string('status', 30)->default('completed'); + $table->json('pricing_snapshot')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'status', 'created_at']); + $table->index(['voucher_agent_id', 'payment_status']); + }); + + Schema::create('hotspot_vouchers', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('voucher_sale_id')->constrained()->cascadeOnDelete(); + $table->foreignId('nas_package_profile_id')->constrained()->restrictOnDelete(); + $table->foreignId('nas_mikrotik_id')->nullable()->constrained()->nullOnDelete(); + $table->string('username'); + $table->text('password'); + $table->string('status', 30)->default('generated'); + $table->string('sync_status', 30)->default('pending'); + $table->string('external_reference')->nullable(); + $table->timestamp('valid_until')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('used_at')->nullable(); + $table->timestamp('synced_at')->nullable(); + $table->text('sync_error')->nullable(); + $table->json('profile_snapshot')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'username']); + $table->index(['tenant_id', 'status', 'created_at']); + $table->index(['nas_mikrotik_id', 'sync_status']); + }); + + Schema::create('voucher_login_page_settings', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->unique()->constrained()->cascadeOnDelete(); + $table->foreignId('payment_gateway_account_id')->nullable()->constrained()->nullOnDelete(); + $table->string('public_key')->unique(); + $table->boolean('enabled')->default(false); + $table->string('title')->default('Beli Voucher Hotspot'); + $table->string('theme_color', 20)->default('#ff8c00'); + $table->json('allowed_package_profile_ids')->nullable(); + $table->json('settings')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('voucher_login_page_settings'); + Schema::dropIfExists('hotspot_vouchers'); + Schema::dropIfExists('voucher_sales'); + Schema::dropIfExists('voucher_agents'); + } +}; diff --git a/database/migrations/2026_07_31_130000_scope_wallets_to_tenant_and_rename_balance_types.php b/database/migrations/2026_07_31_130000_scope_wallets_to_tenant_and_rename_balance_types.php new file mode 100644 index 0000000..42b7d01 --- /dev/null +++ b/database/migrations/2026_07_31_130000_scope_wallets_to_tenant_and_rename_balance_types.php @@ -0,0 +1,56 @@ +foreignId('tenant_id')->nullable()->after('uuid')->constrained()->cascadeOnDelete(); + $table->dropUnique('wallet_owner_type_unique'); + }); + + DB::table('wallets')->where('owner_type', 'App\\Models\\Tenant')->orderBy('id')->each(function ($wallet) { + DB::table('wallets')->where('id', $wallet->id)->update([ + 'tenant_id' => $wallet->owner_id, + 'wallet_type' => 'tenant_settlement', + ]); + }); + + DB::table('wallets')->where('owner_type', 'App\\Models\\User')->orderBy('id')->each(function ($wallet) { + $tenantId = DB::table('user_tenants')->where('user_id', $wallet->owner_id) + ->orderByDesc('is_default')->orderBy('id')->value('tenant_id'); + if ($tenantId) { + DB::table('wallets')->where('id', $wallet->id)->update([ + 'tenant_id' => $tenantId, + 'wallet_type' => 'deposit', + ]); + } elseif ((float) $wallet->available_balance === 0.0 && (float) $wallet->pending_balance === 0.0 && (float) $wallet->locked_balance === 0.0) { + DB::table('wallets')->where('id', $wallet->id)->delete(); + } + }); + + Schema::table('wallets', function (Blueprint $table) { + $table->unique(['owner_type', 'owner_id', 'tenant_id', 'wallet_type', 'currency'], 'wallet_owner_tenant_type_unique'); + $table->index(['tenant_id', 'wallet_type', 'status']); + }); + } + + public function down(): void + { + Schema::table('wallets', function (Blueprint $table) { + $table->dropUnique('wallet_owner_tenant_type_unique'); + $table->dropIndex(['tenant_id', 'wallet_type', 'status']); + }); + DB::table('wallets')->where('wallet_type', 'deposit')->update(['wallet_type' => 'user']); + DB::table('wallets')->where('wallet_type', 'tenant_settlement')->update(['wallet_type' => 'tenant']); + Schema::table('wallets', function (Blueprint $table) { + $table->dropConstrainedForeignId('tenant_id'); + $table->unique(['owner_type', 'owner_id', 'wallet_type', 'currency'], 'wallet_owner_type_unique'); + }); + } +}; diff --git a/database/migrations/2026_07_31_160000_create_role_applications_and_customer_user_links.php b/database/migrations/2026_07_31_160000_create_role_applications_and_customer_user_links.php new file mode 100644 index 0000000..24f0ac2 --- /dev/null +++ b/database/migrations/2026_07_31_160000_create_role_applications_and_customer_user_links.php @@ -0,0 +1,61 @@ +boolean('is_platform_staff')->default(false)->after('is_master')->index(); + }); + + Schema::create('role_applications', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('applicant_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('application_type', 20); + $table->string('status', 30)->default('pending'); + $table->json('payload'); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('review_notes')->nullable(); + $table->timestamp('submitted_at'); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + $table->index(['applicant_user_id', 'status', 'submitted_at']); + $table->index(['application_type', 'tenant_id', 'status']); + }); + + Schema::create('customer_user_links', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('linked_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('relationship', 30)->default('owner'); + $table->boolean('is_primary')->default(true); + $table->timestamps(); + $table->unique(['customer_id', 'user_id']); + $table->index(['user_id', 'tenant_id']); + }); + + DB::table('customers')->whereNotNull('user_id')->orderBy('id')->each(function ($customer) { + DB::table('customer_user_links')->insertOrIgnore([ + 'tenant_id' => $customer->tenant_id, 'customer_id' => $customer->id, + 'user_id' => $customer->user_id, 'relationship' => 'owner', 'is_primary' => true, + 'created_at' => now(), 'updated_at' => now(), + ]); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_user_links'); + Schema::dropIfExists('role_applications'); + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_platform_staff')); + } +}; diff --git a/database/migrations/2026_08_01_000001_allow_global_notification_channels.php b/database/migrations/2026_08_01_000001_allow_global_notification_channels.php new file mode 100644 index 0000000..d9941ce --- /dev/null +++ b/database/migrations/2026_08_01_000001_allow_global_notification_channels.php @@ -0,0 +1,35 @@ +dropForeign(['tenant_id']); + }); + + Schema::table('notification_channels', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->change(); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + }); + } + + public function down(): void + { + DB::table('notification_channels')->whereNull('tenant_id')->delete(); + + Schema::table('notification_channels', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + }); + + Schema::table('notification_channels', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + }); + } +}; diff --git a/database/migrations/2026_08_01_090000_add_role_to_user_tenants_table.php b/database/migrations/2026_08_01_090000_add_role_to_user_tenants_table.php new file mode 100644 index 0000000..034f875 --- /dev/null +++ b/database/migrations/2026_08_01_090000_add_role_to_user_tenants_table.php @@ -0,0 +1,48 @@ +string('role', 30)->default('customer')->after('tenant_id'); + $table->index(['tenant_id', 'role']); + }); + + DB::table('user_tenants') + ->whereIn('user_id', DB::table('users')->where('access_level', 'staff')->select('id')) + ->update(['role' => 'staff']); + + $ownerUsers = DB::table('users')->where('access_level', 'tenant_owner')->pluck('id'); + foreach ($ownerUsers as $userId) { + $ownerTenantId = DB::table('user_tenants') + ->where('user_id', $userId) + ->orderByDesc('is_default') + ->orderBy('id') + ->value('tenant_id'); + + if ($ownerTenantId) { + DB::table('user_tenants') + ->where('user_id', $userId) + ->where('tenant_id', $ownerTenantId) + ->update(['role' => 'tenant_owner']); + } + } + + DB::statement("CREATE UNIQUE INDEX user_tenants_one_owner_per_user ON user_tenants (user_id) WHERE role = 'tenant_owner'"); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS user_tenants_one_owner_per_user'); + Schema::table('user_tenants', function (Blueprint $table) { + $table->dropIndex(['tenant_id', 'role']); + $table->dropColumn('role'); + }); + } +}; diff --git a/database/migrations/2026_08_01_130000_strengthen_system_notifications.php b/database/migrations/2026_08_01_130000_strengthen_system_notifications.php new file mode 100644 index 0000000..094ebc8 --- /dev/null +++ b/database/migrations/2026_08_01_130000_strengthen_system_notifications.php @@ -0,0 +1,53 @@ +foreignId('tenant_id')->nullable()->change(); + }); + Schema::table('user_notifications', function (Blueprint $table) { + $table->foreignId('tenant_id')->nullable()->change(); + }); + + Schema::create('system_notification_campaigns', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('title'); + $table->text('body'); + $table->string('action_url')->nullable(); + $table->string('icon')->default('cil-bell'); + $table->json('audience'); + $table->string('status', 30)->default('queued'); + $table->unsignedInteger('estimated_recipients')->default(0); + $table->unsignedInteger('processed_recipients')->default(0); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'status', 'created_at']); + }); + + Schema::table('user_notifications', function (Blueprint $table) { + $table->foreignId('system_notification_campaign_id')->nullable() + ->after('notification_message_id')->constrained()->cascadeOnDelete(); + $table->unique(['system_notification_campaign_id', 'user_id'], 'user_notifications_campaign_user_unique'); + }); + } + + public function down(): void + { + Schema::table('user_notifications', function (Blueprint $table) { + $table->dropUnique('user_notifications_campaign_user_unique'); + $table->dropConstrainedForeignId('system_notification_campaign_id'); + }); + Schema::dropIfExists('system_notification_campaigns'); + Schema::table('user_notifications', fn (Blueprint $table) => $table->foreignId('tenant_id')->nullable(false)->change()); + Schema::table('notification_messages', fn (Blueprint $table) => $table->foreignId('tenant_id')->nullable(false)->change()); + } +}; diff --git a/database/seeders/MenuListSeeder.php b/database/seeders/MenuListSeeder.php index 6e113c2..67b7985 100644 --- a/database/seeders/MenuListSeeder.php +++ b/database/seeders/MenuListSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\MenuGroup; use App\Models\MenuList; use Illuminate\Database\Seeder; @@ -188,6 +189,40 @@ class MenuListSeeder extends Seeder ); } + $accessApplications = MenuList::updateOrCreate(['slug' => 'role-applications'], [ + 'parent_id' => null, 'name' => 'Permintaan Akses', 'url' => null, + 'icon' => 'cil-user-follow', 'component' => null, 'sort_order' => 83, + ]); + foreach ([ + ['slug' => 'role-applications-tenants', 'name' => 'Pengajuan Tenant', 'url' => '/access-applications/tenants', 'sort_order' => 1], + ['slug' => 'role-applications-agents', 'name' => 'Pengajuan Agen', 'url' => '/access-applications/agents', 'sort_order' => 2], + ['slug' => 'role-applications-staff', 'name' => 'Pengajuan Staff', 'url' => '/access-applications/staff', 'sort_order' => 3], + ] as $item) { + MenuList::updateOrCreate(['slug' => $item['slug']], [ + 'parent_id' => $accessApplications->id, 'name' => $item['name'], 'url' => $item['url'], + 'icon' => 'cil-user-follow', 'component' => null, 'sort_order' => $item['sort_order'], + ]); + } + + $staffApplicationMenu = MenuList::where('slug', 'role-applications-staff')->firstOrFail(); + $groupManagementMenu = MenuList::where('slug', 'users-group-menus')->first(); + if ($groupManagementMenu) { + MenuGroup::query() + ->whereNull('tenant_id') + ->where('is_system', true) + ->whereHas('menus', fn ($query) => $query->whereKey($groupManagementMenu->id)) + ->each(function ($group) use ($accessApplications, $staffApplicationMenu) { + $permissions = [ + 'can_view' => true, 'can_create' => false, 'can_update' => false, + 'can_delete' => false, 'can_approve' => true, 'can_export' => false, + ]; + $group->menus()->syncWithoutDetaching([ + $accessApplications->id => $permissions, + $staffApplicationMenu->id => $permissions, + ]); + }); + } + $paymentGateway = MenuList::updateOrCreate( ['slug' => 'payment-gateway'], [ @@ -206,10 +241,28 @@ class MenuListSeeder extends Seeder } MenuList::updateOrCreate(['slug' => 'wallet'], [ - 'parent_id' => null, 'name' => 'Dompet', 'url' => '/wallet', + 'parent_id' => null, 'name' => 'Saldo Deposit', 'url' => '/deposit-balance', 'icon' => 'cil-wallet', 'component' => null, 'sort_order' => 81, ]); + $voucherHotspot = MenuList::updateOrCreate( + ['slug' => 'voucher-hotspot'], + [ + 'parent_id' => null, 'name' => 'Voucher Hotspot', 'url' => null, + 'icon' => 'cil-barcode', 'component' => null, 'sort_order' => 82, + ] + ); + foreach ([ + ['slug' => 'voucher-hotspot-sales', 'name' => 'Jual Voucher', 'url' => '/voucher-hotspot/sales', 'sort_order' => 1], + ['slug' => 'voucher-hotspot-agents', 'name' => 'Agen Voucher', 'url' => '/voucher-hotspot/agents', 'sort_order' => 2], + ['slug' => 'voucher-hotspot-login-page', 'name' => 'Voucher LoginPage', 'url' => '/voucher-hotspot/login-page', 'sort_order' => 3], + ] as $item) { + MenuList::updateOrCreate(['slug' => $item['slug']], [ + 'parent_id' => $voucherHotspot->id, 'name' => $item['name'], 'url' => $item['url'], + 'icon' => 'cil-barcode', 'component' => null, 'sort_order' => $item['sort_order'], + ]); + } + $nas = MenuList::updateOrCreate( ['slug' => 'nas'], [ diff --git a/routes/api.php b/routes/api.php index 961fc83..c799f43 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ response()->json(['messa Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () { + Route::get('me/role-applications', [RoleApplicationController::class, 'mine'])->name('role-applications.mine'); + Route::post('me/role-applications', [RoleApplicationController::class, 'store'])->name('role-applications.store'); + Route::get('role-applications/tenant/menu-group-options', [RoleApplicationController::class, 'tenantMenuGroupOptions']) + ->name('role-applications.tenant.menu-group-options'); + Route::get('role-applications/staff/menu-group-options', [RoleApplicationController::class, 'staffMenuGroupOptions']) + ->name('role-applications.staff.menu-group-options'); + Route::get('role-applications/{type}', [RoleApplicationController::class, 'reviewList']) + ->whereIn('type', ['tenant', 'agent', 'staff'])->name('role-applications.review.index'); + Route::put('role-applications/{application}/review', [RoleApplicationController::class, 'review']) + ->whereNumber('application')->name('role-applications.review.update'); + Route::post('customer-user-links', [RoleApplicationController::class, 'linkCustomer'])->name('customer-user-links.store'); + + Route::get('voucher-hotspot/sales-options', [VoucherController::class, 'options']) + ->defaults('voucher_scope', 'sales')->middleware('menu.permission:voucher-hotspot-sales,view')->name('voucher-hotspot.sales.options'); + Route::get('voucher-hotspot/sales', [VoucherController::class, 'sales']) + ->middleware('menu.permission:voucher-hotspot-sales,view')->name('voucher-hotspot.sales.index'); + Route::post('voucher-hotspot/sales', [VoucherController::class, 'storeSale']) + ->middleware('menu.permission:voucher-hotspot-sales,create')->name('voucher-hotspot.sales.store'); + Route::get('voucher-hotspot/sales/{sale}', [VoucherController::class, 'showSale']) + ->middleware('menu.permission:voucher-hotspot-sales,view')->whereNumber('sale')->name('voucher-hotspot.sales.show'); + + Route::middleware(['access.level:staff,tenant_owner', 'menu.resource:voucher-hotspot-agents'])->group(function () { + Route::get('voucher-hotspot/agent-options', [VoucherController::class, 'options'])->defaults('voucher_scope', 'agents')->name('voucher-hotspot.agents.options'); + Route::get('voucher-hotspot/agents', [VoucherController::class, 'agents'])->name('voucher-hotspot.agents.index'); + Route::post('voucher-hotspot/agents', [VoucherController::class, 'storeAgent'])->name('voucher-hotspot.agents.store'); + Route::put('voucher-hotspot/agents/{agent}', [VoucherController::class, 'updateAgent'])->whereNumber('agent')->name('voucher-hotspot.agents.update'); + Route::delete('voucher-hotspot/agents/{agent}', [VoucherController::class, 'destroyAgent'])->whereNumber('agent')->name('voucher-hotspot.agents.destroy'); + }); + Route::middleware(['access.level:staff,tenant_owner', 'menu.resource:voucher-hotspot-login-page'])->group(function () { + Route::get('voucher-hotspot/login-page-options', [VoucherController::class, 'options'])->defaults('voucher_scope', 'login-page')->name('voucher-hotspot.login-page.options'); + Route::get('voucher-hotspot/login-page', [VoucherController::class, 'loginPage'])->name('voucher-hotspot.login-page.show'); + Route::put('voucher-hotspot/login-page', [VoucherController::class, 'saveLoginPage'])->name('voucher-hotspot.login-page.update'); + }); + Route::get('payment-gateway/providers', [PaymentGatewayController::class, 'providers']) ->middleware('menu.permission:payment-gateway-settings,view')->name('payment-gateway.providers'); Route::get('payment-gateway/settings', [PaymentGatewayController::class, 'settings']) @@ -53,12 +89,27 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () { Route::get('payment-gateway/transactions', [PaymentGatewayController::class, 'transactions']) ->middleware('menu.permission:payment-gateway-logs,view')->name('payment-gateway.transactions.index'); - Route::get('wallets', [WalletController::class, 'index'])->name('wallets.index'); - Route::get('wallets/{wallet}/ledger', [WalletController::class, 'ledger'])->whereNumber('wallet')->name('wallets.ledger'); - Route::post('wallets/{wallet}/transfer', [WalletController::class, 'transfer'])->whereNumber('wallet')->name('wallets.transfer'); - Route::post('wallets/{wallet}/withdraw', [WalletController::class, 'withdraw'])->whereNumber('wallet')->name('wallets.withdraw'); + Route::middleware('menu.resource:wallet')->group(function () { + Route::get('wallets', [WalletController::class, 'index'])->name('wallets.index'); + Route::get('wallets/{wallet}/ledger', [WalletController::class, 'ledger'])->whereNumber('wallet')->name('wallets.ledger'); + Route::post('wallets/{wallet}/withdraw', [WalletController::class, 'withdraw'])->whereNumber('wallet')->name('wallets.withdraw'); + }); Route::get('notifications/events', [NotificationController::class, 'events'])->name('notifications.events'); + Route::get('notifications/system/campaigns', [NotificationController::class, 'systemCampaigns']) + ->middleware('menu.permission:notifications-system,view')->name('notifications.system.campaigns.index'); + Route::post('notifications/system/campaigns', [NotificationController::class, 'storeSystemCampaign']) + ->middleware('menu.permission:notifications-system,create')->name('notifications.system.campaigns.store'); + Route::get('notifications/system/audience-options', [NotificationController::class, 'systemAudienceOptions']) + ->middleware('menu.permission:notifications-system,view')->name('notifications.system.audience-options'); + Route::get('notifications/channels/whatsapp-unofficial/settings/{notificationChannel}/state', [NotificationController::class, 'whapiState']) + ->middleware('menu.permission:notifications-wa-unofficial,view')->whereNumber('notificationChannel')->name('notifications.whapi.state'); + Route::post('notifications/channels/whatsapp-unofficial/settings/{notificationChannel}/start', [NotificationController::class, 'whapiStart']) + ->middleware('menu.permission:notifications-wa-unofficial,view')->whereNumber('notificationChannel')->name('notifications.whapi.start'); + Route::get('notifications/channels/whatsapp-unofficial/settings/{notificationChannel}/qr', [NotificationController::class, 'whapiQr']) + ->middleware('menu.permission:notifications-wa-unofficial,view')->whereNumber('notificationChannel')->name('notifications.whapi.qr'); + Route::post('notifications/channels/whatsapp-unofficial/settings/{notificationChannel}/test-message', [NotificationController::class, 'whapiTestMessage']) + ->middleware('menu.permission:notifications-wa-unofficial,view')->whereNumber('notificationChannel')->name('notifications.whapi.test-message'); Route::get('notifications/preferences', [NotificationController::class, 'preferences']) ->middleware('menu.permission:notifications-system,view')->name('notifications.preferences.index'); Route::put('notifications/preferences', [NotificationController::class, 'savePreferences']) @@ -99,6 +150,7 @@ Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () { Route::post('/settings', 'storeChannel')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.settings.store"); Route::put('/settings/{notificationChannel}', 'updateChannel')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationChannel')->name("notifications.{$path}.settings.update"); Route::post('/settings/{notificationChannel}/test', 'testChannel')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationChannel')->name("notifications.{$path}.settings.test"); + Route::delete('/settings/{notificationChannel}', 'destroyChannel')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationChannel')->name("notifications.{$path}.settings.destroy"); Route::get('/templates', 'templates')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.templates.index"); Route::post('/templates', 'storeTemplate')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.templates.store"); Route::put('/templates/{notificationTemplate}', 'updateTemplate')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationTemplate')->name("notifications.{$path}.templates.update");