fix: enforce unique national ID validation and fix sync updated_at corruption

This commit is contained in:
SajjadMahmoody 2026-04-28 23:31:19 +03:30
parent dc524f1fb6
commit 721ee30948
9 changed files with 77 additions and 186 deletions

View File

@ -195,7 +195,15 @@ public static function form(Schema $schema): Schema
TextInput::make('icno')
->label(__('patients.fields.icno'))
->required()
->maxLength(11),
->maxLength(11)
->unique(
table: Patient::class,
column: 'icno',
ignorable: fn ($record) => $record,
)
->validationMessages([
'unique' => __('patients.validation.icno_unique'),
]),
TextInput::make('hand_phone')
->label(__('patients.fields.hand_phone'))
@ -807,7 +815,7 @@ public static function table(Table $table): Table
->iconColor(fn ($state) => $state ? 'success' : 'gray')
->placeholder('-'),
])
->defaultSort('created_at', 'desc')
->defaultSort('updated_at', 'desc')
->recordActions([
Action::make('surgeryAppointment')
->label(__('patients.actions.surgery_appointment'))

View File

@ -26,81 +26,6 @@ public function checkPeerStatus(): void
$this->peerPath = $result['path'];
$this->peerChecked = true;
}
public function runBackup(): void
{
$dbPath = database_path('database.sqlite');
if (! file_exists($dbPath)) {
Notification::make()
->title(__('navigation.backup.failed'))
->danger()
->send();
return;
}
$timestamp = now()->format('Y-m-d_H-i-s');
$zipFilename = "matab-backup-{$timestamp}.zip";
$zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $zipFilename;
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
Notification::make()
->title(__('navigation.backup.failed'))
->danger()
->send();
return;
}
$noCompressExts = [
'jpg', 'jpeg', 'png', 'gif', 'webp',
'mp4', 'mov', 'avi', 'mkv', 'webm',
'mp3', 'wav', 'ogg', 'opus', 'm4a',
'zip', 'gz', 'rar', '7z',
];
$addFile = function (string $realPath, string $zipEntry) use ($zip, $noCompressExts): void {
$zip->addFile($realPath, $zipEntry);
$index = $zip->locateName($zipEntry);
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
if (in_array($ext, $noCompressExts, true)) {
$zip->setCompressionIndex($index, \ZipArchive::CM_STORE);
} else {
$zip->setCompressionIndex($index, \ZipArchive::CM_DEFLATE, 9);
}
};
$addFile($dbPath, 'database/database.sqlite');
$publicPath = storage_path('app/public');
foreach (['files', 'initials'] as $folder) {
$folderPath = $publicPath . '/' . $folder;
if (! is_dir($folderPath)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($folderPath) + 1));
$addFile($file->getPathname(), $folder . '/' . $relativePath);
}
}
}
$zip->close();
session(['backup_tmp_path' => $zipPath, 'backup_tmp_name' => $zipFilename]);
$this->dispatch('backup-ready', url: route('backup.download'));
Notification::make()
->title(__('navigation.backup.success'))
->success()
->send();
}
public function runSync(): void
{
$service = SyncService::fromConfig();

View File

@ -13,6 +13,7 @@ class Visit extends Model
{
use HasUserStamps;
protected $touches = ['patientRecord'];
protected $fillable = [
'patient',
'visit_date',

View File

@ -38,8 +38,10 @@ public function updated(Model $model): void
}
$changedData = [];
foreach ($dirty as $field => $newValue) {
$changedData[$field] = $model->getAttribute($field);
foreach ($dirty as $field => $rawValue) {
$changedData[$field] = $rawValue instanceof \BackedEnum
? $rawValue->value
: (is_object($rawValue) ? (string) $rawValue : $rawValue);
}
SyncLog::create([

View File

@ -13,6 +13,7 @@
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustHosts(at: fn () => ['.*'], subdomains: false);
$middleware->encryptCookies(except: ['backup_token']);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@ -81,5 +81,6 @@
'last_name_required' => 'Last name is required.',
'father_name_required' => 'Father name is required.',
'hand_phone_required' => 'Mobile number is required.',
'icno_unique' => 'This national ID is already registered for another patient.',
],
];

View File

@ -83,5 +83,6 @@
'last_name_required' => 'وارد کردن نام خانوادگی الزامی است.',
'father_name_required' => 'وارد کردن نام پدر الزامی است.',
'hand_phone_required' => 'وارد کردن شماره موبایل الزامی است.',
'icno_unique' => 'این کد ملی قبلاً برای بیمار دیگری ثبت شده است.',
],
];

View File

@ -40,13 +40,6 @@ class="relative"
failed: false,
interval: null,
showBackupProgress: false,
backupProgress: 0,
backupStatusText: '',
backupDone: false,
backupInterval: null,
backupUrl: null,
confirmSync() {
this.open = false;
this.showConfirm = true;
@ -84,32 +77,6 @@ class="relative"
}
},
startBackup() {
this.open = false;
this.showBackupProgress = true;
this.backupProgress = 5;
this.backupDone = false;
this.backupUrl = null;
this.backupStatusText = '{{ __('navigation.backup.status_preparing') }}';
this.backupInterval = setInterval(() => {
if (this.backupProgress < 80) {
this.backupProgress += Math.random() * 8;
if (this.backupProgress > 30) {
this.backupStatusText = '{{ __('navigation.backup.status_compressing') }}';
}
}
}, 400);
$wire.runBackup();
},
closeBackupModal() {
this.showBackupProgress = false;
if (this.backupUrl) {
window.location.href = this.backupUrl;
}
}
}"
x-on:sync-finished.window="
clearInterval(interval);
@ -122,13 +89,6 @@ class="relative"
failed = true;
}
"
x-on:backup-ready.window="
clearInterval(backupInterval);
backupProgress = 100;
backupStatusText = '{{ __('navigation.backup.status_ready') }}';
backupDone = true;
backupUrl = $event.detail.url;
"
@click.outside="open = false"
>
<button
@ -168,14 +128,14 @@ class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-
{{ __('navigation.sync.details') }}
</a>
<button
type="button"
@click="startBackup()"
class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition w-full"
<a
href="{{ route('backup.download') }}"
@click="open = false"
class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
>
<x-heroicon-o-arrow-down-tray class="w-4 h-4 text-success-500 shrink-0" />
{{ __('navigation.backup.label') }}
</button>
</a>
</div>
<template x-teleport="body">
@ -277,61 +237,6 @@ class="px-4 py-2 text-sm font-medium text-white rounded-lg transition"
</div>
</template>
<template x-teleport="body">
<div
x-show="showBackupProgress"
x-transition.opacity
dir="rtl"
class="fixed inset-0 z-999 flex items-center justify-center bg-black/50 backdrop-blur-sm"
style="display:none"
>
<div style="position:relative; background:white; border-radius:1rem; box-shadow:0 20px 60px rgba(0,0,0,.2); width:100%; max-width:28rem; margin:0 1rem; overflow:hidden" class="dark:bg-gray-800">
{{-- Close button: top-left, completely independent of layout --}}
<button
@click="showBackupProgress = false; clearInterval(backupInterval)"
style="position:absolute; left:12px; top:12px; z-index:10"
class="flex items-center justify-center w-9 h-9 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-200 dark:hover:bg-gray-700 transition"
>
<x-heroicon-o-x-mark class="w-4 h-4" />
</button>
<div class="px-6 pt-6 pb-4">
<div class="flex items-center gap-3 mb-5">
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-success-50 dark:bg-success-900/30">
<template x-if="!backupDone">
<svg class="w-5 h-5 text-success-500 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</template>
<template x-if="backupDone">
<x-heroicon-o-check-circle class="w-5 h-5 text-success-500" />
</template>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ __('navigation.backup.label') }}</h3>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 mb-3 overflow-hidden">
<div
class="h-3 rounded-full transition-all duration-500 ease-out bg-success-500"
:style="'width: ' + Math.min(backupProgress, 100) + '%'"
></div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1" x-text="backupStatusText"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="Math.round(Math.min(backupProgress, 100)) + '%'"></p>
</div>
<div class="flex items-center justify-end px-6 py-4 bg-gray-50 dark:bg-gray-900/50">
<button
x-show="backupDone"
@click="showBackupProgress = false; window.location.href = backupUrl"
class="px-4 py-2 text-sm font-medium text-white bg-success-600 hover:bg-success-700 rounded-lg transition"
>
{{ __('navigation.backup.download_button') }}
</button>
</div>
</div>
</div>
</template>
</div>
@endcan

View File

@ -39,17 +39,64 @@
return response()->json($result);
})->name('sync.run');
Route::get('/admin/backup/download', function () {
$zipPath = session('backup_tmp_path');
$zipName = session('backup_tmp_name', 'matab-backup.zip');
Route::get('/backup/download', function () {
set_time_limit(0);
ini_set('memory_limit', '256M');
if (! $zipPath || ! file_exists($zipPath)) {
abort(404, 'Backup file not found.');
$dbPath = database_path('database.sqlite');
if (! file_exists($dbPath)) {
abort(404, 'Database not found.');
}
session()->forget(['backup_tmp_path', 'backup_tmp_name']);
$zipFilename = 'matab-backup-' . now()->format('Y-m-d_H-i-s') . '.zip';
$zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $zipFilename;
return response()->download($zipPath, $zipName, [
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
abort(500, 'Could not create zip archive.');
}
$noCompressExts = [
'jpg', 'jpeg', 'png', 'gif', 'webp',
'mp4', 'mov', 'avi', 'mkv', 'webm',
'mp3', 'wav', 'ogg', 'opus', 'm4a',
'zip', 'gz', 'rar', '7z',
];
$addFile = function (string $realPath, string $zipEntry) use ($zip, $noCompressExts): void {
$zip->addFile($realPath, $zipEntry);
$index = $zip->locateName($zipEntry);
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
if (in_array($ext, $noCompressExts, true)) {
$zip->setCompressionIndex($index, \ZipArchive::CM_STORE);
} else {
$zip->setCompressionIndex($index, \ZipArchive::CM_DEFLATE, 1);
}
};
$addFile($dbPath, 'database/database.sqlite');
$publicPath = storage_path('app/public');
foreach (['files', 'initials'] as $folder) {
$folderPath = $publicPath . '/' . $folder;
if (! is_dir($folderPath)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($folderPath) + 1));
$addFile($file->getPathname(), $folder . '/' . $relativePath);
}
}
}
$zip->close();
return response()->download($zipPath, $zipFilename, [
'Content-Type' => 'application/zip',
])->deleteFileAfterSend(true);
})->name('backup.download');