update api register

This commit is contained in:
Wian Drs
2026-08-01 11:42:42 +07:00
parent b0d08211ec
commit 75342dc7b7
55 changed files with 2254 additions and 35 deletions
+2
View File
@@ -77,3 +77,5 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
REGISTRATION_SHOW_VERIFICATION_CODE=true
+3
View File
@@ -72,3 +72,6 @@ MINIO_USE_PATH_STYLE_ENDPOINT=true
MINIO_CA_BUNDLE= MINIO_CA_BUNDLE=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Sementara aktifkan selama channel pengiriman kode registrasi belum tersedia.
REGISTRATION_SHOW_VERIFICATION_CODE=true
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Access;
use App\Http\Controllers\ApiController;
use App\Http\Requests\Access\ReviewRoleApplicationRequest;
use App\Http\Requests\Access\StoreRoleApplicationRequest;
use App\Models\RoleApplication;
use App\Services\Access\RoleApplicationService;
use Illuminate\Http\Request;
class RoleApplicationController extends ApiController
{
public function __construct(protected RoleApplicationService $service) {}
public function mine(Request $request)
{
return $this->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');
}
}
+27 -1
View File
@@ -13,14 +13,16 @@ class MenuController extends ApiController
{ {
$user = auth()->user(); $user = auth()->user();
$tenantId = request()->attributes->get('tenant_id'); $tenantId = request()->attributes->get('tenant_id');
$tenantRole = $user?->tenantRole($tenantId);
if (! $user) { if (! $user) {
return $this->error('Unauthenticated', 401); return $this->error('Unauthenticated', 401);
} }
if ($user->isMasterAdmin()) { if ($user->isMasterAdmin() || $user->is_platform_staff) {
$menus = MenuList::query() $menus = MenuList::query()
->where('is_active', true) ->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('sort_order')
->orderBy('id') ->orderBy('id')
->get() ->get()
@@ -108,10 +110,22 @@ class MenuController extends ApiController
$tree = $this->buildMenuTree($menus); $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( return $this->success(
[ [
'is_master' => (bool) $user->is_master, 'is_master' => (bool) $user->is_master,
'access_level' => $user->access_level->value, 'access_level' => $user->access_level->value,
'tenant_role' => $tenantRole,
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'menus' => $tree, 'menus' => $tree,
], ],
@@ -143,4 +157,16 @@ class MenuController extends ApiController
}) })
->all(); ->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();
}
} }
@@ -9,7 +9,9 @@ use App\Http\Requests\Notification\StoreCustomerContactRequest;
use App\Http\Requests\Notification\StoreNotificationBroadcastRequest; use App\Http\Requests\Notification\StoreNotificationBroadcastRequest;
use App\Http\Requests\Notification\StoreNotificationChannelRequest; use App\Http\Requests\Notification\StoreNotificationChannelRequest;
use App\Http\Requests\Notification\StoreNotificationTemplateRequest; use App\Http\Requests\Notification\StoreNotificationTemplateRequest;
use App\Http\Requests\Notification\StoreSystemNotificationRequest;
use App\Http\Resources\Notification\NotificationDeliveryResource; use App\Http\Resources\Notification\NotificationDeliveryResource;
use App\Http\Resources\Notification\SystemNotificationCampaignResource;
use App\Models\Customer; use App\Models\Customer;
use App\Models\CustomerContact; use App\Models\CustomerContact;
use App\Models\NotificationBroadcast; use App\Models\NotificationBroadcast;
@@ -17,11 +19,12 @@ use App\Models\NotificationChannel;
use App\Models\NotificationEvent; use App\Models\NotificationEvent;
use App\Models\NotificationTemplate; use App\Models\NotificationTemplate;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Notification\WhapiIdService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class NotificationController extends ApiController class NotificationController extends ApiController
{ {
public function __construct(protected NotificationService $service) {} public function __construct(protected NotificationService $service, protected WhapiIdService $whapi) {}
public function events() 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'); 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) public function storeChannel(StoreNotificationChannelRequest $request)
{ {
abort_unless($request->validated('channel') === $this->channel($request), 422); 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'); 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); $this->authorizeTenant($request, $notificationChannel->tenant_id);
abort_unless($notificationChannel->channel === $this->channel($request), 404); 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'); 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); $this->authorizeTenant($request, $notificationChannel->tenant_id);
abort_unless($notificationChannel->channel === $this->channel($request), 404); abort_unless($notificationChannel->channel === $this->channel($request), 404);
$this->authorizeChannelMutation($request);
return $this->success($this->service->testChannel($notificationChannel), 'Pengujian channel selesai'); 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) public function templates(Request $request)
{ {
return $this->success($this->service->templates($this->channel($request), $request->user(), $this->tenantId($request)), 'Daftar template berhasil diambil'); 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'); 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);
} }
} }
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Voucher;
use App\Http\Controllers\ApiController;
use App\Http\Requests\Voucher\IndexVoucherRequest;
use App\Http\Requests\Voucher\SaveVoucherLoginPageRequest;
use App\Http\Requests\Voucher\StoreVoucherAgentRequest;
use App\Http\Requests\Voucher\StoreVoucherSaleRequest;
use App\Http\Resources\Voucher\VoucherAgentResource;
use App\Http\Resources\Voucher\VoucherSaleResource;
use App\Models\VoucherAgent;
use App\Models\VoucherSale;
use App\Services\Voucher\VoucherService;
use Illuminate\Http\Request;
class VoucherController extends ApiController
{
public function __construct(protected VoucherService $service) {}
public function sales(IndexVoucherRequest $request)
{
return $this->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');
}
}
+4 -1
View File
@@ -12,7 +12,10 @@ class EnsureAccessLevel
{ {
$user = $request->user(); $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([ return response()->json([
'success' => false, 'success' => false,
'code' => 403, 'code' => 403,
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Access;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ReviewRoleApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$application = $this->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'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Access;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreRoleApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'application_type' => ['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'],
];
}
}
@@ -17,6 +17,7 @@ class IndexNotificationRequest extends FormRequest
'search' => ['nullable', 'string', 'max:255'], 'search' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'string', 'max:30'], 'status' => ['nullable', 'string', 'max:30'],
'unread' => ['nullable', 'boolean'], 'unread' => ['nullable', 'boolean'],
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
'page' => ['nullable', 'integer', 'min:1'], 'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]; ];
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Notification;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSystemNotificationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['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'],
];
}
}
@@ -22,6 +22,7 @@ class StoreUserRequest extends FormRequest
'whatsapp_number' => ['nullable', 'string', 'max:20'], 'whatsapp_number' => ['nullable', 'string', 'max:20'],
'password' => ['required', 'string', 'min:8'], 'password' => ['required', 'string', 'min:8'],
'is_master' => ['nullable', 'boolean'], 'is_master' => ['nullable', 'boolean'],
'is_platform_staff' => ['nullable', 'boolean'],
'access_level' => ['nullable', Rule::enum(UserAccessLevel::class)], 'access_level' => ['nullable', Rule::enum(UserAccessLevel::class)],
'status' => ['required', Rule::in(['active', 'inactive', 'suspended'])], 'status' => ['required', Rule::in(['active', 'inactive', 'suspended'])],
@@ -56,6 +57,7 @@ class StoreUserRequest extends FormRequest
'user_tenants' => ['nullable', 'array'], 'user_tenants' => ['nullable', 'array'],
'user_tenants.*.tenant_id' => ['required_with:user_tenants', 'integer', 'exists:tenants,id'], '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_tenants.*.is_default' => ['nullable', 'boolean'],
'user_menu_groups' => ['nullable', 'array'], 'user_menu_groups' => ['nullable', 'array'],
@@ -35,6 +35,7 @@ class UpdateUserRequest extends FormRequest
'whatsapp_number' => ['nullable', 'string', 'max:20'], 'whatsapp_number' => ['nullable', 'string', 'max:20'],
'password' => ['nullable', 'string', 'min:8'], 'password' => ['nullable', 'string', 'min:8'],
'is_master' => ['nullable', 'boolean'], 'is_master' => ['nullable', 'boolean'],
'is_platform_staff' => ['nullable', 'boolean'],
'access_level' => ['sometimes', 'required', Rule::enum(UserAccessLevel::class)], 'access_level' => ['sometimes', 'required', Rule::enum(UserAccessLevel::class)],
'status' => ['sometimes', 'required', Rule::in(['active', 'inactive', 'suspended'])], 'status' => ['sometimes', 'required', Rule::in(['active', 'inactive', 'suspended'])],
@@ -69,6 +70,7 @@ class UpdateUserRequest extends FormRequest
'user_tenants' => ['nullable', 'array'], 'user_tenants' => ['nullable', 'array'],
'user_tenants.*.tenant_id' => ['required_with:user_tenants', 'integer', 'exists:tenants,id'], '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_tenants.*.is_default' => ['nullable', 'boolean'],
'user_menu_groups' => ['nullable', 'array'], 'user_menu_groups' => ['nullable', 'array'],
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Voucher;
use Illuminate\Foundation\Http\FormRequest;
class IndexVoucherRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => ['nullable', 'string', 'max:100'], 'payment_status' => ['nullable', 'string', 'max:30'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], 'page' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Voucher;
use Illuminate\Foundation\Http\FormRequest;
class SaveVoucherLoginPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'enabled' => ['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'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Voucher;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreVoucherAgentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'tenant_id' => ['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'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Voucher;
use Illuminate\Foundation\Http\FormRequest;
class StoreVoucherSaleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'nas_package_profile_id' => ['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'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources\Notification;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SystemNotificationCampaignResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
+1
View File
@@ -55,6 +55,7 @@ class UserResource extends JsonResource
'id' => $item->id, 'id' => $item->id,
'user_id' => $item->user_id, 'user_id' => $item->user_id,
'tenant_id' => $item->tenant_id, 'tenant_id' => $item->tenant_id,
'role' => $item->role,
'is_default' => (bool) $item->is_default, 'is_default' => (bool) $item->is_default,
'created_at' => $item->created_at, 'created_at' => $item->created_at,
'updated_at' => $item->updated_at, 'updated_at' => $item->updated_at,
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources\Voucher;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class VoucherAgentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Voucher;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class VoucherSaleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
])),
];
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Jobs;
use App\Models\SystemNotificationCampaign;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ProcessSystemNotificationCampaign implements ShouldQueue
{
use Queueable;
public function __construct(public int $campaignId) {}
public function handle(): void
{
$campaign = SystemNotificationCampaign::find($this->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))));
}
}
+5
View File
@@ -95,4 +95,9 @@ class Customer extends Model
{ {
return $this->hasMany(CustomerContact::class); return $this->hasMany(CustomerContact::class);
} }
public function linkedUsers()
{
return $this->belongsToMany(User::class, 'customer_user_links')->withPivot(['tenant_id', 'relationship', 'is_primary'])->withTimestamps();
}
} }
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CustomerUserLink extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['is_primary' => '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);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class HotspotVoucher extends Model
{
protected $guarded = ['id'];
protected $hidden = ['password'];
protected function casts(): array
{
return [
'password' => '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');
}
}
+5
View File
@@ -30,4 +30,9 @@ class NotificationChannel extends Model
{ {
return $this->hasMany(NotificationTemplate::class); return $this->hasMany(NotificationTemplate::class);
} }
public function deliveries()
{
return $this->hasMany(NotificationDelivery::class);
}
} }
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RoleApplication extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['payload' => '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');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SystemNotificationCampaign extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['audience' => '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);
}
}
+35 -2
View File
@@ -36,6 +36,7 @@ class User extends Authenticatable
'profile_photo_file_id', 'profile_photo_file_id',
'password', 'password',
'is_master', 'is_master',
'is_platform_staff',
'access_level', 'access_level',
'status', 'status',
'verification_channel', 'verification_channel',
@@ -66,6 +67,7 @@ class User extends Authenticatable
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_master' => 'boolean', 'is_master' => 'boolean',
'is_platform_staff' => 'boolean',
'access_level' => UserAccessLevel::class, 'access_level' => UserAccessLevel::class,
'verification_code_expires_at' => 'datetime', 'verification_code_expires_at' => 'datetime',
'verified_at' => 'datetime', 'verified_at' => 'datetime',
@@ -112,14 +114,45 @@ class User extends Authenticatable
return $this->morphMany(Wallet::class, 'owner'); 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 public function isMasterAdmin(): bool
{ {
return $this->is_master || $this->access_level === UserAccessLevel::MASTER_ADMIN; 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');
} }
} }
+9 -1
View File
@@ -13,5 +13,13 @@ class UserNotification extends Model
return ['read_at' => 'datetime']; 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');
}
} }
+1
View File
@@ -11,6 +11,7 @@ class UserTenant extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'tenant_id', 'tenant_id',
'role',
'is_default', 'is_default',
]; ];
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VoucherAgent extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'discount_percent' => '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);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VoucherLoginPageSetting extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'enabled' => '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');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VoucherSale extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'unit_price' => '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);
}
}
+5
View File
@@ -18,6 +18,11 @@ class Wallet extends Model
return $this->morphTo(); return $this->morphTo();
} }
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
public function ledgerEntries() public function ledgerEntries()
{ {
return $this->hasMany(WalletLedgerEntry::class); return $this->hasMany(WalletLedgerEntry::class);
+1 -1
View File
@@ -66,7 +66,7 @@ class AccessService
return true; return true;
} }
if (! $actor->isTenantOwner() || ! $tenantId || $target->isMasterAdmin()) { if (! $actor->isTenantOwner($tenantId) || ! $tenantId || $target->isMasterAdmin()) {
return false; return false;
} }
@@ -0,0 +1,288 @@
<?php
namespace App\Services\Access;
use App\Models\Customer;
use App\Models\CustomerUserLink;
use App\Models\MenuGroup;
use App\Models\MenuList;
use App\Models\RoleApplication;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserMenuGroup;
use App\Models\UserTenant;
use App\Models\VoucherAgent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class RoleApplicationService
{
public function mine(User $user)
{
return $user->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;
}
}
+8 -4
View File
@@ -19,6 +19,8 @@ class AuthService
'whatsapp_number' => $data['whatsapp_number'], 'whatsapp_number' => $data['whatsapp_number'],
'password' => Hash::make($data['password']), 'password' => Hash::make($data['password']),
'is_master' => false, 'is_master' => false,
'is_platform_staff' => false,
'access_level' => 'customer',
'status' => 'inactive', 'status' => 'inactive',
'verification_channel' => $data['verification_channel'], 'verification_channel' => $data['verification_channel'],
'verification_target' => $data['verification_target'], 'verification_target' => $data['verification_target'],
@@ -32,7 +34,8 @@ class AuthService
'verification' => [ 'verification' => [
'channel' => $user->verification_channel, 'channel' => $user->verification_channel,
'target' => $user->verification_target, '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, 'expires_at' => $user->verification_code_expires_at,
], ],
]; ];
@@ -45,7 +48,7 @@ class AuthService
->where('verification_target', $data['verification_target']) ->where('verification_target', $data['verification_target'])
->first(); ->first();
if (!$user) { if (! $user) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'verification_target' => 'Target verifikasi tidak ditemukan.', 'verification_target' => 'Target verifikasi tidak ditemukan.',
]); ]);
@@ -83,7 +86,7 @@ class AuthService
->where('verification_target', $data['verification_target']) ->where('verification_target', $data['verification_target'])
->first(); ->first();
if (!$user) { if (! $user) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'verification_target' => 'Target verifikasi tidak ditemukan.', 'verification_target' => 'Target verifikasi tidak ditemukan.',
]); ]);
@@ -105,7 +108,8 @@ class AuthService
return [ return [
'channel' => $user->verification_channel, 'channel' => $user->verification_channel,
'target' => $user->verification_target, '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, 'expires_at' => $user->verification_code_expires_at,
]; ];
} }
+12
View File
@@ -100,6 +100,18 @@ class CustomerService
'registration', 'registration',
['action_url' => "/customers/orders/{$customer->id}/detail"], ['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); return $customer->load(self::DETAIL_RELATIONS);
} }
@@ -4,6 +4,7 @@ namespace App\Services\Notification\Adapters;
use App\Models\NotificationDelivery; use App\Models\NotificationDelivery;
use App\Services\Notification\Contracts\NotificationChannelAdapter; use App\Services\Notification\Contracts\NotificationChannelAdapter;
use App\Services\Notification\WhapiIdService;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -12,6 +13,11 @@ class WhatsAppUnofficialAdapter implements NotificationChannelAdapter
public function send(NotificationDelivery $delivery): array public function send(NotificationDelivery $delivery): array
{ {
$config = $delivery->channelConfig; $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 ?? []; $credentials = $config?->credentials ?? [];
$settings = $config?->settings ?? []; $settings = $config?->settings ?? [];
$url = $settings['send_url'] ?? null; $url = $settings['send_url'] ?? null;
@@ -40,12 +40,17 @@ class NotificationManager
} }
return DB::transaction(function () use ($event, $recipient, $subject, $payload, $occurrenceKey, $context, $tenantId) { return DB::transaction(function () use ($event, $recipient, $subject, $payload, $occurrenceKey, $context, $tenantId) {
$recipientOccurrenceKey = Str::limit(
$occurrenceKey.'|'.$recipient->getMorphClass().'|'.$recipient->getKey(),
255,
''
);
$occurrence = NotificationEventOccurrence::firstOrCreate([ $occurrence = NotificationEventOccurrence::firstOrCreate([
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'notification_event_id' => $event->id, 'notification_event_id' => $event->id,
'subjectable_type' => $subject->getMorphClass(), 'subjectable_type' => $subject->getMorphClass(),
'subjectable_id' => $subject->getKey(), 'subjectable_id' => $subject->getKey(),
'occurrence_key' => $occurrenceKey, 'occurrence_key' => $recipientOccurrenceKey,
]); ]);
if (! $occurrence->wasRecentlyCreated) { if (! $occurrence->wasRecentlyCreated) {
return null; return null;
@@ -57,6 +62,20 @@ class NotificationManager
->where('enabled', true) ->where('enabled', true)
->orderBy('priority') ->orderBy('priority')
->get(); ->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()) { if ($preferences->isEmpty()) {
$occurrence->update(['processed_at' => now()]); $occurrence->update(['processed_at' => now()]);
@@ -4,6 +4,7 @@ namespace App\Services\Notification;
use App\Jobs\ProcessBroadcast; use App\Jobs\ProcessBroadcast;
use App\Jobs\ProcessNotificationDelivery; use App\Jobs\ProcessNotificationDelivery;
use App\Jobs\ProcessSystemNotificationCampaign;
use App\Models\Customer; use App\Models\Customer;
use App\Models\CustomerContact; use App\Models\CustomerContact;
use App\Models\NotificationBroadcast; use App\Models\NotificationBroadcast;
@@ -14,6 +15,7 @@ use App\Models\NotificationEvent;
use App\Models\NotificationMessage; use App\Models\NotificationMessage;
use App\Models\NotificationPreference; use App\Models\NotificationPreference;
use App\Models\NotificationTemplate; use App\Models\NotificationTemplate;
use App\Models\SystemNotificationCampaign;
use App\Models\TopologyLink; use App\Models\TopologyLink;
use App\Models\User; use App\Models\User;
use App\Models\UserNotification; use App\Models\UserNotification;
@@ -32,29 +34,126 @@ class NotificationService
return NotificationChannel::with('tenant:id,tenant_code,tenant_name') return NotificationChannel::with('tenant:id,tenant_code,tenant_name')
->where('channel', $channel) ->where('channel', $channel)
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) ->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() ->orderByDesc('is_default')->orderBy('name')->get()
->each(fn ($item) => $item->setAttribute('credentials_configured', ! empty($item->getRawOriginal('credentials')))); ->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 public function saveChannel(array $data, User $actor, ?int $tenantId, ?NotificationChannel $channel = null): NotificationChannel
{ {
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $channel?->tenant_id ?? $tenantId) : $tenantId; $effectiveTenant = $actor->isMasterAdmin()
if (! $effectiveTenant) { ? (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.']); 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.']); 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); $credentials = array_filter($data['credentials'] ?? [], fn ($value) => $value !== '' && $value !== null);
if ($channel && $credentials) { if ($channel && $credentials) {
$credentials = [...($channel->credentials ?? []), ...$credentials]; $credentials = [...($channel->credentials ?? []), ...$credentials];
} }
if (($data['is_default'] ?? false) === true) { 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)) ->when($channel, fn ($q) => $q->whereKeyNot($channel->id))
->update(['is_default' => false]); ->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 = [ $payload = [
...$data, ...$data,
'tenant_id' => $effectiveTenant, 'tenant_id' => $effectiveTenant,
@@ -72,6 +171,16 @@ class NotificationService
->setAttribute('credentials_configured', ! empty($channel->getRawOriginal('credentials'))); ->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) public function templates(string $channel, User $actor, ?int $tenantId)
{ {
return NotificationTemplate::with(['event:id,code,name,category,variables', 'channelConfig:id,name']) return NotificationTemplate::with(['event:id,code,name,category,variables', 'channelConfig:id,name'])
@@ -170,7 +279,9 @@ class NotificationService
'system' => 'Penyimpanan notifikasi aplikasi siap.', '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.'), '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_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.'), '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()]); $channel->update(['last_health_status' => 'healthy', 'last_health_message' => (string) $message, 'last_health_check_at' => now()]);
@@ -0,0 +1,93 @@
<?php
namespace App\Services\Notification;
use App\Models\NotificationChannel;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class WhapiIdService
{
public function state(NotificationChannel $channel): array
{
return $this->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;
}
}
+23
View File
@@ -65,6 +65,7 @@ class UserService
'profile_photo_file_id', 'profile_photo_file_id',
'password', 'password',
'is_master', 'is_master',
'is_platform_staff',
'access_level', 'access_level',
'status', 'status',
]); ]);
@@ -109,6 +110,7 @@ class UserService
'whatsapp_number', 'whatsapp_number',
'profile_photo_file_id', 'profile_photo_file_id',
'is_master', 'is_master',
'is_platform_staff',
'access_level', 'access_level',
'status', 'status',
]); ]);
@@ -176,9 +178,12 @@ class UserService
protected function syncTenants(User $user, array $rows): void protected function syncTenants(User $user, array $rows): void
{ {
$existingRoles = $user->userTenants()->pluck('role', 'tenant_id');
$normalized = collect($rows) $normalized = collect($rows)
->map(fn ($row) => [ ->map(fn ($row) => [
'tenant_id' => $row['tenant_id'] ?? null, '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), 'is_default' => (bool) ($row['is_default'] ?? false),
]) ])
->filter(fn ($row) => ! empty($row['tenant_id'])) ->filter(fn ($row) => ! empty($row['tenant_id']))
@@ -186,6 +191,12 @@ class UserService
->values() ->values()
->all(); ->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; $defaultSet = false;
foreach ($normalized as $index => $row) { foreach ($normalized as $index => $row) {
if ($row['is_default'] && ! $defaultSet) { if ($row['is_default'] && ! $defaultSet) {
@@ -243,6 +254,13 @@ class UserService
return; 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()) { if ($target?->isMasterAdmin()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'user' => 'Master Admin hanya dapat diubah oleh Master Admin.', '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.', $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.',
]);
}
} }
} }
} }
+241
View File
@@ -0,0 +1,241 @@
<?php
namespace App\Services\Voucher;
use App\Enums\UserAccessLevel;
use App\Models\HotspotVoucher;
use App\Models\NasMikrotik;
use App\Models\NasPackageProfile;
use App\Models\PaymentGatewayAccount;
use App\Models\TenantPaymentSetting;
use App\Models\User;
use App\Models\VoucherAgent;
use App\Models\VoucherLoginPageSetting;
use App\Models\VoucherSale;
use App\Services\Wallet\WalletService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class VoucherService
{
public function __construct(protected WalletService $wallets) {}
public function sales(array $filters, User $actor, ?int $tenantId)
{
return VoucherSale::with(['agent.user:id,name,username', 'packageProfile:id,name,external_profile_name,session_timeout', 'creator:id,name'])
->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 '<div id="voucher-container"></div>'."\n".'<script src="https://manjapro.net/voucherpay/cl-style.js?i='.$setting->public_key.'"></script>';
}
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);
}
}
}
+76 -10
View File
@@ -3,7 +3,9 @@
namespace App\Services\Wallet; namespace App\Services\Wallet;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPaymentSetting;
use App\Models\User; use App\Models\User;
use App\Models\VoucherAgent;
use App\Models\Wallet; use App\Models\Wallet;
use App\Models\WalletLedgerEntry; use App\Models\WalletLedgerEntry;
use App\Models\WalletTransaction; use App\Models\WalletTransaction;
@@ -15,14 +17,60 @@ use Illuminate\Validation\ValidationException;
class WalletService class WalletService
{ {
public function wallets(User $user, ?int $tenantId) public function chargeUser(User $user, int $tenantId, float $amount, string $idempotencyKey, string $description): WalletTransaction
{ {
$wallets = collect([$this->walletFor($user, 'user')]); return DB::transaction(function () use ($user, $tenantId, $amount, $idempotencyKey, $description) {
if ($tenantId && ($user->isMasterAdmin() || $user->isTenantOwner())) { $existing = WalletTransaction::where('idempotency_key', $idempotencyKey)->first();
$wallets->push($this->walletFor(Tenant::findOrFail($tenantId), 'tenant')); if ($existing) {
return $existing;
} }
return $wallets->values(); $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)
{
if (! $tenantId) {
return collect();
}
$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) public function ledger(Wallet $wallet, User $actor, ?int $tenantId, array $filters)
@@ -37,6 +85,9 @@ class WalletService
{ {
$this->authorizeWallet($source, $actor, $tenantId); $this->authorizeWallet($source, $actor, $tenantId);
$this->authorizeWallet($destination, $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)) { if ($source->is($destination)) {
throw ValidationException::withMessages(['destination_wallet_id' => 'Dompet tujuan harus berbeda.']); 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 public function requestWithdrawal(Wallet $wallet, array $data, User $actor, ?int $tenantId): WalletWithdrawal
{ {
$this->authorizeWallet($wallet, $actor, $tenantId); $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) { return DB::transaction(function () use ($wallet, $data, $actor) {
$locked = Wallet::lockForUpdate()->findOrFail($wallet->id); $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( 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'] ['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 private function newTransaction(string $type, float $amount, User $actor, string $description, float $fee = 0): WalletTransaction
{ {
return WalletTransaction::create([ return WalletTransaction::create([
@@ -116,8 +181,9 @@ class WalletService
if ($actor->isMasterAdmin()) { if ($actor->isMasterAdmin()) {
return; return;
} }
$allowed = ($wallet->owner_type === $actor->getMorphClass() && (int) $wallet->owner_id === (int) $actor->id) $sameTenant = $tenantId && (int) $wallet->tenant_id === (int) $tenantId;
|| ($actor->isTenantOwner() && $wallet->owner_type === (new Tenant)->getMorphClass() && (int) $wallet->owner_id === (int) $tenantId); $allowed = $sameTenant && (($wallet->owner_type === $actor->getMorphClass() && (int) $wallet->owner_id === (int) $actor->id)
abort_unless($allowed, 403, 'Dompet tidak dapat diakses.'); || ($actor->isTenantOwner($tenantId) && $wallet->owner_type === (new Tenant)->getMorphClass() && (int) $wallet->owner_id === (int) $tenantId));
abort_unless($allowed, 403, 'Saldo tidak dapat diakses.');
} }
} }
+15
View File
@@ -0,0 +1,15 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Temporary verification code display
|--------------------------------------------------------------------------
|
| Enable this only while the verification delivery channels are not ready.
| Once email/WhatsApp delivery is active, set the environment value to false
| so the verification code is no longer exposed by the API.
|
*/
'show_verification_code' => env('REGISTRATION_SHOW_VERIFICATION_CODE', false),
];
@@ -0,0 +1,100 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('voucher_agents', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallets', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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'));
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('notification_channels', function (Blueprint $table) {
$table->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();
});
}
};
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('user_tenants', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('notification_messages', function (Blueprint $table) {
$table->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());
}
};
+54 -1
View File
@@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\MenuGroup;
use App\Models\MenuList; use App\Models\MenuList;
use Illuminate\Database\Seeder; 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( $paymentGateway = MenuList::updateOrCreate(
['slug' => 'payment-gateway'], ['slug' => 'payment-gateway'],
[ [
@@ -206,10 +241,28 @@ class MenuListSeeder extends Seeder
} }
MenuList::updateOrCreate(['slug' => 'wallet'], [ 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, '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( $nas = MenuList::updateOrCreate(
['slug' => 'nas'], ['slug' => 'nas'],
[ [
+54 -2
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\Access\RoleApplicationController;
use App\Http\Controllers\Audit\AuditLogController; use App\Http\Controllers\Audit\AuditLogController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\Billing\BillingProfileController; use App\Http\Controllers\Billing\BillingProfileController;
@@ -22,8 +23,9 @@ use App\Http\Controllers\Ticket\TicketTypeController;
use App\Http\Controllers\Ticket\TicketWorkflowController; use App\Http\Controllers\Ticket\TicketWorkflowController;
use App\Http\Controllers\Topology\TopologyController; use App\Http\Controllers\Topology\TopologyController;
use App\Http\Controllers\User\UserController; use App\Http\Controllers\User\UserController;
use App\Http\Controllers\Wilayah\WilayahController; use App\Http\Controllers\Voucher\VoucherController;
use App\Http\Controllers\Wallet\WalletController; use App\Http\Controllers\Wallet\WalletController;
use App\Http\Controllers\Wilayah\WilayahController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
@@ -40,6 +42,40 @@ Route::post('/payment-webhooks/{accountUuid}', fn () => response()->json(['messa
Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () { 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']) Route::get('payment-gateway/providers', [PaymentGatewayController::class, 'providers'])
->middleware('menu.permission:payment-gateway-settings,view')->name('payment-gateway.providers'); ->middleware('menu.permission:payment-gateway-settings,view')->name('payment-gateway.providers');
Route::get('payment-gateway/settings', [PaymentGatewayController::class, 'settings']) 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']) Route::get('payment-gateway/transactions', [PaymentGatewayController::class, 'transactions'])
->middleware('menu.permission:payment-gateway-logs,view')->name('payment-gateway.transactions.index'); ->middleware('menu.permission:payment-gateway-logs,view')->name('payment-gateway.transactions.index');
Route::middleware('menu.resource:wallet')->group(function () {
Route::get('wallets', [WalletController::class, 'index'])->name('wallets.index'); Route::get('wallets', [WalletController::class, 'index'])->name('wallets.index');
Route::get('wallets/{wallet}/ledger', [WalletController::class, 'ledger'])->whereNumber('wallet')->name('wallets.ledger'); 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::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/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']) Route::get('notifications/preferences', [NotificationController::class, 'preferences'])
->middleware('menu.permission:notifications-system,view')->name('notifications.preferences.index'); ->middleware('menu.permission:notifications-system,view')->name('notifications.preferences.index');
Route::put('notifications/preferences', [NotificationController::class, 'savePreferences']) 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::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::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::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::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::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"); Route::put('/templates/{notificationTemplate}', 'updateTemplate')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationTemplate')->name("notifications.{$path}.templates.update");