update api register
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
+56
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+61
@@ -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());
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user