big update base struktur tahap 1

This commit is contained in:
Wian Drs
2026-07-27 16:24:09 +07:00
parent 2b3592c4c2
commit 1dd02baa72
169 changed files with 24405 additions and 977 deletions
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Services\Access;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class AccessService
{
public function tenantId(User $user, ?int $requestedTenantId = null): ?int
{
if ($requestedTenantId) {
if ($user->isMasterAdmin() || $user->userTenants()->where('tenant_id', $requestedTenantId)->exists()) {
return $requestedTenantId;
}
return null;
}
return $user->userTenants()
->orderByDesc('is_default')
->orderBy('id')
->value('tenant_id');
}
public function hasMenuPermission(
User $user,
string $menuSlug,
string $permission,
?int $tenantId
): bool {
if ($user->isMasterAdmin()) {
return true;
}
$column = 'can_'.$permission;
$allowed = [
'can_view',
'can_create',
'can_update',
'can_delete',
'can_approve',
'can_export',
];
if (! in_array($column, $allowed, true) || ! $tenantId) {
return false;
}
return DB::table('user_menu_groups as umg')
->join('menu_groups as mg', 'mg.id', '=', 'umg.menu_group_id')
->join('menu_group_menus as mgm', 'mgm.menu_group_id', '=', 'mg.id')
->join('menu_lists as ml', 'ml.id', '=', 'mgm.menu_id')
->where('umg.user_id', $user->id)
->where('umg.tenant_id', $tenantId)
->where('mg.is_active', true)
->where('ml.is_active', true)
->where('ml.slug', $menuSlug)
->where("mgm.$column", true)
->exists();
}
public function canManageUser(User $actor, User $target, ?int $tenantId): bool
{
if ($actor->isMasterAdmin()) {
return true;
}
if (! $actor->isTenantOwner() || ! $tenantId || $target->isMasterAdmin()) {
return false;
}
return $target->userTenants()->where('tenant_id', $tenantId)->exists();
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthService
{
public function register(array $data): array
{
$code = $this->generateCode();
$user = User::create([
'name' => $data['name'],
'username' => $data['username'] ?? null,
'email' => $data['email'],
'whatsapp_number' => $data['whatsapp_number'],
'password' => Hash::make($data['password']),
'is_master' => false,
'status' => 'inactive',
'verification_channel' => $data['verification_channel'],
'verification_target' => $data['verification_target'],
'verification_code' => $code,
'verification_code_expires_at' => now()->addMinutes(10),
'verified_at' => null,
]);
return [
'user' => $user,
'verification' => [
'channel' => $user->verification_channel,
'target' => $user->verification_target,
'code' => $code,
'expires_at' => $user->verification_code_expires_at,
],
];
}
public function verifyRegistration(array $data): User
{
$user = User::query()
->where('verification_channel', $data['verification_channel'])
->where('verification_target', $data['verification_target'])
->first();
if (!$user) {
throw ValidationException::withMessages([
'verification_target' => 'Target verifikasi tidak ditemukan.',
]);
}
if (empty($user->verification_code) || $user->verification_code !== $data['verification_code']) {
throw ValidationException::withMessages([
'verification_code' => 'Kode verifikasi tidak valid.',
]);
}
if (
empty($user->verification_code_expires_at) ||
now()->greaterThan($user->verification_code_expires_at)
) {
throw ValidationException::withMessages([
'verification_code' => 'Kode verifikasi sudah kadaluarsa.',
]);
}
$user->update([
'status' => 'active',
'verified_at' => now(),
'verification_code' => null,
'verification_code_expires_at' => null,
]);
return $user->fresh();
}
public function resendVerificationCode(array $data): array
{
$user = User::query()
->where('verification_channel', $data['verification_channel'])
->where('verification_target', $data['verification_target'])
->first();
if (!$user) {
throw ValidationException::withMessages([
'verification_target' => 'Target verifikasi tidak ditemukan.',
]);
}
if ($user->status === 'active') {
throw ValidationException::withMessages([
'user' => 'User sudah aktif dan tidak memerlukan verifikasi ulang.',
]);
}
$code = $this->generateCode();
$user->update([
'verification_code' => $code,
'verification_code_expires_at' => now()->addMinutes(10),
]);
return [
'channel' => $user->verification_channel,
'target' => $user->verification_target,
'code' => $code,
'expires_at' => $user->verification_code_expires_at,
];
}
protected function generateCode(): string
{
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Services\File;
use App\Models\StoredFile;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class StoredFileService
{
public function list(array $filters, User $user, ?int $tenantId): LengthAwarePaginator
{
return StoredFile::query()
->when(! $user->isMasterAdmin(), function ($query) use ($user, $tenantId) {
$query->where(function ($scope) use ($user, $tenantId) {
$scope->where('uploaded_by', $user->id)
->when($tenantId, fn ($tenantScope) => $tenantScope->orWhere('tenant_id', $tenantId));
});
})
->when($filters['search'] ?? null, function ($query, $search) {
$query->where('original_name', 'like', "%{$search}%");
})
->when($filters['category'] ?? null, fn ($query, $category) => $query->where('category', $category))
->when($filters['mime_type'] ?? null, fn ($query, $mimeType) => $query->where('mime_type', $mimeType))
->latest('id')
->paginate($filters['per_page'] ?? 10);
}
public function store(UploadedFile $upload, User $user, ?int $tenantId, string $category): StoredFile
{
$uuid = (string) Str::uuid();
$extension = strtolower($upload->getClientOriginalExtension() ?: $upload->extension() ?: 'bin');
$scope = $tenantId ? "tenants/{$tenantId}" : 'global';
$objectKey = "{$scope}/{$category}/".now()->format('Y/m')."/{$uuid}.{$extension}";
$disk = Storage::disk('minio');
$disk->putFileAs(dirname($objectKey), $upload, basename($objectKey), [
'visibility' => 'private',
'ContentType' => $upload->getMimeType() ?: 'application/octet-stream',
]);
try {
return DB::transaction(fn () => StoredFile::create([
'uuid' => $uuid,
'tenant_id' => $tenantId,
'uploaded_by' => $user->id,
'disk' => 'minio',
'bucket' => (string) config('filesystems.disks.minio.bucket'),
'object_key' => $objectKey,
'original_name' => $upload->getClientOriginalName(),
'extension' => $extension,
'mime_type' => $upload->getMimeType(),
'size' => $upload->getSize(),
'category' => $category,
'visibility' => 'private',
]));
} catch (\Throwable $exception) {
$disk->delete($objectKey);
throw $exception;
}
}
public function temporaryUrl(StoredFile $file, User $user): array
{
$this->authorizeAccess($file, $user);
$expiresAt = now()->addMinutes(10);
return [
'url' => Storage::disk($file->disk)->temporaryUrl(
$file->object_key,
$expiresAt,
['ResponseContentDisposition' => 'inline; filename="'.addslashes($file->original_name).'"']
),
'expires_at' => $expiresAt->toIso8601String(),
];
}
public function delete(StoredFile $file, User $user): void
{
if (! $user->isMasterAdmin() && $file->uploaded_by !== $user->id) {
abort(403, 'File hanya dapat dihapus oleh pengunggah atau Master Admin.');
}
Storage::disk($file->disk)->delete($file->object_key);
$file->delete();
}
public function authorizeAccess(StoredFile $file, User $user): void
{
if ($user->isMasterAdmin() || $file->uploaded_by === $user->id) {
return;
}
$canAccessTenant = $file->tenant_id && $user->userTenants()
->where('tenant_id', $file->tenant_id)
->exists();
abort_unless($canAccessTenant, 403, 'Anda tidak memiliki akses ke file ini.');
}
public function validateProfilePhoto(StoredFile $file, User $user): void
{
if ($file->uploaded_by !== $user->id
|| $file->category !== 'profile'
|| ! str_starts_with((string) $file->mime_type, 'image/')
|| $file->size > 5 * 1024 * 1024) {
throw ValidationException::withMessages([
'profile_photo_file_id' => 'Foto profil harus berupa file gambar kategori profile yang Anda unggah sendiri.',
]);
}
}
}
+45 -18
View File
@@ -2,10 +2,9 @@
namespace App\Services\Material;
use App\Models\TechnicianMaterial;
use App\Models\TechnicianConsumable;
use App\Models\MaterialMovement;
use Carbon\Carbon;
use App\Models\TechnicianConsumable;
use App\Models\TechnicianMaterial;
use DB;
class MaterialService
@@ -52,11 +51,36 @@ class MaterialService
DB::raw("'consumable' as type"),
]);
if (!empty($filters['user_id'])) {
if (! empty($filters['user_id'])) {
$serialized->where('user_id', $filters['user_id']);
$consumable->where('user_id', $filters['user_id']);
}
if (! empty($filters['search'])) {
$search = $filters['search'];
$serialized->where(function ($query) use ($search) {
$query->where('barcode_id', 'ILIKE', "%$search%")
->orWhere('material_name', 'ILIKE', "%$search%");
});
$consumable->where(function ($query) use ($search) {
$query->where('barcode_id', 'ILIKE', "%$search%")
->orWhere('material_name', 'ILIKE', "%$search%");
});
}
if (! empty($filters['status'])) {
$serialized->where('status', $filters['status']);
$consumable->where('status', $filters['status']);
}
if (($filters['type'] ?? null) === 'serialized') {
$consumable->whereRaw('1 = 0');
}
if (($filters['type'] ?? null) === 'consumable') {
$serialized->whereRaw('1 = 0');
}
$query = $serialized->unionAll($consumable);
return DB::query()
@@ -64,6 +88,7 @@ class MaterialService
->orderByDesc('created_at')
->paginate($filters['per_page'] ?? 15);
}
/*
|--------------------------------------------------------------------------
| ASSIGN MATERIAL (AUTO DETECT TYPE)
@@ -73,7 +98,7 @@ class MaterialService
{
return DB::transaction(function () use ($data) {
$type = $data['type'];
$type = $data['type'];
// type dari API Gudang: serialized / consumable
/*
@@ -154,7 +179,7 @@ class MaterialService
if ($serialized) {
$serialized->update([
'user_id' => $data['to_user_id']
'user_id' => $data['to_user_id'],
]);
MaterialMovement::create([
@@ -173,7 +198,7 @@ class MaterialService
$consumable = TechnicianConsumable::where('barcode_id', $barcode)->first();
$consumable->update([
'user_id' => $data['to_user_id']
'user_id' => $data['to_user_id'],
]);
MaterialMovement::create([
@@ -207,7 +232,7 @@ class MaterialService
$serialized->update([
'user_id' => null,
'status' => 'returned'
'status' => 'returned',
]);
MaterialMovement::create([
@@ -226,7 +251,7 @@ class MaterialService
$consumable->update([
'user_id' => null,
'status' => 'returned'
'status' => 'returned',
]);
MaterialMovement::create([
@@ -249,6 +274,7 @@ class MaterialService
->get()
->map(function ($item) {
$item->type = 'serialized';
return $item;
});
@@ -256,6 +282,7 @@ class MaterialService
->get()
->map(function ($item) {
$item->type = 'consumable';
return $item;
});
@@ -272,15 +299,15 @@ class MaterialService
->map(function ($item) {
return [
'barcode_id' => $item->barcode_id,
'type' => $item->movement_type,
'from_user_id' => $item->from_user_id,
'to_user_id' => $item->to_user_id,
'ticket_id' => $item->ticket_id,
'qty' => $item->qty,
'description' => $item->description,
'created_at' => $item->created_at,
'barcode_id' => $item->barcode_id,
'type' => $item->movement_type,
'from_user_id' => $item->from_user_id,
'to_user_id' => $item->to_user_id,
'ticket_id' => $item->ticket_id,
'qty' => $item->qty,
'description' => $item->description,
'created_at' => $item->created_at,
];
});
}
}
}
+392
View File
@@ -0,0 +1,392 @@
<?php
namespace App\Services\Menu;
use App\Models\MenuGroup;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class MenuGroupService
{
public function list(array $filters = [], ?User $actor = null, ?int $tenantId = null)
{
return MenuGroup::query()
->with('tenant')
->when(
$actor && ! $actor->isMasterAdmin(),
fn ($q) => $q->where('tenant_id', $tenantId)
)
->when(
! empty($filters['search']),
fn ($q) => $q->where(function ($x) use ($filters) {
$x->where('name', 'ILIKE', "%{$filters['search']}%")
->orWhere('description', 'ILIKE', "%{$filters['search']}%");
})
)
->when(
array_key_exists('is_active', $filters) && $filters['is_active'] !== null && $filters['is_active'] !== '',
fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN))
)
->orderBy(
$filters['sort_by'] ?? 'id',
$filters['sort_direction'] ?? 'desc'
)
->paginate($filters['per_page'] ?? 10);
}
public function create(array $data, User $actor, ?int $tenantId): MenuGroup
{
if (! array_key_exists('is_active', $data) || $data['is_active'] === null) {
$data['is_active'] = true;
}
$scopeTenantId = $actor->isMasterAdmin()
? ($data['tenant_id'] ?? null)
: $tenantId;
if (! $actor->isMasterAdmin() && ! $scopeTenantId) {
throw ValidationException::withMessages([
'tenant_id' => 'Tenant aktif wajib tersedia untuk membuat Menu Group.',
]);
}
$data['tenant_id'] = $scopeTenantId;
$data['is_system'] = $actor->isMasterAdmin() && ! $scopeTenantId;
return MenuGroup::create($data)->load('tenant');
}
public function update(
MenuGroup $menuGroup,
array $data,
User $actor,
?int $tenantId
): MenuGroup {
if (array_key_exists('is_active', $data) && $data['is_active'] === null) {
unset($data['is_active']);
}
if ($actor->isMasterAdmin()) {
if (array_key_exists('tenant_id', $data)) {
$data['is_system'] = ! $data['tenant_id'];
}
} else {
unset($data['tenant_id'], $data['is_system']);
if ((int) $menuGroup->tenant_id !== (int) $tenantId) {
abort(403);
}
}
$menuGroup->update($data);
return $menuGroup->fresh('tenant');
}
public function delete(MenuGroup $menuGroup): bool
{
$hasMenuMappings = $menuGroup->menus()->exists();
$hasUserMappings = $menuGroup->userMenuGroups()->exists();
if ($hasMenuMappings || $hasUserMappings) {
throw ValidationException::withMessages([
'menu_group' => 'Menu Group tidak dapat dihapus karena masih memiliki relasi menu/user.',
]);
}
return $menuGroup->delete();
}
public function listMenus(MenuGroup $menuGroup): array
{
$rows = DB::table('menu_lists as m')
->leftJoin('menu_group_menus as mgm', function ($join) use ($menuGroup) {
$join->on('mgm.menu_id', '=', 'm.id')
->where('mgm.menu_group_id', '=', $menuGroup->id);
})
->where('m.is_active', true)
->select(
'm.id',
'm.parent_id',
'm.name',
'm.slug',
'm.url',
'm.icon',
'm.component',
'm.sort_order',
DB::raw('COALESCE(mgm.can_view, false) as can_view'),
DB::raw('COALESCE(mgm.can_create, false) as can_create'),
DB::raw('COALESCE(mgm.can_update, false) as can_update'),
DB::raw('COALESCE(mgm.can_delete, false) as can_delete'),
DB::raw('COALESCE(mgm.can_approve, false) as can_approve'),
DB::raw('COALESCE(mgm.can_export, false) as can_export')
)
->orderBy('m.sort_order')
->orderBy('m.id')
->get();
return $rows->map(function ($row) {
return [
'menu_id' => (int) $row->id,
'parent_id' => $row->parent_id ? (int) $row->parent_id : null,
'name' => $row->name,
'slug' => $row->slug,
'url' => $row->url,
'icon' => $row->icon,
'component' => $row->component,
'sort_order' => (int) $row->sort_order,
'permissions' => [
'can_view' => (bool) $row->can_view,
'can_create' => (bool) $row->can_create,
'can_update' => (bool) $row->can_update,
'can_delete' => (bool) $row->can_delete,
'can_approve' => (bool) $row->can_approve,
'can_export' => (bool) $row->can_export,
],
'available_permissions' => [
'can_view' => true,
'can_create' => true,
'can_update' => true,
'can_delete' => true,
'can_approve' => true,
'can_export' => true,
],
];
})->all();
}
public function syncMenus(MenuGroup $menuGroup, array $menus, ?User $actor = null): void
{
if ($actor && ! $actor->isMasterAdmin()) {
$allowed = $this->getAllowedMenuPermissionsForUser(
$actor,
request()->attributes->get('tenant_id')
);
foreach ($menus as $menu) {
$actorPermissions = $allowed[(int) $menu['menu_id']] ?? null;
if (! $actorPermissions) {
throw ValidationException::withMessages([
'menus' => 'Terdapat menu di luar hak akses user login.',
]);
}
foreach (['view', 'create', 'update', 'delete', 'approve', 'export'] as $permission) {
if (($menu["can_$permission"] ?? false) && ! $actorPermissions["can_$permission"]) {
throw ValidationException::withMessages([
'menus' => "Permission can_$permission tidak boleh melebihi akses Anda.",
]);
}
}
}
}
$syncData = [];
foreach ($menus as $menu) {
$syncData[$menu['menu_id']] = [
'can_view' => (bool) ($menu['can_view'] ?? false),
'can_create' => (bool) ($menu['can_create'] ?? false),
'can_update' => (bool) ($menu['can_update'] ?? false),
'can_delete' => (bool) ($menu['can_delete'] ?? false),
'can_approve' => (bool) ($menu['can_approve'] ?? false),
'can_export' => (bool) ($menu['can_export'] ?? false),
'updated_at' => now(),
'created_at' => now(),
];
}
$menuGroup->menus()->sync($syncData);
}
public function listAvailableMenusForGroup(MenuGroup $menuGroup, User $actor): array
{
if ($actor->isMasterAdmin()) {
return $this->listMenus($menuGroup);
}
$allowedPermissions = $this->getAllowedMenuPermissionsForUser(
$actor,
request()->attributes->get('tenant_id')
);
$allowedMenuIds = array_keys($allowedPermissions);
$rows = DB::table('menu_lists as m')
->leftJoin('menu_group_menus as mgm', function ($join) use ($menuGroup) {
$join->on('mgm.menu_id', '=', 'm.id')
->where('mgm.menu_group_id', '=', $menuGroup->id);
})
->where('m.is_active', true)
->whereIn('m.id', $allowedMenuIds)
->select(
'm.id',
'm.parent_id',
'm.name',
'm.sort_order',
DB::raw('COALESCE(mgm.can_view, false) as can_view'),
DB::raw('COALESCE(mgm.can_create, false) as can_create'),
DB::raw('COALESCE(mgm.can_update, false) as can_update'),
DB::raw('COALESCE(mgm.can_delete, false) as can_delete'),
DB::raw('COALESCE(mgm.can_approve, false) as can_approve'),
DB::raw('COALESCE(mgm.can_export, false) as can_export')
)
->orderBy('m.sort_order')
->orderBy('m.id')
->get();
return $rows->map(function ($row) use ($allowedPermissions) {
$available = $allowedPermissions[(int) $row->id];
return [
'menu_id' => (int) $row->id,
'parent_id' => $row->parent_id ? (int) $row->parent_id : null,
'name' => $row->name,
'sort_order' => (int) $row->sort_order,
'permissions' => [
'can_view' => (bool) $row->can_view,
'can_create' => (bool) $row->can_create,
'can_update' => (bool) $row->can_update,
'can_delete' => (bool) $row->can_delete,
'can_approve' => (bool) $row->can_approve,
'can_export' => (bool) $row->can_export,
],
'available_permissions' => $available,
];
})->all();
}
public function getAllowedMenuIdsForUser(User $user, ?int $tenantId = null): array
{
if ($user->isMasterAdmin()) {
return DB::table('menu_lists')
->where('is_active', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
}
return DB::table('menu_group_menus as mgm')
->join('user_menu_groups as umg', 'umg.menu_group_id', '=', 'mgm.menu_group_id')
->join('menu_lists as m', 'm.id', '=', 'mgm.menu_id')
->where('umg.user_id', $user->id)
->where('umg.tenant_id', $tenantId)
->where('m.is_active', true)
->where(function ($q) {
$q->where('mgm.can_view', true)
->orWhere('mgm.can_create', true)
->orWhere('mgm.can_update', true)
->orWhere('mgm.can_delete', true)
->orWhere('mgm.can_approve', true)
->orWhere('mgm.can_export', true);
})
->distinct()
->pluck('mgm.menu_id')
->map(fn ($id) => (int) $id)
->all();
}
public function getAllowedMenuPermissionsForUser(User $user, ?int $tenantId): array
{
return DB::table('menu_group_menus as mgm')
->join('user_menu_groups as umg', 'umg.menu_group_id', '=', 'mgm.menu_group_id')
->join('menu_groups as mg', 'mg.id', '=', 'mgm.menu_group_id')
->join('menu_lists as m', 'm.id', '=', 'mgm.menu_id')
->where('umg.user_id', $user->id)
->where('umg.tenant_id', $tenantId)
->where('mg.is_active', true)
->where('m.is_active', true)
->groupBy('mgm.menu_id')
->select(
'mgm.menu_id',
DB::raw('MAX(CASE WHEN mgm.can_view THEN 1 ELSE 0 END) as can_view'),
DB::raw('MAX(CASE WHEN mgm.can_create THEN 1 ELSE 0 END) as can_create'),
DB::raw('MAX(CASE WHEN mgm.can_update THEN 1 ELSE 0 END) as can_update'),
DB::raw('MAX(CASE WHEN mgm.can_delete THEN 1 ELSE 0 END) as can_delete'),
DB::raw('MAX(CASE WHEN mgm.can_approve THEN 1 ELSE 0 END) as can_approve'),
DB::raw('MAX(CASE WHEN mgm.can_export THEN 1 ELSE 0 END) as can_export')
)
->get()
->mapWithKeys(fn ($row) => [(int) $row->menu_id => [
'can_view' => (bool) $row->can_view,
'can_create' => (bool) $row->can_create,
'can_update' => (bool) $row->can_update,
'can_delete' => (bool) $row->can_delete,
'can_approve' => (bool) $row->can_approve,
'can_export' => (bool) $row->can_export,
]])
->all();
}
public function listUserAssignments(User $user, ?int $tenantId = null): array
{
return DB::table('user_menu_groups as umg')
->join('menu_groups as mg', 'mg.id', '=', 'umg.menu_group_id')
->leftJoin('tenants as t', 't.id', '=', 'umg.tenant_id')
->where('umg.user_id', $user->id)
->when($tenantId, fn ($q) => $q->where('umg.tenant_id', $tenantId))
->select(
'umg.menu_group_id',
'mg.name as menu_group_name',
'mg.is_active as menu_group_is_active',
'umg.tenant_id',
't.tenant_name as tenant_name',
'umg.created_at',
'umg.updated_at'
)
->orderBy('umg.menu_group_id')
->orderBy('umg.tenant_id')
->get()
->map(function ($row) {
return [
'menu_group_id' => (int) $row->menu_group_id,
'menu_group_name' => $row->menu_group_name,
'menu_group_is_active' => (bool) $row->menu_group_is_active,
'tenant_id' => $row->tenant_id ? (int) $row->tenant_id : null,
'tenant_name' => $row->tenant_name,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
];
})
->all();
}
public function syncUserAssignments(User $user, array $assignments, ?int $tenantId = null): void
{
$rows = [];
foreach ($assignments as $assignment) {
if ($tenantId && (int) ($assignment['tenant_id'] ?? 0) !== $tenantId) {
throw ValidationException::withMessages([
'assignments' => 'Assignment hanya boleh dibuat pada tenant aktif.',
]);
}
$group = MenuGroup::find($assignment['menu_group_id']);
if ($tenantId && $group && (int) $group->tenant_id !== $tenantId) {
throw ValidationException::withMessages([
'assignments' => 'Menu group bukan milik tenant aktif.',
]);
}
$rows[] = [
'user_id' => $user->id,
'menu_group_id' => (int) $assignment['menu_group_id'],
'tenant_id' => $assignment['tenant_id'] ?? null,
'created_at' => now(),
'updated_at' => now(),
];
}
DB::transaction(function () use ($user, $rows) {
DB::table('user_menu_groups')
->where('user_id', $user->id)
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->delete();
if (! empty($rows)) {
DB::table('user_menu_groups')->insert($rows);
}
});
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Services\Menu;
use App\Models\MenuList;
class MenuListService
{
public function list(array $filters = [])
{
return MenuList::query()
->when(
!empty($filters['search']),
fn($q) => $q->where(function ($x) use ($filters) {
$x->where('name', 'ILIKE', "%{$filters['search']}%")
->orWhere('slug', 'ILIKE', "%{$filters['search']}%")
->orWhere('url', 'ILIKE', "%{$filters['search']}%");
})
)
->when(
isset($filters['is_active']) && $filters['is_active'] !== '',
fn($q) => $q->where(
'is_active',
filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)
)
)
->latest('id')
->paginate($filters['per_page'] ?? 10);
}
public function create(array $data): MenuList
{
return MenuList::create($data);
}
public function update(MenuList $menuList, array $data): MenuList
{
$menuList->update($data);
return $menuList->fresh();
}
public function delete(MenuList $menuList): bool
{
return $menuList->delete();
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Nas;
use App\Models\NasMikrotik;
use App\Models\NasOlt;
use App\Models\NasPackageProfile;
use App\Models\NasWebfigDevice;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
class NasResourceService
{
private const MODELS = [
'mikrotik' => NasMikrotik::class,
'package-profile' => NasPackageProfile::class,
'olt' => NasOlt::class,
'webfig' => NasWebfigDevice::class,
];
private const SECRET_FIELDS = [
'mikrotik' => ['api_password', 'radius_secret'],
'olt' => ['community', 'auth_password', 'privacy_password'],
'webfig' => ['password'],
'package-profile' => [],
];
public function list(string $resource, array $filters, User $actor, ?int $tenantId)
{
$model = $this->model($resource);
return $model::query()
->with('tenant:id,tenant_code,tenant_name')
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
->when(! empty($filters['connection_type']) && $resource === 'mikrotik', fn ($q) => $q->where('connection_type', $filters['connection_type']))
->when(! empty($filters['service_type']) && $resource === 'package-profile', fn ($q) => $q->where('service_type', $filters['service_type']))
->when(! empty($filters['search']), function ($q) use ($filters, $resource) {
$columns = match ($resource) {
'mikrotik' => ['name', 'host', 'api_username'],
'package-profile' => ['name', 'external_profile_name'],
'olt' => ['name', 'host', 'vendor', 'model'],
'webfig' => ['name', 'url', 'device_type'],
};
$q->where(function ($search) use ($columns, $filters) {
foreach ($columns as $index => $column) {
$method = $index === 0 ? 'where' : 'orWhere';
$search->{$method}($column, 'ILIKE', "%{$filters['search']}%");
}
});
})
->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc')
->paginate($filters['per_page'] ?? 10);
}
public function find(string $resource, int $id): Model
{
$model = $this->model($resource);
return $model::with('tenant:id,tenant_code,tenant_name')->findOrFail($id);
}
public function create(string $resource, array $data, User $actor, ?int $tenantId): Model
{
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
if (! $data['tenant_id']) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
}
$data['status'] ??= 'active';
$model = $this->model($resource);
return $model::create($data)->load('tenant:id,tenant_code,tenant_name');
}
public function update(string $resource, Model $model, array $data, User $actor): Model
{
if (! $actor->isMasterAdmin()) {
unset($data['tenant_id']);
}
foreach (self::SECRET_FIELDS[$resource] as $field) {
if (array_key_exists($field, $data) && blank($data[$field])) {
unset($data[$field]);
}
}
$model->update($data);
return $model->fresh()->load('tenant:id,tenant_code,tenant_name');
}
public function delete(Model $model): bool
{
return $model->delete();
}
private function model(string $resource): string
{
abort_unless(isset(self::MODELS[$resource]), 404);
return self::MODELS[$resource];
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Services\Tenant;
use App\Models\Tenant;
use App\Models\User;
class TenantService
{
public function list(array $filters = [], ?User $actor = null)
{
return Tenant::query()
->when(
$actor && ! $actor->isMasterAdmin(),
fn ($q) => $q->whereIn(
'id',
$actor->userTenants()->select('tenant_id')
)
)
->when(
! empty($filters['search']),
fn ($q) => $q->where(function ($x) use ($filters) {
$x->where('tenant_code', 'ILIKE', "%{$filters['search']}%")
->orWhere('tenant_name', 'ILIKE', "%{$filters['search']}%")
->orWhere('email', 'ILIKE', "%{$filters['search']}%");
})
)
->when(
! empty($filters['status']),
fn ($q) => $q->where('status', $filters['status'])
)
->orderBy(
$filters['sort_by'] ?? 'id',
$filters['sort_direction'] ?? 'desc'
)
->paginate($filters['per_page'] ?? 10);
}
public function create(array $data): Tenant
{
if (! array_key_exists('status', $data) || empty($data['status'])) {
$data['status'] = 'active';
}
return Tenant::create($data);
}
public function update(Tenant $tenant, array $data): Tenant
{
$tenant->update($data);
return $tenant->fresh();
}
public function delete(Tenant $tenant): bool
{
return $tenant->delete();
}
}
@@ -6,16 +6,29 @@ use App\Models\TicketIncidentType;
class TicketIncidentTypeService
{
public function getAll()
public function list(array $filters = [])
{
return TicketIncidentType::orderBy('name')->get();
return TicketIncidentType::query()
->when(
! empty($filters['search']),
fn ($query) => $query->where('name', 'ILIKE', "%{$filters['search']}%")
)
->when(
array_key_exists('is_active', $filters) && $filters['is_active'] !== '',
fn ($query) => $query->where(
'is_active',
filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)
)
)
->orderBy('name')
->paginate($filters['per_page'] ?? 10);
}
public function create(array $data): TicketIncidentType
{
return TicketIncidentType::create([
'name' => $data['name'],
'is_active' => $data['is_active'] ?? true
'is_active' => $data['is_active'] ?? true,
]);
}
@@ -25,7 +38,7 @@ class TicketIncidentTypeService
): TicketIncidentType {
$ticketIncidentType->update([
'name' => $data['name'],
'is_active' => $data['is_active']
'is_active' => $data['is_active'],
]);
return $ticketIncidentType->fresh();
@@ -35,4 +48,4 @@ class TicketIncidentTypeService
{
$ticketIncidentType->delete();
}
}
}
+281
View File
@@ -0,0 +1,281 @@
<?php
namespace App\Services\User;
use App\Enums\UserAccessLevel;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UserService
{
public function list(array $filters = [], ?User $actor = null, ?int $tenantId = null)
{
return User::query()
->with(['profilePhoto', 'userProfile', 'userTenants.tenant', 'userMenuGroups.menuGroup', 'userMenuGroups.tenant'])
->when(
! empty($filters['search']),
fn ($q) => $q->where(function ($x) use ($filters) {
$x->where('name', 'ILIKE', "%{$filters['search']}%")
->orWhere('username', 'ILIKE', "%{$filters['search']}%")
->orWhere('email', 'ILIKE', "%{$filters['search']}%")
->orWhere('whatsapp_number', 'ILIKE', "%{$filters['search']}%");
})
)
->when(
isset($filters['status']) && $filters['status'] !== '',
fn ($q) => $q->where('status', $filters['status'])
)
->when(
! empty($filters['access_level']),
fn ($q) => $q->where('access_level', $filters['access_level'])
)
->when(
$actor && ! $actor->isMasterAdmin(),
fn ($q) => $q->whereHas(
'userTenants',
fn ($tenantQuery) => $tenantQuery->where('tenant_id', $tenantId)
)->where('is_master', false)
)
->orderBy(
$filters['sort_by'] ?? 'id',
$filters['sort_direction'] ?? 'desc'
)
->paginate($filters['per_page'] ?? 10);
}
public function create(array $payload, ?User $actor = null): User
{
$this->guardPrivilegeChange($payload, $actor);
$this->guardTenantPayload($payload, $actor);
if ($actor && ! $actor->isMasterAdmin() && empty($payload['user_tenants'])) {
throw ValidationException::withMessages([
'user_tenants' => 'Tenant Owner wajib membuat user pada tenant aktif.',
]);
}
return DB::transaction(function () use ($payload) {
$baseData = Arr::only($payload, [
'name',
'username',
'email',
'whatsapp_number',
'profile_photo_file_id',
'password',
'is_master',
'access_level',
'status',
]);
$user = User::create($baseData);
$this->syncProfile($user, $payload['user_profile'] ?? null);
$this->syncTenants($user, $payload['user_tenants'] ?? []);
$this->syncMenuGroups($user, $payload['user_menu_groups'] ?? []);
return $user->fresh([
'userProfile',
'profilePhoto',
'userTenants.tenant',
'userMenuGroups.menuGroup',
'userMenuGroups.tenant',
]);
});
}
public function show(User $user): User
{
return $user->load([
'userProfile',
'profilePhoto',
'userTenants.tenant',
'userMenuGroups.menuGroup',
'userMenuGroups.tenant',
]);
}
public function update(User $user, array $payload, ?User $actor = null): User
{
$this->guardPrivilegeChange($payload, $actor, $user);
$this->guardTenantPayload($payload, $actor);
return DB::transaction(function () use ($user, $payload) {
$baseData = Arr::only($payload, [
'name',
'username',
'email',
'whatsapp_number',
'profile_photo_file_id',
'is_master',
'access_level',
'status',
]);
if (! empty($payload['password'])) {
$baseData['password'] = $payload['password'];
}
if (! empty($baseData)) {
$user->update($baseData);
}
if (array_key_exists('user_profile', $payload)) {
$this->syncProfile($user, $payload['user_profile']);
}
if (array_key_exists('user_tenants', $payload)) {
$this->syncTenants($user, $payload['user_tenants'] ?? []);
}
if (array_key_exists('user_menu_groups', $payload)) {
$this->syncMenuGroups($user, $payload['user_menu_groups'] ?? []);
}
return $user->fresh([
'userProfile',
'profilePhoto',
'userTenants.tenant',
'userMenuGroups.menuGroup',
'userMenuGroups.tenant',
]);
});
}
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$user->userProfile()->delete();
$user->userTenants()->delete();
$user->userMenuGroups()->delete();
$user->delete();
});
}
protected function syncProfile(User $user, ?array $profile): void
{
if ($profile === null) {
return;
}
$data = Arr::only($profile, [
'nik',
'address',
'provinsi_id',
'kabupaten_id',
'kecamatan_id',
'desa_id',
]);
$user->userProfile()->updateOrCreate(
['user_id' => $user->id],
$data
);
}
protected function syncTenants(User $user, array $rows): void
{
$normalized = collect($rows)
->map(fn ($row) => [
'tenant_id' => $row['tenant_id'] ?? null,
'is_default' => (bool) ($row['is_default'] ?? false),
])
->filter(fn ($row) => ! empty($row['tenant_id']))
->unique('tenant_id')
->values()
->all();
$defaultSet = false;
foreach ($normalized as $index => $row) {
if ($row['is_default'] && ! $defaultSet) {
$defaultSet = true;
} else {
$normalized[$index]['is_default'] = false;
}
}
if (! $defaultSet && count($normalized) > 0) {
$normalized[0]['is_default'] = true;
}
$user->userTenants()->delete();
if (! empty($normalized)) {
$user->userTenants()->createMany($normalized);
}
}
protected function syncMenuGroups(User $user, array $rows): void
{
$normalized = collect($rows)
->map(fn ($row) => [
'menu_group_id' => $row['menu_group_id'] ?? null,
'tenant_id' => $row['tenant_id'] ?? null,
])
->filter(fn ($row) => ! empty($row['menu_group_id']))
->unique(fn ($row) => ($row['menu_group_id'] ?? '').'|'.($row['tenant_id'] ?? ''))
->values()
->all();
$user->userMenuGroups()->delete();
if (! empty($normalized)) {
$user->userMenuGroups()->createMany($normalized);
}
}
protected function guardPrivilegeChange(array &$payload, ?User $actor, ?User $target = null): void
{
if (! $actor) {
unset($payload['is_master'], $payload['access_level']);
return;
}
if ($actor->isMasterAdmin()) {
if (($payload['access_level'] ?? null) === UserAccessLevel::MASTER_ADMIN->value) {
$payload['is_master'] = true;
} elseif (array_key_exists('access_level', $payload)) {
$payload['is_master'] = false;
}
return;
}
if ($target?->isMasterAdmin()) {
throw ValidationException::withMessages([
'user' => 'Master Admin hanya dapat diubah oleh Master Admin.',
]);
}
if (($payload['is_master'] ?? false)
|| ($payload['access_level'] ?? null) === UserAccessLevel::MASTER_ADMIN->value
|| ($payload['access_level'] ?? null) === UserAccessLevel::TENANT_OWNER->value) {
throw ValidationException::withMessages([
'access_level' => 'Anda tidak dapat memberikan level akses ini.',
]);
}
unset($payload['is_master']);
}
protected function guardTenantPayload(array $payload, ?User $actor): void
{
if (! $actor || $actor->isMasterAdmin()) {
return;
}
$tenantId = request()->attributes->get('tenant_id');
foreach (['user_tenants', 'user_menu_groups'] as $key) {
foreach ($payload[$key] ?? [] as $row) {
if ((int) ($row['tenant_id'] ?? 0) !== (int) $tenantId) {
throw ValidationException::withMessages([
$key => 'Assignment hanya boleh dibuat untuk tenant yang sedang aktif.',
]);
}
}
}
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Services\Wilayah;
use App\Models\WilayahDesa;
use App\Models\WilayahKabupaten;
use App\Models\WilayahKecamatan;
use App\Models\WilayahProvinsi;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
class WilayahService
{
private const MODELS = [
'provinsi' => WilayahProvinsi::class,
'kabupaten' => WilayahKabupaten::class,
'kecamatan' => WilayahKecamatan::class,
'desa' => WilayahDesa::class,
];
private const PARENTS = [
'kabupaten' => 'provinsi_id',
'kecamatan' => 'kabupaten_id',
'desa' => 'kecamatan_id',
];
private const RELATIONS = [
'provinsi' => [],
'kabupaten' => ['provinsi'],
'kecamatan' => ['kabupaten.provinsi'],
'desa' => ['kecamatan.kabupaten.provinsi'],
];
public function list(string $tingkat, array $filters = [])
{
$model = $this->model($tingkat);
return $model::query()
->with(self::RELATIONS[$tingkat])
->when($filters['search'] ?? null, function ($query, $search) {
$value = mb_strtolower($search);
$query->where(function ($scope) use ($value) {
$scope->whereRaw('LOWER(kode) LIKE ?', ["%{$value}%"])
->orWhereRaw('LOWER(nama) LIKE ?', ["%{$value}%"]);
});
})
->when(
isset(self::PARENTS[$tingkat]) && ! empty($filters[self::PARENTS[$tingkat]]),
fn ($query) => $query->where(
self::PARENTS[$tingkat],
$filters[self::PARENTS[$tingkat]]
)
)
->orderBy(
$filters['sort_by'] ?? 'nama',
$filters['sort_direction'] ?? 'asc'
)
->paginate($filters['per_page'] ?? 10);
}
public function options(string $tingkat, array $filters = [], ?int $parentId = null)
{
$model = $this->model($tingkat);
$parentColumn = self::PARENTS[$tingkat] ?? null;
return $model::query()
->select(['id', 'kode', 'nama'])
->when(
$parentColumn && $parentId,
fn ($query) => $query->where($parentColumn, $parentId)
)
->when($filters['search'] ?? null, function ($query, $search) {
$value = mb_strtolower($search);
$query->where(function ($scope) use ($value) {
$scope->whereRaw('LOWER(kode) LIKE ?', ["%{$value}%"])
->orWhereRaw('LOWER(nama) LIKE ?', ["%{$value}%"]);
});
})
->orderBy('nama')
->get();
}
public function create(string $tingkat, array $data): Model
{
$model = $this->model($tingkat);
return $model::create($this->payload($tingkat, $data))
->load(self::RELATIONS[$tingkat]);
}
public function find(string $tingkat, int $id): Model
{
$model = $this->model($tingkat);
return $model::with(self::RELATIONS[$tingkat])->findOrFail($id);
}
public function update(string $tingkat, int $id, array $data): Model
{
$wilayah = $this->find($tingkat, $id);
$wilayah->update($this->payload($tingkat, $data));
return $wilayah->fresh(self::RELATIONS[$tingkat]);
}
public function delete(string $tingkat, int $id): void
{
$wilayah = $this->find($tingkat, $id);
$childRelation = match ($tingkat) {
'provinsi' => 'kabupaten',
'kabupaten' => 'kecamatan',
'kecamatan' => 'desa',
default => null,
};
if ($childRelation && $wilayah->{$childRelation}()->exists()) {
throw ValidationException::withMessages([
'wilayah' => "Wilayah {$tingkat} tidak dapat dihapus karena masih memiliki data di bawahnya.",
]);
}
$wilayah->delete();
}
public function alamatLengkap(array $filters): array
{
if (! empty($filters['desa_id'])) {
$desa = WilayahDesa::with('kecamatan.kabupaten.provinsi')->findOrFail($filters['desa_id']);
return $this->addressPayload(
$desa->kecamatan?->kabupaten?->provinsi,
$desa->kecamatan?->kabupaten,
$desa->kecamatan,
$desa
);
}
if (! empty($filters['kecamatan_id'])) {
$kecamatan = WilayahKecamatan::with('kabupaten.provinsi')->findOrFail($filters['kecamatan_id']);
return $this->addressPayload(
$kecamatan->kabupaten?->provinsi,
$kecamatan->kabupaten,
$kecamatan
);
}
if (! empty($filters['kabupaten_id'])) {
$kabupaten = WilayahKabupaten::with('provinsi')->findOrFail($filters['kabupaten_id']);
return $this->addressPayload($kabupaten->provinsi, $kabupaten);
}
$provinsi = WilayahProvinsi::findOrFail($filters['provinsi_id']);
return $this->addressPayload($provinsi);
}
private function model(string $tingkat): string
{
abort_unless(isset(self::MODELS[$tingkat]), 404, 'Tingkat wilayah tidak ditemukan.');
return self::MODELS[$tingkat];
}
private function payload(string $tingkat, array $data): array
{
$fields = ['kode', 'nama'];
if (isset(self::PARENTS[$tingkat])) {
$fields[] = self::PARENTS[$tingkat];
}
return collect($data)->only($fields)->all();
}
private function addressPayload(
?WilayahProvinsi $provinsi,
?WilayahKabupaten $kabupaten = null,
?WilayahKecamatan $kecamatan = null,
?WilayahDesa $desa = null
): array {
$node = fn ($item) => $item ? [
'id' => $item->id,
'kode' => $item->kode,
'nama' => $item->nama,
] : null;
$parts = array_filter([
$desa ? "Desa/Kelurahan {$desa->nama}" : null,
$kecamatan ? "Kecamatan {$kecamatan->nama}" : null,
$kabupaten?->nama,
$provinsi ? $provinsi->nama : null,
]);
return [
'desa' => $node($desa),
'kecamatan' => $node($kecamatan),
'kabupaten' => $node($kabupaten),
'provinsi' => $node($provinsi),
'alamat_wilayah' => implode(', ', $parts),
];
}
}