diff --git a/app/Commands/StorageMigrateLocalToS3.php b/app/Commands/StorageMigrateLocalToS3.php new file mode 100644 index 0000000..7ea17b0 --- /dev/null +++ b/app/Commands/StorageMigrateLocalToS3.php @@ -0,0 +1,306 @@ + 'List files that would be migrated without uploading.', + '--force' => 'Re-upload even if the object already exists on S3.', + '--delete-local' => 'Delete local file after successful S3 upload + verify.', + '--verify' => 'Compare MD5 of local file vs S3 read-back after upload (default: on).', + '--no-verify' => 'Skip read-back verification after upload.', + '--module' => 'Migrate only one module: policy or endorsement.', + ]; + + /** @var array */ + private array $stats = [ + 'scanned' => 0, + 'uploaded' => 0, + 'skipped' => 0, + 'failed' => 0, + 'deleted' => 0, + ]; + + public function run(array $params) + { + $config = config('Storage'); + + if ($config->s3Bucket === '' || $config->s3AccessKey === '' || $config->s3SecretKey === '') { + CLI::error('S3 is not configured. Set AWS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY in .env'); + + return EXIT_ERROR; + } + + $dryRun = (bool) CLI::getOption('dry-run'); + $force = (bool) CLI::getOption('force'); + $deleteLocal = (bool) CLI::getOption('delete-local'); + $verify = ! CLI::getOption('no-verify'); + $moduleFilter = CLI::getOption('module'); + + if (is_string($moduleFilter) && $moduleFilter !== '' && ! in_array($moduleFilter, ['policy', 'endorsement'], true)) { + CLI::error('Invalid --module value. Allowed: policy, endorsement'); + + return EXIT_ERROR; + } + + try { + $s3Driver = new S3StorageDriver($config); + } catch (FileStorageException $e) { + CLI::error($e->getMessage()); + + return EXIT_ERROR; + } + + $storage = new FileStorageService($config); + + CLI::write('Local to S3 migration', 'green'); + CLI::write('Local root : ' . $config->localRoot, 'cyan'); + CLI::write('S3 bucket : ' . $config->s3Bucket, 'cyan'); + CLI::write('S3 region : ' . $config->s3Region, 'cyan'); + CLI::write('S3 prefix : ' . ($config->s3Prefix !== '' ? $config->s3Prefix : '(none)'), 'cyan'); + CLI::write('Mode : ' . ($dryRun ? 'DRY RUN' : 'LIVE'), $dryRun ? 'yellow' : 'green'); + CLI::newLine(); + + $targets = $this->migrationTargets(); + + if (is_string($moduleFilter) && $moduleFilter !== '') { + $targets = array_values(array_filter( + $targets, + static fn (array $target) => $target['module'] === $moduleFilter + )); + } + + foreach ($targets as $target) { + $this->migrateTarget($target, $s3Driver, $storage, $dryRun, $force, $deleteLocal, $verify); + } + + CLI::newLine(); + CLI::write('Migration summary', 'yellow'); + CLI::write(' Scanned : ' . $this->stats['scanned'], 'white'); + CLI::write(' Uploaded: ' . $this->stats['uploaded'], 'green'); + CLI::write(' Skipped : ' . $this->stats['skipped'], 'yellow'); + CLI::write(' Failed : ' . $this->stats['failed'], $this->stats['failed'] > 0 ? 'red' : 'white'); + CLI::write(' Deleted : ' . $this->stats['deleted'], 'white'); + + if ($dryRun) { + CLI::newLine(); + CLI::write('Dry run complete. Re-run without --dry-run to upload.', 'yellow'); + } + + return $this->stats['failed'] === 0 ? EXIT_SUCCESS : EXIT_ERROR; + } + + /** + * @return list + */ + private function migrationTargets(): array + { + return [ + [ + 'label' => 'policy_pdf', + 'module' => 'policy', + 'subFolder' => 'policy_pdf', + 'localRelative' => 'uploads/policy/policy_pdf', + 'topLevelOnly' => true, + ], + [ + 'label' => 'policy_payment_receipt', + 'module' => 'policy', + 'subFolder' => 'policy_payment_receipt', + 'localRelative' => 'uploads/policy/policy_payment_receipt', + 'topLevelOnly' => true, + ], + [ + 'label' => 'policy_md', + 'module' => 'policy', + 'subFolder' => 'policy_md', + 'localRelative' => 'uploads/policy/policy_md', + 'topLevelOnly' => true, + ], + [ + 'label' => 'endorsement_original', + 'module' => 'endorsement', + 'subFolder' => '', + 'localRelative' => 'uploads/endorsement', + 'topLevelOnly' => true, + ], + [ + 'label' => 'endorsement_completion', + 'module' => 'endorsement', + 'subFolder' => 'endorsement_pdf', + 'localRelative' => 'uploads/endorsement/endorsement_pdf', + 'topLevelOnly' => true, + ], + ]; + } + + /** + * @param array{label: string, module: string, subFolder: string, localRelative: string, topLevelOnly: bool} $target + */ + private function migrateTarget( + array $target, + S3StorageDriver $s3Driver, + FileStorageService $storage, + bool $dryRun, + bool $force, + bool $deleteLocal, + bool $verify + ): void { + $localDir = rtrim(config('Storage')->localRoot, '/\\') + . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $target['localRelative']); + + CLI::write('[' . $target['label'] . '] ' . $target['localRelative'], 'yellow'); + + if (! is_dir($localDir)) { + CLI::write(' Directory not found, skipping.', 'dark_gray'); + CLI::newLine(); + + return; + } + + $files = $this->listLocalFiles($localDir, $target['topLevelOnly']); + + if ($files === []) { + CLI::write(' No files found.', 'dark_gray'); + CLI::newLine(); + + return; + } + + foreach ($files as $localPath) { + $this->stats['scanned']++; + + $fileName = basename($localPath); + $key = $storage->resolveKey($target['module'], $target['subFolder'], $fileName); + $size = filesize($localPath) ?: 0; + + try { + if (! $force && $s3Driver->exists($key)) { + $this->stats['skipped']++; + CLI::write(" skip (exists on S3): {$key}", 'dark_gray'); + continue; + } + + if ($dryRun) { + $this->stats['uploaded']++; + CLI::write(" would upload: {$key} ({$size} bytes)", 'cyan'); + continue; + } + + $s3Driver->upload($localPath, $key); + + if ($verify) { + $this->verifyUpload($localPath, $s3Driver, $key); + } + + $this->stats['uploaded']++; + CLI::write(" uploaded: {$key} ({$size} bytes)", 'green'); + + if ($deleteLocal) { + if (@unlink($localPath)) { + $this->stats['deleted']++; + CLI::write(' deleted local: ' . $localPath, 'dark_gray'); + } else { + throw new FileStorageException('Uploaded to S3 but failed to delete local file: ' . $localPath); + } + } + } catch (\Throwable $e) { + $this->stats['failed']++; + CLI::error(" failed: {$key} -> " . $e->getMessage()); + } + } + + CLI::newLine(); + } + + /** + * @return list absolute local file paths + */ + private function listLocalFiles(string $directory, bool $topLevelOnly): array + { + $files = []; + + if ($topLevelOnly) { + foreach (scandir($directory) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $item; + + if (! is_file($path)) { + continue; + } + + if ($this->shouldSkipFile($item)) { + continue; + } + + $files[] = $path; + } + + sort($files); + + return $files; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $fileInfo) { + if (! $fileInfo->isFile()) { + continue; + } + + $name = $fileInfo->getFilename(); + + if ($this->shouldSkipFile($name)) { + continue; + } + + $files[] = $fileInfo->getPathname(); + } + + sort($files); + + return $files; + } + + private function shouldSkipFile(string $fileName): bool + { + return in_array(strtolower($fileName), ['index.html', '.htaccess', '.gitkeep'], true); + } + + private function verifyUpload(string $localPath, S3StorageDriver $s3Driver, string $key): void + { + $localHash = md5_file($localPath); + + if ($localHash === false) { + throw new FileStorageException('Unable to hash local file: ' . $localPath); + } + + $remoteContents = $s3Driver->read($key); + $remoteHash = md5($remoteContents); + + if ($localHash !== $remoteHash) { + throw new FileStorageException('Verification failed: S3 content hash mismatch for ' . $key); + } + } +} diff --git a/app/Commands/StorageTest.php b/app/Commands/StorageTest.php new file mode 100644 index 0000000..7d28051 --- /dev/null +++ b/app/Commands/StorageTest.php @@ -0,0 +1,93 @@ + 'Run local/helper tests only (skip live S3 integration group).', + '--s3' => 'Run live S3 integration tests only (requires AWS env in .env).', + '--filter' => 'PHPUnit filter pattern, e.g. PolicyStorageHelper.', + ]; + + public function run(array $params) + { + $phpunit = ROOTPATH . 'vendor/bin/phpunit'; + $phpunitConfig = ROOTPATH . 'phpunit.xml.dist'; + $testsPath = ROOTPATH . 'tests/unit/Storage'; + + if (! is_file($phpunit)) { + CLI::error('PHPUnit not found. Run: composer install'); + + return EXIT_ERROR; + } + + if (! is_file($phpunitConfig)) { + CLI::error('PHPUnit config not found: ' . $phpunitConfig); + + return EXIT_ERROR; + } + + if (! is_dir($testsPath)) { + CLI::error('Storage tests directory not found: ' . $testsPath); + + return EXIT_ERROR; + } + + $previousDirectory = getcwd() ?: ROOTPATH; + chdir(ROOTPATH); + + $command = [ + PHP_BINARY, + $phpunit, + '-c', + $phpunitConfig, + $testsPath, + '--testdox', + '--colors=always', + '--no-coverage', + ]; + + if (CLI::getOption('local')) { + $command[] = '--exclude-group'; + $command[] = 's3-integration'; + } elseif (CLI::getOption('s3')) { + $command[] = '--group'; + $command[] = 's3-integration'; + } + + $filter = CLI::getOption('filter'); + + if (is_string($filter) && $filter !== '') { + $command[] = '--filter'; + $command[] = $filter; + } + + $display = implode(' ', array_map(static fn ($part) => escapeshellarg((string) $part), $command)); + CLI::write('Running storage tests...', 'green'); + CLI::write($display, 'dark_gray'); + + passthru($display, $exitCode); + + chdir($previousDirectory); + + if ($exitCode === 0) { + CLI::write('Storage tests passed.', 'green'); + } else { + CLI::error('Storage tests failed with exit code ' . $exitCode); + } + + return $exitCode; + } +} diff --git a/app/Commands/StorageUploadTest.php b/app/Commands/StorageUploadTest.php new file mode 100644 index 0000000..96de2e5 --- /dev/null +++ b/app/Commands/StorageUploadTest.php @@ -0,0 +1,300 @@ + 'Keep uploaded test files in storage (default: delete after success).', + '--pdf' => 'Use this PDF file path for policy_pdf upload instead of a generated sample.', + ]; + + /** @var list */ + private array $uploadedObjects = []; + + private string $tempDir = ''; + + public function run(array $params) + { + helper('storage_helper'); + + $config = config('Storage'); + $this->printEnvironment($config); + + $this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nhance_storage_upload_test_' . uniqid('', true); + if (! mkdir($this->tempDir, 0777, true) && ! is_dir($this->tempDir)) { + CLI::error('Unable to create temp directory: ' . $this->tempDir); + + return EXIT_ERROR; + } + + $keepFiles = (bool) CLI::getOption('keep'); + $failed = 0; + $passed = 0; + + CLI::newLine(); + CLI::write('Starting live storage upload scenarios...', 'yellow'); + CLI::newLine(); + + $scenarios = [ + 'policy_pdf' => fn () => $this->scenarioPolicyPdf(), + 'policy_payment_receipt' => fn () => $this->scenarioPolicyReceipt(), + 'policy_md' => fn () => $this->scenarioPolicyMarkdown(), + 'endorsement_original' => fn () => $this->scenarioEndorsementOriginal(), + 'endorsement_completion' => fn () => $this->scenarioEndorsementCompletion(), + ]; + + foreach ($scenarios as $label => $callback) { + try { + $callback(); + CLI::write('[PASS] ' . $label, 'green'); + $passed++; + } catch (\Throwable $e) { + CLI::error('[FAIL] ' . $label . ' -> ' . $e->getMessage()); + $failed++; + } + + CLI::newLine(); + } + + if (! $keepFiles && $failed === 0) { + $this->cleanupUploadedObjects(); + CLI::write('Cleaned up uploaded test files from storage.', 'dark_gray'); + } elseif ($keepFiles) { + CLI::write('Uploaded test files were kept (--keep).', 'yellow'); + foreach ($this->uploadedObjects as $object) { + CLI::write( + ' - ' . storage()->resolveKey($object['module'], $object['subFolder'], $object['fileName']), + 'white' + ); + } + } + + $this->cleanupTempDir(); + + CLI::newLine(); + CLI::write("Summary: {$passed} passed, {$failed} failed", $failed === 0 ? 'green' : 'red'); + + return $failed === 0 ? EXIT_SUCCESS : EXIT_ERROR; + } + + private function printEnvironment(Storage $config): void + { + CLI::write('Storage upload test', 'green'); + CLI::write('Driver: ' . ($config->driver === 's3' ? 's3' : 'local'), 'cyan'); + + if ($config->driver === 's3') { + CLI::write('Bucket: ' . ($config->s3Bucket ?: '(empty)'), 'cyan'); + CLI::write('Region: ' . $config->s3Region, 'cyan'); + CLI::write('Prefix: ' . ($config->s3Prefix !== '' ? $config->s3Prefix : '(none)'), 'cyan'); + } else { + CLI::write('Local root: ' . $config->localRoot, 'cyan'); + } + } + + private function scenarioPolicyPdf(): void + { + $customPdf = CLI::getOption('pdf'); + $source = is_string($customPdf) && $customPdf !== '' && is_file($customPdf) + ? $customPdf + : $this->createSamplePdf(); + + $fileName = time() . '_spark_test_policy.pdf'; + $content = file_get_contents($source); + + if ($content === false || $content === '') { + throw new FileStorageException('Sample PDF is empty or unreadable'); + } + + $this->uploadFile('policy', 'policy_pdf', $fileName, $source, $content); + $this->assertReadable('policy', 'policy_pdf', $fileName, $content); + $this->assertLocalProcessingPath('policy', 'policy_pdf', $fileName); + $this->assertTemporaryUrl('policy', 'policy_pdf', $fileName); + } + + private function scenarioPolicyReceipt(): void + { + $source = $this->createTempFile('sample_receipt.jpg', 'JPEG receipt test content for spark upload-test'); + $fileName = time() . '_spark_test_receipt.jpg'; + $content = file_get_contents($source); + + $this->uploadFile('policy', 'policy_payment_receipt', $fileName, $source, $content); + $this->assertReadable('policy', 'policy_payment_receipt', $fileName, $content); + $this->assertTemporaryUrl('policy', 'policy_payment_receipt', $fileName); + } + + private function scenarioPolicyMarkdown(): void + { + $fileName = time() . '_spark_test_policy.md'; + $content = "# Spark Storage Test\n\n" . str_repeat('Policy markdown body. ', 40); + + storage_put('policy', 'policy_md', $fileName, $content, ['content_type' => 'text/markdown']); + $this->trackUploaded('policy', 'policy_md', $fileName); + + if (! policy_md_exists($fileName)) { + throw new FileStorageException('policy_md file not found after upload'); + } + + if (policy_read_md($fileName) !== $content) { + throw new FileStorageException('policy_md content mismatch after read'); + } + } + + private function scenarioEndorsementOriginal(): void + { + $source = $this->createTempFile('endorsement_original.pdf', '%PDF-1.4 endorsement original test'); + $fileName = time() . '_spark_test_endorsement.pdf'; + $content = file_get_contents($source); + + $this->uploadFile('endorsement', '', $fileName, $source, $content); + $this->assertReadable('endorsement', '', $fileName, $content); + $this->assertTemporaryUrl('endorsement', '', $fileName); + } + + private function scenarioEndorsementCompletion(): void + { + $source = $this->createTempFile('endorsement_completion.pdf', '%PDF-1.4 endorsement completion test'); + $fileName = time() . '_spark_test_completion.pdf'; + $content = file_get_contents($source); + + $this->uploadFile('endorsement', 'endorsement_pdf', $fileName, $source, $content); + $this->assertReadable('endorsement', 'endorsement_pdf', $fileName, $content); + $this->assertTemporaryUrl('endorsement', 'endorsement_pdf', $fileName); + } + + /** + * @param string|false $content + */ + private function uploadFile( + string $module, + string $subFolder, + string $fileName, + string $sourcePath, + $content + ): void { + storage()->upload($sourcePath, storage()->resolveKey($module, $subFolder, $fileName)); + $this->trackUploaded($module, $subFolder, $fileName); + + if (! storage_exists($module, $subFolder, $fileName)) { + throw new FileStorageException('File not found immediately after upload'); + } + + if ($content !== false && storage_read($module, $subFolder, $fileName) !== $content) { + throw new FileStorageException('Uploaded file content mismatch on read-back'); + } + + CLI::write(' uploaded: ' . storage()->resolveKey($module, $subFolder, $fileName), 'white'); + } + + /** + * @param string|false $expectedContent + */ + private function assertReadable(string $module, string $subFolder, string $fileName, $expectedContent): void + { + if (! storage_exists($module, $subFolder, $fileName)) { + throw new FileStorageException('exists() returned false'); + } + + $readBack = storage_read($module, $subFolder, $fileName); + + if ($expectedContent !== false && $readBack !== $expectedContent) { + throw new FileStorageException('read() content mismatch'); + } + + CLI::write(' read-back: OK (' . strlen($readBack) . ' bytes)', 'white'); + } + + private function assertLocalProcessingPath(string $module, string $subFolder, string $fileName): void + { + $path = storage_local_path($module, $subFolder, $fileName); + + if (! is_file($path)) { + throw new FileStorageException('Local processing path not readable: ' . $path); + } + + CLI::write(' local path: ' . $path, 'white'); + } + + private function assertTemporaryUrl(string $module, string $subFolder, string $fileName): void + { + $url = storage_temporary_url($module, $subFolder, $fileName); + + if ($url === '') { + throw new FileStorageException('temporary URL is empty'); + } + + CLI::write(' url: ' . $url, 'white'); + } + + private function trackUploaded(string $module, string $subFolder, string $fileName): void + { + $this->uploadedObjects[] = [ + 'module' => $module, + 'subFolder' => $subFolder, + 'fileName' => $fileName, + ]; + } + + private function cleanupUploadedObjects(): void + { + foreach ($this->uploadedObjects as $object) { + try { + storage_delete($object['module'], $object['subFolder'], $object['fileName']); + } catch (\Throwable $e) { + CLI::write( + ' cleanup warning: ' . $object['fileName'] . ' -> ' . $e->getMessage(), + 'yellow' + ); + } + } + } + + private function cleanupTempDir(): void + { + if ($this->tempDir === '' || ! is_dir($this->tempDir)) { + return; + } + + foreach (scandir($this->tempDir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + @unlink($this->tempDir . DIRECTORY_SEPARATOR . $item); + } + + @rmdir($this->tempDir); + } + + private function createSamplePdf(): string + { + return $this->createTempFile( + 'sample_policy.pdf', + "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n" + ); + } + + private function createTempFile(string $name, string $contents): string + { + $path = $this->tempDir . DIRECTORY_SEPARATOR . $name; + + if (file_put_contents($path, $contents) === false) { + throw new FileStorageException('Failed to create temp file: ' . $path); + } + + return $path; + } +} diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index b09d2c4..fd7be56 100755 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -90,5 +90,5 @@ class Autoload extends AutoloadConfig * * @var list */ - public $helpers = ['JwtHelper','common_helper','mail_helper','url_helper','status_helper','sms_helper']; + public $helpers = ['JwtHelper','common_helper','mail_helper','url_helper','status_helper','sms_helper','storage_helper']; } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 5a6df97..921d363 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -269,7 +269,13 @@ $routes->cli('cli/processjob', 'JobWorker::processJob'); $routes->cli('cli/processjobs', 'JobWorker::processJobs'); $routes->get("processjob", "JobWorker::processJob"); - +$routes->group('storage/browser', static function ($routes) { + $routes->get('/', 'StorageBrowserController::index'); + $routes->get('folders', 'StorageBrowserController::folders'); + $routes->get('files', 'StorageBrowserController::files'); + $routes->get('open', 'StorageBrowserController::open'); + $routes->get('download', 'StorageBrowserController::download'); +}); $routes->get('checkPolicyDoc', 'PolicyController::readFile'); diff --git a/app/Config/Services.php b/app/Config/Services.php index df7c8ad..314ed3d 100755 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -19,14 +19,21 @@ use CodeIgniter\Config\BaseService; */ class Services extends BaseService { - /* - * public static function example($getShared = true) - * { - * if ($getShared) { - * return static::getSharedInstance('example'); - * } - * - * return new \CodeIgniter\Example(); - * } - */ + public static function fileStorage($getShared = true) + { + if ($getShared) { + return static::getSharedInstance('fileStorage'); + } + + return new \App\Services\FileStorageService(config('Storage')); + } + + public static function storageBrowser($getShared = true) + { + if ($getShared) { + return static::getSharedInstance('storageBrowser'); + } + + return new \App\Services\StorageBrowserService(config('Storage')); + } } diff --git a/app/Config/Storage.php b/app/Config/Storage.php new file mode 100644 index 0000000..ce0dfba --- /dev/null +++ b/app/Config/Storage.php @@ -0,0 +1,52 @@ +driver = in_array($driver, ['local', 's3'], true) ? $driver : 'local'; + + $this->retainLocal = filter_var(getenv('RETAIN_LOCAL') ?: 'false', FILTER_VALIDATE_BOOLEAN); + $this->localRoot = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR; + + $this->s3Bucket = getenv('AWS_BUCKET') ?: ''; + $this->s3Region = getenv('AWS_DEFAULT_REGION') ?: 'ap-south-1'; + $this->s3Prefix = trim(getenv('AWS_S3_PREFIX') ?: '', '/'); + $this->s3AccessKey = getenv('AWS_ACCESS_KEY_ID') ?: ''; + $this->s3SecretKey = getenv('AWS_SECRET_ACCESS_KEY') ?: ''; + + $ttl = getenv('AWS_PRESIGNED_TTL'); + if ($ttl !== false && $ttl !== '' && is_numeric($ttl)) { + $this->presignedTtl = max(60, (int) $ttl); + } + } +} diff --git a/app/Controllers/EndorsementController.php b/app/Controllers/EndorsementController.php index 74ffb3f..2416522 100644 --- a/app/Controllers/EndorsementController.php +++ b/app/Controllers/EndorsementController.php @@ -192,19 +192,7 @@ class EndorsementController extends ResourceController } //FILE UPLOAD - $uploadedFileName = null; - $uploadFile = $this->request->getFile('endorsement_file_name'); - - if ($uploadFile && $uploadFile->isValid()) { - - $uploadPath = WRITEPATH . 'uploads/endorsement/'; - if (!is_dir($uploadPath)) { - mkdir($uploadPath, 0777, true); - } - - $uploadedFileName = time() . '_' . $uploadFile->getRandomName(); - $uploadFile->move($uploadPath, $uploadedFileName); - } + $uploadedFileName = endorsement_upload_original($this->request->getFile('endorsement_file_name')); // PREPARE DATA if ($reqData['policy_from'] === 'Internal') { @@ -288,33 +276,12 @@ class EndorsementController extends ResourceController if (!$endorsement) { return $this->respond([ 'status' => 'failed', 'code' => 404,'data' => 'Endorsement not found' ], 404); } // FILE UPLOAD - $uploadedOriginalCompletionFile = $endorsement['endorsement_file_name']; - - $uploadOriginalFile = $this->request->getFile('endorsement_file_name'); - - if ($uploadOriginalFile && $uploadOriginalFile->isValid()) { - - $uploadOriginalPath = WRITEPATH . 'uploads/endorsement/'; - if (!is_dir($uploadOriginalPath)) { - mkdir($uploadOriginalPath, 0777, true); - } - - $uploadedOriginalCompletionFile = time() . '_' . $uploadOriginalFile->getRandomName(); - $uploadOriginalFile->move($uploadOriginalPath, $uploadedOriginalCompletionFile); // ✅ fixed variable - } + $uploadedOriginalCompletionFile = endorsement_upload_original($this->request->getFile('endorsement_file_name')) + ?? $endorsement['endorsement_file_name']; // FILE REVISED UPLOAD - $uploadedRevisedCompletionFile = $endorsement['endorsement_completion_file']; - $uploadRevisedFile = $this->request->getFile('endorsement_completion_file'); - - if ($uploadRevisedFile && $uploadRevisedFile->isValid()) { - $uploadRevisedPath = WRITEPATH . 'uploads/endorsement/endorsement_pdf/'; - if (!is_dir($uploadRevisedPath)) { - mkdir($uploadRevisedPath, 0777, true); - } - $uploadedRevisedCompletionFile = time() . '_' . $uploadRevisedFile->getRandomName(); - $uploadRevisedFile->move($uploadRevisedPath, $uploadedRevisedCompletionFile); - } + $uploadedRevisedCompletionFile = endorsement_upload_completion($this->request->getFile('endorsement_completion_file')) + ?? $endorsement['endorsement_completion_file']; /* * COMMON UPDATE FIELDS @@ -671,14 +638,11 @@ class EndorsementController extends ResourceController return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'No file uploaded yet'], 200); } - $filePath = WRITEPATH . 'uploads/endorsement/' . $subPath . $fileName; - - if (!file_exists($filePath)) { + if (!endorsement_exists_by_type($fileName, $type ?: 'original')) { return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'File missing on server'], 200); } - // Force file download - return $this->response->download($filePath, null); + return endorsement_download_by_type($fileName, $type ?: 'original'); } catch (\Exception $e) { return $this->respond(['status' => 'failed', 'code' => 500, 'message' => $e->getMessage()], 500); @@ -747,30 +711,20 @@ class EndorsementController extends ResourceController $originalFile = $fileRecord['endorsement_file_name'] ?? null; if (!empty($completionFile)) { - // ✅ Use completion file path $fileName = $completionFile; - $filePath = WRITEPATH . 'uploads/endorsement/endorsement_pdf/' . $fileName; + $fileType = 'completion'; } elseif (!empty($originalFile)) { - // ✅ Fallback to original file path $fileName = $originalFile; - $filePath = WRITEPATH . 'uploads/endorsement/' . $fileName; + $fileType = 'original'; } else { - // ❌ Neither file exists in DB return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'No file uploaded yet'], 200); } - // ✅ STEP 2: Check file exists on disk - if (!file_exists($filePath)) { + if (!endorsement_exists_by_type($fileName, $fileType)) { return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'File missing on server'], 200); } - // ✅ STEP 3: Stream file to browser / Flutter - $mime = mime_content_type($filePath); - - return $this->response - ->setHeader('Content-Type', $mime) - ->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"') - ->setBody(file_get_contents($filePath)); + return endorsement_download_by_type($fileName, $fileType, true); } catch (\Exception $e) { return $this->respond(['status' => 'failed', 'code' => 500, 'message' => $e->getMessage()], 500); @@ -787,9 +741,7 @@ class EndorsementController extends ResourceController return $this->respond(['status' => 'failed', 'code' => 400, 'data' => 'Missing required fields'], 400); } - $uploadPath = WRITEPATH . 'uploads/endorsement/'; $allowedTypes = ['application/pdf']; - $pdfPath = $uploadPath . 'endorsement_pdf/'; $endorsementPdf = $this->request->getFile('endorsement_completion_file'); @@ -807,17 +759,9 @@ class EndorsementController extends ResourceController $existingRecord = $this->EndorsementModel->find($data['id']); $oldFileName = $existingRecord['endorsement_completion_file'] ?? null; - // ✅ Ensure upload directory exists - if (!is_dir($pdfPath)) { - mkdir($pdfPath, 0777, true); - } + $pdfFileName = endorsement_upload_completion($endorsementPdf); - // ✅ Upload new file - $pdfFileName = time() . '_' . $endorsementPdf->getRandomName(); - $endorsementPdf->move($pdfPath, $pdfFileName); - - // ✅ Guard: ensure file was actually saved on disk - if (!$pdfFileName || !file_exists($pdfPath . $pdfFileName)) { + if (!$pdfFileName || !endorsement_exists_by_type($pdfFileName, 'completion')) { return $this->respond(['status' => 'failed', 'code' => 400, 'data' => 'File upload failed'], 400); } @@ -831,22 +775,17 @@ class EndorsementController extends ResourceController // ✅ DB update failed → delete the newly uploaded file to avoid orphan files if (!$updated) { - if (file_exists($pdfPath . $pdfFileName)) { - unlink($pdfPath . $pdfFileName); - } + endorsement_delete_completion($pdfFileName); log_message('error', 'DB update failed for endorsement ID: ' . $data['id'] . '. New file removed.'); return $this->respond(['status' => 'failed', 'code' => 500, 'data' => 'DB update failed, file not saved'], 500); } // ✅ DB success → NOW safe to delete old file if ($oldFileName) { - $oldFilePath = $pdfPath . $oldFileName; - if (file_exists($oldFilePath)) { - if (!unlink($oldFilePath)) { - log_message('warning', 'DB updated but failed to delete old file: ' . $oldFilePath); - } else { - log_message('info', 'Old file deleted after successful DB update: ' . $oldFilePath); - } + if (!endorsement_delete_completion($oldFileName)) { + log_message('warning', 'DB updated but failed to delete old file: ' . $oldFileName); + } else { + log_message('info', 'Old file deleted after successful DB update: ' . $oldFileName); } } diff --git a/app/Controllers/PolicyController.php b/app/Controllers/PolicyController.php index c5177c6..dc8d69e 100644 --- a/app/Controllers/PolicyController.php +++ b/app/Controllers/PolicyController.php @@ -125,33 +125,9 @@ class PolicyController extends ResourceController { try { $data = $this->request->getPost(); - $uploadPath = WRITEPATH . 'uploads/policy/'; - $policyPdf = $this->request->getFile('policy_pdf_file_name'); - $policyReceipt = $this->request->getFile('policy_payment_receipt_file_name'); - - $pdfFileName = null; - $receiptFileName = null; - - // PDF Upload - if ($policyPdf && $policyPdf->isValid()) { - $pdfPath = $uploadPath . 'policy_pdf/'; - if (!is_dir($pdfPath)) { - mkdir($pdfPath, 0777, true); - } - $pdfFileName = time() . '_' . $policyPdf->getRandomName(); - $policyPdf->move($pdfPath, $pdfFileName); - } - - // Receipt Upload - if ($policyReceipt && $policyReceipt->isValid()) { - $receiptPath = $uploadPath . 'policy_payment_receipt/'; - if (!is_dir($receiptPath)) { - mkdir($receiptPath, 0777, true); - } - $receiptFileName = time() . '_' . $policyReceipt->getRandomName(); - $policyReceipt->move($receiptPath, $receiptFileName); - } + $pdfFileName = policy_upload_pdf($this->request->getFile('policy_pdf_file_name')); + $receiptFileName = policy_upload_receipt($this->request->getFile('policy_payment_receipt_file_name')); //fetch enquiry_id & agent_id $quotData = $this->QuotationModel->select('partner_quotation.*,E.agent_id') @@ -359,30 +335,14 @@ class PolicyController extends ResourceController } - $uploadPath = WRITEPATH . 'uploads/policy/'; - - // PDF Update - $policyPdf = $this->request->getFile('policy_pdf_file_name'); - if ($policyPdf && $policyPdf->isValid()) { - $pdfPath = $uploadPath . 'policy_pdf/'; - if (!is_dir($pdfPath)) { - mkdir($pdfPath, 0777, true); - } - $pdfFileName = time() . '_' . $policyPdf->getRandomName(); - $policyPdf->move($pdfPath, $pdfFileName); - $updateData['policy_pdf_file_name'] = $pdfFileName; + $policyPdf = policy_upload_pdf($this->request->getFile('policy_pdf_file_name')); + if ($policyPdf !== null) { + $updateData['policy_pdf_file_name'] = $policyPdf; } - // Receipt Update - $policyReceipt = $this->request->getFile('policy_payment_receipt_file_name'); - if ($policyReceipt && $policyReceipt->isValid()) { - $receiptPath = $uploadPath . 'policy_payment_receipt/'; - if (!is_dir($receiptPath)) { - mkdir($receiptPath, 0777, true); - } - $receiptFileName = time() . '_' . $policyReceipt->getRandomName(); - $policyReceipt->move($receiptPath, $receiptFileName); - $updateData['policy_payment_receipt_file_name'] = $receiptFileName; + $policyReceipt = policy_upload_receipt($this->request->getFile('policy_payment_receipt_file_name')); + if ($policyReceipt !== null) { + $updateData['policy_payment_receipt_file_name'] = $policyReceipt; } $this->PolicyModel->update($id, $updateData); @@ -494,21 +454,8 @@ class PolicyController extends ResourceController try { $data = $this->request->getPost(); - $uploadPath = WRITEPATH . 'uploads/policy/'; - $policyPdf = $this->request->getFile('policy_pdf_file_name'); - - $pdfFileName = null; - - // PDF Upload - if ($policyPdf && $policyPdf->isValid()) { - $pdfPath = $uploadPath . 'policy_pdf/'; - if (!is_dir($pdfPath)) { - mkdir($pdfPath, 0777, true); - } - $pdfFileName = time() . '_' . $policyPdf->getRandomName(); - $policyPdf->move($pdfPath, $pdfFileName); - } + $pdfFileName = policy_upload_pdf($this->request->getFile('policy_pdf_file_name')); //fetch enquiry_id & agent_id $quotData = $this->QuotationModel->select('partner_quotation.*,E.agent_id,E.name , VT.vehicle_type') @@ -606,17 +553,13 @@ class PolicyController extends ResourceController } // Map file_type to DB column and folder - $fileMap = [ - 'policy_pdf' => ['column' => 'policy_pdf_file_name', 'folder' => 'policy_pdf'], - 'policy_payment_receipt'=> ['column' => 'policy_payment_receipt_file_name', 'folder' => 'policy_payment_receipt'], - ]; + $fileMap = policy_file_type_map(); if (!array_key_exists($fileType, $fileMap)) { return $this->respond(['status' => 'failed','code' => 400,'data' => 'Invalid file_type'], 200); } $fileColumn = $fileMap[$fileType]['column']; - $folder = $fileMap[$fileType]['folder']; // Fetch record from DB $fileRecord = $this->PolicyModel->where('is_active', 1)->find((int)$policyId); @@ -625,14 +568,11 @@ class PolicyController extends ResourceController return $this->respond([ 'status' => 'failed','code' => 404,'data' => 'File not found in database'], 200); } - $filePath = WRITEPATH . "uploads/policy/{$folder}/" . $fileRecord[$fileColumn]; - - if (!file_exists($filePath)) { + if (!policy_exists_by_type($fileRecord[$fileColumn], $fileType)) { return $this->respond(['status' => 'failed','code' => 404,'data' => 'File missing on server'], 200); } - // Force file download - return $this->response->download($filePath, null); + return policy_download_by_type($fileRecord[$fileColumn], $fileType); } catch (\Exception $e) { return $this->respond(['status' => 'failed','code' => 500,'message' => $e->getMessage() ], 500); @@ -650,17 +590,13 @@ class PolicyController extends ResourceController } // Map file_type to DB column and folder - $fileMap = [ - 'policy_pdf' => ['column' => 'policy_pdf_file_name', 'folder' => 'policy_pdf'], - 'policy_payment_receipt'=> ['column' => 'policy_payment_receipt_file_name', 'folder' => 'policy_payment_receipt'], - ]; + $fileMap = policy_file_type_map(); if (!array_key_exists($fileType, $fileMap)) { return $this->respond(['status' => 'failed','code' => 400,'data' => 'Invalid file_type'], 200); } $fileColumn = $fileMap[$fileType]['column']; - $folder = $fileMap[$fileType]['folder']; // Fetch record from DB $fileRecord = $this->PolicyModel->where('is_active', 1)->find((int)$policyId); @@ -669,24 +605,11 @@ class PolicyController extends ResourceController return $this->respond([ 'status' => 'failed','code' => 404,'data' => 'File not found in database'], 200); } - $filePath = WRITEPATH . "uploads/policy/{$folder}/" . $fileRecord[$fileColumn]; - $publicUrl = base_url("uploads/policy/{$folder}/" . $fileRecord[$fileColumn]); - - if (!file_exists($filePath)) { + if (!policy_exists_by_type($fileRecord[$fileColumn], $fileType)) { return $this->respond(['status' => 'failed','code' => 404,'data' => 'File missing on server'], 200); } - - // return $this->respond(['status' => 'success', 'code' => 200, 'data' => $publicUrl ], 200); - - // Detect the file mime type - $mime = mime_content_type($filePath); - - // Stream file to browser / Flutter - return $this->response - ->setHeader('Content-Type', $mime) - ->setHeader('Content-Disposition', 'inline; filename="' . $fileRecord[$fileColumn] . '"') - ->setBody(file_get_contents($filePath)); + return policy_download_by_type($fileRecord[$fileColumn], $fileType, true); } catch (\Exception $e) { @@ -828,22 +751,22 @@ class PolicyController extends ResourceController $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $record = $this->PolicyModel->find((int)$policyId); - $uploadedPath = WRITEPATH . 'uploads/policy/policy_pdf/'; - $mdUploadedPath = WRITEPATH . 'uploads/policy/policy_md/'; - $pdfFilePath = $uploadedPath . $record['policy_pdf_file_name']; - $mdFileName = preg_replace('/\.pdf$/i', '.md', $record['policy_pdf_file_name']); - $mdFilePath = $mdUploadedPath . $mdFileName; + $pdfFileName = $record['policy_pdf_file_name'] ?? ''; + $mdFileName = policy_md_file_name($pdfFileName); - if (!file_exists($pdfFilePath)) { - log_message('info',"Error: File not found at {$pdfFilePath}"); + if ($pdfFileName === '' || !policy_exists_by_type($pdfFileName, 'policy_pdf')) { + $missingPath = policy_local_pdf_path($pdfFileName); + log_message('info',"Error: File not found at {$missingPath}"); if($return == true) { - return $this->respond(['status'=>"failed", 'message'=> "File not found at {$pdfFilePath}"], 200); + return $this->respond(['status'=>"failed", 'message'=> "File not found at {$missingPath}"], 200); }else{ - return ['status'=>"failed", 'message'=> "File not found at {$pdfFilePath}"]; + return ['status'=>"failed", 'message'=> "File not found at {$missingPath}"]; } } + $pdfFilePath = policy_local_pdf_path($pdfFileName); + $markdownResult = convert_policy_pdf_to_markdown($pdfFilePath); if ($markdownResult['status'] !== 'success') { log_message('info', 'Policy PDF to markdown conversion failed: ' . $markdownResult['message']); @@ -854,27 +777,18 @@ class PolicyController extends ResourceController return ['status' => 'failed', 'message' => $markdownResult['message']]; } - if (!file_exists($mdFilePath)) { - log_message('info',"Error: Markdown file not found at {$mdFilePath}"); + if (!policy_md_exists($mdFileName)) { + log_message('info',"Error: Markdown file not found for {$mdFileName}"); if($return == true) { - return $this->respond(['status'=>"failed", 'message'=> "Markdown file not found at {$mdFilePath}"], 200); + return $this->respond(['status'=>"failed", 'message'=> "Markdown file not found for {$mdFileName}"], 200); }else{ - return ['status'=>"failed", 'message'=> "Markdown file not found at {$mdFilePath}"]; + return ['status'=>"failed", 'message'=> "Markdown file not found for {$mdFileName}"]; } } - $filePath = $mdFilePath; - - // Get the file's MIME type using the finfo extension - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mimeType = finfo_file($finfo, $filePath); - finfo_close($finfo); - - // Gemini inline supports text/plain reliably for markdown files - if (!in_array($mimeType, ['text/plain', 'text/markdown', 'text/x-markdown'], true)) { - $mimeType = 'text/plain'; - } + $fileContent = $markdownResult['content'] ?? policy_read_md($mdFileName); + $mimeType = 'text/markdown'; // Define supported inline MIME types $supportedInlineMimeTypes = ['application/pdf', 'text/csv', 'text/plain', 'text/markdown', 'text/x-markdown']; @@ -891,16 +805,13 @@ class PolicyController extends ResourceController // Conditionally handle the file upload based on MIME type if (in_array($mimeType, $supportedInlineMimeTypes)) { - // Read saved markdown file from policy_md directory - $fileContent = file_get_contents($filePath); - - if ($fileContent === false || trim($fileContent) === '') { - log_message('info', "Markdown file is empty at {$filePath}"); + if ($fileContent === '' || trim($fileContent) === '') { + log_message('info', "Markdown file is empty for {$mdFileName}"); if ($return == true) { - return $this->respond(['status' => 'failed', 'message' => "Markdown file is empty at {$filePath}"], 200); + return $this->respond(['status' => 'failed', 'message' => "Markdown file is empty for {$mdFileName}"], 200); } - return ['status' => 'failed', 'message' => "Markdown file is empty at {$filePath}"]; + return ['status' => 'failed', 'message' => "Markdown file is empty for {$mdFileName}"]; } $base64Content = base64_encode($fileContent); diff --git a/app/Controllers/PolicyRagController.php b/app/Controllers/PolicyRagController.php index 304e551..54343de 100644 --- a/app/Controllers/PolicyRagController.php +++ b/app/Controllers/PolicyRagController.php @@ -167,14 +167,16 @@ class PolicyRagController extends ResourceController 'pdf_file' => $record['policy_pdf_file_name'] ?? null, ]); - $uploadedPath = WRITEPATH . 'uploads/policy/policy_pdf/'; - $pdfFilePath = $uploadedPath . $record['policy_pdf_file_name']; + $pdfFileName = $record['policy_pdf_file_name'] ?? ''; - if (!file_exists($pdfFilePath)) { + if ($pdfFileName === '' || !policy_exists_by_type($pdfFileName, 'policy_pdf')) { + $pdfFilePath = $pdfFileName !== '' ? policy_local_pdf_path($pdfFileName) : ''; $this->logRagStep('PDF_CHECK', 'failed', 'PDF file not found', ['path' => $pdfFilePath]); return $this->formatReadResponse('failed', "File not found at {$pdfFilePath}"); } + $pdfFilePath = policy_local_pdf_path($pdfFileName); + $this->logRagStep('PDF_CHECK', 'success', 'PDF file found', ['path' => $pdfFilePath]); $this->logRagStep('UPLOAD', 'started', 'Uploading PDF to RAG API', ['path' => $pdfFilePath]); diff --git a/app/Controllers/QuotationController.php b/app/Controllers/QuotationController.php index 2e04a79..9804958 100644 --- a/app/Controllers/QuotationController.php +++ b/app/Controllers/QuotationController.php @@ -290,20 +290,8 @@ class QuotationController extends ResourceController //create policy file - $uploadPath = WRITEPATH . 'uploads/policy/'; - $policyPdf = $this->request->getFile('policy_pdf_file_name'); - $pdfFileName = null; - // PDF Upload - if ($policyPdf && $policyPdf->isValid()) { - $pdfPath = $uploadPath . 'policy_pdf/'; - if (!is_dir($pdfPath)) { - mkdir($pdfPath, 0777, true); - } - $pdfFileName = time() . '_' . $policyPdf->getRandomName(); - $policyPdf->move($pdfPath, $pdfFileName); - - - //fetch enquiry_id & agent_id + $pdfFileName = policy_upload_pdf($this->request->getFile('policy_pdf_file_name')); + if ($pdfFileName !== null) { $quotData = $this->QuotationModel->select('partner_quotation.*,E.agent_id,E.name') ->join('partner_enquiry E', 'E.id = partner_quotation.enquiry_id', 'left') ->where('partner_quotation.id',$quotationId) @@ -351,23 +339,18 @@ class QuotationController extends ResourceController /* ------------------------------------------------------ * Update Policy (policy_id + file uploaded) * ------------------------------------------------------ */ - if (!empty($policyId) && $policyPdf && $policyPdf->isValid()) { + if (!empty($policyId)) { + $pdfFileName = policy_upload_pdf($policyPdf); - $uploadPath = WRITEPATH . 'uploads/policy/policy_pdf/'; - if (!is_dir($uploadPath)) { - mkdir($uploadPath, 0777, true); + if ($pdfFileName !== null) { + $updateData = [ + 'policy_pdf_file_name' => $pdfFileName, + 'updated_by' => $data['created_by'], + ]; + + $this->PolicyModel->update($policyId, $updateData); } - $pdfFileName = time() . '_' . $policyPdf->getRandomName(); - $policyPdf->move($uploadPath, $pdfFileName); - - $updateData = [ - 'policy_pdf_file_name' => $pdfFileName, - 'updated_by' => $data['created_by'], - ]; - - $this->PolicyModel->update($policyId, $updateData); - } @@ -429,54 +412,48 @@ class QuotationController extends ResourceController /* ------------------------------------------------------ * If file uploaded → Create Policy * ------------------------------------------------------ */ - if (empty($policyId) && $policyPdf && $policyPdf->isValid()) { + if (empty($policyId) && $policyPdf && $policyPdf->isValid()) { + $pdfFileName = policy_upload_pdf($policyPdf); - $uploadDir = WRITEPATH . 'uploads/policy/policy_pdf/'; - if (!is_dir($uploadDir)) { - mkdir($uploadDir, 0777, true); + if ($pdfFileName !== null) { + $quot = $this->QuotationModel + ->select('partner_quotation.*,E.agent_id,E.name') + ->join('partner_enquiry E', 'E.id = partner_quotation.enquiry_id') + ->where('partner_quotation.id', $newQuotationId ?? $quotationId) + ->first(); + + $policyInsert = [ + 'enquiry_id' => $quot['enquiry_id'], + 'quotation_id' => $newQuotationId ?? $quotationId, + 'insured_name' => $quot['name'], + 'manager_id' => $quot['manager_id'], + 'agent_id' => $quot['agent_id'], + 'policy_pdf_file_name' => $pdfFileName, + 'created_by' => $data['created_by'] ?? 0, + ]; + + $newPolicyId = $this->PolicyModel->insert($policyInsert); + + // Update enquiry status → Policy Created + $this->EnquiryModel->update($quot['enquiry_id'], ['status' => 'Policy Created']); + + Jobs::addJob(['job_name' => 'readFileAndCalculateCommission','payload' => ['policy_id' => $newPolicyId]]); + log_message("info",'readFileAndCalculateCommission job pushed'); + + // Call helper to create BDS record after creating client,clientPolicy,vehicle records + // $policyData = $this->PolicyModel->select('partner_policy.*,Q.insurer_id,Q.insurer_branch_id,E.name as client_name,E.mobile as client_mobile,E.email as client_email,E.reg_no,E.vehicle_type_id,A.id as agent_id,A.agent_code') + // ->join('partner_quotation Q', 'Q.id = partner_policy.quotation_id AND Q.status = "Accepted"', 'left') + // ->join('partner_enquiry E', 'E.id = partner_policy.enquiry_id', 'left') + // ->join('partner_agent A', 'A.id = partner_policy.agent_id', 'left') + // ->where('partner_policy.id',$newPolicyId) + // ->first(); + // $bdsLogs = createBDS($policyData, $newPolicyId); + // if (!empty($bdsLogs)) { + // foreach ($bdsLogs as $msg) { + // log_message('info', '[BDS Entry] ' . $msg); + // } + // } } - - $pdfFileName = time() . '_' . $policyPdf->getRandomName(); - $policyPdf->move($uploadDir, $pdfFileName); - - // fetch enquiry + agent - $quot = $this->QuotationModel - ->select('partner_quotation.*,E.agent_id,E.name') - ->join('partner_enquiry E', 'E.id = partner_quotation.enquiry_id') - ->where('partner_quotation.id', $newQuotationId ?? $quotationId) - ->first(); - - $policyInsert = [ - 'enquiry_id' => $quot['enquiry_id'], - 'quotation_id' => $newQuotationId ?? $quotationId, - 'insured_name' => $quot['name'], - 'manager_id' => $quot['manager_id'], - 'agent_id' => $quot['agent_id'], - 'policy_pdf_file_name' => $pdfFileName, - 'created_by' => $data['created_by'] ?? 0, - ]; - - $newPolicyId = $this->PolicyModel->insert($policyInsert); - - // Update enquiry status → Policy Created - $this->EnquiryModel->update($quot['enquiry_id'], ['status' => 'Policy Created']); - - Jobs::addJob(['job_name' => 'readFileAndCalculateCommission','payload' => ['policy_id' => $newPolicyId]]); - log_message("info",'readFileAndCalculateCommission job pushed'); - - // Call helper to create BDS record after creating client,clientPolicy,vehicle records - // $policyData = $this->PolicyModel->select('partner_policy.*,Q.insurer_id,Q.insurer_branch_id,E.name as client_name,E.mobile as client_mobile,E.email as client_email,E.reg_no,E.vehicle_type_id,A.id as agent_id,A.agent_code') - // ->join('partner_quotation Q', 'Q.id = partner_policy.quotation_id AND Q.status = "Accepted"', 'left') - // ->join('partner_enquiry E', 'E.id = partner_policy.enquiry_id', 'left') - // ->join('partner_agent A', 'A.id = partner_policy.agent_id', 'left') - // ->where('partner_policy.id',$newPolicyId) - // ->first(); - // $bdsLogs = createBDS($policyData, $newPolicyId); - // if (!empty($bdsLogs)) { - // foreach ($bdsLogs as $msg) { - // log_message('info', '[BDS Entry] ' . $msg); - // } - // } } diff --git a/app/Controllers/StorageBrowserController.php b/app/Controllers/StorageBrowserController.php new file mode 100644 index 0000000..98d019b --- /dev/null +++ b/app/Controllers/StorageBrowserController.php @@ -0,0 +1,109 @@ +browser = new StorageBrowserService(); + } + + public function index(): string + { + return view('storage/file_browser', [ + 'driver' => $this->browser->driverLabel(), + 'bucket' => $this->browser->bucketLabel(), + ]); + } + + public function folders(): ResponseInterface + { + return $this->respond([ + 'status' => 'success', + 'driver' => $this->browser->driverLabel(), + 'bucket' => $this->browser->bucketLabel(), + 'folders' => $this->browser->folders(), + ]); + } + + public function files(): ResponseInterface + { + try { + $folder = (string) ($this->request->getGet('folder') ?? ''); + $search = $this->request->getGet('q'); + $token = $this->request->getGet('page_token'); + $limit = max(1, min(50, (int) ($this->request->getGet('limit') ?? 10))); + + if ($folder === '') { + return $this->respond([ + 'status' => 'success', + 'driver' => $this->browser->driverLabel(), + 'bucket' => $this->browser->bucketLabel(), + 'folder' => '', + 'files' => [], + 'has_more' => false, + 'next_token' => null, + ]); + } + + $result = $this->browser->listFiles( + $folder, + $limit, + is_string($token) && $token !== '' ? $token : null, + is_string($search) ? $search : null + ); + + return $this->respond([ + 'status' => 'success', + 'driver' => $this->browser->driverLabel(), + 'bucket' => $this->browser->bucketLabel(), + 'folder' => $folder, + 'files' => $result['files'], + 'has_more' => $result['has_more'], + 'next_token' => $result['next_token'], + ]); + } catch (FileStorageException $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => $e->getMessage(), + ], 400); + } + } + + public function open() + { + try { + $key = (string) ($this->request->getGet('key') ?? ''); + + return redirect()->to($this->browser->getOpenUrl($key)); + } catch (FileStorageException $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => $e->getMessage(), + ], 404); + } + } + + public function download() + { + try { + $key = (string) ($this->request->getGet('key') ?? ''); + + return $this->browser->downloadResponse($key); + } catch (FileStorageException $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => $e->getMessage(), + ], 404); + } + } +} diff --git a/app/Helpers/policy_pdf_helper.php b/app/Helpers/policy_pdf_helper.php index d52b90c..7e80a96 100644 --- a/app/Helpers/policy_pdf_helper.php +++ b/app/Helpers/policy_pdf_helper.php @@ -133,25 +133,22 @@ if (!function_exists('convert_policy_pdf_to_markdown')) { ]; } - if (!$forceRefresh && is_file($mdPath) && filemtime($mdPath) >= filemtime($pdfPath)) { - $cached = file_get_contents($mdPath); + if (!$forceRefresh && policy_md_exists($mdFileName)) { + $cached = policy_read_md($mdFileName); - if ($cached !== false && policy_md_is_valid($cached)) { - log_message('info', "Using cached policy markdown: {$mdPath}"); + if ($cached !== '' && policy_md_is_valid($cached)) { + log_message('info', "Using cached policy markdown: {$mdFileName}"); return [ 'status' => 'success', - 'message' => 'Cached markdown loaded from disk', + 'message' => 'Cached markdown loaded from storage', 'content' => $cached, 'md_path' => $mdPath, 'md_file_name' => $mdFileName, ]; } - if ($cached !== false) { - log_message('info', "Invalid cached markdown detected, reconverting: {$mdPath}"); - @unlink($mdPath); - } + log_message('info', "Invalid cached markdown detected, reconverting: {$mdFileName}"); } $extractedText = policy_pdf_extract_text($pdfPath); @@ -172,6 +169,8 @@ if (!function_exists('convert_policy_pdf_to_markdown')) { ]; } + policy_put_md($mdFileName, $markdown); + $savedContent = file_get_contents($mdPath); if ($savedContent === false || !policy_md_is_valid($savedContent)) { diff --git a/app/Helpers/storage_helper.php b/app/Helpers/storage_helper.php new file mode 100644 index 0000000..4769b78 --- /dev/null +++ b/app/Helpers/storage_helper.php @@ -0,0 +1,224 @@ +isValid() || $file->hasMoved()) { + return null; + } + + return storage()->uploadModuleFile($file, $module, $subFolder); + } +} + +if (!function_exists('storage_exists')) { + function storage_exists(string $module, string $subFolder, string $fileName): bool + { + return $fileName !== '' && storage()->exists($module, $subFolder, $fileName); + } +} + +if (!function_exists('storage_download')) { + function storage_download(string $module, string $subFolder, string $fileName): ResponseInterface + { + return storage()->download($module, $subFolder, $fileName); + } +} + +if (!function_exists('storage_inline')) { + function storage_inline(string $module, string $subFolder, string $fileName): ResponseInterface + { + return storage()->download($module, $subFolder, $fileName) + ->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"'); + } +} + +if (!function_exists('storage_temporary_url')) { + function storage_temporary_url(string $module, string $subFolder, string $fileName): string + { + return storage()->getTemporaryUrl($module, $subFolder, $fileName); + } +} + +if (!function_exists('storage_local_path')) { + function storage_local_path(string $module, string $subFolder, string $fileName): string + { + return storage()->getLocalPathForProcessing($module, $subFolder, $fileName); + } +} + +if (!function_exists('storage_delete')) { + function storage_delete(string $module, string $subFolder, string $fileName): bool + { + return storage()->delete($module, $subFolder, $fileName); + } +} + +if (!function_exists('storage_put')) { + function storage_put(string $module, string $subFolder, string $fileName, string $contents, array $options = []): string + { + return storage()->put(storage()->resolveKey($module, $subFolder, $fileName), $contents, $options); + } +} + +if (!function_exists('storage_read')) { + function storage_read(string $module, string $subFolder, string $fileName): string + { + return storage()->read($module, $subFolder, $fileName); + } +} + +if (!function_exists('policy_file_type_map')) { + /** + * @return array + */ + function policy_file_type_map(): array + { + return [ + 'policy_pdf' => ['column' => 'policy_pdf_file_name', 'folder' => 'policy_pdf'], + 'policy_payment_receipt' => ['column' => 'policy_payment_receipt_file_name', 'folder' => 'policy_payment_receipt'], + ]; + } +} + +if (!function_exists('policy_upload_pdf')) { + function policy_upload_pdf(?UploadedFile $file): ?string + { + return storage_upload_if_valid($file, 'policy', 'policy_pdf'); + } +} + +if (!function_exists('policy_upload_receipt')) { + function policy_upload_receipt(?UploadedFile $file): ?string + { + return storage_upload_if_valid($file, 'policy', 'policy_payment_receipt'); + } +} + +if (!function_exists('policy_exists_by_type')) { + function policy_exists_by_type(string $fileName, string $fileType): bool + { + $map = policy_file_type_map(); + + if (! isset($map[$fileType])) { + return false; + } + + return storage_exists('policy', $map[$fileType]['folder'], $fileName); + } +} + +if (!function_exists('policy_download_by_type')) { + function policy_download_by_type(string $fileName, string $fileType, bool $inline = false): ResponseInterface + { + $map = policy_file_type_map(); + + if (! isset($map[$fileType])) { + throw new InvalidArgumentException('Invalid policy file type'); + } + + $folder = $map[$fileType]['folder']; + + return $inline + ? storage_inline('policy', $folder, $fileName) + : storage_download('policy', $folder, $fileName); + } +} + +if (!function_exists('policy_local_pdf_path')) { + function policy_local_pdf_path(string $fileName): string + { + return storage_local_path('policy', 'policy_pdf', $fileName); + } +} + +if (!function_exists('policy_md_file_name')) { + function policy_md_file_name(string $pdfFileName): string + { + $mdFileName = preg_replace('/\.pdf$/i', '.md', $pdfFileName); + + if ($mdFileName === $pdfFileName) { + $mdFileName .= '.md'; + } + + return $mdFileName; + } +} + +if (!function_exists('policy_put_md')) { + function policy_put_md(string $fileName, string $contents): string + { + return storage_put('policy', 'policy_md', $fileName, $contents, ['content_type' => 'text/markdown']); + } +} + +if (!function_exists('policy_read_md')) { + function policy_read_md(string $fileName): string + { + return storage_read('policy', 'policy_md', $fileName); + } +} + +if (!function_exists('policy_md_exists')) { + function policy_md_exists(string $fileName): bool + { + return storage_exists('policy', 'policy_md', $fileName); + } +} + +if (!function_exists('endorsement_upload_original')) { + function endorsement_upload_original(?UploadedFile $file): ?string + { + return storage_upload_if_valid($file, 'endorsement', ''); + } +} + +if (!function_exists('endorsement_upload_completion')) { + function endorsement_upload_completion(?UploadedFile $file): ?string + { + return storage_upload_if_valid($file, 'endorsement', 'endorsement_pdf'); + } +} + +if (!function_exists('endorsement_subfolder_for_type')) { + function endorsement_subfolder_for_type(?string $type): string + { + return ($type === 'completion') ? 'endorsement_pdf' : ''; + } +} + +if (!function_exists('endorsement_exists_by_type')) { + function endorsement_exists_by_type(string $fileName, ?string $type = 'original'): bool + { + return storage_exists('endorsement', endorsement_subfolder_for_type($type), $fileName); + } +} + +if (!function_exists('endorsement_download_by_type')) { + function endorsement_download_by_type(string $fileName, ?string $type = 'original', bool $inline = false): ResponseInterface + { + $folder = endorsement_subfolder_for_type($type); + + return $inline + ? storage_inline('endorsement', $folder, $fileName) + : storage_download('endorsement', $folder, $fileName); + } +} + +if (!function_exists('endorsement_delete_completion')) { + function endorsement_delete_completion(string $fileName): bool + { + return storage_delete('endorsement', 'endorsement_pdf', $fileName); + } +} diff --git a/app/Services/FileStorageService.php b/app/Services/FileStorageService.php new file mode 100644 index 0000000..04ef047 --- /dev/null +++ b/app/Services/FileStorageService.php @@ -0,0 +1,176 @@ +config = $config ?? config('Storage'); + $this->driver = $this->createDriver($this->config->driver); + } + + public function driver(): FileStorageInterface + { + return $this->driver; + } + + public function isS3(): bool + { + return $this->config->driver === 's3'; + } + + public function resolveKey(string $module, string $subFolder, string $fileName): string + { + $module = trim($module, '/'); + $subFolder = trim($subFolder, '/'); + $fileName = ltrim(str_replace('\\', '/', $fileName), '/'); + + if ($subFolder === '') { + return "uploads/{$module}/{$fileName}"; + } + + return "uploads/{$module}/{$subFolder}/{$fileName}"; + } + + public function upload(UploadedFile|string $source, string $key, array $options = []): string + { + $storedKey = $this->driver->upload($source, $key, $options); + + if ($this->config->retainLocal && $this->isS3()) { + (new LocalStorageDriver($this->config))->upload($source, $key, $options); + } + + return $storedKey; + } + + public function uploadModuleFile( + UploadedFile $file, + string $module, + string $subFolder = '', + ?string $fileName = null + ): string { + $fileName = $fileName ?: (time() . '_' . $file->getRandomName()); + + $this->upload($file, $this->resolveKey($module, $subFolder, $fileName)); + + return $fileName; + } + + public function put(string $key, string $contents, array $options = []): string + { + $storedKey = $this->driver->put($key, $contents, $options); + + if ($this->config->retainLocal && $this->isS3()) { + (new LocalStorageDriver($this->config))->put($key, $contents, $options); + } + + return $storedKey; + } + + public function exists(string $module, string $subFolder, string $fileName): bool + { + return $this->driver->exists($this->resolveKey($module, $subFolder, $fileName)); + } + + public function existsKey(string $key): bool + { + return $this->driver->exists($key); + } + + public function delete(string $module, string $subFolder, string $fileName): bool + { + return $this->driver->delete($this->resolveKey($module, $subFolder, $fileName)); + } + + public function deleteKey(string $key): bool + { + return $this->driver->delete($key); + } + + public function read(string $module, string $subFolder, string $fileName): string + { + return $this->driver->read($this->resolveKey($module, $subFolder, $fileName)); + } + + public function readKey(string $key): string + { + return $this->driver->read($key); + } + + public function download(string $module, string $subFolder, string $fileName): ResponseInterface + { + return $this->driver->streamDownload( + $this->resolveKey($module, $subFolder, $fileName), + $fileName + ); + } + + public function downloadKey(string $key, ?string $downloadName = null): ResponseInterface + { + return $this->driver->streamDownload($key, $downloadName ?: basename($key)); + } + + /** + * Returns a local filesystem path suitable for pdftotext, Gemini, RAG, etc. + * For S3, this downloads to a temp file first. + */ + public function getLocalPathForProcessing(string $module, string $subFolder, string $fileName): string + { + return $this->getLocalPathForKey($this->resolveKey($module, $subFolder, $fileName)); + } + + public function getLocalPathForKey(string $key): string + { + if ($this->config->driver === 'local') { + return rtrim($this->config->localRoot, '/\\') . DIRECTORY_SEPARATOR . ltrim(str_replace('/', DIRECTORY_SEPARATOR, $key), '/\\'); + } + + return $this->driver->downloadToTemp($key); + } + + public function getTemporaryUrl(string $module, string $subFolder, string $fileName, ?int $ttlSeconds = null): string + { + return $this->getTemporaryUrlForKey( + $this->resolveKey($module, $subFolder, $fileName), + $ttlSeconds + ); + } + + public function getTemporaryUrlForKey(string $key, ?int $ttlSeconds = null): string + { + return $this->driver->temporaryUrl($key, $ttlSeconds ?? $this->config->presignedTtl); + } + + public function getUrl(string $module, string $subFolder, string $fileName): string + { + return $this->driver->url($this->resolveKey($module, $subFolder, $fileName)); + } + + public function copy(string $fromModule, string $fromSubFolder, string $fromFileName, string $toModule, string $toSubFolder, string $toFileName): bool + { + return $this->driver->copy( + $this->resolveKey($fromModule, $fromSubFolder, $fromFileName), + $this->resolveKey($toModule, $toSubFolder, $toFileName) + ); + } + + private function createDriver(string $driver): FileStorageInterface + { + return match ($driver) { + 's3' => new S3StorageDriver($this->config), + default => new LocalStorageDriver($this->config), + }; + } +} diff --git a/app/Services/Storage/FileStorageException.php b/app/Services/Storage/FileStorageException.php new file mode 100644 index 0000000..6002837 --- /dev/null +++ b/app/Services/Storage/FileStorageException.php @@ -0,0 +1,9 @@ +config->localRoot . str_replace('/', DIRECTORY_SEPARATOR, $key); + } + + private function ensureDirectory(string $fullPath): void + { + $directory = dirname($fullPath); + + if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new FileStorageException("Unable to create directory: {$directory}"); + } + } + + public function upload(UploadedFile|string $source, string $key, array $options = []): string + { + $fullPath = $this->fullPath($key); + $this->ensureDirectory($fullPath); + + if ($source instanceof UploadedFile) { + if (! $source->isValid()) { + throw new FileStorageException($source->getErrorString() ?: 'Invalid uploaded file'); + } + + if ($source->hasMoved()) { + throw new FileStorageException('Uploaded file has already been moved'); + } + + $source->move(dirname($fullPath), basename($fullPath)); + + return $key; + } + + if (! is_file($source)) { + throw new FileStorageException("Source file not found: {$source}"); + } + + if (! copy($source, $fullPath)) { + throw new FileStorageException("Failed to copy file to {$fullPath}"); + } + + return $key; + } + + public function put(string $key, string $contents, array $options = []): string + { + $fullPath = $this->fullPath($key); + $this->ensureDirectory($fullPath); + + if (file_put_contents($fullPath, $contents) === false) { + throw new FileStorageException("Failed to write file: {$fullPath}"); + } + + return $key; + } + + public function exists(string $key): bool + { + return is_file($this->fullPath($key)); + } + + public function delete(string $key): bool + { + $fullPath = $this->fullPath($key); + + if (! is_file($fullPath)) { + return false; + } + + return unlink($fullPath); + } + + public function read(string $key): string + { + $fullPath = $this->fullPath($key); + + if (! is_file($fullPath)) { + throw new FileStorageException("File not found: {$key}"); + } + + $contents = file_get_contents($fullPath); + + if ($contents === false) { + throw new FileStorageException("Unable to read file: {$key}"); + } + + return $contents; + } + + public function downloadToTemp(string $key): string + { + $fullPath = $this->fullPath($key); + + if (! is_file($fullPath)) { + throw new FileStorageException("File not found: {$key}"); + } + + return $fullPath; + } + + public function streamDownload(string $key, ?string $downloadName = null): ResponseInterface + { + $fullPath = $this->fullPath($key); + + if (! is_file($fullPath)) { + throw new FileStorageException("File not found: {$key}"); + } + + return service('response')->download($fullPath, null)->setFileName($downloadName ?: basename($key)); + } + + public function temporaryUrl(string $key, int $ttlSeconds = 900): string + { + if (! $this->exists($key)) { + throw new FileStorageException("File not found: {$key}"); + } + + return base_url(ltrim(str_replace('\\', '/', $key), '/')); + } + + public function url(string $key): string + { + return $this->temporaryUrl($key); + } + + public function copy(string $fromKey, string $toKey): bool + { + $fromPath = $this->fullPath($fromKey); + $toPath = $this->fullPath($toKey); + + if (! is_file($fromPath)) { + throw new FileStorageException("Source file not found: {$fromKey}"); + } + + $this->ensureDirectory($toPath); + + return copy($fromPath, $toPath); + } +} diff --git a/app/Services/Storage/S3StorageDriver.php b/app/Services/Storage/S3StorageDriver.php new file mode 100644 index 0000000..1d0a638 --- /dev/null +++ b/app/Services/Storage/S3StorageDriver.php @@ -0,0 +1,232 @@ +assertConfigured(); + $this->client = $this->createClient(); + } + + private function assertConfigured(): void + { + if ($this->config->s3Bucket === '' || $this->config->s3AccessKey === '' || $this->config->s3SecretKey === '') { + throw new FileStorageException('S3 storage is not configured. Check AWS_BUCKET, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY.'); + } + } + + private function createClient(): S3Client + { + return new S3Client([ + 'version' => 'latest', + 'region' => $this->config->s3Region, + 'credentials' => [ + 'key' => $this->config->s3AccessKey, + 'secret' => $this->config->s3SecretKey, + ], + ]); + } + + private function objectKey(string $key): string + { + $key = ltrim(str_replace('\\', '/', $key), '/'); + + return $this->config->s3Prefix !== '' + ? $this->config->s3Prefix . '/' . $key + : $key; + } + + private function resolveContentType(UploadedFile|string $source, array $options): string + { + if (! empty($options['content_type'])) { + return (string) $options['content_type']; + } + + if ($source instanceof UploadedFile) { + return $source->getClientMimeType() ?: 'application/octet-stream'; + } + + $mimeType = mime_content_type($source); + + return $mimeType !== false ? $mimeType : 'application/octet-stream'; + } + + /** + * @param array $params + */ + private function putObject(array $params): void + { + try { + $this->client->putObject($params); + } catch (AwsException $e) { + throw new FileStorageException('S3 upload failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + } + + public function upload(UploadedFile|string $source, string $key, array $options = []): string + { + $params = [ + 'Bucket' => $this->config->s3Bucket, + 'Key' => $this->objectKey($key), + 'ContentType' => $this->resolveContentType($source, $options), + ]; + + if ($source instanceof UploadedFile) { + if (! $source->isValid()) { + throw new FileStorageException($source->getErrorString() ?: 'Invalid uploaded file'); + } + + $params['SourceFile'] = $source->getTempName(); + } else { + if (! is_file($source)) { + throw new FileStorageException("Source file not found: {$source}"); + } + + $params['SourceFile'] = $source; + } + + $this->putObject($params); + + return $key; + } + + public function put(string $key, string $contents, array $options = []): string + { + $this->putObject([ + 'Bucket' => $this->config->s3Bucket, + 'Key' => $this->objectKey($key), + 'Body' => $contents, + 'ContentType' => $options['content_type'] ?? 'application/octet-stream', + ]); + + return $key; + } + + public function exists(string $key): bool + { + try { + return $this->client->doesObjectExist( + $this->config->s3Bucket, + $this->objectKey($key) + ); + } catch (AwsException $e) { + throw new FileStorageException('S3 exists check failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + } + + public function delete(string $key): bool + { + try { + $this->client->deleteObject([ + 'Bucket' => $this->config->s3Bucket, + 'Key' => $this->objectKey($key), + ]); + + return true; + } catch (AwsException $e) { + throw new FileStorageException('S3 delete failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + } + + public function read(string $key): string + { + try { + $result = $this->client->getObject([ + 'Bucket' => $this->config->s3Bucket, + 'Key' => $this->objectKey($key), + ]); + + return (string) $result['Body']; + } catch (AwsException $e) { + throw new FileStorageException('S3 read failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + } + + public function downloadToTemp(string $key): string + { + $tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('s3_', true) . '_' . basename($key); + + try { + $this->client->getObject([ + 'Bucket' => $this->config->s3Bucket, + 'Key' => $this->objectKey($key), + 'SaveAs' => $tempPath, + ]); + } catch (AwsException $e) { + throw new FileStorageException('S3 download failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + + return $tempPath; + } + + public function streamDownload(string $key, ?string $downloadName = null): ResponseInterface + { + try { + $result = $this->client->getObject([ + 'Bucket' => $this->config->s3Bucket, + 'Key' => $this->objectKey($key), + ]); + } catch (AwsException $e) { + throw new FileStorageException('S3 download failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + + return service('response') + ->setHeader('Content-Type', $result['ContentType'] ?? 'application/octet-stream') + ->setHeader( + 'Content-Disposition', + 'attachment; filename="' . ($downloadName ?: basename($key)) . '"' + ) + ->setBody((string) $result['Body']); + } + + public function temporaryUrl(string $key, int $ttlSeconds = 900): string + { + try { + $command = $this->client->getCommand('GetObject', [ + 'Bucket' => $this->config->s3Bucket, + 'Key' => $this->objectKey($key), + ]); + + $request = $this->client->createPresignedRequest($command, "+{$ttlSeconds} seconds"); + + return (string) $request->getUri(); + } catch (AwsException $e) { + throw new FileStorageException('S3 presigned URL failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + } + + public function url(string $key): string + { + return sprintf( + 'https://%s.s3.%s.amazonaws.com/%s', + $this->config->s3Bucket, + $this->config->s3Region, + rawurlencode($this->objectKey($key)) + ); + } + + public function copy(string $fromKey, string $toKey): bool + { + try { + $this->client->copyObject([ + 'Bucket' => $this->config->s3Bucket, + 'CopySource' => $this->config->s3Bucket . '/' . $this->objectKey($fromKey), + 'Key' => $this->objectKey($toKey), + ]); + + return true; + } catch (AwsException $e) { + throw new FileStorageException('S3 copy failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + } +} diff --git a/app/Services/StorageBrowserService.php b/app/Services/StorageBrowserService.php new file mode 100644 index 0000000..d114eab --- /dev/null +++ b/app/Services/StorageBrowserService.php @@ -0,0 +1,340 @@ + */ + private array $folders = [ + 'uploads/policy/policy_pdf', + 'uploads/policy/policy_payment_receipt', + 'uploads/policy/policy_md', + 'uploads/endorsement', + 'uploads/endorsement/endorsement_pdf', + ]; + + /** @var list */ + private array $skipFiles = ['index.html', '.htaccess', '.gitkeep']; + + public function __construct(?Storage $config = null, ?FileStorageService $storage = null) + { + $this->config = $config ?? config('Storage'); + $this->storage = $storage ?? service('fileStorage'); + } + + public function driverLabel(): string + { + return $this->storage->isS3() ? 's3' : 'local'; + } + + public function bucketLabel(): string + { + if ($this->config->s3Bucket !== '') { + return $this->config->s3Prefix !== '' + ? $this->config->s3Bucket . '/' . $this->config->s3Prefix + : $this->config->s3Bucket; + } + + return $this->storage->isS3() ? 's3' : 'local-storage'; + } + + /** + * @return list + */ + public function folders(): array + { + $items = []; + + foreach ($this->folders as $folder) { + $items[] = [ + 'value' => $folder, + 'label' => $folder, + ]; + } + + return $items; + } + + /** + * @return array{ + * files: list, + * next_token: ?string, + * has_more: bool + * } + */ + public function listFiles(string $folder, int $perPage = 10, ?string $continuationToken = null, ?string $search = null): array + { + $folder = $this->normalizeFolder($folder); + $search = $search !== null ? trim($search) : ''; + + if ($this->storage->isS3()) { + return $this->listS3Files($folder, $perPage, $continuationToken, $search); + } + + return $this->listLocalFiles($folder, $perPage, $continuationToken, $search); + } + + public function assertAllowedKey(string $key): void + { + $key = ltrim(str_replace('\\', '/', $key), '/'); + + if ($key === '' || str_contains($key, '..')) { + throw new FileStorageException('Invalid file key.'); + } + + if (! str_starts_with($key, 'uploads/')) { + throw new FileStorageException('Access denied for this path.'); + } + } + + public function getOpenUrl(string $key): string + { + $this->assertAllowedKey($key); + + if (! $this->storage->existsKey($key)) { + throw new FileStorageException('File not found.'); + } + + if ($this->storage->isS3()) { + return $this->storage->getTemporaryUrlForKey($key); + } + + return site_url('storage/browser/download?key=' . rawurlencode($key)); + } + + public function downloadResponse(string $key): ResponseInterface + { + $this->assertAllowedKey($key); + + return $this->storage->downloadKey($key, basename($key)); + } + + private function normalizeFolder(string $folder): string + { + $folder = trim(str_replace('\\', '/', $folder), '/'); + + if ($folder === '' || str_contains($folder, '..')) { + throw new FileStorageException('Invalid folder.'); + } + + if (! in_array($folder, $this->folders, true)) { + throw new FileStorageException('Folder is not allowed.'); + } + + return $folder; + } + + /** + * @return array{ + * files: list, + * next_token: ?string, + * has_more: bool + * } + */ + private function listLocalFiles(string $folder, int $perPage, ?string $continuationToken, string $search): array + { + $dir = rtrim($this->config->localRoot, '/\\') + . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folder); + + if (! is_dir($dir)) { + return ['files' => [], 'next_token' => null, 'has_more' => false]; + } + + $offset = 0; + if ($continuationToken !== null && $continuationToken !== '') { + $decoded = json_decode(base64_decode($continuationToken, true) ?: '', true); + $offset = is_array($decoded) ? max(0, (int) ($decoded['offset'] ?? 0)) : 0; + } + + $entries = []; + $handle = opendir($dir); + + if ($handle === false) { + throw new FileStorageException('Unable to read folder.'); + } + + while (($entry = readdir($handle)) !== false) { + if ($entry === '.' || $entry === '..') { + continue; + } + + if (in_array(strtolower($entry), $this->skipFiles, true)) { + continue; + } + + $fullPath = $dir . DIRECTORY_SEPARATOR . $entry; + + if (! is_file($fullPath)) { + continue; + } + + if ($search !== '' && stripos($entry, $search) === false) { + continue; + } + + $entries[] = [ + 'key' => $folder . '/' . $entry, + 'file_name' => $entry, + 'size' => (int) (filesize($fullPath) ?: 0), + 'size_human' => $this->formatBytes((int) (filesize($fullPath) ?: 0)), + 'last_modified' => date('Y-m-d H:i:s', (int) filemtime($fullPath)), + ]; + } + + closedir($handle); + + usort($entries, static fn (array $a, array $b): int => strcmp($b['last_modified'], $a['last_modified'])); + + $slice = array_slice($entries, $offset, $perPage); + $next = $offset + $perPage; + $hasMore = $next < count($entries); + + return [ + 'files' => $slice, + 'next_token' => $hasMore ? base64_encode(json_encode(['offset' => $next], JSON_THROW_ON_ERROR)) : null, + 'has_more' => $hasMore, + ]; + } + + /** + * @return array{ + * files: list, + * next_token: ?string, + * has_more: bool + * } + */ + private function listS3Files(string $folder, int $perPage, ?string $continuationToken, string $search): array + { + if ($this->config->s3Bucket === '' || $this->config->s3AccessKey === '' || $this->config->s3SecretKey === '') { + throw new FileStorageException('S3 is not configured.'); + } + + $client = new S3Client([ + 'version' => 'latest', + 'region' => $this->config->s3Region, + 'credentials' => [ + 'key' => $this->config->s3AccessKey, + 'secret' => $this->config->s3SecretKey, + ], + ]); + + $prefix = $this->objectKey($folder . '/'); + $files = []; + $token = $continuationToken ?: null; + $hasMore = false; + + try { + while (count($files) < $perPage) { + $params = [ + 'Bucket' => $this->config->s3Bucket, + 'Prefix' => $prefix, + 'MaxKeys' => max($perPage * 3, 30), + ]; + + if ($token !== null) { + $params['ContinuationToken'] = $token; + } + + $result = $client->listObjectsV2($params); + $items = $result['Contents'] ?? []; + + foreach ($items as $item) { + $s3Key = (string) ($item['Key'] ?? ''); + + if ($s3Key === '' || str_ends_with($s3Key, '/')) { + continue; + } + + $logicalKey = $this->stripObjectPrefix($s3Key); + $fileName = basename($logicalKey); + + if (in_array(strtolower($fileName), $this->skipFiles, true)) { + continue; + } + + if ($search !== '' && stripos($fileName, $search) === false) { + continue; + } + + $files[] = [ + 'key' => $logicalKey, + 'file_name' => $fileName, + 'size' => (int) ($item['Size'] ?? 0), + 'size_human' => $this->formatBytes((int) ($item['Size'] ?? 0)), + 'last_modified' => isset($item['LastModified']) + ? $item['LastModified']->format('Y-m-d H:i:s') + : '', + ]; + + if (count($files) >= $perPage) { + break; + } + } + + $token = $result['IsTruncated'] ? ($result['NextContinuationToken'] ?? null) : null; + $hasMore = $token !== null; + + if ($token === null) { + break; + } + + if (count($files) >= $perPage) { + break; + } + } + } catch (AwsException $e) { + throw new FileStorageException('S3 list failed: ' . $e->getAwsErrorMessage(), 0, $e); + } + + return [ + 'files' => $files, + 'next_token' => $hasMore ? $token : null, + 'has_more' => $hasMore, + ]; + } + + private function objectKey(string $key): string + { + $key = ltrim(str_replace('\\', '/', $key), '/'); + + return $this->config->s3Prefix !== '' + ? $this->config->s3Prefix . '/' . $key + : $key; + } + + private function stripObjectPrefix(string $s3Key): string + { + if ($this->config->s3Prefix === '') { + return $s3Key; + } + + $prefix = $this->config->s3Prefix . '/'; + + return str_starts_with($s3Key, $prefix) + ? substr($s3Key, strlen($prefix)) + : $s3Key; + } + + private function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return $bytes . ' B'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / 1048576, 1) . ' MB'; + } +} diff --git a/app/Views/storage/file_browser.php b/app/Views/storage/file_browser.php new file mode 100644 index 0000000..4c27fa2 --- /dev/null +++ b/app/Views/storage/file_browser.php @@ -0,0 +1,402 @@ + + + + + + File browser + + + + +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + +
File nameSizeLast modifiedAction
+
+
📁
+
Select a folder to load files
+
+
+
+ + +
+
+ + + + diff --git a/build/.phpunit.cache/test-results b/build/.phpunit.cache/test-results new file mode 100644 index 0000000..f20e40b --- /dev/null +++ b/build/.phpunit.cache/test-results @@ -0,0 +1 @@ +{"version":2,"defects":{"Tests\\Unit\\Storage\\FileStorageServiceTest::testUploadModuleFileStoresPolicyPdf":8,"Tests\\Unit\\Storage\\FileStorageServiceTest::testDownloadReturnsAttachmentResponse":7,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testUploadFromUploadedFileStoresUnderLocalRoot":8,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testStreamDownloadReturnsResponseWithBody":7,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadPdfStoresFile":8,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadReceiptStoresFile":8,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeAttachment":7,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeInline":7,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyPdfUploadExistsReadAndDelete":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyReceiptUploadAndTemporaryUrl":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3EndorsementOriginalAndCompletionUpload":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testLocalDriverScenarioStillWorksWhenEnvIsLocal":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3DriverIsActiveFromEnv":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyMarkdownPutAndRead":1},"times":{"Tests\\Unit\\Storage\\FileStorageServiceTest::testResolveKeyWithSubFolder":0.003,"Tests\\Unit\\Storage\\FileStorageServiceTest::testResolveKeyWithoutSubFolder":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testUploadModuleFileStoresPolicyPdf":0.005,"Tests\\Unit\\Storage\\FileStorageServiceTest::testReadWriteAndDeleteByModule":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testGetLocalPathForProcessingPointsToWritableFile":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testCopyBetweenKeys":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testDownloadReturnsAttachmentResponse":0.003,"Tests\\Unit\\Storage\\FileStorageServiceTest::testGetTemporaryUrlForLocalUsesBaseUrl":0.002,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testUploadFromUploadedFileStoresUnderLocalRoot":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testUploadFromLocalPathCopiesFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testPutWriteRawContents":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testExistsReturnsFalseForMissingFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testDeleteRemovesFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testDownloadToTempReturnsExistingLocalPath":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testCopyDuplicatesFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testTemporaryUrlUsesBaseUrlForLocalDriver":0.002,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testStreamDownloadReturnsResponseWithBody":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyFileTypeMapContainsExpectedTypes":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyMdFileNameConvertsPdfToMd":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadPdfStoresFile":0.002,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadReceiptStoresFile":0.002,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadReturnsNullWhenNoFile":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyExistsByTypeReturnsFalseForInvalidType":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeAttachment":0.002,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeInline":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeThrowsForInvalidType":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyLocalPdfPathResolvesStoredPdf":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyMarkdownPutReadAndExists":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3DriverIsActiveFromEnv":0.003,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyPdfUploadExistsReadAndDelete":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyReceiptUploadAndTemporaryUrl":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyMarkdownPutAndRead":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3EndorsementOriginalAndCompletionUpload":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testLocalDriverScenarioStillWorksWhenEnvIsLocal":0.006}} \ No newline at end of file diff --git a/build/logs/logfile.xml b/build/logs/logfile.xml new file mode 100644 index 0000000..efb456f --- /dev/null +++ b/build/logs/logfile.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/logs/testdox.html b/build/logs/testdox.html new file mode 100644 index 0000000..2e1da8b --- /dev/null +++ b/build/logs/testdox.html @@ -0,0 +1,64 @@ + + + + + Test Documentation + + + +

S3Storage Integration (Tests\Unit\Storage\S3StorageIntegration)

+
    +
  • S 3 driver is active from env
  • +
  • S 3 policy pdf upload exists read and delete
  • +
  • S 3 policy receipt upload and temporary url
  • +
  • S 3 policy markdown put and read
  • +
  • S 3 endorsement original and completion upload
  • +
  • Local driver scenario still works when env is local
  • +
+ + \ No newline at end of file diff --git a/build/logs/testdox.txt b/build/logs/testdox.txt new file mode 100644 index 0000000..a5e89df --- /dev/null +++ b/build/logs/testdox.txt @@ -0,0 +1,8 @@ +S3Storage Integration (Tests\Unit\Storage\S3StorageIntegration) + [ ] S 3 driver is active from env + [ ] S 3 policy pdf upload exists read and delete + [ ] S 3 policy receipt upload and temporary url + [ ] S 3 policy markdown put and read + [ ] S 3 endorsement original and completion upload + [x] Local driver scenario still works when env is local + diff --git a/composer.json b/composer.json index 1f70a62..8a4863e 100755 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "php": "^8.1", "ext-intl": "*", "ext-mbstring": "*", + "aws/aws-sdk-php": "^3.388", "firebase/php-jwt": "^6.11", "google/apiclient": "^2.15.0", "google/apiclient-services": "^0.396.0", diff --git a/composer.lock b/composer.lock index a9e34ae..f4f8449 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5deed3009af506892988b5576e8718bb", + "content-hash": "2d78e1b5528d683dee2775cdbade09a1", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.388.7", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "9ad7cc3619513bb7379e2f1dc8f6763cc54dcb28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9ad7cc3619513bb7379e2f1dc8f6763cc54dcb28", + "reference": "9ad7cc3619513bb7379e2f1dc8f6763cc54dcb28", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.388.7" + }, + "time": "2026-07-15T18:08:26+00:00" + }, { "name": "composer/pcre", "version": "3.3.2", @@ -1457,6 +1608,72 @@ }, "time": "2023-05-03T06:19:36+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" + }, + "time": "2026-07-06T18:56:19+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -9687,5 +9904,5 @@ "ext-mbstring": "*" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/tests/_support/Storage/StorageTestCase.php b/tests/_support/Storage/StorageTestCase.php new file mode 100644 index 0000000..8505fe7 --- /dev/null +++ b/tests/_support/Storage/StorageTestCase.php @@ -0,0 +1,128 @@ +resetServices(); + + $this->tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nhance_storage_test_' . uniqid('', true); + mkdir($this->tempRoot, 0777, true); + + $this->storageConfig = new Storage(); + $this->storageConfig->driver = 'local'; + $this->storageConfig->retainLocal = false; + $this->storageConfig->localRoot = $this->tempRoot . DIRECTORY_SEPARATOR; + $this->storageConfig->presignedTtl = 900; + + Services::injectMock('fileStorage', new FileStorageService($this->storageConfig)); + + helper('storage_helper'); + } + + protected function tearDown(): void + { + $this->deleteDirectory($this->tempRoot); + $this->resetServices(); + + parent::tearDown(); + } + + protected function useLocalDriver(): void + { + $this->storageConfig->driver = 'local'; + Services::injectMock('fileStorage', new FileStorageService($this->storageConfig)); + } + + protected function useS3DriverFromEnv(): void + { + $this->storageConfig->driver = 's3'; + $this->storageConfig->s3Bucket = getenv('AWS_BUCKET') ?: ''; + $this->storageConfig->s3Region = getenv('AWS_DEFAULT_REGION') ?: 'ap-south-1'; + $this->storageConfig->s3AccessKey = getenv('AWS_ACCESS_KEY_ID') ?: ''; + $this->storageConfig->s3SecretKey = getenv('AWS_SECRET_ACCESS_KEY') ?: ''; + $this->storageConfig->s3Prefix = trim(getenv('AWS_S3_PREFIX') ?: '', '/'); + + Services::injectMock('fileStorage', new FileStorageService($this->storageConfig)); + } + + protected function s3IntegrationEnabled(): bool + { + return getenv('FILE_STORAGE_DRIVER') === 's3' + && getenv('AWS_BUCKET') !== false + && getenv('AWS_BUCKET') !== '' + && getenv('AWS_ACCESS_KEY_ID') !== false + && getenv('AWS_ACCESS_KEY_ID') !== '' + && getenv('AWS_SECRET_ACCESS_KEY') !== false + && getenv('AWS_SECRET_ACCESS_KEY') !== ''; + } + + protected function skipUnlessS3Integration(): void + { + if (! $this->s3IntegrationEnabled()) { + $this->markTestSkipped('S3 integration env is not configured (FILE_STORAGE_DRIVER=s3 and AWS_* vars).'); + } + + $this->useS3DriverFromEnv(); + } + + protected function createUploadedFile( + string $contents, + string $originalName, + string $mime = 'application/octet-stream' + ): UploadedFile { + $temp = tempnam(sys_get_temp_dir(), 'ci_upload_'); + file_put_contents($temp, $contents); + + return new TestUploadedFile($temp, $originalName, $mime, strlen($contents), UPLOAD_ERR_OK, false); + } + + protected function localFilePath(string $key): string + { + return $this->tempRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, ltrim($key, '/')); + } + + protected function deleteDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + foreach (scandir($dir) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir . DIRECTORY_SEPARATOR . $item; + + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } + + protected function assertDownloadHeaders(\CodeIgniter\HTTP\DownloadResponse $response, string $disposition): void + { + $response->buildHeaders(); + + $this->assertStringContainsString($disposition, $response->getHeaderLine('Content-Disposition')); + } +} diff --git a/tests/_support/Storage/TestUploadedFile.php b/tests/_support/Storage/TestUploadedFile.php new file mode 100644 index 0000000..6a38449 --- /dev/null +++ b/tests/_support/Storage/TestUploadedFile.php @@ -0,0 +1,49 @@ +getError() === UPLOAD_ERR_OK && is_file($this->getTempName()); + } + + public function move(string $targetPath, ?string $name = null, bool $overwrite = false) + { + $targetPath = rtrim($targetPath, '/') . '/'; + + if (! is_dir($targetPath) && ! mkdir($targetPath, 0777, true) && ! is_dir($targetPath)) { + throw HTTPException::forMoveFailed(basename($this->getTempName()), $targetPath, 'Unable to create target directory'); + } + + if ($this->hasMoved()) { + throw HTTPException::forAlreadyMoved(); + } + + if (! $this->isValid()) { + throw HTTPException::forInvalidFile(); + } + + $name = $name ?? $this->getName(); + $destination = $overwrite ? $targetPath . $name : $targetPath . $name; + + if (! @rename($this->getTempName(), $destination)) { + if (! @copy($this->getTempName(), $destination)) { + throw HTTPException::forMoveFailed(basename($this->getTempName()), $targetPath, 'rename/copy failed'); + } + + @unlink($this->getTempName()); + } + + $this->hasMoved = true; + + return true; + } +} diff --git a/tests/unit/Storage/FileStorageServiceTest.php b/tests/unit/Storage/FileStorageServiceTest.php new file mode 100644 index 0000000..d073bca --- /dev/null +++ b/tests/unit/Storage/FileStorageServiceTest.php @@ -0,0 +1,136 @@ +resolveKey('policy', 'policy_pdf', 'abc.pdf'); + + $this->assertSame('uploads/policy/policy_pdf/abc.pdf', $key); + } + + public function testResolveKeyWithoutSubFolder(): void + { + $key = storage()->resolveKey('endorsement', '', 'original.pdf'); + + $this->assertSame('uploads/endorsement/original.pdf', $key); + } + + public function testUploadModuleFileStoresPolicyPdf(): void + { + $file = $this->createUploadedFile('%PDF', 'policy.pdf', 'application/pdf'); + + $storedName = storage()->uploadModuleFile($file, 'policy', 'policy_pdf'); + + $this->assertNotSame('', $storedName); + $this->assertTrue(storage()->exists('policy', 'policy_pdf', $storedName)); + } + + public function testReadWriteAndDeleteByModule(): void + { + storage()->put(storage()->resolveKey('policy', 'policy_md', 'doc.md'), '# md'); + + $this->assertTrue(storage()->exists('policy', 'policy_md', 'doc.md')); + $this->assertSame('# md', storage()->read('policy', 'policy_md', 'doc.md')); + + $this->assertTrue(storage()->delete('policy', 'policy_md', 'doc.md')); + $this->assertFalse(storage()->exists('policy', 'policy_md', 'doc.md')); + } + + public function testGetLocalPathForProcessingPointsToWritableFile(): void + { + $fileName = 'local-path.pdf'; + storage()->put(storage()->resolveKey('policy', 'policy_pdf', $fileName), 'local'); + + $path = storage()->getLocalPathForProcessing('policy', 'policy_pdf', $fileName); + + $this->assertFileExists($path); + $this->assertSame('local', file_get_contents($path)); + } + + public function testCopyBetweenKeys(): void + { + storage()->put(storage()->resolveKey('policy', 'policy_pdf', 'from.pdf'), 'from'); + storage()->copy('policy', 'policy_pdf', 'from.pdf', 'policy', 'policy_pdf', 'to.pdf'); + + $this->assertSame('from', storage()->read('policy', 'policy_pdf', 'to.pdf')); + } + + public function testDownloadReturnsAttachmentResponse(): void + { + storage()->put(storage()->resolveKey('policy', 'policy_pdf', 'dl.pdf'), 'download'); + + $response = storage()->download('policy', 'policy_pdf', 'dl.pdf'); + + $this->assertInstanceOf(DownloadResponse::class, $response); + $this->assertDownloadHeaders($response, 'attachment'); + $this->assertGreaterThan(0, $response->getContentLength()); + } + + public function testGetTemporaryUrlForLocalUsesBaseUrl(): void + { + storage()->put(storage()->resolveKey('policy', 'policy_pdf', 'url.pdf'), 'url'); + + $url = storage()->getTemporaryUrl('policy', 'policy_pdf', 'url.pdf'); + + $this->assertStringContainsString('uploads/policy/policy_pdf/url.pdf', $url); + } +} + +/** + * @internal + */ +final class StorageHelperTest extends StorageTestCase +{ + public function testStorageUploadIfValidReturnsNullForInvalidFile(): void + { + $this->assertNull(storage_upload_if_valid(null, 'policy', 'policy_pdf')); + } + + public function testStorageExistsReturnsFalseForEmptyFileName(): void + { + $this->assertFalse(storage_exists('policy', 'policy_pdf', '')); + } + + public function testStoragePutAndReadRoundTrip(): void + { + storage_put('policy', 'policy_md', 'helper.md', '# helper'); + + $this->assertTrue(storage_exists('policy', 'policy_md', 'helper.md')); + $this->assertSame('# helper', storage_read('policy', 'policy_md', 'helper.md')); + } + + public function testStorageDeleteRemovesFile(): void + { + storage_put('endorsement', 'endorsement_pdf', 'delete-me.pdf', 'pdf'); + $this->assertTrue(storage_delete('endorsement', 'endorsement_pdf', 'delete-me.pdf')); + $this->assertFalse(storage_exists('endorsement', 'endorsement_pdf', 'delete-me.pdf')); + } + + public function testStorageInlineSetsInlineDisposition(): void + { + storage_put('policy', 'policy_pdf', 'inline.pdf', 'inline'); + + $response = storage_inline('policy', 'policy_pdf', 'inline.pdf'); + + $this->assertStringContainsString('inline', $response->getHeaderLine('Content-Disposition')); + } + + public function testStorageLocalPathMatchesStoredFile(): void + { + storage_put('policy', 'policy_pdf', 'process.pdf', 'process'); + + $path = storage_local_path('policy', 'policy_pdf', 'process.pdf'); + + $this->assertFileExists($path); + $this->assertSame('process', file_get_contents($path)); + } +} diff --git a/tests/unit/Storage/LocalStorageDriverTest.php b/tests/unit/Storage/LocalStorageDriverTest.php new file mode 100644 index 0000000..0092c99 --- /dev/null +++ b/tests/unit/Storage/LocalStorageDriverTest.php @@ -0,0 +1,189 @@ +assertSame('local', $config->driver); + } + + public function testInvalidDriverFallsBackToLocal(): void + { + $previous = getenv('FILE_STORAGE_DRIVER'); + putenv('FILE_STORAGE_DRIVER=invalid'); + + try { + $config = new Storage(); + $this->assertSame('local', $config->driver); + } finally { + if ($previous === false) { + putenv('FILE_STORAGE_DRIVER'); + } else { + putenv('FILE_STORAGE_DRIVER=' . $previous); + } + } + } + + public function testRetainLocalParsesBooleanEnv(): void + { + $previous = getenv('RETAIN_LOCAL'); + putenv('RETAIN_LOCAL=true'); + + try { + $config = new Storage(); + $this->assertTrue($config->retainLocal); + } finally { + if ($previous === false) { + putenv('RETAIN_LOCAL'); + } else { + putenv('RETAIN_LOCAL=' . $previous); + } + } + } + + public function testS3DriverRequiresCredentials(): void + { + $config = new Storage(); + $config->driver = 's3'; + $config->s3Bucket = ''; + $config->s3AccessKey = ''; + $config->s3SecretKey = ''; + + $this->expectException(FileStorageException::class); + $this->expectExceptionMessage('S3 storage is not configured'); + + new S3StorageDriver($config); + } + + public function testFileStorageServiceReportsDriverMode(): void + { + $this->assertFalse(storage()->isS3()); + + $this->storageConfig->driver = 's3'; + $this->storageConfig->s3Bucket = 'test-bucket'; + $this->storageConfig->s3AccessKey = 'key'; + $this->storageConfig->s3SecretKey = 'secret'; + + \Config\Services::injectMock('fileStorage', new \App\Services\FileStorageService($this->storageConfig)); + + $this->assertTrue(storage()->isS3()); + } +} + +/** + * @internal + */ +final class LocalStorageDriverTest extends StorageTestCase +{ + private LocalStorageDriver $driver; + + protected function setUp(): void + { + parent::setUp(); + + $this->driver = new LocalStorageDriver($this->storageConfig); + } + + public function testUploadFromUploadedFileStoresUnderLocalRoot(): void + { + $file = $this->createUploadedFile('%PDF-1.4 policy', 'policy.pdf', 'application/pdf'); + $key = 'uploads/policy/policy_pdf/test_policy.pdf'; + + $this->driver->upload($file, $key); + + $this->assertFileExists($this->localFilePath($key)); + $this->assertSame('%PDF-1.4 policy', file_get_contents($this->localFilePath($key))); + } + + public function testUploadFromLocalPathCopiesFile(): void + { + $source = tempnam(sys_get_temp_dir(), 'src_'); + file_put_contents($source, 'receipt bytes'); + $key = 'uploads/policy/policy_payment_receipt/receipt.pdf'; + + $this->driver->upload($source, $key); + + $this->assertFileExists($this->localFilePath($key)); + $this->assertSame('receipt bytes', file_get_contents($this->localFilePath($key))); + } + + public function testPutWriteRawContents(): void + { + $key = 'uploads/policy/policy_md/sample.md'; + + $this->driver->put($key, '# Markdown content'); + + $this->assertTrue($this->driver->exists($key)); + $this->assertSame('# Markdown content', $this->driver->read($key)); + } + + public function testExistsReturnsFalseForMissingFile(): void + { + $this->assertFalse($this->driver->exists('uploads/policy/policy_pdf/missing.pdf')); + } + + public function testDeleteRemovesFile(): void + { + $key = 'uploads/endorsement/original.pdf'; + $this->driver->put($key, 'endorsement'); + + $this->assertTrue($this->driver->delete($key)); + $this->assertFalse($this->driver->exists($key)); + } + + public function testDownloadToTempReturnsExistingLocalPath(): void + { + $key = 'uploads/policy/policy_pdf/read.pdf'; + $this->driver->put($key, 'pdf-data'); + + $path = $this->driver->downloadToTemp($key); + + $this->assertSame($this->localFilePath($key), $path); + } + + public function testCopyDuplicatesFile(): void + { + $from = 'uploads/policy/policy_pdf/source.pdf'; + $to = 'uploads/policy/policy_pdf/copy.pdf'; + $this->driver->put($from, 'copy-me'); + + $this->assertTrue($this->driver->copy($from, $to)); + $this->assertSame('copy-me', $this->driver->read($to)); + } + + public function testTemporaryUrlUsesBaseUrlForLocalDriver(): void + { + $key = 'uploads/policy/policy_pdf/url.pdf'; + $this->driver->put($key, 'url'); + + $url = $this->driver->temporaryUrl($key); + + $this->assertStringContainsString('uploads/policy/policy_pdf/url.pdf', $url); + } + + public function testStreamDownloadReturnsResponseWithBody(): void + { + $key = 'uploads/endorsement/endorsement_pdf/completion.pdf'; + $this->driver->put($key, 'completion pdf'); + + $response = $this->driver->streamDownload($key, 'completion.pdf'); + + $this->assertInstanceOf(DownloadResponse::class, $response); + $this->assertDownloadHeaders($response, 'attachment'); + $this->assertSame(strlen('completion pdf'), $response->getContentLength()); + } +} diff --git a/tests/unit/Storage/PolicyStorageHelperTest.php b/tests/unit/Storage/PolicyStorageHelperTest.php new file mode 100644 index 0000000..4cd9f09 --- /dev/null +++ b/tests/unit/Storage/PolicyStorageHelperTest.php @@ -0,0 +1,179 @@ +assertArrayHasKey('policy_pdf', $map); + $this->assertArrayHasKey('policy_payment_receipt', $map); + $this->assertSame('policy_pdf', $map['policy_pdf']['folder']); + $this->assertSame('policy_payment_receipt', $map['policy_payment_receipt']['folder']); + } + + public function testPolicyMdFileNameConvertsPdfToMd(): void + { + $this->assertSame('1764999905_abc.md', policy_md_file_name('1764999905_abc.pdf')); + $this->assertSame('file.md', policy_md_file_name('file')); + } + + public function testPolicyUploadPdfStoresFile(): void + { + $file = $this->createUploadedFile('%PDF policy', 'policy.pdf', 'application/pdf'); + + $stored = policy_upload_pdf($file); + + $this->assertNotNull($stored); + $this->assertTrue(policy_exists_by_type($stored, 'policy_pdf')); + } + + public function testPolicyUploadReceiptStoresFile(): void + { + $file = $this->createUploadedFile('receipt image', 'receipt.jpg', 'image/jpeg'); + + $stored = policy_upload_receipt($file); + + $this->assertNotNull($stored); + $this->assertTrue(policy_exists_by_type($stored, 'policy_payment_receipt')); + } + + public function testPolicyUploadReturnsNullWhenNoFile(): void + { + $this->assertNull(policy_upload_pdf(null)); + $this->assertNull(policy_upload_receipt(null)); + } + + public function testPolicyExistsByTypeReturnsFalseForInvalidType(): void + { + $this->assertFalse(policy_exists_by_type('any.pdf', 'invalid_type')); + } + + public function testPolicyDownloadByTypeAttachment(): void + { + storage_put('policy', 'policy_pdf', 'download.pdf', 'policy-body'); + + $response = policy_download_by_type('download.pdf', 'policy_pdf'); + + $this->assertInstanceOf(DownloadResponse::class, $response); + $this->assertDownloadHeaders($response, 'attachment'); + $this->assertGreaterThan(0, $response->getContentLength()); + } + + public function testPolicyDownloadByTypeInline(): void + { + storage_put('policy', 'policy_payment_receipt', 'receipt.pdf', 'receipt-body'); + + $response = policy_download_by_type('receipt.pdf', 'policy_payment_receipt', true); + + $this->assertInstanceOf(DownloadResponse::class, $response); + $this->assertDownloadHeaders($response, 'inline'); + $this->assertGreaterThan(0, $response->getContentLength()); + } + + public function testPolicyDownloadByTypeThrowsForInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + + policy_download_by_type('file.pdf', 'unknown'); + } + + public function testPolicyLocalPdfPathResolvesStoredPdf(): void + { + storage_put('policy', 'policy_pdf', 'rag.pdf', 'rag-pdf'); + + $path = policy_local_pdf_path('rag.pdf'); + + $this->assertFileExists($path); + $this->assertSame('rag-pdf', file_get_contents($path)); + } + + public function testPolicyMarkdownPutReadAndExists(): void + { + policy_put_md('sample.md', str_repeat('x', 600)); + + $this->assertTrue(policy_md_exists('sample.md')); + $this->assertSame(str_repeat('x', 600), policy_read_md('sample.md')); + } +} + +/** + * @internal + */ +final class EndorsementStorageHelperTest extends StorageTestCase +{ + public function testEndorsementSubfolderForType(): void + { + $this->assertSame('', endorsement_subfolder_for_type('original')); + $this->assertSame('', endorsement_subfolder_for_type(null)); + $this->assertSame('endorsement_pdf', endorsement_subfolder_for_type('completion')); + } + + public function testEndorsementUploadOriginalStoresInRootFolder(): void + { + $file = $this->createUploadedFile('original endorsement', 'endorsement.pdf', 'application/pdf'); + + $stored = endorsement_upload_original($file); + + $this->assertNotNull($stored); + $this->assertTrue(endorsement_exists_by_type($stored, 'original')); + $this->assertFileExists($this->localFilePath('uploads/endorsement/' . $stored)); + } + + public function testEndorsementUploadCompletionStoresInSubfolder(): void + { + $file = $this->createUploadedFile('completion endorsement', 'completion.pdf', 'application/pdf'); + + $stored = endorsement_upload_completion($file); + + $this->assertNotNull($stored); + $this->assertTrue(endorsement_exists_by_type($stored, 'completion')); + $this->assertFileExists($this->localFilePath('uploads/endorsement/endorsement_pdf/' . $stored)); + } + + public function testEndorsementDownloadOriginalAndCompletion(): void + { + storage_put('endorsement', '', 'orig.pdf', 'orig-body'); + storage_put('endorsement', 'endorsement_pdf', 'done.pdf', 'done-body'); + + $originalResponse = endorsement_download_by_type('orig.pdf', 'original'); + $completionResponse = endorsement_download_by_type('done.pdf', 'completion'); + + $this->assertInstanceOf(DownloadResponse::class, $originalResponse); + $this->assertInstanceOf(DownloadResponse::class, $completionResponse); + $this->assertGreaterThan(0, $originalResponse->getContentLength()); + $this->assertGreaterThan(0, $completionResponse->getContentLength()); + } + + public function testEndorsementDownloadInlineUsesInlineDisposition(): void + { + storage_put('endorsement', 'endorsement_pdf', 'view.pdf', 'view-body'); + + $response = endorsement_download_by_type('view.pdf', 'completion', true); + + $this->assertStringContainsString('inline', $response->getHeaderLine('Content-Disposition')); + } + + public function testEndorsementDeleteCompletionRemovesFile(): void + { + storage_put('endorsement', 'endorsement_pdf', 'old.pdf', 'old'); + + $this->assertTrue(endorsement_delete_completion('old.pdf')); + $this->assertFalse(endorsement_exists_by_type('old.pdf', 'completion')); + } + + public function testEndorsementUploadReturnsNullWhenNoFile(): void + { + $this->assertNull(endorsement_upload_original(null)); + $this->assertNull(endorsement_upload_completion(null)); + } +} diff --git a/tests/unit/Storage/S3StorageIntegrationTest.php b/tests/unit/Storage/S3StorageIntegrationTest.php new file mode 100644 index 0000000..f286283 --- /dev/null +++ b/tests/unit/Storage/S3StorageIntegrationTest.php @@ -0,0 +1,191 @@ +s3IntegrationEnabled()) { + $this->useS3DriverFromEnv(); + + if ($this->uploadedPolicyKey !== null) { + @storage()->deleteKey($this->uploadedPolicyKey); + } + + if ($this->uploadedEndorsementKey !== null) { + @storage()->deleteKey($this->uploadedEndorsementKey); + } + } + + parent::tearDown(); + } + + public function testS3DriverIsActiveFromEnv(): void + { + $this->skipUnlessS3Integration(); + + $this->assertTrue(storage()->isS3()); + } + + public function testS3PolicyPdfUploadExistsReadAndDelete(): void + { + $this->skipUnlessS3Integration(); + + $fileName = 'test_policy_' . uniqid('', true) . '.pdf'; + $file = $this->createUploadedFile('%PDF-1.4 test policy', $fileName, 'application/pdf'); + + $stored = policy_upload_pdf($file); + + $this->assertIsString($stored); + $this->assertNotSame('', $stored); + $this->uploadedPolicyKey = storage()->resolveKey('policy', 'policy_pdf', $stored); + $this->assertTrue(policy_exists_by_type($stored, 'policy_pdf')); + $this->assertSame('%PDF-1.4 test policy', storage_read('policy', 'policy_pdf', $stored)); + + $tempPath = policy_local_pdf_path($stored); + $this->assertFileExists($tempPath); + + $this->assertTrue(storage_delete('policy', 'policy_pdf', $stored)); + $this->uploadedPolicyKey = null; + $this->assertFalse(policy_exists_by_type($stored, 'policy_pdf')); + } + + public function testS3PolicyReceiptUploadAndTemporaryUrl(): void + { + $this->skipUnlessS3Integration(); + + $fileName = 'test_receipt_' . uniqid('', true) . '.jpg'; + $file = $this->createUploadedFile('receipt-image-bytes', $fileName, 'image/jpeg'); + + $stored = policy_upload_receipt($file); + + $this->assertIsString($stored); + $this->assertNotSame('', $stored); + $this->uploadedPolicyKey = storage()->resolveKey('policy', 'policy_payment_receipt', $stored); + $this->assertTrue(policy_exists_by_type($stored, 'policy_payment_receipt')); + + $url = storage_temporary_url('policy', 'policy_payment_receipt', $stored); + $this->assertStringStartsWith('https://', $url); + } + + public function testS3PolicyMarkdownPutAndRead(): void + { + $this->skipUnlessS3Integration(); + + $mdName = 'test_' . uniqid('', true) . '.md'; + $content = str_repeat('policy markdown ', 40); + + policy_put_md($mdName, $content); + $this->uploadedPolicyKey = storage()->resolveKey('policy', 'policy_md', $mdName); + + $this->assertTrue(policy_md_exists($mdName)); + $this->assertSame($content, policy_read_md($mdName)); + } + + public function testS3EndorsementOriginalAndCompletionUpload(): void + { + $this->skipUnlessS3Integration(); + + $originalFile = $this->createUploadedFile('original', 'orig.pdf', 'application/pdf'); + $completionFile = $this->createUploadedFile('completion', 'comp.pdf', 'application/pdf'); + + $original = endorsement_upload_original($originalFile); + $completion = endorsement_upload_completion($completionFile); + + $this->assertIsString($original); + $this->assertIsString($completion); + $this->uploadedEndorsementKey = storage()->resolveKey('endorsement', '', $original); + $this->assertTrue(endorsement_exists_by_type($original, 'original')); + $this->assertTrue(endorsement_exists_by_type($completion, 'completion')); + + $response = endorsement_download_by_type($completion, 'completion', true); + $this->assertStringContainsString('inline', $response->getHeaderLine('Content-Disposition')); + } + + public function testLocalDriverScenarioStillWorksWhenEnvIsLocal(): void + { + if (getenv('FILE_STORAGE_DRIVER') === 's3') { + $this->markTestSkipped('This scenario validates explicit local override; current env is s3.'); + } + + $this->useLocalDriver(); + + $stored = policy_upload_pdf( + $this->createUploadedFile('local pdf', 'local.pdf', 'application/pdf') + ); + + $this->assertNotNull($stored); + $this->assertTrue(policy_exists_by_type($stored, 'policy_pdf')); + $this->assertFileExists($this->localFilePath(storage()->resolveKey('policy', 'policy_pdf', $stored))); + } +} + +/** + * @internal + */ +final class StorageScenarioMatrixTest extends StorageTestCase +{ + public function testAllPolicyFoldersCanStoreAndRetrieve(): void + { + $scenarios = [ + ['policy_pdf', 'policy.pdf', 'pdf-content'], + ['policy_payment_receipt', 'receipt.jpg', 'receipt-content'], + ['policy_md', 'policy.md', str_repeat('md ', 200)], + ]; + + foreach ($scenarios as [$folder, $name, $contents]) { + storage_put('policy', $folder, $name, $contents); + + $this->assertTrue(storage_exists('policy', $folder, $name), "Missing {$folder}/{$name}"); + $this->assertSame($contents, storage_read('policy', $folder, $name), "Read failed {$folder}/{$name}"); + } + } + + public function testAllEndorsementFoldersCanStoreAndRetrieve(): void + { + storage_put('endorsement', '', 'original.pdf', 'original'); + storage_put('endorsement', 'endorsement_pdf', 'completion.pdf', 'completion'); + + $this->assertSame('original', storage_read('endorsement', '', 'original.pdf')); + $this->assertSame('completion', storage_read('endorsement', 'endorsement_pdf', 'completion.pdf')); + } + + public function testMissingFileDownloadScenarioThrowsOrFailsExistsCheck(): void + { + $this->assertFalse(policy_exists_by_type('missing.pdf', 'policy_pdf')); + $this->assertFalse(endorsement_exists_by_type('missing.pdf', 'original')); + $this->assertFalse(endorsement_exists_by_type('missing.pdf', 'completion')); + } + + public function testReplaceEndorsementCompletionDeletesOldFile(): void + { + $oldName = endorsement_upload_completion( + $this->createUploadedFile('old completion', 'old.pdf', 'application/pdf') + ); + $this->assertTrue(endorsement_exists_by_type($oldName, 'completion')); + + endorsement_delete_completion($oldName); + $this->assertFalse(endorsement_exists_by_type($oldName, 'completion')); + + $newName = endorsement_upload_completion( + $this->createUploadedFile('new completion', 'new.pdf', 'application/pdf') + ); + $this->assertTrue(endorsement_exists_by_type($newName, 'completion')); + } +} diff --git a/writable/uploads/endorsement/1784195838_spark_test_endorsement.pdf b/writable/uploads/endorsement/1784195838_spark_test_endorsement.pdf new file mode 100644 index 0000000..32ee333 --- /dev/null +++ b/writable/uploads/endorsement/1784195838_spark_test_endorsement.pdf @@ -0,0 +1 @@ +%PDF-1.4 endorsement original test \ No newline at end of file diff --git a/writable/uploads/endorsement/endorsement_pdf/1784195838_spark_test_completion.pdf b/writable/uploads/endorsement/endorsement_pdf/1784195838_spark_test_completion.pdf new file mode 100644 index 0000000..f971d71 --- /dev/null +++ b/writable/uploads/endorsement/endorsement_pdf/1784195838_spark_test_completion.pdf @@ -0,0 +1 @@ +%PDF-1.4 endorsement completion test \ No newline at end of file