first init
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\KJCReport\Entities\KJCReport;
|
||||
use Modules\KJCReport\Jobs\GenerateTeamWeeklyReports;
|
||||
use Modules\Unit\Entities\Unit;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class AutoGenerateWeeklyReportForUnit extends Command
|
||||
{
|
||||
protected $signature = 'kjc:auto-generate-weekly-report
|
||||
{unit : Unit name to generate reports for (e.g. "Jabatan Arah RAJD")}
|
||||
{--force : Force generation even if reports already exist}';
|
||||
|
||||
protected $description = 'Auto-generate weekly KJC reports for a specific unit';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$unitName = $this->argument('unit');
|
||||
|
||||
$unit = Unit::where('name', $unitName)->first();
|
||||
|
||||
if (! $unit) {
|
||||
$this->error("Unit '{$unitName}' not found.");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$month = strtolower(now()->format('F'));
|
||||
$week = (int) ceil(now()->day / 7);
|
||||
|
||||
if (! $this->option('force')) {
|
||||
$existingReports = KJCReport::where('unit_id', $unit->id)
|
||||
->where('report_type', 'weekly')
|
||||
->where('report_month', $month)
|
||||
->where('report_week', $week)
|
||||
->exists();
|
||||
|
||||
if ($existingReports) {
|
||||
$this->info("Reports for {$unitName} - {$month} week {$week} already exist. Skipping.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::where('unit_id', $unit->id)->first();
|
||||
|
||||
GenerateTeamWeeklyReports::dispatch($unit->id, $month, $week, $user?->id);
|
||||
|
||||
$this->info("✓ Queued weekly report generation for {$unitName} - {$month} week {$week}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CaptureKJCHistoricalDataJob;
|
||||
use App\Services\KJCHistoricalDataService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TriggerKJCHistoricalDataCapture extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'kjc:historical:trigger
|
||||
{--month= : Month to capture (1-12)}
|
||||
{--year= : Year to capture (YYYY)}
|
||||
{--queue : Dispatch to queue instead of running immediately}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Manually trigger KJC historical data capture';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$month = $this->option('month');
|
||||
$year = $this->option('year');
|
||||
$useQueue = $this->option('queue');
|
||||
|
||||
// If no month/year provided, use previous month
|
||||
if (! $month || ! $year) {
|
||||
$lastMonth = Carbon::now()->subMonth();
|
||||
$month = $month ?: $lastMonth->format('m');
|
||||
$year = $year ?: $lastMonth->format('Y');
|
||||
}
|
||||
|
||||
$this->info("Triggering KJC historical data capture for {$month}/{$year}...");
|
||||
|
||||
try {
|
||||
if ($useQueue) {
|
||||
// Dispatch to queue
|
||||
CaptureKJCHistoricalDataJob::dispatch($month, $year);
|
||||
$this->info('✓ KJC historical data capture job dispatched to queue');
|
||||
$this->info('You can monitor the job in Horizon dashboard');
|
||||
} else {
|
||||
// Run immediately
|
||||
$job = new CaptureKJCHistoricalDataJob($month, $year);
|
||||
$job->handle(app(KJCHistoricalDataService::class));
|
||||
$this->info('✓ KJC historical data capture completed immediately');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CapturePKJHistoricalDataJob;
|
||||
use App\Services\PKJHistoricalDataService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TriggerPKJHistoricalDataCapture extends Command
|
||||
{
|
||||
protected $signature = 'pkj:historical:trigger
|
||||
{--month= : Month to capture (1-12)}
|
||||
{--year= : Year to capture (YYYY)}
|
||||
{--queue : Dispatch to queue instead of running immediately}';
|
||||
|
||||
protected $description = 'Manually trigger PKJ historical data capture';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$month = $this->option('month');
|
||||
$year = $this->option('year');
|
||||
$useQueue = $this->option('queue');
|
||||
|
||||
// If no month/year provided, use previous month
|
||||
if (! $month || ! $year) {
|
||||
$lastMonth = Carbon::now()->subMonth();
|
||||
$month = $month ?: $lastMonth->format('m');
|
||||
$year = $year ?: $lastMonth->format('Y');
|
||||
}
|
||||
|
||||
$this->info("Triggering PKJ historical data capture for {$month}/{$year}...");
|
||||
|
||||
try {
|
||||
if ($useQueue) {
|
||||
// Dispatch to queue
|
||||
CapturePKJHistoricalDataJob::dispatch($month, $year);
|
||||
$this->info('✓ PKJ historical data capture job dispatched to queue');
|
||||
$this->info('You can monitor the job in Horizon dashboard');
|
||||
} else {
|
||||
// Run immediately
|
||||
$job = new CapturePKJHistoricalDataJob($month, $year);
|
||||
$job->handle(app(PKJHistoricalDataService::class));
|
||||
$this->info('✓ PKJ historical data capture completed immediately');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ActivityLogger;
|
||||
use App\Traits\NotifiesAdmins;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
abstract class BaseCrudController extends Controller
|
||||
{
|
||||
use AuthorizesRequests, NotifiesAdmins;
|
||||
|
||||
/**
|
||||
* The repository interface for this controller
|
||||
*/
|
||||
protected $repository;
|
||||
|
||||
/**
|
||||
* The model class for authorization
|
||||
*/
|
||||
protected $modelClass;
|
||||
|
||||
/**
|
||||
* The resource class for API responses
|
||||
*/
|
||||
protected $resourceClass;
|
||||
|
||||
/**
|
||||
* The request class for validation
|
||||
*/
|
||||
protected $requestClass;
|
||||
|
||||
/**
|
||||
* The name of the resource for logging (e.g., 'category', 'model')
|
||||
*/
|
||||
protected $resourceName;
|
||||
|
||||
/**
|
||||
* The plural name of the resource for messages
|
||||
*/
|
||||
protected $resourceNamePlural;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct($repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', $this->modelClass);
|
||||
|
||||
try {
|
||||
$perPage = $request->get('per_page', 10) ?? 10;
|
||||
$perPage = min($perPage, 1000); // Limit max per page to 1000
|
||||
$search = (string) ($request->get('search', '') ?? '');
|
||||
$sortBy = (string) ($request->get('sort_by', 'id') ?? 'id');
|
||||
$sortOrder = (string) ($request->get('sort_order', 'asc') ?? 'asc');
|
||||
|
||||
$items = $this->getIndexData($request, $perPage, $search, $sortBy, $sortOrder);
|
||||
|
||||
// Check if the result is paginated
|
||||
if ($items instanceof LengthAwarePaginator) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->resourceClass::collection($items->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $items->currentPage(),
|
||||
'per_page' => $items->perPage(),
|
||||
'total' => $items->total(),
|
||||
'last_page' => $items->lastPage(),
|
||||
'from' => $items->firstItem(),
|
||||
'to' => $items->lastItem(),
|
||||
'has_more_pages' => $items->hasMorePages(),
|
||||
],
|
||||
'message' => $this->getSuccessMessage('index'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Fallback for non-paginated results
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->resourceClass::collection($items),
|
||||
'message' => $this->getSuccessMessage('index'),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error fetching {$this->resourceNamePlural}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('index'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', $this->modelClass);
|
||||
|
||||
try {
|
||||
$validated = $this->validateRequest($request);
|
||||
$data = $this->prepareStoreData($validated);
|
||||
|
||||
$item = $this->repository->create($data);
|
||||
|
||||
ActivityLogger::log("Created {$this->resourceName}: {$this->getItemName($item)}", $item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new $this->resourceClass($item),
|
||||
'message' => $this->getSuccessMessage('store'),
|
||||
], 201);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error creating {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('store').': '.$e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $this->modelClass);
|
||||
|
||||
try {
|
||||
$item = $this->repository->findById($id);
|
||||
|
||||
if (! $item) {
|
||||
return $this->errorResponse($this->getNotFoundMessage(), 404);
|
||||
}
|
||||
|
||||
$item = $this->loadShowRelations($item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new $this->resourceClass($item),
|
||||
'message' => $this->getSuccessMessage('show'),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error fetching {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('show'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $this->modelClass);
|
||||
|
||||
try {
|
||||
$item = $this->repository->findById($id);
|
||||
|
||||
if (! $item) {
|
||||
return $this->errorResponse($this->getNotFoundMessage(), 404);
|
||||
}
|
||||
|
||||
$validated = $this->validateRequest($request);
|
||||
$data = $this->prepareUpdateData($validated, $item);
|
||||
|
||||
$item->update($data);
|
||||
|
||||
ActivityLogger::log("Updated {$this->resourceName}: {$this->getItemName($item)}", $item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new $this->resourceClass($item),
|
||||
'message' => $this->getSuccessMessage('update'),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error updating {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('update').': '.$e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update the specified resource in storage (PATCH).
|
||||
*/
|
||||
public function patch(Request $request, string $id): JsonResponse
|
||||
{
|
||||
// PATCH uses the same logic as update
|
||||
return $this->update($request, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('deleteAny', $this->modelClass);
|
||||
|
||||
try {
|
||||
$item = $this->repository->findById($id);
|
||||
|
||||
if (! $item) {
|
||||
return $this->errorResponse($this->getNotFoundMessage(), 404);
|
||||
}
|
||||
|
||||
// Check for dependencies before deletion
|
||||
$dependencyCheck = $this->checkDependencies($item);
|
||||
if ($dependencyCheck !== null) {
|
||||
return $dependencyCheck;
|
||||
}
|
||||
|
||||
$this->repository->delete($id);
|
||||
|
||||
ActivityLogger::log("Deleted {$this->resourceName}: {$this->getItemName($item)}", $item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $this->getSuccessMessage('destroy'),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error deleting {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('destroy').': '.$e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data for index method - can be overridden by child classes
|
||||
*/
|
||||
protected function getIndexData(Request $request, int $perPage = 10, string $search = '', string $sortBy = 'id', string $sortOrder = 'asc')
|
||||
{
|
||||
// Always try paginated methods first when perPage is specified
|
||||
if ($perPage > 0) {
|
||||
if (method_exists($this->repository, 'getAllWithRelationsPaginated')) {
|
||||
return $this->repository->getAllWithRelationsPaginated($perPage, $search, $sortBy, $sortOrder);
|
||||
}
|
||||
|
||||
if (method_exists($this->repository, 'getAllPaginated')) {
|
||||
return $this->repository->getAllPaginated($perPage, $search, $sortBy, $sortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to non-paginated methods
|
||||
if (method_exists($this->repository, 'getAllWithRelations')) {
|
||||
return $this->repository->getAllWithRelations($search, $sortBy, $sortOrder);
|
||||
}
|
||||
|
||||
return $this->repository->all($search, $sortBy, $sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate request data - can be overridden by child classes
|
||||
*/
|
||||
protected function validateRequest(Request $request): array
|
||||
{
|
||||
if ($this->requestClass) {
|
||||
// For multipart/form-data, use direct validation to avoid FormRequest parsing issues
|
||||
if (str_contains($request->header('Content-Type', ''), 'multipart/form-data')) {
|
||||
return $request->validate(app($this->requestClass)->rules());
|
||||
}
|
||||
|
||||
// Create FormRequest instance with the current request
|
||||
$formRequest = $this->requestClass::createFrom($request);
|
||||
$formRequest->setContainer(app());
|
||||
$formRequest->setRedirector(app('redirect'));
|
||||
|
||||
// Validate the request
|
||||
$formRequest->validateResolved();
|
||||
|
||||
return $formRequest->validated();
|
||||
}
|
||||
|
||||
return $request->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data for store method - can be overridden by child classes
|
||||
*/
|
||||
protected function prepareStoreData(array $validated): array
|
||||
{
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data for update method - can be overridden by child classes
|
||||
*/
|
||||
protected function prepareUpdateData(array $validated, $item): array
|
||||
{
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load relations for show method - can be overridden by child classes
|
||||
*/
|
||||
protected function loadShowRelations($item)
|
||||
{
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check dependencies before deletion - can be overridden by child classes
|
||||
* Return null if deletion is allowed, or a JsonResponse if not
|
||||
*/
|
||||
protected function checkDependencies($item): ?JsonResponse
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the item for logging - can be overridden by child classes
|
||||
*/
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->name ?? $item->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success message for different operations
|
||||
*/
|
||||
protected function getSuccessMessage(string $operation): string
|
||||
{
|
||||
$messages = [
|
||||
'index' => ucfirst($this->resourceNamePlural).' berjaya dimuatkan',
|
||||
'store' => ucfirst($this->resourceName).' berjaya ditambah',
|
||||
'show' => ucfirst($this->resourceName).' berjaya dimuatkan',
|
||||
'update' => ucfirst($this->resourceName).' berjaya dikemaskini',
|
||||
'destroy' => ucfirst($this->resourceName).' berjaya dipadam',
|
||||
];
|
||||
|
||||
return $messages[$operation] ?? 'Operasi berjaya';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for different operations
|
||||
*/
|
||||
protected function getErrorMessage(string $operation): string
|
||||
{
|
||||
$messages = [
|
||||
'index' => 'Gagal memuatkan '.$this->resourceNamePlural,
|
||||
'store' => 'Gagal menambah '.$this->resourceName,
|
||||
'show' => 'Gagal memuatkan '.$this->resourceName,
|
||||
'update' => 'Gagal mengemaskini '.$this->resourceName,
|
||||
'destroy' => 'Gagal memadam '.$this->resourceName,
|
||||
];
|
||||
|
||||
return $messages[$operation] ?? 'Operasi gagal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get not found message
|
||||
*/
|
||||
protected function getNotFoundMessage(): string
|
||||
{
|
||||
return ucfirst($this->resourceName).' tidak dijumpai.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a standardized error response
|
||||
*/
|
||||
protected function errorResponse(string $message, int $statusCode = 400): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
], $statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Services\ContactService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
protected $contactService;
|
||||
|
||||
public function __construct(ContactService $contactService)
|
||||
{
|
||||
$this->contactService = $contactService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active contact persons
|
||||
*/
|
||||
public function getContacts(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$contacts = $this->contactService->getActiveContacts();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $contacts,
|
||||
'message' => 'Contact persons retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve contact persons',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contact settings for admin (including inactive)
|
||||
*/
|
||||
public function getContactSettings(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$settings = $this->contactService->getAllContactSettings();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $settings,
|
||||
'message' => 'Contact settings retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve contact settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update contact settings (Admin only)
|
||||
*/
|
||||
public function updateContacts(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'contacts' => 'required|array',
|
||||
'contacts.*.name' => 'required|string|max:100',
|
||||
'contacts.*.position' => 'nullable|string|max:100',
|
||||
'contacts.*.email' => 'nullable|email|max:255',
|
||||
'contacts.*.phone' => 'nullable|string|max:50',
|
||||
'contacts.*.department' => 'nullable|string|max:100',
|
||||
'contacts.*.is_active' => 'boolean',
|
||||
'contacts.*.sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$result = $this->contactService->updateContactSettings($request->contacts);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact settings updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update contact settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new contact person (Admin only)
|
||||
*/
|
||||
public function addContact(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'position' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'department' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->contactService->addContactPerson($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person added successfully'
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to add contact person',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific contact person (Admin only)
|
||||
*/
|
||||
public function updateContact(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'position' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'department' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->contactService->updateContactPerson($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update contact person',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact person (Admin only)
|
||||
*/
|
||||
public function deleteContact(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->contactService->deleteContactPerson($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person deleted successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to delete contact person',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status of a contact person (Admin only)
|
||||
*/
|
||||
public function toggleContact(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->contactService->toggleContactPerson($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person status toggled successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to toggle contact person status',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Country;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CountryController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$countries = Country::all();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $countries,
|
||||
'message' => 'Countries fetched successfully',
|
||||
]);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $countries,
|
||||
'message' => 'Countries fetched successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Services\ActiveRoleService;
|
||||
use App\Support\AuthCookie;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Auth\Transformers\UserResource;
|
||||
|
||||
class ImpersonateController extends Controller
|
||||
{
|
||||
private const IMPERSONATION_TOKEN = 'impersonation-token';
|
||||
|
||||
private const AUTH_TOKEN_NAMES = ['authToken', 'auth-token'];
|
||||
|
||||
public function take(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$target = User::findOrFail($id);
|
||||
$admin = $request->user();
|
||||
|
||||
if (! $admin->can('impersonate.user', $target)) {
|
||||
return $this->error('Anda tidak mempunyai keizinan untuk menyamar pengguna.', 403);
|
||||
}
|
||||
|
||||
if ($request->hasCookie(AuthCookie::originalUserCookieName())) {
|
||||
return $this->error('Anda sudah menyamar sebagai pengguna.', 400);
|
||||
}
|
||||
|
||||
$issued = $this->issueToken($target, self::IMPERSONATION_TOKEN);
|
||||
|
||||
return AuthCookie::attachAuthToken(
|
||||
response()->json([
|
||||
'success' => true,
|
||||
'message' => "Anda sekarang menyamar sebagai {$target->name}",
|
||||
'impersonated_user' => $this->userSummary($target),
|
||||
...$this->sessionPayload($target, $issued['accessToken']),
|
||||
]),
|
||||
$issued['plainTextToken']
|
||||
)->withCookie(AuthCookie::makeOriginalUserId($admin->id));
|
||||
}
|
||||
|
||||
public function leave(Request $request): JsonResponse
|
||||
{
|
||||
$originalUserId = $request->cookie(AuthCookie::originalUserCookieName());
|
||||
|
||||
if (! $originalUserId) {
|
||||
return $this->error('Maklumat pengguna asal tidak ditemui.', 400);
|
||||
}
|
||||
|
||||
$original = User::findOrFail($originalUserId);
|
||||
|
||||
$request->user()?->tokens()->where('name', self::IMPERSONATION_TOKEN)->delete();
|
||||
$original->tokens()->whereIn('name', [self::IMPERSONATION_TOKEN, ...self::AUTH_TOKEN_NAMES])->delete();
|
||||
|
||||
$issued = $this->issueToken($original, 'auth-token', expires: true);
|
||||
|
||||
$response = response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Anda telah kembali ke akaun anda',
|
||||
'original_user' => $this->userSummary($original),
|
||||
...$this->sessionPayload($original, $issued['accessToken']),
|
||||
]);
|
||||
|
||||
if (AuthCookie::shouldExposeTokenInResponse()) {
|
||||
$response->setData(array_merge($response->getData(true), [
|
||||
'original_token' => $issued['plainTextToken'],
|
||||
]));
|
||||
}
|
||||
|
||||
return AuthCookie::attachAuthToken($response, $issued['plainTextToken'])
|
||||
->withCookie(AuthCookie::forgetOriginalUserId());
|
||||
}
|
||||
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
if (! $request->hasCookie(AuthCookie::originalUserCookieName())) {
|
||||
return response()->json(['is_impersonating' => false]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'is_impersonating' => true,
|
||||
'impersonated_user' => $user ? $this->userSummary($user) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{plainTextToken: string, accessToken: PersonalAccessToken}
|
||||
*/
|
||||
private function issueToken(User $user, string $name, bool $expires = false): array
|
||||
{
|
||||
$user->loadMissing(['roles.permissions']);
|
||||
|
||||
$result = $user->createToken(
|
||||
$name,
|
||||
['*'],
|
||||
$expires ? now()->addMinutes((int) config('auth_cookie.lifetime_minutes', 720)) : null
|
||||
);
|
||||
|
||||
ActiveRoleService::assignDefaultToToken($user, $result->accessToken);
|
||||
|
||||
return [
|
||||
'plainTextToken' => $result->plainTextToken,
|
||||
'accessToken' => $result->accessToken,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sessionPayload(User $user, PersonalAccessToken $accessToken): array
|
||||
{
|
||||
$user->loadMissing(['roles.permissions']);
|
||||
|
||||
$activeRole = $user->roles->firstWhere('id', $accessToken->active_role_id)
|
||||
?? ActiveRoleService::resolveDefaultRole($user);
|
||||
|
||||
return [
|
||||
'data' => new UserResource($user),
|
||||
'active_role' => ActiveRoleService::formatRole($activeRole),
|
||||
'can_switch_role' => $user->roles->count() > 1,
|
||||
'redirect_path' => $activeRole
|
||||
? ActiveRoleService::redirectPathForRole($activeRole)
|
||||
: config('active_role.member_redirect', '/profile'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: mixed, name: string, email: string}
|
||||
*/
|
||||
private function userSummary(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
];
|
||||
}
|
||||
|
||||
private function error(string $message, int $status): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\OnlineUsersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OnlineUsersController extends Controller
|
||||
{
|
||||
protected OnlineUsersService $onlineUsersService;
|
||||
|
||||
public function __construct(OnlineUsersService $onlineUsersService)
|
||||
{
|
||||
$this->onlineUsersService = $onlineUsersService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of online users
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$timeoutMinutes = 5; // Fixed to 5 minutes
|
||||
|
||||
$onlineUsers = $this->onlineUsersService->getOnlineUsers($timeoutMinutes);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $onlineUsers,
|
||||
'meta' => [
|
||||
'timeout_minutes' => $timeoutMinutes,
|
||||
'total_online' => $onlineUsers->count(),
|
||||
'timestamp' => now()->toISOString()
|
||||
],
|
||||
'message' => 'Online users retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching online users: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve online users.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online users count
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function count(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$timeoutMinutes = 5; // Fixed to 5 minutes
|
||||
|
||||
$count = $this->onlineUsersService->getOnlineUsersCount($timeoutMinutes);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'count' => $count,
|
||||
'timeout_minutes' => $timeoutMinutes
|
||||
],
|
||||
'message' => 'Online users count retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching online users count: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve online users count.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online users statistics
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$stats = $this->onlineUsersService->getOnlineUsersStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
'message' => 'Online users statistics retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching online users stats: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve online users statistics.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's session information
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function mySession(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User not authenticated.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
$sessionInfo = $this->onlineUsersService->getUserSessionInfo($user->id);
|
||||
|
||||
if (!$sessionInfo) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No active session found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $sessionInfo,
|
||||
'message' => 'Session information retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching user session info: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve session information.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear online users cache (admin only)
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function clearCache(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->onlineUsersService->clearCache();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Online users cache cleared successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error clearing online users cache: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to clear cache.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Services\SocialMediaService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SocialMediaController extends Controller
|
||||
{
|
||||
protected $socialMediaService;
|
||||
|
||||
public function __construct(SocialMediaService $socialMediaService)
|
||||
{
|
||||
$this->socialMediaService = $socialMediaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active social media platforms
|
||||
*/
|
||||
public function getSocialMedia(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$socialMedia = $this->socialMediaService->getActiveSocialMedia();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $socialMedia,
|
||||
'message' => 'Social media platforms retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve social media platforms',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update social media settings (Admin only)
|
||||
*/
|
||||
public function updateSocialMedia(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'social_media' => 'required|array',
|
||||
'social_media.*.platform' => 'required|string|max:50',
|
||||
'social_media.*.name' => 'required|string|max:100',
|
||||
'social_media.*.url' => 'required|url|max:255',
|
||||
'social_media.*.icon' => 'nullable|string|max:100',
|
||||
'social_media.*.is_active' => 'boolean',
|
||||
'social_media.*.sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$result = $this->socialMediaService->updateSocialMediaSettings($request->social_media);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media settings updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update social media settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get social media settings for admin (including inactive)
|
||||
*/
|
||||
public function getSocialMediaSettings(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$settings = $this->socialMediaService->getAllSocialMediaSettings();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $settings,
|
||||
'message' => 'Social media settings retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve social media settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new social media platform (Admin only)
|
||||
*/
|
||||
public function addSocialMedia(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'platform' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'url' => 'required|url|max:255',
|
||||
'icon' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->socialMediaService->addSocialMediaPlatform($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform added successfully'
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to add social media platform',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific social media platform (Admin only)
|
||||
*/
|
||||
public function updateSocialMediaPlatform(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'platform' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'url' => 'required|url|max:255',
|
||||
'icon' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->socialMediaService->updateSocialMediaPlatform($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update social media platform',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a social media platform (Admin only)
|
||||
*/
|
||||
public function deleteSocialMediaPlatform(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->socialMediaService->deleteSocialMediaPlatform($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform deleted successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to delete social media platform',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status of a social media platform (Admin only)
|
||||
*/
|
||||
public function toggleSocialMediaPlatform(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->socialMediaService->toggleSocialMediaPlatform($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform status toggled successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to toggle social media platform status',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ApiKeyAuthenticationMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Check if API key authentication is enabled
|
||||
if (!config('api_security.enable_api_key_auth', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Get API key from request
|
||||
$apiKey = $this->extractApiKey($request);
|
||||
|
||||
if (!$apiKey) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'API key is required for external system access.',
|
||||
'error' => 'API_KEY_REQUIRED'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if (!$this->isValidApiKey($apiKey)) {
|
||||
// Log invalid API key attempt
|
||||
if (config('api_security.log_api_key_usage', true)) {
|
||||
Log::warning('Invalid API key attempt', [
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->header('User-Agent'),
|
||||
'endpoint' => $request->fullUrl(),
|
||||
'method' => $request->method(),
|
||||
'api_key_prefix' => substr($apiKey, 0, 8) . '...',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid API key provided.',
|
||||
'error' => 'API_KEY_INVALID'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Log successful API key usage
|
||||
if (config('api_security.log_api_key_usage', true)) {
|
||||
Log::info('API key authenticated', [
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->header('User-Agent'),
|
||||
'endpoint' => $request->fullUrl(),
|
||||
'method' => $request->method(),
|
||||
'api_key_prefix' => substr($apiKey, 0, 8) . '...',
|
||||
]);
|
||||
}
|
||||
|
||||
// Add API key info to request for downstream use
|
||||
$request->merge(['_api_key' => $apiKey]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract API key from request headers
|
||||
*/
|
||||
private function extractApiKey(Request $request): ?string
|
||||
{
|
||||
// Check multiple header formats
|
||||
$apiKeyHeaders = config('api_security.api_key_headers', [
|
||||
'X-API-Key',
|
||||
'API-Key',
|
||||
'Authorization'
|
||||
]);
|
||||
|
||||
foreach ($apiKeyHeaders as $header) {
|
||||
$value = $request->header($header);
|
||||
|
||||
if ($value) {
|
||||
// Handle Bearer token format
|
||||
if ($header === 'Authorization' && preg_match('/Bearer\s+(.*)$/i', $value, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Direct API key
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key against configured valid keys
|
||||
*/
|
||||
private function isValidApiKey(string $apiKey): bool
|
||||
{
|
||||
$validApiKeys = config('api_security.valid_api_keys', []);
|
||||
|
||||
if (empty($validApiKeys)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($apiKey, $validApiKeys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AuthCookie;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateFromCookie
|
||||
{
|
||||
/**
|
||||
* Promote HttpOnly auth cookie to Authorization header for Sanctum.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->bearerToken()) {
|
||||
$token = $request->cookie(AuthCookie::name());
|
||||
|
||||
if (is_string($token) && $token !== '') {
|
||||
$request->headers->set('Authorization', 'Bearer '.$token);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BlockApiToolsMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Check if API tool blocking is enabled
|
||||
if (!config('api_security.block_api_tools_in_production')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip blocking for external API routes - they use API key authentication
|
||||
if ($request->is('api/external*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Only block in production environment
|
||||
if (app()->environment('production')) {
|
||||
$userAgent = strtolower($request->header('User-Agent', ''));
|
||||
$clientIp = $request->ip();
|
||||
|
||||
// Check if request has valid API key - bypass blocking for external systems
|
||||
if ($this->hasValidApiKey($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if IP is in allowed list
|
||||
$allowedIps = config('api_security.allowed_ips', []);
|
||||
if (!empty($allowedIps) && in_array($clientIp, $allowedIps)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if User-Agent is in allowed list
|
||||
$allowedUserAgents = config('api_security.allowed_user_agents', []);
|
||||
foreach ($allowedUserAgents as $allowedAgent) {
|
||||
if (str_contains($userAgent, strtolower($allowedAgent))) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the User-Agent matches any blocked patterns
|
||||
$blockedUserAgents = config('api_security.blocked_user_agents', []);
|
||||
foreach ($blockedUserAgents as $blockedAgent) {
|
||||
if (str_contains($userAgent, $blockedAgent)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => config('api_security.blocked_message', 'API access is restricted in production environment. Please use the web interface.'),
|
||||
'error' => 'API_TOOLS_BLOCKED'
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check for requests without proper browser User-Agent
|
||||
// This catches tools that might not be in our list but don't look like browsers
|
||||
if ($this->isSuspiciousUserAgent($userAgent)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => config('api_security.blocked_message', 'API access is restricted in production environment. Please use the web interface.'),
|
||||
'error' => 'API_TOOLS_BLOCKED'
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the User-Agent looks suspicious (not a real browser)
|
||||
*/
|
||||
private function isSuspiciousUserAgent(string $userAgent): bool
|
||||
{
|
||||
$minLength = config('api_security.min_user_agent_length', 10);
|
||||
|
||||
// If User-Agent is empty or very short, it's suspicious
|
||||
if (empty($userAgent) || strlen($userAgent) < $minLength) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for common browser patterns
|
||||
$browserPatterns = [
|
||||
'mozilla',
|
||||
'chrome',
|
||||
'safari',
|
||||
'firefox',
|
||||
'edge',
|
||||
'opera',
|
||||
'webkit',
|
||||
'gecko',
|
||||
'trident',
|
||||
'msie',
|
||||
];
|
||||
|
||||
$hasBrowserPattern = false;
|
||||
foreach ($browserPatterns as $pattern) {
|
||||
if (str_contains($userAgent, $pattern)) {
|
||||
$hasBrowserPattern = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no browser pattern is found, it's likely an API tool
|
||||
return !$hasBrowserPattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request has a valid API key for external system access
|
||||
*/
|
||||
private function hasValidApiKey(Request $request): bool
|
||||
{
|
||||
// Check for API key in headers (X-API-Key, Authorization Bearer, or API-Key)
|
||||
$apiKey = $request->header('X-API-Key')
|
||||
?? $request->header('API-Key')
|
||||
?? $this->extractBearerToken($request->header('Authorization'));
|
||||
|
||||
if (!$apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get valid API keys from configuration
|
||||
$validApiKeys = config('api_security.valid_api_keys', []);
|
||||
|
||||
// If no API keys configured, return false
|
||||
if (empty($validApiKeys)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the provided API key is valid
|
||||
return in_array($apiKey, $validApiKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bearer token from Authorization header
|
||||
*/
|
||||
private function extractBearerToken(?string $authorization): ?string
|
||||
{
|
||||
if (!$authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/Bearer\s+(.*)$/i', $authorization, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AuthCookie;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class SingleSessionMiddleware
|
||||
{
|
||||
/**
|
||||
* Cache TTL for token processing lock (seconds)
|
||||
*/
|
||||
private const PROCESSING_LOCK_TTL = 5;
|
||||
|
||||
/**
|
||||
* Cache TTL for token name cache (seconds)
|
||||
*/
|
||||
private const TOKEN_NAME_CACHE_TTL = 60;
|
||||
|
||||
/**
|
||||
* Fresh token age threshold (seconds)
|
||||
*/
|
||||
private const FRESH_TOKEN_AGE = 30;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user) {
|
||||
try {
|
||||
$this->enforceSingleSession($user, $request);
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't block the request
|
||||
Log::error('SingleSessionMiddleware error', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce single session per user
|
||||
*/
|
||||
private function enforceSingleSession(User $user, Request $request): void
|
||||
{
|
||||
if (app()->environment('local')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip single session enforcement during impersonation
|
||||
if ($this->isImpersonationRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip single session enforcement for post-impersonation requests
|
||||
if ($this->isPostImpersonationRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current token from request (Bearer header or HttpOnly cookie)
|
||||
$currentToken = $this->resolveToken($request);
|
||||
if (!$currentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the token to get the token ID and hash
|
||||
$tokenParts = explode('|', $currentToken);
|
||||
if (count($tokenParts) !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenId = $tokenParts[0];
|
||||
$tokenHash = $tokenParts[1];
|
||||
|
||||
// Find current token record with caching
|
||||
$currentTokenRecord = $this->getTokenRecord($tokenId, $tokenHash);
|
||||
|
||||
if (!$currentTokenRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an impersonation token - skip enforcement
|
||||
if ($currentTokenRecord->name === 'impersonation-token') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip enforcement for fresh login tokens (SSO login or fresh auth-token)
|
||||
if ($this->isFreshLoginToken($currentTokenRecord, $user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use atomic cache lock per user to prevent race conditions
|
||||
$lockKey = "single_session_user_{$user->id}";
|
||||
$lock = Cache::lock($lockKey, self::PROCESSING_LOCK_TTL);
|
||||
|
||||
if (!$lock->get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all active tokens for this user (excluding impersonation), ordered by newest first
|
||||
$activeTokens = PersonalAccessToken::where('tokenable_type', get_class($user))
|
||||
->where('tokenable_id', $user->id)
|
||||
->where('name', '!=', 'impersonation-token')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
// Keep only the most recently created token (latest login); revoke all others
|
||||
if ($activeTokens->count() > 1) {
|
||||
$latestToken = $activeTokens->first();
|
||||
$idsToDelete = $activeTokens->where('id', '!=', $latestToken->id)->pluck('id');
|
||||
|
||||
PersonalAccessToken::whereIn('id', $idsToDelete)->delete();
|
||||
|
||||
$this->notifyConcurrentSession($user, $request);
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token record with caching
|
||||
*/
|
||||
private function getTokenRecord(string $tokenId, string $tokenHash): ?PersonalAccessToken
|
||||
{
|
||||
$cacheKey = "token_record_{$tokenId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::TOKEN_NAME_CACHE_TTL, function () use ($tokenId, $tokenHash) {
|
||||
return PersonalAccessToken::where('id', $tokenId)
|
||||
->where('token', hash('sha256', $tokenHash))
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an impersonation-related request
|
||||
*/
|
||||
private function isImpersonationRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
|
||||
// Check if the request is to impersonation endpoints
|
||||
if (str_contains($path, 'impersonate')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if there's an impersonation header
|
||||
if ($request->hasHeader('X-Original-User-Id')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the request has impersonation token
|
||||
$currentToken = $this->resolveToken($request);
|
||||
if ($currentToken) {
|
||||
$tokenParts = explode('|', $currentToken);
|
||||
if (count($tokenParts) === 2) {
|
||||
$tokenId = $tokenParts[0];
|
||||
$tokenHash = $tokenParts[1];
|
||||
|
||||
// Use cached token record to avoid duplicate queries
|
||||
$tokenRecord = $this->getTokenRecord($tokenId, $tokenHash);
|
||||
|
||||
if ($tokenRecord && $tokenRecord->name === 'impersonation-token') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a post-impersonation request (after leaving impersonation)
|
||||
*/
|
||||
private function isPostImpersonationRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
|
||||
// Check if this is a request after leaving impersonation
|
||||
// Look for requests that have X-Original-User-Id header but are not impersonation endpoints
|
||||
if ($request->hasHeader('X-Original-User-Id') && !str_contains($path, 'impersonate')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current token is a fresh login token (SSO login or post-impersonation)
|
||||
* This prevents false positives when a user legitimately logs in
|
||||
*/
|
||||
private function isFreshLoginToken(PersonalAccessToken $currentTokenRecord, User $user): bool
|
||||
{
|
||||
// Check if token was created recently (within threshold)
|
||||
$tokenAge = $currentTokenRecord->created_at->diffInSeconds(now());
|
||||
|
||||
if ($tokenAge > self::FRESH_TOKEN_AGE) {
|
||||
return false; // Token is not fresh
|
||||
}
|
||||
|
||||
// For SSO tokens, if they're fresh, skip enforcement (legitimate login)
|
||||
if ($currentTokenRecord->name === 'sso-token') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for both 'authToken' (camelCase - regular login) and 'auth-token' (kebab-case - post-impersonation)
|
||||
$isAuthToken = in_array($currentTokenRecord->name, ['authToken', 'auth-token'], true);
|
||||
|
||||
if ($isAuthToken) {
|
||||
// Check if there are any other auth tokens for this user that were created before this one
|
||||
// When logging out properly, all tokens should be deleted, so if there are no older
|
||||
// auth tokens, this is likely a fresh login or post-impersonation
|
||||
$olderAuthTokens = PersonalAccessToken::where('tokenable_type', get_class($user))
|
||||
->where('tokenable_id', $user->id)
|
||||
->where('id', '!=', $currentTokenRecord->id)
|
||||
->whereIn('name', ['authToken', 'auth-token']) // Check for both naming conventions
|
||||
->where('created_at', '<', $currentTokenRecord->created_at)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->exists(); // Use exists() instead of count() for better performance
|
||||
|
||||
// If there are no older auth tokens, this is a fresh login or post-impersonation
|
||||
if (!$olderAuthTokens) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify user about concurrent session
|
||||
*/
|
||||
private function notifyConcurrentSession(User $user, Request $request): void
|
||||
{
|
||||
// Notification creation removed per user request
|
||||
// Concurrent session detection still works, but no notification is created
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Sanctum plain-text token from Authorization header or auth cookie.
|
||||
*/
|
||||
private function resolveToken(Request $request): ?string
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if (is_string($token) && $token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$cookieToken = $request->cookie(AuthCookie::name());
|
||||
|
||||
return is_string($cookieToken) && $cookieToken !== '' ? $cookieToken : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\KJCHistoricalDataService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CaptureKJCHistoricalDataJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 300; // 5 minutes timeout
|
||||
|
||||
public $tries = 3; // Retry 3 times if failed
|
||||
|
||||
public $backoff = 60; // Wait 60 seconds between retries
|
||||
|
||||
protected $month;
|
||||
|
||||
protected $year;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct($month = null, $year = null)
|
||||
{
|
||||
$this->month = $month;
|
||||
$this->year = $year;
|
||||
|
||||
// Set queue name for Horizon monitoring
|
||||
$this->onQueue('kjc-historical-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(KJCHistoricalDataService $kjcHistoricalService)
|
||||
{
|
||||
try {
|
||||
Log::info('Starting KJC historical data capture job', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
]);
|
||||
|
||||
$success = $kjcHistoricalService->captureMonthlySnapshots($this->month, $this->year);
|
||||
|
||||
if ($success) {
|
||||
Log::info('KJC historical data capture job completed successfully', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
]);
|
||||
} else {
|
||||
Log::error('KJC historical data capture job failed', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
]);
|
||||
|
||||
throw new \Exception('KJC historical data capture failed');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('KJC historical data capture job exception', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception)
|
||||
{
|
||||
Log::error('KJC historical data capture job failed permanently', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags that should be assigned to the job.
|
||||
*/
|
||||
public function tags()
|
||||
{
|
||||
return ['kjc-historical-data', "month-{$this->month}", "year-{$this->year}"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\PKJHistoricalDataService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CapturePKJHistoricalDataJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 300; // 5 minutes timeout
|
||||
|
||||
public $tries = 3; // Retry 3 times if failed
|
||||
|
||||
public $backoff = 60; // Wait 60 seconds between retries
|
||||
|
||||
protected $month;
|
||||
|
||||
protected $year;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct($month = null, $year = null)
|
||||
{
|
||||
$this->month = $month;
|
||||
$this->year = $year;
|
||||
|
||||
// Set queue name for Horizon monitoring
|
||||
$this->onQueue('pkj-historical-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(PKJHistoricalDataService $historicalService)
|
||||
{
|
||||
try {
|
||||
Log::info('Starting PKJ historical data capture job', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
]);
|
||||
|
||||
$success = $historicalService->captureMonthlySnapshots($this->month, $this->year);
|
||||
|
||||
if ($success) {
|
||||
Log::info('PKJ historical data capture job completed successfully', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
]);
|
||||
} else {
|
||||
Log::error('PKJ historical data capture job failed', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
]);
|
||||
|
||||
throw new \Exception('PKJ historical data capture failed');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PKJ historical data capture job exception', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception)
|
||||
{
|
||||
Log::error('PKJ historical data capture job failed permanently', [
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags that should be assigned to the job.
|
||||
*/
|
||||
public function tags()
|
||||
{
|
||||
return ['pkj-historical-data', "month-{$this->month}", "year-{$this->year}"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\KJCAssetHolding\Entities\KJCAssetHolding;
|
||||
|
||||
class Country extends Model
|
||||
{
|
||||
protected $table = 'countries';
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Spatie\Permission\Models\Permission as SpatiePermission;
|
||||
|
||||
class Permission extends SpatiePermission
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
|
||||
use Modules\Role\Entities\Role;
|
||||
|
||||
class PersonalAccessToken extends SanctumPersonalAccessToken
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'token',
|
||||
'abilities',
|
||||
'expires_at',
|
||||
'active_role_id',
|
||||
];
|
||||
|
||||
public function activeRole()
|
||||
{
|
||||
return $this->belongsTo(Role::class, 'active_role_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class ImpersonatePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Determine whether the user can impersonate another user.
|
||||
*/
|
||||
public function impersonate(User $user): bool
|
||||
{
|
||||
// DEVELOPER role bypasses all permission checks
|
||||
if ($user->hasRole('DEVELOPER')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->hasPermissionTo('menyamar pengguna');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can impersonate a specific target user.
|
||||
*/
|
||||
public function impersonateUser(User $user, User $targetUser): bool
|
||||
{
|
||||
// DEVELOPER cannot be impersonated by anyone
|
||||
if ($targetUser->hasRole('DEVELOPER')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// DEVELOPER role bypasses all permission checks
|
||||
if ($user->hasRole('DEVELOPER')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has permission to impersonate
|
||||
if (!$user->hasPermissionTo('menyamar pengguna')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Users with permission can impersonate other users (except DEVELOPER)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Auth\Providers\AuthServiceProvider;
|
||||
use App\Policies\ImpersonatePolicy;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(AuthServiceProvider::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
|
||||
|
||||
// Register impersonate policy
|
||||
Gate::define('impersonate', [ImpersonatePolicy::class, 'impersonate']);
|
||||
Gate::define('impersonate.user', [ImpersonatePolicy::class, 'impersonateUser']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
use Laravel\Fortify\Contracts\LoginResponse;
|
||||
use Laravel\Fortify\Contracts\LogoutResponse;
|
||||
use Laravel\Fortify\Contracts\RegisterResponse;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Modules\Auth\Actions\Fortify\CreateNewUser;
|
||||
use Modules\Auth\Actions\Fortify\LoginResponse as ApiLoginResponse;
|
||||
use Modules\Auth\Actions\Fortify\LogoutResponse as ApiLogoutResponse;
|
||||
use Modules\Auth\Actions\Fortify\RegisterResponse as ApiRegisterResponse;
|
||||
use Modules\Auth\Actions\Fortify\ResetUserPassword;
|
||||
use Modules\Auth\Actions\Fortify\UpdateUserPassword;
|
||||
use Modules\Auth\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(LoginResponse::class, ApiLoginResponse::class);
|
||||
$this->app->singleton(LogoutResponse::class, ApiLogoutResponse::class);
|
||||
$this->app->singleton(RegisterResponse::class, ApiRegisterResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('email-verification', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input('email', '')).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('email-verification-resend', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input('email', '')).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(1)->by($throttleKey);
|
||||
});
|
||||
|
||||
Fortify::authenticateUsing(function (Request $request) {
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
// Bypass password check in local or development environments
|
||||
$shouldBypassPassword = config('app.env', 'local');
|
||||
|
||||
if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if (! $user->canAuthenticate()) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [$user->getLoginRestrictionMessage()],
|
||||
]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\Horizon;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', fn() => true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Telescope\IncomingEntry;
|
||||
use Laravel\Telescope\Telescope;
|
||||
use Laravel\Telescope\TelescopeApplicationServiceProvider;
|
||||
|
||||
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Telescope::night();
|
||||
|
||||
$this->hideSensitiveRequestDetails();
|
||||
|
||||
$isLocal = $this->app->environment('local');
|
||||
|
||||
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
|
||||
return $isLocal ||
|
||||
$entry->isReportableException() ||
|
||||
$entry->isFailedRequest() ||
|
||||
$entry->isFailedJob() ||
|
||||
$entry->isScheduledTask() ||
|
||||
$entry->hasMonitoredTag();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent sensitive request details from being logged by Telescope.
|
||||
*/
|
||||
protected function hideSensitiveRequestDetails(): void
|
||||
{
|
||||
if ($this->app->environment('local')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Telescope::hideRequestParameters(['_token']);
|
||||
|
||||
Telescope::hideRequestHeaders([
|
||||
'cookie',
|
||||
'x-csrf-token',
|
||||
'x-xsrf-token',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Telescope gate.
|
||||
*
|
||||
* This gate determines who can access Telescope in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewTelescope', function ($user) {
|
||||
return in_array($user->email, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Role\Entities\Role;
|
||||
|
||||
class ActiveRoleService
|
||||
{
|
||||
public static function resolveDefaultRole(User $user): ?Role
|
||||
{
|
||||
$roles = $user->roles;
|
||||
|
||||
if ($roles->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($roles->count() === 1) {
|
||||
return $roles->first();
|
||||
}
|
||||
|
||||
if (config('active_role.prefer_member_on_login', true)) {
|
||||
$memberRole = $roles->first(fn (Role $role) => self::roleContext($role) === 'member');
|
||||
|
||||
if ($memberRole) {
|
||||
return $memberRole;
|
||||
}
|
||||
}
|
||||
|
||||
return $roles->first();
|
||||
}
|
||||
|
||||
public static function assignToCurrentToken(User $user, Role $role): void
|
||||
{
|
||||
$token = $user->currentAccessToken();
|
||||
|
||||
if ($token instanceof PersonalAccessToken) {
|
||||
$token->forceFill(['active_role_id' => $role->id])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public static function assignDefaultToToken(User $user, PersonalAccessToken $accessToken): void
|
||||
{
|
||||
$user->loadMissing('roles');
|
||||
|
||||
$role = self::resolveDefaultRole($user);
|
||||
|
||||
if ($role) {
|
||||
$accessToken->forceFill(['active_role_id' => $role->id])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public static function getActiveRole(User $user): ?Role
|
||||
{
|
||||
$user->loadMissing(['roles.permissions']);
|
||||
|
||||
$token = $user->currentAccessToken();
|
||||
|
||||
if ($token instanceof PersonalAccessToken && $token->active_role_id) {
|
||||
$role = $user->roles->firstWhere('id', $token->active_role_id);
|
||||
|
||||
if ($role) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
return self::resolveDefaultRole($user);
|
||||
}
|
||||
|
||||
public static function switchRole(User $user, string $roleId): ?Role
|
||||
{
|
||||
$role = $user->roles()->where('roles.id', $roleId)->first();
|
||||
|
||||
if (! $role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::assignToCurrentToken($user, $role);
|
||||
|
||||
return $role->load('permissions');
|
||||
}
|
||||
|
||||
public static function roleContext(Role $role): string
|
||||
{
|
||||
$context = $role->context ?? 'member';
|
||||
|
||||
return in_array($context, ['admin', 'member'], true) ? $context : 'member';
|
||||
}
|
||||
|
||||
public static function redirectPathForRole(Role $role): string
|
||||
{
|
||||
return self::roleContext($role) === 'admin'
|
||||
? config('active_role.admin_redirect', '/profile')
|
||||
: config('active_role.member_redirect', '/profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function formatRole(?Role $role): ?array
|
||||
{
|
||||
if (! $role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'fullname' => $role->fullname ?? null,
|
||||
'guard_name' => $role->guard_name,
|
||||
'context' => self::roleContext($role),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function sessionMeta(User $user): array
|
||||
{
|
||||
$activeRole = self::getActiveRole($user);
|
||||
|
||||
return [
|
||||
'active_role' => self::formatRole($activeRole),
|
||||
'can_switch_role' => $user->roles->count() > 1,
|
||||
'redirect_path' => $activeRole
|
||||
? self::redirectPathForRole($activeRole)
|
||||
: config('active_role.member_redirect', '/profile'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
public static function log(string $description, ?Model $subject = null, array $properties = [], string $logName = 'default'): Activity
|
||||
{
|
||||
$activity = activity($logName)
|
||||
->causedBy(Auth::user())
|
||||
->withProperties($properties)
|
||||
->log($description);
|
||||
|
||||
if ($subject) {
|
||||
$activity->update(['subject_type' => get_class($subject), 'subject_id' => $subject->getKey()]);
|
||||
}
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
public static function logLogin(string $email): void
|
||||
{
|
||||
self::log("User logged in with email: {$email}", null, [
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
], 'authentication');
|
||||
}
|
||||
|
||||
public static function logLogout(): void
|
||||
{
|
||||
self::log('User logged out', null, [
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
], 'authentication');
|
||||
}
|
||||
|
||||
public static function logView(Model $model, ?string $customDescription = null): void
|
||||
{
|
||||
$modelName = class_basename($model);
|
||||
$description = $customDescription ?: "Viewed {$modelName}";
|
||||
|
||||
self::log($description, $model, [
|
||||
'action' => 'view',
|
||||
'url' => request()->fullUrl(),
|
||||
'method' => request()->method(),
|
||||
], 'view');
|
||||
}
|
||||
|
||||
public static function logCustomAction(string $action, string $description, ?Model $subject = null, array $properties = []): void
|
||||
{
|
||||
$properties = array_merge($properties, [
|
||||
'action' => $action,
|
||||
'url' => request()->fullUrl(),
|
||||
'method' => request()->method(),
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
self::log($description, $subject, $properties, 'custom');
|
||||
}
|
||||
|
||||
public static function logSearch(string $query, string $module): void
|
||||
{
|
||||
self::log("Searched for '{$query}' in {$module}", null, [
|
||||
'search_query' => $query,
|
||||
'module' => $module,
|
||||
'results_count' => 0, // You can update this if needed
|
||||
], 'search');
|
||||
}
|
||||
|
||||
public static function logExport(string $type, ?string $filename = null): void
|
||||
{
|
||||
self::log("Exported {$type} data", null, [
|
||||
'export_type' => $type,
|
||||
'filename' => $filename,
|
||||
'format' => pathinfo($filename, PATHINFO_EXTENSION) ?? 'unknown',
|
||||
], 'export');
|
||||
}
|
||||
|
||||
public static function logError(string $error, ?Model $subject = null): void
|
||||
{
|
||||
self::log("Error occurred: {$error}", $subject, [
|
||||
'error_message' => $error,
|
||||
'url' => request()->fullUrl(),
|
||||
'method' => request()->method(),
|
||||
], 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ContactService
|
||||
{
|
||||
/**
|
||||
* Get all active contact persons
|
||||
*/
|
||||
public function getActiveContacts(): array
|
||||
{
|
||||
try {
|
||||
$contacts = DB::table('contact_settings')
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('name', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return array_map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'position' => $item->position,
|
||||
'email' => $item->email,
|
||||
'phone' => $item->phone,
|
||||
'department' => $item->department,
|
||||
'sort_order' => $item->sort_order,
|
||||
];
|
||||
}, $contacts);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching active contacts: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contact settings (including inactive) for admin
|
||||
*/
|
||||
public function getAllContactSettings(): array
|
||||
{
|
||||
try {
|
||||
$settings = DB::table('contact_settings')
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('name', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return array_map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'position' => $item->position,
|
||||
'email' => $item->email,
|
||||
'phone' => $item->phone,
|
||||
'department' => $item->department,
|
||||
'is_active' => (bool) $item->is_active,
|
||||
'sort_order' => $item->sort_order,
|
||||
'created_at' => $item->created_at,
|
||||
'updated_at' => $item->updated_at,
|
||||
];
|
||||
}, $settings);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching all contact settings: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update contact settings (bulk update)
|
||||
*/
|
||||
public function updateContactSettings(array $contactData): array
|
||||
{
|
||||
try {
|
||||
// Clear existing settings
|
||||
DB::table('contact_settings')->truncate();
|
||||
|
||||
// Insert new settings
|
||||
$insertData = [];
|
||||
foreach ($contactData as $index => $item) {
|
||||
$insertData[] = [
|
||||
'name' => $item['name'],
|
||||
'position' => $item['position'] ?? null,
|
||||
'email' => $item['email'] ?? null,
|
||||
'phone' => $item['phone'] ?? null,
|
||||
'department' => $item['department'] ?? null,
|
||||
'is_active' => $item['is_active'] ?? true,
|
||||
'sort_order' => $item['sort_order'] ?? $index,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('contact_settings')->insert($insertData);
|
||||
|
||||
// Return updated settings
|
||||
return $this->getAllContactSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating contact settings: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single contact person
|
||||
*/
|
||||
public function addContactPerson(array $data): array
|
||||
{
|
||||
try {
|
||||
$id = DB::table('contact_settings')->insertGetId([
|
||||
'name' => $data['name'],
|
||||
'position' => $data['position'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'department' => $data['department'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->getAllContactSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error adding contact person: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single contact person
|
||||
*/
|
||||
public function updateContactPerson(int $id, array $data): array
|
||||
{
|
||||
try {
|
||||
DB::table('contact_settings')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'name' => $data['name'],
|
||||
'position' => $data['position'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'department' => $data['department'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->getAllContactSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating contact person: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact person
|
||||
*/
|
||||
public function deleteContactPerson(int $id): array
|
||||
{
|
||||
try {
|
||||
DB::table('contact_settings')->where('id', $id)->delete();
|
||||
|
||||
return $this->getAllContactSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting contact person: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status of a contact person
|
||||
*/
|
||||
public function toggleContactPerson(int $id): array
|
||||
{
|
||||
try {
|
||||
$contact = DB::table('contact_settings')->where('id', $id)->first();
|
||||
|
||||
if (!$contact) {
|
||||
throw new \Exception('Contact person not found');
|
||||
}
|
||||
|
||||
DB::table('contact_settings')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'is_active' => !$contact->is_active,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->getAllContactSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error toggling contact person: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Document;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentService
|
||||
{
|
||||
/**
|
||||
* Upload a document for any model.
|
||||
*/
|
||||
public function uploadDocument(
|
||||
Model $model,
|
||||
UploadedFile $file,
|
||||
string $documentType = 'general',
|
||||
?string $description = null
|
||||
): Document {
|
||||
// Generate unique filename
|
||||
$fileName = time().'_'.$file->getClientOriginalName();
|
||||
|
||||
// Store file in a folder named after the model
|
||||
$folderName = strtolower(class_basename($model));
|
||||
$filePath = $file->storeAs("documents/{$folderName}", $fileName, 'public');
|
||||
|
||||
// Create document record
|
||||
return Document::create([
|
||||
'documentable_type' => get_class($model),
|
||||
'documentable_id' => $model->id,
|
||||
'document_name' => $file->getClientOriginalName(),
|
||||
'document_path' => $filePath,
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'document_type' => $documentType,
|
||||
'description' => $description,
|
||||
'uploaded_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document.
|
||||
*/
|
||||
public function deleteDocument(int $documentId): bool
|
||||
{
|
||||
$document = Document::findOrFail($documentId);
|
||||
|
||||
return $document->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all documents for a model.
|
||||
*/
|
||||
public function getDocuments(Model $model, ?string $documentType = null)
|
||||
{
|
||||
$query = $model->documents();
|
||||
|
||||
if ($documentType) {
|
||||
$query->where('document_type', $documentType);
|
||||
}
|
||||
|
||||
return $query->with('uploadedBy')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document by ID with validation.
|
||||
*/
|
||||
public function getDocument(int $documentId): Document
|
||||
{
|
||||
return Document::with('uploadedBy')->findOrFail($documentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a document.
|
||||
*/
|
||||
public function downloadDocument(int $documentId)
|
||||
{
|
||||
$document = $this->getDocument($documentId);
|
||||
|
||||
if (! Storage::exists($document->document_path)) {
|
||||
throw new Exception('File not found');
|
||||
}
|
||||
|
||||
return Storage::download($document->document_path, $document->document_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documents count for a model.
|
||||
*/
|
||||
public function getDocumentsCount(Model $model, ?string $documentType = null): int
|
||||
{
|
||||
$query = $model->documents();
|
||||
|
||||
if ($documentType) {
|
||||
$query->where('document_type', $documentType);
|
||||
}
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model has documents.
|
||||
*/
|
||||
public function hasDocuments(Model $model, ?string $documentType = null): bool
|
||||
{
|
||||
return $this->getDocumentsCount($model, $documentType) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported file types.
|
||||
*/
|
||||
public function getSupportedFileTypes(): array
|
||||
{
|
||||
return [
|
||||
'pdf' => 'application/pdf',
|
||||
'doc' => 'application/msword',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max file size in KB.
|
||||
*/
|
||||
public function getMaxFileSize(): int
|
||||
{
|
||||
return 10240; // 10MB
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file upload.
|
||||
*/
|
||||
public function validateFile(UploadedFile $file): bool
|
||||
{
|
||||
$supportedTypes = $this->getSupportedFileTypes();
|
||||
$maxSize = $this->getMaxFileSize() * 1024; // Convert to bytes
|
||||
|
||||
// Check file size
|
||||
if ($file->getSize() > $maxSize) {
|
||||
throw new Exception('File size exceeds maximum limit of '.$this->getMaxFileSize().'KB');
|
||||
}
|
||||
|
||||
// Check mime type
|
||||
if (! in_array($file->getClientMimeType(), $supportedTypes)) {
|
||||
throw new Exception('File type not supported. Supported types: '.implode(', ', array_keys($supportedTypes)));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete documents for a model.
|
||||
*/
|
||||
public function bulkDeleteDocuments(Model $model, array $documentIds): int
|
||||
{
|
||||
$deletedCount = 0;
|
||||
|
||||
foreach ($documentIds as $documentId) {
|
||||
$document = $model->documents()->find($documentId);
|
||||
|
||||
if ($document) {
|
||||
$document->delete();
|
||||
$deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $deletedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Carbon\Carbon;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class OnlineUsersService
|
||||
{
|
||||
/**
|
||||
* Get all currently online users
|
||||
*
|
||||
* @param int $timeoutMinutes Minutes of inactivity to consider user offline
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getOnlineUsers(int $timeoutMinutes = 5)
|
||||
{
|
||||
$cacheKey = "online_users_{$timeoutMinutes}";
|
||||
|
||||
return Cache::remember($cacheKey, 30, function () use ($timeoutMinutes) {
|
||||
$timeoutSeconds = $timeoutMinutes * 60;
|
||||
$cutoffTime = now()->subSeconds($timeoutSeconds);
|
||||
|
||||
// Get active Sanctum tokens with user information
|
||||
$onlineTokens = PersonalAccessToken::with(['tokenable.unit', 'tokenable.rank', 'tokenable.position'])
|
||||
->where('tokenable_type', User::class)
|
||||
->where('last_used_at', '>', $cutoffTime)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->whereHas('tokenable', function ($query) {
|
||||
$query->whereNull('deleted_at');
|
||||
})
|
||||
->orderBy('last_used_at', 'desc')
|
||||
->get();
|
||||
|
||||
// Group by user to handle multiple tokens
|
||||
$onlineUsers = $onlineTokens->groupBy('tokenable_id')->map(function ($tokens) {
|
||||
$token = $tokens->first();
|
||||
$user = $token->tokenable;
|
||||
$latestToken = $tokens->sortByDesc('last_used_at')->first();
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'army_number' => $user->army_number,
|
||||
'image_url' => $user->image_url ? Storage::disk('public')->url($user->image_url) : null,
|
||||
'status' => $user->status,
|
||||
'unit_name' => $user->unit?->name,
|
||||
'rank_name' => $user->rank?->name,
|
||||
'position_name' => $user->position?->name,
|
||||
'ip_address' => 'N/A', // Sanctum doesn't store IP by default
|
||||
'user_agent' => $this->parseUserAgent('Sanctum Token'),
|
||||
'last_activity' => $latestToken->last_used_at?->timestamp ?? $latestToken->created_at->timestamp,
|
||||
'last_activity_human' => $latestToken->last_used_at?->diffForHumans() ?? $latestToken->created_at->diffForHumans(),
|
||||
'session_count' => $tokens->count(),
|
||||
'is_online' => true
|
||||
];
|
||||
});
|
||||
|
||||
return $onlineUsers->values();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online users count
|
||||
*
|
||||
* @param int $timeoutMinutes Minutes of inactivity to consider user offline
|
||||
* @return int
|
||||
*/
|
||||
public function getOnlineUsersCount(int $timeoutMinutes = 5): int
|
||||
{
|
||||
$cacheKey = "online_users_count_{$timeoutMinutes}";
|
||||
|
||||
return Cache::remember($cacheKey, 30, function () use ($timeoutMinutes) {
|
||||
$timeoutSeconds = $timeoutMinutes * 60;
|
||||
$cutoffTime = now()->subSeconds($timeoutSeconds);
|
||||
|
||||
return PersonalAccessToken::where('tokenable_type', User::class)
|
||||
->where('last_used_at', '>', $cutoffTime)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->whereHas('tokenable', function ($query) {
|
||||
$query->whereNull('deleted_at');
|
||||
})
|
||||
->distinct('tokenable_id')
|
||||
->count('tokenable_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current session information
|
||||
*
|
||||
* @param int $userId
|
||||
* @return array|null
|
||||
*/
|
||||
public function getUserSessionInfo(int $userId): ?array
|
||||
{
|
||||
$token = PersonalAccessToken::where('tokenable_type', User::class)
|
||||
->where('tokenable_id', $userId)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->orderBy('last_used_at', 'desc')
|
||||
->first();
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'session_id' => $token->id,
|
||||
'ip_address' => 'N/A', // Sanctum doesn't store IP by default
|
||||
'user_agent' => $this->parseUserAgent('Sanctum Token'),
|
||||
'last_activity' => $token->last_used_at?->timestamp ?? $token->created_at->timestamp,
|
||||
'last_activity_human' => $token->last_used_at?->diffForHumans() ?? $token->created_at->diffForHumans(),
|
||||
'is_online' => $token->last_used_at && $token->last_used_at->gt(now()->subMinutes(5))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user agent string to extract browser and OS info
|
||||
*
|
||||
* @param string $userAgent
|
||||
* @return array
|
||||
*/
|
||||
private function parseUserAgent(string $userAgent): array
|
||||
{
|
||||
$browser = 'Unknown';
|
||||
$os = 'Unknown';
|
||||
|
||||
// Simple browser detection
|
||||
if (strpos($userAgent, 'Chrome') !== false) {
|
||||
$browser = 'Chrome';
|
||||
} elseif (strpos($userAgent, 'Firefox') !== false) {
|
||||
$browser = 'Firefox';
|
||||
} elseif (strpos($userAgent, 'Safari') !== false) {
|
||||
$browser = 'Safari';
|
||||
} elseif (strpos($userAgent, 'Edge') !== false) {
|
||||
$browser = 'Edge';
|
||||
}
|
||||
|
||||
// Simple OS detection
|
||||
if (strpos($userAgent, 'Windows') !== false) {
|
||||
$os = 'Windows';
|
||||
} elseif (strpos($userAgent, 'Mac') !== false) {
|
||||
$os = 'macOS';
|
||||
} elseif (strpos($userAgent, 'Linux') !== false) {
|
||||
$os = 'Linux';
|
||||
} elseif (strpos($userAgent, 'Android') !== false) {
|
||||
$os = 'Android';
|
||||
} elseif (strpos($userAgent, 'iOS') !== false) {
|
||||
$os = 'iOS';
|
||||
}
|
||||
|
||||
return [
|
||||
'browser' => $browser,
|
||||
'os' => $os,
|
||||
'raw' => $userAgent
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear online users cache
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
Cache::forget('online_users_5');
|
||||
Cache::forget('online_users_10');
|
||||
Cache::forget('online_users_15');
|
||||
Cache::forget('online_users_30');
|
||||
Cache::forget('online_users_count_5');
|
||||
Cache::forget('online_users_count_10');
|
||||
Cache::forget('online_users_count_15');
|
||||
Cache::forget('online_users_count_30');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online users statistics
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getOnlineUsersStats(): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
// Get stats for different timeout periods
|
||||
$timeouts = [5, 10, 15, 30];
|
||||
|
||||
foreach ($timeouts as $timeout) {
|
||||
$stats["online_{$timeout}min"] = $this->getOnlineUsersCount($timeout);
|
||||
}
|
||||
|
||||
// Get total registered users
|
||||
$stats['total_users'] = User::count();
|
||||
|
||||
// Get users by status
|
||||
$stats['active_users'] = User::where('status', 'active')->count();
|
||||
$stats['inactive_users'] = User::where('status', 'inactive')->count();
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SocialMediaService
|
||||
{
|
||||
/**
|
||||
* Get all active social media platforms
|
||||
*/
|
||||
public function getActiveSocialMedia(): array
|
||||
{
|
||||
try {
|
||||
$socialMedia = DB::table('social_media_settings')
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('name', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return array_map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'platform' => $item->platform,
|
||||
'name' => $item->name,
|
||||
'url' => $item->url,
|
||||
'icon' => $item->icon,
|
||||
'sort_order' => $item->sort_order,
|
||||
];
|
||||
}, $socialMedia);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching active social media: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all social media settings (including inactive) for admin
|
||||
*/
|
||||
public function getAllSocialMediaSettings(): array
|
||||
{
|
||||
try {
|
||||
$settings = DB::table('social_media_settings')
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('name', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return array_map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'platform' => $item->platform,
|
||||
'name' => $item->name,
|
||||
'url' => $item->url,
|
||||
'icon' => $item->icon,
|
||||
'is_active' => (bool) $item->is_active,
|
||||
'sort_order' => $item->sort_order,
|
||||
'created_at' => $item->created_at,
|
||||
'updated_at' => $item->updated_at,
|
||||
];
|
||||
}, $settings);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching all social media settings: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update social media settings (bulk update)
|
||||
*/
|
||||
public function updateSocialMediaSettings(array $socialMediaData): array
|
||||
{
|
||||
try {
|
||||
// Clear existing settings
|
||||
DB::table('social_media_settings')->truncate();
|
||||
|
||||
// Insert new settings
|
||||
$insertData = [];
|
||||
foreach ($socialMediaData as $index => $item) {
|
||||
$insertData[] = [
|
||||
'platform' => $item['platform'],
|
||||
'name' => $item['name'],
|
||||
'url' => $item['url'],
|
||||
'icon' => $item['icon'] ?? null,
|
||||
'is_active' => $item['is_active'] ?? true,
|
||||
'sort_order' => $item['sort_order'] ?? $index,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('social_media_settings')->insert($insertData);
|
||||
|
||||
// Return updated settings
|
||||
return $this->getAllSocialMediaSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating social media settings: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single social media platform
|
||||
*/
|
||||
public function addSocialMediaPlatform(array $data): array
|
||||
{
|
||||
try {
|
||||
$id = DB::table('social_media_settings')->insertGetId([
|
||||
'platform' => $data['platform'],
|
||||
'name' => $data['name'],
|
||||
'url' => $data['url'],
|
||||
'icon' => $data['icon'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->getAllSocialMediaSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error adding social media platform: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single social media platform
|
||||
*/
|
||||
public function updateSocialMediaPlatform(int $id, array $data): array
|
||||
{
|
||||
try {
|
||||
DB::table('social_media_settings')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'platform' => $data['platform'],
|
||||
'name' => $data['name'],
|
||||
'url' => $data['url'],
|
||||
'icon' => $data['icon'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->getAllSocialMediaSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating social media platform: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a social media platform
|
||||
*/
|
||||
public function deleteSocialMediaPlatform(int $id): array
|
||||
{
|
||||
try {
|
||||
DB::table('social_media_settings')->where('id', $id)->delete();
|
||||
|
||||
return $this->getAllSocialMediaSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting social media platform: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status of a social media platform
|
||||
*/
|
||||
public function toggleSocialMediaPlatform(int $id): array
|
||||
{
|
||||
try {
|
||||
$platform = DB::table('social_media_settings')->where('id', $id)->first();
|
||||
|
||||
if (!$platform) {
|
||||
throw new \Exception('Social media platform not found');
|
||||
}
|
||||
|
||||
DB::table('social_media_settings')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'is_active' => !$platform->is_active,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->getAllSocialMediaSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error toggling social media platform: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Modules\Formation\Entities\Formation;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class VisibilityService
|
||||
{
|
||||
/**
|
||||
* Apply visibility scoping to a query builder based on user permissions
|
||||
*
|
||||
* @param bool $includeUnitsWithoutFormation When true (process models), SEL PERO 91 REJ
|
||||
* users can see units without formations. Government users never see units without formations.
|
||||
*/
|
||||
public function applyVisibilityToQuery(Builder $query, $user, string $unitColumn = 'unit_id', bool $includeUnitsWithoutFormation = false): Builder
|
||||
{
|
||||
// Check if user has akses peringkat keseluruhan permission (super admin)
|
||||
if ($user->can('akses peringkat keseluruhan', Unit::class)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Get user's unit
|
||||
$userUnit = Unit::find($user->unit_id);
|
||||
if (!$userUnit) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// Government permission: Can view all units under their government
|
||||
// Government users do NOT see units without formations
|
||||
// SEL PERO 91 REJ (process models): ONLY see units without formation - not their gov/formation units
|
||||
if ($user->can('akses peringkat formasi')) {
|
||||
if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
|
||||
$unitIds = Unit::whereNull('formation_id')
|
||||
->whereNull('repair_formation_id')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
} else {
|
||||
$unitIds = $this->getUnitsUnderGovernment($userUnit);
|
||||
$unitIds = $this->excludeUnitsWithoutFormation($unitIds);
|
||||
}
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
return $query->whereIn($unitColumn, $unitIds);
|
||||
}
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// DIV permission: Can view all units under their formation (including repair formations)
|
||||
if ($user->can('akses peringkat divisyen')) {
|
||||
$unitIds = $this->getVisibleUnitIdsForFormationUser($user, $userUnit, $includeUnitsWithoutFormation);
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
return $query->whereIn($unitColumn, $unitIds);
|
||||
}
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// Unit permission: Can only view data within their unit
|
||||
return $query->where($unitColumn, $user->unit_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply visibility scoping for models with indirect unit relationships
|
||||
*
|
||||
* @param bool $includeUnitsWithoutFormation When true (process models), SEL PERO 91 REJ
|
||||
* users can see units without formations. Government users never see units without formations.
|
||||
*/
|
||||
public function applyVisibilityToIndirectQuery(Builder $query, $user, array $unitRelationship, bool $includeUnitsWithoutFormation = false): Builder
|
||||
{
|
||||
// Check if user has akses peringkat keseluruhan permission (super admin)
|
||||
if ($user->can('akses peringkat keseluruhan', Unit::class)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Get user's unit
|
||||
$userUnit = Unit::find($user->unit_id);
|
||||
if (!$userUnit) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// Government permission: Can view all units under their government
|
||||
// SEL PERO 91 REJ (process models): ONLY see units without formation - not their gov/formation units
|
||||
if ($user->can('akses peringkat formasi')) {
|
||||
if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
|
||||
$unitIds = Unit::whereNull('formation_id')
|
||||
->whereNull('repair_formation_id')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
} else {
|
||||
$unitIds = $this->getUnitsUnderGovernment($userUnit);
|
||||
$unitIds = $this->excludeUnitsWithoutFormation($unitIds);
|
||||
}
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
return $query->whereHas($unitRelationship['relationship'], function ($subQuery) use ($unitIds, $unitRelationship) {
|
||||
$subQuery->whereIn($unitRelationship['unitColumn'], $unitIds);
|
||||
});
|
||||
}
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// DIV permission: Can view all units under their formation (including repair formations)
|
||||
if ($user->can('akses peringkat divisyen')) {
|
||||
$unitIds = $this->getVisibleUnitIdsForFormationUser($user, $userUnit, $includeUnitsWithoutFormation);
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
return $query->whereHas($unitRelationship['relationship'], function ($subQuery) use ($unitIds, $unitRelationship) {
|
||||
$subQuery->whereIn($unitRelationship['unitColumn'], $unitIds);
|
||||
});
|
||||
}
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// Unit permission: Can only view data within their unit
|
||||
return $query->whereHas($unitRelationship['relationship'], function ($subQuery) use ($user, $unitRelationship) {
|
||||
$subQuery->where($unitRelationship['unitColumn'], $user->unit_id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all units under the same government as the user's unit
|
||||
* Handles both direct government relationships and formation-government relationships
|
||||
* Priority: Direct government_id takes precedence over formation government
|
||||
*
|
||||
* @param Unit $userUnit
|
||||
* @return array
|
||||
*/
|
||||
public function getUnitsUnderGovernment(Unit $userUnit): array
|
||||
{
|
||||
$unitIds = collect();
|
||||
|
||||
// Case 1: User's unit has direct government relationship (HIGHEST PRIORITY)
|
||||
if ($userUnit->government_id) {
|
||||
$directUnits = Unit::where('government_id', $userUnit->government_id)
|
||||
->pluck('id');
|
||||
$unitIds = $unitIds->merge($directUnits);
|
||||
|
||||
// Also get units in formations under the same direct government
|
||||
$formationUnits = Unit::whereHas('formation', function ($query) use ($userUnit) {
|
||||
$query->where('government_id', $userUnit->government_id);
|
||||
})->pluck('id');
|
||||
$unitIds = $unitIds->merge($formationUnits);
|
||||
|
||||
return $unitIds->unique()->toArray();
|
||||
}
|
||||
|
||||
// Case 2: User's unit belongs to a formation that has a government (FALLBACK)
|
||||
if ($userUnit->formation_id) {
|
||||
$formation = Formation::find($userUnit->formation_id);
|
||||
if ($formation && $formation->government_id) {
|
||||
// Get all units in formations under the same government
|
||||
$formationUnits = Unit::whereHas('formation', function ($query) use ($formation) {
|
||||
$query->where('government_id', $formation->government_id);
|
||||
})->pluck('id');
|
||||
$unitIds = $unitIds->merge($formationUnits);
|
||||
|
||||
// Also get direct government units under the same government
|
||||
$directGovUnits = Unit::where('government_id', $formation->government_id)
|
||||
->pluck('id');
|
||||
$unitIds = $unitIds->merge($directGovUnits);
|
||||
}
|
||||
}
|
||||
|
||||
return $unitIds->unique()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repair formation ID for a unit
|
||||
* Priority: repair_formation_id > formation_id > null
|
||||
*
|
||||
* @param Unit $unit
|
||||
* @return int|null
|
||||
*/
|
||||
public function getRepairFormationId(Unit $unit): ?int
|
||||
{
|
||||
// Priority 1: Check repair_formation_id (custom repair formation)
|
||||
if ($unit->repair_formation_id) {
|
||||
return $unit->repair_formation_id;
|
||||
}
|
||||
|
||||
// Priority 2: Fall back to regular formation_id
|
||||
if ($unit->formation_id) {
|
||||
return $unit->formation_id;
|
||||
}
|
||||
|
||||
// Priority 3: No formation (government-level only)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible unit IDs for a user
|
||||
*
|
||||
* @param bool $includeUnitsWithoutFormation When true (process models), SEL PERO 91 REJ
|
||||
* users can see units without formations. Government users never see units without formations.
|
||||
*/
|
||||
public function getVisibleUnitIds($user, bool $includeUnitsWithoutFormation = false): array
|
||||
{
|
||||
// Check if user has akses peringkat keseluruhan permission (super admin)
|
||||
if ($user->can('akses peringkat keseluruhan', Unit::class)) {
|
||||
return Unit::pluck('id')->toArray();
|
||||
}
|
||||
|
||||
// Get user's unit
|
||||
$userUnit = Unit::find($user->unit_id);
|
||||
if (!$userUnit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Government permission: Can view all units under their government
|
||||
// SEL PERO 91 REJ (process models): ONLY see units without formation - not their gov/formation units
|
||||
if ($user->can('akses peringkat formasi')) {
|
||||
if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
|
||||
return Unit::whereNull('formation_id')
|
||||
->whereNull('repair_formation_id')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
$unitIds = $this->getUnitsUnderGovernment($userUnit);
|
||||
return $this->excludeUnitsWithoutFormation($unitIds);
|
||||
}
|
||||
|
||||
// DIV permission: Can view all units under their formation (including repair formations)
|
||||
if ($user->can('akses peringkat divisyen')) {
|
||||
return $this->getVisibleUnitIdsForFormationUser($user, $userUnit, $includeUnitsWithoutFormation);
|
||||
}
|
||||
|
||||
// Unit permission: Can only view data within their unit
|
||||
return [$user->unit_id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude units that have no formation (both formation_id and repair_formation_id are null).
|
||||
* Government users do not see these units.
|
||||
*/
|
||||
protected function excludeUnitsWithoutFormation(array $unitIds): array
|
||||
{
|
||||
if (empty($unitIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Unit::whereIn('id', $unitIds)
|
||||
->where(function ($q) {
|
||||
$q->whereNotNull('formation_id')
|
||||
->orWhereNotNull('repair_formation_id');
|
||||
})
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible unit IDs for a user with akses peringkat divisyen permission.
|
||||
*
|
||||
* Asset view (includeUnitsWithoutFormation=false): Only units where formation_id matches.
|
||||
* Excludes units that have repair_formation_id but formation_id null (e.g. 10 SKN RAJD uses
|
||||
* 3 DIV for repair only - 3 DIV users see their repairs but NOT their asset data).
|
||||
*
|
||||
* Process view (includeUnitsWithoutFormation=true): Units where repair_formation_id OR
|
||||
* formation_id matches. SEL PERO 91 REJ also sees units without any formation.
|
||||
*/
|
||||
protected function getVisibleUnitIdsForFormationUser($user, Unit $userUnit, bool $includeUnitsWithoutFormation): array
|
||||
{
|
||||
$userRepairFormationId = $this->getRepairFormationId($userUnit);
|
||||
|
||||
if ($userRepairFormationId) {
|
||||
if (!$includeUnitsWithoutFormation) {
|
||||
// Asset view: Only units that belong to the formation (formation_id matches).
|
||||
// Exclude units that use formation only for repair (formation_id null, repair_formation_id set).
|
||||
return Unit::where('formation_id', $userRepairFormationId)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
// SEL PERO 91 REJ: ONLY see units without formation - not their formation's units
|
||||
// (formation units are handled by SEL PERO DIV)
|
||||
if ($user->hasRole('SEL PERO 91 REJ')) {
|
||||
return Unit::whereNull('formation_id')
|
||||
->whereNull('repair_formation_id')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
// Process view: Units in user's formation (repair_formation_id or formation_id)
|
||||
return Unit::where(function ($q) use ($userRepairFormationId) {
|
||||
$q->where('repair_formation_id', $userRepairFormationId)
|
||||
->orWhere(function ($q2) use ($userRepairFormationId) {
|
||||
$q2->whereNull('repair_formation_id')
|
||||
->where('formation_id', $userRepairFormationId);
|
||||
});
|
||||
})->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
// User's unit has no formation - check if they have SEL PERO 91 REJ (process models only)
|
||||
if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
|
||||
return Unit::whereNull('formation_id')
|
||||
->whereNull('repair_formation_id')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
|
||||
class AuthCookie
|
||||
{
|
||||
public static function name(): string
|
||||
{
|
||||
return config('auth_cookie.name', 'auth_token');
|
||||
}
|
||||
|
||||
public static function originalUserCookieName(): string
|
||||
{
|
||||
return config('auth_cookie.original_user_cookie', 'original_user_id');
|
||||
}
|
||||
|
||||
public static function make(string $plainTextToken): Cookie
|
||||
{
|
||||
return cookie(
|
||||
self::name(),
|
||||
$plainTextToken,
|
||||
config('auth_cookie.lifetime_minutes', 720),
|
||||
'/',
|
||||
null,
|
||||
(bool) config('auth_cookie.secure', true),
|
||||
true,
|
||||
false,
|
||||
config('auth_cookie.same_site', 'lax')
|
||||
);
|
||||
}
|
||||
|
||||
public static function forget(): Cookie
|
||||
{
|
||||
return cookie(
|
||||
self::name(),
|
||||
null,
|
||||
-1,
|
||||
'/',
|
||||
null,
|
||||
(bool) config('auth_cookie.secure', true),
|
||||
true,
|
||||
false,
|
||||
config('auth_cookie.same_site', 'lax')
|
||||
);
|
||||
}
|
||||
|
||||
public static function makeOriginalUserId(string $userId): Cookie
|
||||
{
|
||||
return cookie(
|
||||
self::originalUserCookieName(),
|
||||
(string) $userId,
|
||||
config('auth_cookie.lifetime_minutes', 720),
|
||||
'/',
|
||||
null,
|
||||
(bool) config('auth_cookie.secure', true),
|
||||
true,
|
||||
false,
|
||||
config('auth_cookie.same_site', 'lax')
|
||||
);
|
||||
}
|
||||
|
||||
public static function forgetOriginalUserId(): Cookie
|
||||
{
|
||||
return cookie(
|
||||
self::originalUserCookieName(),
|
||||
null,
|
||||
-1,
|
||||
'/',
|
||||
null,
|
||||
(bool) config('auth_cookie.secure', true),
|
||||
true,
|
||||
false,
|
||||
config('auth_cookie.same_site', 'lax')
|
||||
);
|
||||
}
|
||||
|
||||
public static function attachAuthToken(JsonResponse $response, string $plainTextToken): JsonResponse
|
||||
{
|
||||
return $response->withCookie(self::make($plainTextToken));
|
||||
}
|
||||
|
||||
public static function clearAuthCookies(JsonResponse $response): JsonResponse
|
||||
{
|
||||
return $response
|
||||
->withCookie(self::forget())
|
||||
->withCookie(self::forgetOriginalUserId());
|
||||
}
|
||||
|
||||
public static function shouldExposeTokenInResponse(): bool
|
||||
{
|
||||
return (bool) config('auth_cookie.expose_token_in_response', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
trait HasDocuments
|
||||
{
|
||||
/**
|
||||
* Get all documents for this model.
|
||||
*/
|
||||
public function documents()
|
||||
{
|
||||
return $this->morphMany(Document::class, 'documentable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documents of a specific type.
|
||||
*/
|
||||
public function documentsOfType($type)
|
||||
{
|
||||
return $this->documents()->where('document_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a document for this model.
|
||||
*/
|
||||
public function uploadDocument(UploadedFile $file, $documentType = 'general', $description = null)
|
||||
{
|
||||
// Generate unique filename
|
||||
$fileName = time().'_'.$file->getClientOriginalName();
|
||||
|
||||
// Store file in a folder named after the model
|
||||
$folderName = strtolower(class_basename($this));
|
||||
$filePath = $file->storeAs("documents/{$folderName}", $fileName);
|
||||
|
||||
// Create document record
|
||||
return $this->documents()->create([
|
||||
'document_name' => $file->getClientOriginalName(),
|
||||
'document_path' => $filePath,
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'document_type' => $documentType,
|
||||
'description' => $description,
|
||||
'uploaded_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document by ID.
|
||||
*/
|
||||
public function deleteDocument($documentId)
|
||||
{
|
||||
$document = $this->documents()->findOrFail($documentId);
|
||||
|
||||
return $document->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documents count.
|
||||
*/
|
||||
public function getDocumentsCountAttribute()
|
||||
{
|
||||
return $this->documents()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documents count by type.
|
||||
*/
|
||||
public function getDocumentsCountByType($type)
|
||||
{
|
||||
return $this->documentsOfType($type)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model has documents.
|
||||
*/
|
||||
public function hasDocuments()
|
||||
{
|
||||
return $this->documents()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model has documents of specific type.
|
||||
*/
|
||||
public function hasDocumentsOfType($type)
|
||||
{
|
||||
return $this->documentsOfType($type)->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Services\VisibilityService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait HasVisibility
|
||||
{
|
||||
/**
|
||||
* Get the visibility service instance
|
||||
*/
|
||||
protected function getVisibilityService(): VisibilityService
|
||||
{
|
||||
return app(VisibilityService::class);
|
||||
}
|
||||
|
||||
public function scopeVisibleTo(Builder $query, $user)
|
||||
{
|
||||
$includeUnitsWithoutFormation = property_exists($this, 'includeUnitsWithoutFormation')
|
||||
? (bool) $this->includeUnitsWithoutFormation
|
||||
: false;
|
||||
|
||||
// Check if model has a direct unit column
|
||||
$unitColumn = property_exists($this, 'unitColumn') ? $this->unitColumn : 'unit_id';
|
||||
|
||||
// Check if model has an indirect unit relationship
|
||||
$unitRelationship = property_exists($this, 'unitRelationship') ? $this->unitRelationship : null;
|
||||
|
||||
if ($unitRelationship) {
|
||||
return $this->getVisibilityService()->applyVisibilityToIndirectQuery(
|
||||
$query,
|
||||
$user,
|
||||
$unitRelationship,
|
||||
$includeUnitsWithoutFormation
|
||||
);
|
||||
}
|
||||
|
||||
return $this->getVisibilityService()->applyVisibilityToQuery(
|
||||
$query,
|
||||
$user,
|
||||
$unitColumn,
|
||||
$includeUnitsWithoutFormation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all units under the same government as the user's unit
|
||||
* Handles both direct government relationships and formation-government relationships
|
||||
* Priority: Direct government_id takes precedence over formation government
|
||||
*
|
||||
* @deprecated Use VisibilityService::getUnitsUnderGovernment() instead
|
||||
*/
|
||||
protected function getUnitsUnderGovernment($userUnit)
|
||||
{
|
||||
return $this->getVisibilityService()->getUnitsUnderGovernment($userUnit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
trait HttpClientTrait
|
||||
{
|
||||
/**
|
||||
* Get a configured HTTP client with global settings.
|
||||
*
|
||||
* @param array $additionalHeaders Additional headers to add
|
||||
* @param bool $withoutVerifying Whether to skip SSL verification
|
||||
* @return \Illuminate\Http\Client\PendingRequest
|
||||
*/
|
||||
protected function getHttpClient(array $additionalHeaders = [], bool $withoutVerifying = false)
|
||||
{
|
||||
$client = Http::withHeaders($additionalHeaders);
|
||||
|
||||
if ($withoutVerifying) {
|
||||
$client = $client->withoutVerifying();
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configured HTTP client with authentication.
|
||||
*
|
||||
* @param string $token Bearer token for authentication
|
||||
* @param array $additionalHeaders Additional headers to add
|
||||
* @param bool $withoutVerifying Whether to skip SSL verification
|
||||
* @return \Illuminate\Http\Client\PendingRequest
|
||||
*/
|
||||
protected function getAuthenticatedHttpClient(string $token, array $additionalHeaders = [], bool $withoutVerifying = false)
|
||||
{
|
||||
$headers = array_merge([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
], $additionalHeaders);
|
||||
|
||||
return $this->getHttpClient($headers, $withoutVerifying);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log HTTP request details for debugging.
|
||||
*
|
||||
* @param string $method HTTP method
|
||||
* @param string $url Request URL
|
||||
* @param array $data Request data
|
||||
* @param array $headers Request headers
|
||||
*/
|
||||
protected function logHttpRequest(string $method, string $url, array $data = [], array $headers = [])
|
||||
{
|
||||
Log::debug('HTTP Request', [
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'data' => $data,
|
||||
'headers' => array_keys($headers), // Don't log sensitive header values
|
||||
'environment' => config('app.env'),
|
||||
'proxy_enabled' => $this->isProxyEnabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log HTTP response details for debugging.
|
||||
*
|
||||
* @param \Illuminate\Http\Client\Response $response
|
||||
* @param string $context Additional context
|
||||
*/
|
||||
protected function logHttpResponse($response, string $context = '')
|
||||
{
|
||||
Log::debug('HTTP Response'.($context ? " - {$context}" : ''), [
|
||||
'status' => $response->status(),
|
||||
'successful' => $response->successful(),
|
||||
'body_length' => strlen($response->body()),
|
||||
'headers' => $response->headers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if proxy is enabled for current environment.
|
||||
*/
|
||||
protected function isProxyEnabled(): bool
|
||||
{
|
||||
$config = config('http');
|
||||
$proxyConfig = $config['proxy'] ?? [];
|
||||
|
||||
return isset($proxyConfig['enabled']) && $proxyConfig['enabled'] &&
|
||||
in_array(config('app.env'), $proxyConfig['environments'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current proxy configuration.
|
||||
*/
|
||||
protected function getProxyConfig(): array
|
||||
{
|
||||
$config = config('http');
|
||||
|
||||
return $config['proxy'] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
trait NotifiesAdmins
|
||||
{
|
||||
/**
|
||||
* Get admin users (PENTADBIR and DEVELOPER) who should receive all notifications
|
||||
*/
|
||||
protected function getAdminUsers(): Collection
|
||||
{
|
||||
return User::whereHas('roles', function ($query) {
|
||||
$query->whereIn('name', ['PENTADBIR', 'DEVELOPER']);
|
||||
})->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge admin users with specific role users and return unique collection
|
||||
*/
|
||||
protected function mergeWithAdmins(Collection $specificUsers): Collection
|
||||
{
|
||||
$adminUsers = $this->getAdminUsers();
|
||||
return $specificUsers->merge($adminUsers)->unique('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with specific roles and merge with admins
|
||||
*/
|
||||
protected function getUsersWithRolesAndAdmins(array $roles): Collection
|
||||
{
|
||||
$specificUsers = User::whereHas('roles', function ($query) use ($roles) {
|
||||
$query->whereIn('name', $roles);
|
||||
})->get();
|
||||
|
||||
return $this->mergeWithAdmins($specificUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs from a collection and merge with admin user IDs
|
||||
*/
|
||||
protected function mergeUserIdsWithAdmins($userIds): Collection
|
||||
{
|
||||
$adminIds = $this->getAdminUsers()->pluck('id');
|
||||
|
||||
if ($userIds instanceof Collection) {
|
||||
return $userIds->merge($adminIds)->unique()->filter();
|
||||
}
|
||||
|
||||
return collect($userIds)->merge($adminIds)->unique()->filter();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user