Compare commits

...

10 Commits

Author SHA1 Message Date
071e94166b misc 2025-01-21 14:53:06 +06:00
bf1e3ff8c7 FIX #13 - UA table 2025-01-21 14:44:55 +06:00
8b99410b45 wip - UA table 2025-01-21 14:21:40 +06:00
c9e87ef1bb misc 2025-01-21 13:49:32 +06:00
b2d8739527 resources 2025-01-21 13:19:23 +06:00
2816e7e40c audit log 2025-01-21 13:10:28 +06:00
708496ea7e misc 2025-01-21 12:28:48 +06:00
29927c4922 resources 2025-01-21 12:28:21 +06:00
b1b9bbf6e6 optimize 2025-01-21 12:01:18 +06:00
9892f4431d JS optim 2025-01-21 11:40:20 +06:00
26 changed files with 271 additions and 146 deletions

View File

@ -579,7 +579,7 @@ private function generateActionButtons(Study $study): string
foreach (WorklistGuard::worklistButtons($study) as $button) { foreach (WorklistGuard::worklistButtons($study) as $button) {
switch ($button) { switch ($button) {
case WorklistButton::StudyMetadata: case WorklistButton::StudyMetadata:
$btns[] = $this->renderImageLink($study->hash, 'info.png', 'showStudy', 'Info'); $btns[] = $this->renderImageLink($study->hash, 'info.png', 'show-study', 'Info');
break; break;
case WorklistButton::Assign: case WorklistButton::Assign:
$btns[] = $this->renderImageLink($study->hash, 'assign.png', 'show-assign', 'Assign'); $btns[] = $this->renderImageLink($study->hash, 'assign.png', 'show-assign', 'Assign');
@ -587,6 +587,9 @@ private function generateActionButtons(Study $study): string
case WorklistButton::Notes: case WorklistButton::Notes:
$btns[] = $this->renderImageLink($study->hash, 'chat.png', 'show-notes', 'Chat'); $btns[] = $this->renderImageLink($study->hash, 'chat.png', 'show-notes', 'Chat');
break; break;
case WorklistButton::Audit:
$btns[] = $this->renderImageLink($study->hash, 'audit.png', 'show-audit', 'Audit Trail');
break;
} }
} }

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Staff;
use App\Http\Controllers\HashedStudyControllerBase;
use App\Services\AuditTrail\Activity;
use App\Services\AuditTrail\Category;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class AuditLogController extends HashedStudyControllerBase
{
public function popup()
{
$study = $this->getStudy();
$logs = DB::table('audit_logs')
->leftjoin('users', 'users.id', '=', 'audit_logs.user_id')
->leftjoin('user_agents', 'user_agents.id', '=', 'audit_logs.user_agent_id')
->selectRaw('users.display_name as user_name, audit_logs.created_at as log_time, audit_logs.*, user_agents.*')
->orderByDesc('audit_logs.id')
->where('audit_logs.study_id', $study->id)
->get();
$logs->each(function ($log) {
$log->category_name = Category::from($log->category)->name;
$log->activity_name = Str::of(Activity::from($log->activity)->name)->slug('-')->replace('-', ' ')->title();
if (filled($log->user_agent)) {
$log->browser = "Browser: {$log->browser_name}\nDevice: {$log->device_type}\nPlatform: {$log->platform}";
} else {
$log->browser = null;
}
});
return view('staff.audit.popup', compact('study', 'logs'));
}
}

View File

@ -10,4 +10,5 @@ enum WorklistButton: string
case Attachment = 'attachment'; case Attachment = 'attachment';
case Assign = 'assign'; case Assign = 'assign';
case Report = 'report'; case Report = 'report';
case Audit = 'audit';
} }

View File

@ -51,6 +51,7 @@ public static function worklistButtons(Study $study, User|int|null $usr = null):
return collect([ return collect([
WorklistButton::StudyMetadata, WorklistButton::StudyMetadata,
WorklistButton::Notes, WorklistButton::Notes,
WorklistButton::Audit,
]); ]);
} }
@ -63,6 +64,7 @@ public static function worklistButtons(Study $study, User|int|null $usr = null):
if ($study->canAssignRad()) { if ($study->canAssignRad()) {
$buttons->push(WorklistButton::Assign); $buttons->push(WorklistButton::Assign);
} }
$buttons->push(WorklistButton::Audit);
return $buttons; return $buttons;
} }

View File

@ -2,47 +2,48 @@
namespace App\Services\AuditTrail; namespace App\Services\AuditTrail;
final class Activity enum Activity: int
{ {
// studies // studies
public const int Study_Open = 101; case Study_Open = 101;
public const int Study_Metadata_View = 102; case Study_Metadata_View = 102;
public const int Study_Metadata_Edit = 103; case Study_Metadata_Edit = 103;
public const int Study_History_View = 104; case Study_History_View = 104;
public const int Study_History_Update = 105; case Study_History_Update = 105;
public const int Study_Create = 106; case Study_Create = 106;
public const int Study_Update = 107; case Study_Update = 107;
public const int Study_Archive = 108; case Study_Archive = 108;
public const int Study_Delete = 109; case Study_Delete = 109;
public const int Study_Lock = 110; case Study_Lock = 110;
public const int Study_Unlock = 111; case Study_Unlock = 111;
public const int Attachment_Upload = 112; case Attachment_Upload = 112;
public const int Attachment_Download = 113; case Attachment_Download = 113;
public const int Attachment_Delete = 114; case Attachment_Delete = 114;
// report // report
public const int Report_Save = 201; case Report_Save = 201;
public const int Report_Delete = 202; case Report_Delete = 202;
public const int Report_Finalize = 203; case Report_Finalize = 203;
public const int User_Login = 301; case User_Login = 301;
public const int User_Failed_Login = 302; case User_Failed_Login = 302;
public const int User_Logout = 303; case User_Logout = 303;
case Assign_Physician = 401;
case Unassign_Physician = 402;
public const int Assign_Physician = 401;
public const int Unassign_Physician = 402;
} }

View File

@ -3,6 +3,7 @@
namespace App\Services\AuditTrail; namespace App\Services\AuditTrail;
use App\Models\Study; use App\Models\Study;
use App\Services\BrowserService;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -12,15 +13,15 @@ class ActivityLogger
private ?int $userId = null; private ?int $userId = null;
private int $activity; private Activity $activity;
private int $category; private Category $category;
private ?string $url = null; private ?string $url = null;
private ?string $notes = null; private ?string $notes = null;
private ?string $userAgent = null; private ?int $userAgentId = null;
private ?string $ipAddr = null; private ?string $ipAddr = null;
@ -59,14 +60,14 @@ public function by(Authenticatable|int|null $user = null): static
return $this; return $this;
} }
public function did(int $activity): static public function did(Activity $activity): static
{ {
$this->activity = $activity; $this->activity = $activity;
return $this; return $this;
} }
public function category(int $category): static public function category(Category $category): static
{ {
$this->category = $category; $this->category = $category;
@ -96,7 +97,7 @@ public function notes(string $notes): static
public function ua(?string $agent = null): static public function ua(?string $agent = null): static
{ {
$this->userAgent = $agent ?? request()->userAgent(); $this->userAgentId = BrowserService::upsertBrowser($agent);
return $this; return $this;
} }
@ -131,11 +132,11 @@ public function log(bool $initDefaults = true): bool
->insert([ ->insert([
'study_id' => $this->studyId, 'study_id' => $this->studyId,
'user_id' => $this->userId, 'user_id' => $this->userId,
'category' => $this->category, 'category' => $this->category->value,
'activity' => $this->activity, 'activity' => $this->activity->value,
'orthanc_uuid' => $this->orthancId, 'orthanc_uuid' => $this->orthancId,
'ip_addr' => $this->ipAddr, 'ip_addr' => $this->ipAddr,
'user_agent' => $this->userAgent, 'user_agent_id' => $this->userAgentId,
'url' => $this->url, 'url' => $this->url,
'notes' => $this->notes, 'notes' => $this->notes,
'created_at' => now(), 'created_at' => now(),

View File

@ -2,13 +2,13 @@
namespace App\Services\AuditTrail; namespace App\Services\AuditTrail;
final class Category enum Category: int
{ {
public const int GENERAL = 10; case GENERAL = 10;
public const int SYSTEM = 20; case SYSTEM = 20;
public const int PACS = 30; case PACS = 30;
public const int AUTH = 40; case AUTH = 40;
} }

View File

@ -0,0 +1,50 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
final class BrowserService
{
public static function userAgent()
{
return app('browser-detect')->userAgent();
}
public static function findUA(?string $ua = null): ?int
{
$ua ??= app('browser-detect')->userAgent();
$row = DB::table('user_agents')->where('user_agent', $ua)->first(['id']);
if ($row) {
return $row->id;
}
return null;
}
public static function upsertBrowser(?string $ua = null): int
{
$browser = blank($ua)
? app('browser-detect')->detect()
: app('browser-detect')->parse($ua);
$id = self::findUA($browser->userAgent());
if ($id) {
return $id;
}
$params = [
'user_agent' => $browser->userAgent(),
'device_type' => $browser->deviceType(),
'device_family' => $browser->deviceFamily(),
'device_model' => $browser->deviceModel(),
'browser_name' => $browser->browserName(),
'browser_version' => $browser->browserVersion(),
'platform' => $browser->platformFamily(),
'platform_version' => $browser->platformVersion(),
'created_at' => now(),
];
DB::table('user_agents')->insert($params);
return self::findUA($browser->userAgent());
}
}

View File

@ -13,6 +13,7 @@
"culturegr/presenter": "^1.4", "culturegr/presenter": "^1.4",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"hashids/hashids": "^5.0", "hashids/hashids": "^5.0",
"hisorange/browser-detect": "^5.0",
"laravel/framework": "^11.31", "laravel/framework": "^11.31",
"laravel/jetstream": "^5.3", "laravel/jetstream": "^5.3",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",

21
config/browser-detect.php Normal file
View File

@ -0,0 +1,21 @@
<?php
return [
'cache' => [
/**
* Interval in seconds, as how long a result should be cached.
*/
'interval' => 10080,
/**
* Cache prefix, the user agent string will be hashed and appended at the end.
*/
'prefix' => 'bd4_',
],
'security' => [
/**
* Byte length where the header is cut off, if some attacker sends a long header
* then the library will make a cut this byte point.
*/
'max-header-length' => 2048,
],
];

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations
*/
public function up(): void
{
Schema::create('user_agents', static function (Blueprint $table) {
$table->id();
$table->string('user_agent')->unique();
$table->string('device_type')->nullable();
$table->string('device_family')->nullable();
$table->string('device_model')->nullable();
$table->string('browser_name')->nullable();
$table->string('browser_version')->nullable();
$table->string('platform')->nullable();
$table->string('platform_version')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('user_agents');
}
};

View File

@ -15,7 +15,7 @@ public function up(): void
$table->unsignedTinyInteger('category'); $table->unsignedTinyInteger('category');
$table->unsignedSmallInteger('activity'); $table->unsignedSmallInteger('activity');
$table->ipAddress('ip_addr')->nullable(); $table->ipAddress('ip_addr')->nullable();
$table->string('user_agent')->nullable(); $table->unsignedBigInteger('user_agent_id')->nullable();
$table->string('orthanc_uuid')->nullable(); $table->string('orthanc_uuid')->nullable();
$table->text('url')->nullable(); $table->text('url')->nullable();
$table->text('notes')->nullable(); $table->text('notes')->nullable();

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
resources/imgs/audit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

BIN
resources/imgs/audit_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
resources/imgs/info_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
resources/imgs/url_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,30 @@
<span class="badge rounded-circle p-2">
<img src="{{ asset('imgs/audit_64.png') }}" alt="">
</span>
<table class="table table-sm table-striped">
@foreach ($logs as $log)
<tr>
<td>{{ $log->log_time }}</td>
<td>{{ $log->user_name }}</td>
<td>{{ $log->activity_name }}</td>
<td>{{ $log->notes }}</td>
<td>
@isset($log->ip_addr)
<a target="_blank" href="{{ 'https://ipinfo.io/'.$log->ip_addr }}">{{ $log->ip_addr }}</a>
@endisset
</td>
<td>{{ $log->category_name }}</td>
<td>
@isset($log->browser)
@include('_partials._img-tooltip', ['src' => asset('imgs/browser_64.png'), 'tip' => $log->browser, 'class' => 'msg-icon'])
@endisset
</td>
<td>
@isset($log->url)
@include('_partials._img-tooltip', ['src' => asset('imgs/url_64.png'), 'tip' => $log->url, 'class' => 'msg-icon'])
@endisset
</td>
</tr>
@endforeach
</table>

View File

@ -69,46 +69,12 @@
<script type="text/javascript"> <script type="text/javascript">
$(function () { $(function () {
$('body').on('click', '.showStudy', function () { @include('staff.worklist.partials._modal-js', ['selector' => '.show-study', 'url' => route('staff.studies.show'), 'type' => 'study'])
var study_id = $(this).data('id'); @include('staff.worklist.partials._modal-js', ['selector' => '.show-attach', 'url' => route('staff.studies.attach'), 'type' => 'attach'])
$.get("{{ route('staff.studies.show') }}", {hashid: study_id}, function (data) { @include('staff.worklist.partials._modal-js', ['selector' => '.show-assign', 'url' => route('staff.assign.show'), 'type' => 'assign'])
$('#study-details').html(data); @include('staff.worklist.partials._modal-js', ['selector' => '.show-reports', 'url' => route('staff.report.popup'), 'type' => 'report'])
$('#study-modal').modal('show'); @include('staff.worklist.partials._modal-js', ['selector' => '.show-audit', 'url' => route('staff.audit.popup'), 'type' => 'audit'])
});
});
$('body').on('click', '.show-attach', function () {
var study_id = $(this).data('id');
$.get("{{ route('staff.studies.attach') }}", {hashid: study_id}, function (data) {
$('#study-details').html(data);
$('#study-modal').modal('show');
});
});
$('body').on('click', '.show-assign', function () {
var study_id = $(this).data('id');
$.get("{{ route('staff.assign.show') }}", {hashid: study_id}, function (data) {
$('#assign-details').html(data);
$('#assign-modal').modal('show');
});
});
$('body').on('click', '.show-reports', function () {
var study_id = $(this).data('id');
$.get("{{ route('staff.report.popup') }}", {hashid: study_id}, function (data) {
$('#report-details').html(data);
$('#report-modal').modal('show');
});
});
});
</script>
<script type="text/javascript">
$(function () {
let _status, _study_from, _study_to, _receive_from, _receive_to, _assign_from, _assign_to, _read_from, let _status, _study_from, _study_to, _receive_from, _receive_to, _assign_from, _assign_to, _read_from,
_read_to, _modality, _read_by = null; _read_to, _modality, _read_by = null;
@ -116,19 +82,26 @@ function resetParams() {
_status, _study_from, _study_to, _receive_from, _receive_to, _assign_from, _assign_to, _read_from, _read_to, _modality, _read_by = null; _status, _study_from, _study_to, _receive_from, _receive_to, _assign_from, _assign_to, _read_from, _read_to, _modality, _read_by = null;
} }
const strip_dash = str => str.replace(/^_/, '');
function generateUrl() { function generateUrl() {
const url = new URL("{{ route('staff.worklist.index') }}"); const url = new URL("{{ route('staff.worklist.index') }}");
if (_status) url.searchParams.set('status', _status); const params = {
if (_study_from) url.searchParams.set('study_from', _study_from); _status,
if (_study_to) url.searchParams.set('study_to', _study_to); _study_from,
if (_receive_from) url.searchParams.set('receive_from', _receive_from); _study_to,
if (_receive_to) url.searchParams.set('receive_to', _receive_to); _receive_from,
if (_assign_from) url.searchParams.set('assign_from', _assign_from); _receive_to,
if (_assign_to) url.searchParams.set('assign_to', _assign_to); _assign_from,
if (_read_from) url.searchParams.set('read_from', _read_from); _assign_to,
if (_read_to) url.searchParams.set('read_to', _read_to); _read_from,
if (_modality) url.searchParams.set('modality', _modality); _read_to,
if (_read_by) url.searchParams.set('read_by', _read_by); _modality,
_read_by
};
Object.keys(params).forEach(key => {
if (params[key]) url.searchParams.set(strip_dash(key), params[key]);
});
return url.toString(); return url.toString();
} }
@ -251,62 +224,10 @@ function formatDate(date) {
</div> </div>
</div> </div>
<div class="modal fade" id="study-modal" tabindex="-1" aria-labelledby="studyModalLabel" aria-hidden="true"> @include('staff.worklist.partials._modal', ['type' => 'study', 'title' => 'Study Information'])
<div class="modal-dialog modal-xl"> @include('staff.worklist.partials._modal', ['type' => 'attach', 'title' => 'Attached Docs'])
<div class="modal-content"> @include('staff.worklist.partials._modal', ['type' => 'assign', 'title' => 'Assign Radiologist'])
<div class="modal-header"> @include('staff.worklist.partials._modal', ['type' => 'report', 'title' => 'Reports'])
<h5 class="modal-title" id="studyModalLabel">Study Information</h5> @include('staff.worklist.partials._modal', ['type' => 'audit', 'title' => 'Audit Log'])
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="study-details"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="attach-modal" tabindex="-1" aria-labelledby="label-attach" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="label-attach">Attached Docs</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="attach-details"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="assign-modal" tabindex="-1" aria-labelledby="label-assign" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="label-assign">Assign Radiologist</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="assign-details"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="report-modal" tabindex="-1" aria-labelledby="label-report" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="label-report">Reports</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="report-details"></div>
</div>
</div>
</div>
</div>
@endsection @endsection

View File

@ -0,0 +1,7 @@
$('body').on('click', '{{ $selector }}', function () {
var study_id = $(this).data('id');
$.get("{{ $url }}", {hashid: study_id}, function (data) {
$('#{{ $type }}-body').html(data);
$('#{{ $type }}-modal').modal('show');
});
});

View File

@ -0,0 +1,13 @@
<div class="modal fade" id="{{ $type }}-modal" tabindex="-1" aria-labelledby="label-{{ $type }}" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="label-{{ $type }}">{{ $title }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="{{ $type }}-body"></div>
</div>
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\SocialLoginController; use App\Http\Controllers\SocialLoginController;
use App\Http\Controllers\Staff\AssignmentController; use App\Http\Controllers\Staff\AssignmentController;
use App\Http\Controllers\Staff\AttachmentController; use App\Http\Controllers\Staff\AttachmentController;
use App\Http\Controllers\Staff\AuditLogController;
use App\Http\Controllers\Staff\DicomViewerController; use App\Http\Controllers\Staff\DicomViewerController;
use App\Http\Controllers\Staff\HistoryController; use App\Http\Controllers\Staff\HistoryController;
use App\Http\Controllers\Staff\MetadataController; use App\Http\Controllers\Staff\MetadataController;
@ -78,6 +79,10 @@
Route::post('save', [ReportController::class, 'save'])->name('save'); Route::post('save', [ReportController::class, 'save'])->name('save');
Route::get('download/{uuid}/{format}', ReportDownloadController::class)->name('download'); Route::get('download/{uuid}/{format}', ReportDownloadController::class)->name('download');
}); });
Route::group(['prefix' => 'audit', 'as' => 'audit.'], function () {
Route::get('popup', [AuditLogController::class, 'popup'])->name('popup');
});
}); });
}); });