FEAT_FILE_UPLOAD_LOCAL_TO_S3

This commit is contained in:
VENKATESHWARAN 2026-07-16 18:37:17 +05:30
parent 5d6b9058f9
commit 4a350f0822
35 changed files with 3769 additions and 302 deletions

View File

@ -0,0 +1,306 @@
<?php
namespace App\Commands;
use App\Services\FileStorageService;
use App\Services\Storage\FileStorageException;
use App\Services\Storage\S3StorageDriver;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Storage;
class StorageMigrateLocalToS3 extends BaseCommand
{
protected $group = 'Storage';
protected $name = 'storage:migrate-local-to-s3';
protected $description = 'Migrate existing local writable/uploads files to S3 (policy & endorsement folders).';
protected $usage = 'storage:migrate-local-to-s3 [options]';
protected $options = [
'--dry-run' => '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<string, int> */
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<array{label: string, module: string, subFolder: string, localRelative: string, topLevelOnly: bool}>
*/
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<string> 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);
}
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class StorageTest extends BaseCommand
{
protected $group = 'Testing';
protected $name = 'storage:test';
protected $description = 'Run policy/endorsement storage tests (local driver and optional live S3).';
protected $usage = 'storage:test [options]';
protected $options = [
'--local' => '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;
}
}

View File

@ -0,0 +1,300 @@
<?php
namespace App\Commands;
use App\Services\Storage\FileStorageException;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Storage;
class StorageUploadTest extends BaseCommand
{
protected $group = 'Testing';
protected $name = 'storage:upload-test';
protected $description = 'Run a live file upload/download/delete check against configured storage (local or S3).';
protected $usage = 'storage:upload-test [options]';
protected $options = [
'--keep' => '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<array{module: string, subFolder: string, fileName: string}> */
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;
}
}

View File

@ -90,5 +90,5 @@ class Autoload extends AutoloadConfig
*
* @var list<string>
*/
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'];
}

View File

@ -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');

View File

@ -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'));
}
}

52
app/Config/Storage.php Normal file
View File

@ -0,0 +1,52 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Storage extends BaseConfig
{
/** @var 'local'|'s3' */
public string $driver = 'local';
/** When using S3, also write a copy to local disk (migration / fallback). */
public bool $retainLocal = false;
public string $localRoot;
public string $s3Bucket = '';
public string $s3Region = 'ap-south-1';
/** Optional key prefix, e.g. uat or prod */
public string $s3Prefix = '';
public string $s3AccessKey = '';
public string $s3SecretKey = '';
/** Default presigned URL TTL in seconds. */
public int $presignedTtl = 900;
public function __construct()
{
parent::__construct();
$driver = getenv('FILE_STORAGE_DRIVER') ?: 'local';
$this->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);
}
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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]);

View File

@ -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);
// }
// }
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Controllers;
use App\Services\Storage\FileStorageException;
use App\Services\StorageBrowserService;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\HTTP\ResponseInterface;
class StorageBrowserController extends BaseController
{
use ResponseTrait;
private StorageBrowserService $browser;
public function __construct()
{
$this->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);
}
}
}

View File

@ -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)) {

View File

@ -0,0 +1,224 @@
<?php
use App\Services\FileStorageService;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
if (!function_exists('storage')) {
function storage(): FileStorageService
{
return service('fileStorage');
}
}
if (!function_exists('storage_upload_if_valid')) {
function storage_upload_if_valid(?UploadedFile $file, string $module, string $subFolder = ''): ?string
{
if ($file === null || ! $file->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<string, array{column: string, folder: string}>
*/
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);
}
}

View File

@ -0,0 +1,176 @@
<?php
namespace App\Services;
use App\Services\Storage\FileStorageInterface;
use App\Services\Storage\LocalStorageDriver;
use App\Services\Storage\S3StorageDriver;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Storage;
class FileStorageService
{
private FileStorageInterface $driver;
private Storage $config;
public function __construct(?Storage $config = null)
{
$this->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),
};
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Services\Storage;
use RuntimeException;
class FileStorageException extends RuntimeException
{
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Services\Storage;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
interface FileStorageInterface
{
/**
* Upload from a CI4 UploadedFile or a local filesystem path.
*
* @param array{content_type?: string} $options
*/
public function upload(UploadedFile|string $source, string $key, array $options = []): string;
/**
* Upload raw string/binary content.
*
* @param array{content_type?: string} $options
*/
public function put(string $key, string $contents, array $options = []): string;
public function exists(string $key): bool;
public function delete(string $key): bool;
public function read(string $key): string;
/** Download object to a local temp file and return its path. */
public function downloadToTemp(string $key): string;
public function streamDownload(string $key, ?string $downloadName = null): ResponseInterface;
public function temporaryUrl(string $key, int $ttlSeconds = 900): string;
public function url(string $key): string;
public function copy(string $fromKey, string $toKey): bool;
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Services\Storage;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Storage;
class LocalStorageDriver implements FileStorageInterface
{
public function __construct(private readonly Storage $config)
{
}
private function fullPath(string $key): string
{
$key = ltrim(str_replace('\\', '/', $key), '/');
return $this->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);
}
}

View File

@ -0,0 +1,232 @@
<?php
namespace App\Services\Storage;
use Aws\Exception\AwsException;
use Aws\S3\S3Client;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Storage;
class S3StorageDriver implements FileStorageInterface
{
private S3Client $client;
public function __construct(private readonly Storage $config)
{
$this->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<string, mixed> $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);
}
}
}

View File

@ -0,0 +1,340 @@
<?php
namespace App\Services;
use App\Services\Storage\FileStorageException;
use Aws\Exception\AwsException;
use Aws\S3\S3Client;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Storage;
class StorageBrowserService
{
private Storage $config;
private FileStorageService $storage;
/** @var list<string> */
private array $folders = [
'uploads/policy/policy_pdf',
'uploads/policy/policy_payment_receipt',
'uploads/policy/policy_md',
'uploads/endorsement',
'uploads/endorsement/endorsement_pdf',
];
/** @var list<string> */
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<array{value: string, label: string}>
*/
public function folders(): array
{
$items = [];
foreach ($this->folders as $folder) {
$items[] = [
'value' => $folder,
'label' => $folder,
];
}
return $items;
}
/**
* @return array{
* files: list<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
* 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<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
* 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<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
* 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';
}
}

View File

@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File browser</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {
--brand: #0d9488;
--brand-dark: #0f766e;
--brand-light: #ccfbf1;
}
body {
background: #f8fafc;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: #0f172a;
}
.page-wrap {
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1rem 3rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
}
.page-subtitle {
color: #64748b;
margin: 0.35rem 0 0;
}
.bucket-badge {
background: var(--brand-light);
color: var(--brand-dark);
border: 1px solid #99f6e4;
border-radius: 999px;
padding: 0.45rem 0.9rem;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.filters {
padding: 1.25rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: end;
}
.form-label {
font-size: 0.85rem;
font-weight: 600;
color: #334155;
}
.btn-brand {
background: var(--brand);
border-color: var(--brand);
color: #fff;
min-width: 110px;
}
.btn-brand:hover,
.btn-brand:focus {
background: var(--brand-dark);
border-color: var(--brand-dark);
color: #fff;
}
.table thead th {
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
border-bottom-color: #e2e8f0;
background: #f8fafc;
}
.table tbody td {
vertical-align: middle;
border-bottom-color: #f1f5f9;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.empty-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--brand-light);
color: var(--brand-dark);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
margin-bottom: 0.75rem;
}
.footer-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.9rem 1.25rem;
border-top: 1px solid #e2e8f0;
color: #64748b;
font-size: 0.9rem;
}
.alert-inline {
margin: 1rem 1.25rem 0;
}
@media (max-width: 768px) {
.filters {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="page-wrap">
<div class="page-header">
<div>
<h1 class="page-title">File browser</h1>
<p class="page-subtitle">Browse upload objects by folder. Results load 10 at a time.</p>
</div>
<div class="bucket-badge" title="Storage driver: <?= esc($driver) ?>"><?= esc($bucket) ?></div>
</div>
<div class="panel">
<div id="alertBox"></div>
<div class="filters">
<div>
<label for="folderSelect" class="form-label">Folder</label>
<select id="folderSelect" class="form-select">
<option value="">Search or select folder</option>
</select>
</div>
<div>
<label for="searchInput" class="form-label">File name</label>
<input id="searchInput" type="text" class="form-control" placeholder="Search file name...">
</div>
<div>
<button id="searchBtn" class="btn btn-brand w-100">Search</button>
</div>
</div>
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th>File name</th>
<th>Size</th>
<th>Last modified</th>
<th>Action</th>
</tr>
</thead>
<tbody id="filesBody">
<tr>
<td colspan="4">
<div class="empty-state">
<div class="empty-icon">📁</div>
<div>Select a folder to load files</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="footer-bar">
<div id="pageInfo">Page </div>
<div class="d-flex gap-2">
<button id="prevBtn" class="btn btn-outline-secondary btn-sm" disabled>Previous</button>
<button id="nextBtn" class="btn btn-brand btn-sm" disabled>Next</button>
</div>
</div>
</div>
</div>
<script>
const baseUrl = <?= json_encode(base_url()) ?>.replace(/\/$/, '');
const apiBase = baseUrl + '/storage/browser';
const folderSelect = document.getElementById('folderSelect');
const searchInput = document.getElementById('searchInput');
const searchBtn = document.getElementById('searchBtn');
const filesBody = document.getElementById('filesBody');
const pageInfo = document.getElementById('pageInfo');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const alertBox = document.getElementById('alertBox');
let currentFolder = '';
let currentSearch = '';
let pageNumber = 1;
let tokenStack = [''];
let nextToken = null;
function showAlert(message, type = 'danger') {
alertBox.innerHTML = `<div class="alert alert-${type} alert-inline mb-0">${message}</div>`;
}
function clearAlert() {
alertBox.innerHTML = '';
}
function renderEmpty(message) {
filesBody.innerHTML = `
<tr>
<td colspan="4">
<div class="empty-state">
<div class="empty-icon">📁</div>
<div>${message}</div>
</div>
</td>
</tr>`;
}
function renderLoading() {
filesBody.innerHTML = `
<tr>
<td colspan="4" class="text-center py-4 text-muted">Loading files...</td>
</tr>`;
}
function renderFiles(files) {
if (!files.length) {
renderEmpty('No files found in this folder');
return;
}
filesBody.innerHTML = files.map((file) => {
const openUrl = `${apiBase}/open?key=${encodeURIComponent(file.key)}`;
const downloadUrl = `${apiBase}/download?key=${encodeURIComponent(file.key)}`;
return `
<tr>
<td>${escapeHtml(file.file_name)}</td>
<td>${escapeHtml(file.size_human)}</td>
<td>${escapeHtml(file.last_modified || '—')}</td>
<td>
<a class="btn btn-sm btn-outline-primary me-1" href="${openUrl}" target="_blank" rel="noopener">Open</a>
<a class="btn btn-sm btn-brand" href="${downloadUrl}">Download</a>
</td>
</tr>`;
}).join('');
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function updatePagination() {
pageInfo.textContent = currentFolder ? `Page ${pageNumber}` : 'Page —';
prevBtn.disabled = pageNumber <= 1;
nextBtn.disabled = !nextToken;
}
async function loadFolders() {
const response = await fetch(`${apiBase}/folders`);
const data = await response.json();
if (data.status !== 'success') {
showAlert(data.message || 'Unable to load folders');
return;
}
data.folders.forEach((folder) => {
const option = document.createElement('option');
option.value = folder.value;
option.textContent = folder.label;
folderSelect.appendChild(option);
});
}
async function loadFiles(resetPagination = true) {
clearAlert();
currentFolder = folderSelect.value;
currentSearch = searchInput.value.trim();
if (!currentFolder) {
renderEmpty('Select a folder to load files');
pageNumber = 1;
tokenStack = [''];
nextToken = null;
updatePagination();
return;
}
if (resetPagination) {
pageNumber = 1;
tokenStack = [''];
}
renderLoading();
const params = new URLSearchParams({
folder: currentFolder,
limit: '10',
});
if (currentSearch) {
params.set('q', currentSearch);
}
const pageToken = tokenStack[tokenStack.length - 1];
if (pageToken) {
params.set('page_token', pageToken);
}
try {
const response = await fetch(`${apiBase}/files?${params.toString()}`);
const data = await response.json();
if (data.status !== 'success') {
showAlert(data.message || 'Unable to load files');
renderEmpty('Unable to load files');
nextToken = null;
updatePagination();
return;
}
renderFiles(data.files || []);
nextToken = data.next_token || null;
updatePagination();
} catch (error) {
showAlert('Network error while loading files');
renderEmpty('Unable to load files');
nextToken = null;
updatePagination();
}
}
searchBtn.addEventListener('click', () => loadFiles(true));
searchInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
loadFiles(true);
}
});
prevBtn.addEventListener('click', () => {
if (pageNumber <= 1) {
return;
}
tokenStack.pop();
pageNumber -= 1;
loadFiles(false);
});
nextBtn.addEventListener('click', () => {
if (!nextToken) {
return;
}
tokenStack.push(nextToken);
pageNumber += 1;
loadFiles(false);
});
loadFolders();
</script>
</body>
</html>

View File

@ -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}}

23
build/logs/logfile.xml Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="6" assertions="3" errors="0" failures="0" skipped="5" time="0.027320">
<testsuite name="Tests\Unit\Storage\S3StorageIntegrationTest" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" tests="6" assertions="3" errors="0" failures="0" skipped="5" time="0.027320">
<testcase name="testS3DriverIsActiveFromEnv" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="39" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.010998">
<skipped/>
</testcase>
<testcase name="testS3PolicyPdfUploadExistsReadAndDelete" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="46" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002448">
<skipped/>
</testcase>
<testcase name="testS3PolicyReceiptUploadAndTemporaryUrl" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="69" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002169">
<skipped/>
</testcase>
<testcase name="testS3PolicyMarkdownPutAndRead" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="87" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002258">
<skipped/>
</testcase>
<testcase name="testS3EndorsementOriginalAndCompletionUpload" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="101" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002006">
<skipped/>
</testcase>
<testcase name="testLocalDriverScenarioStillWorksWhenEnvIsLocal" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="121" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="3" time="0.007442"/>
</testsuite>
</testsuite>
</testsuites>

64
build/logs/testdox.html Normal file
View File

@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Test Documentation</title>
<style>
body {
text-rendering: optimizeLegibility;
font-family: Source SansSerif Pro, Arial, sans-serif;
font-variant-ligatures: common-ligatures;
font-kerning: normal;
margin-left: 2rem;
background-color: #fff;
color: #000;
}
body > ul > li {
font-size: larger;
}
h2 {
font-size: larger;
text-decoration-line: underline;
text-decoration-thickness: 2px;
margin: 0;
padding: 0.5rem 0;
}
ul {
list-style: none;
margin: 0 0 2rem;
padding: 0 0 0 1rem;
text-indent: -1rem;
}
.success:before {
color: #4e9a06;
content: '✓';
padding-right: 0.5rem;
}
.defect {
color: #a40000;
}
.defect:before {
color: #a40000;
content: '✗';
padding-right: 0.5rem;
}
</style>
</head>
<body>
<h2>S3Storage Integration (Tests\Unit\Storage\S3StorageIntegration)</h2>
<ul>
<li class="defect">S 3 driver is active from env</li>
<li class="defect">S 3 policy pdf upload exists read and delete</li>
<li class="defect">S 3 policy receipt upload and temporary url</li>
<li class="defect">S 3 policy markdown put and read</li>
<li class="defect">S 3 endorsement original and completion upload</li>
<li class="success">Local driver scenario still works when env is local</li>
</ul>
</body>
</html>

8
build/logs/testdox.txt Normal file
View File

@ -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

View File

@ -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",

221
composer.lock generated
View File

@ -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"
}

View File

@ -0,0 +1,128 @@
<?php
namespace Tests\Support\Storage;
use App\Services\FileStorageService;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\Test\CIUnitTestCase;
use Config\Services;
use Config\Storage;
abstract class StorageTestCase extends CIUnitTestCase
{
protected string $tempRoot;
protected Storage $storageConfig;
protected function setUp(): void
{
parent::setUp();
$this->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'));
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Tests\Support\Storage;
use CodeIgniter\HTTP\Exceptions\HTTPException;
use CodeIgniter\HTTP\Files\UploadedFile;
/**
* UploadedFile test double that works outside HTTP upload context.
*/
class TestUploadedFile extends UploadedFile
{
public function isValid(): bool
{
return $this->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;
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace Tests\Unit\Storage;
use CodeIgniter\HTTP\DownloadResponse;
use Tests\Support\Storage\StorageTestCase;
/**
* @internal
*/
final class FileStorageServiceTest extends StorageTestCase
{
public function testResolveKeyWithSubFolder(): void
{
$key = storage()->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));
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace Tests\Unit\Storage;
use App\Services\Storage\FileStorageException;
use App\Services\Storage\LocalStorageDriver;
use App\Services\Storage\S3StorageDriver;
use CodeIgniter\HTTP\DownloadResponse;
use Config\Storage;
use Tests\Support\Storage\StorageTestCase;
/**
* @internal
*/
final class StorageConfigTest extends StorageTestCase
{
public function testDefaultsToLocalDriverWhenEnvMissing(): void
{
$config = new Storage();
$this->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());
}
}

View File

@ -0,0 +1,179 @@
<?php
namespace Tests\Unit\Storage;
use CodeIgniter\HTTP\DownloadResponse;
use InvalidArgumentException;
use Tests\Support\Storage\StorageTestCase;
/**
* @internal
*/
final class PolicyStorageHelperTest extends StorageTestCase
{
public function testPolicyFileTypeMapContainsExpectedTypes(): void
{
$map = policy_file_type_map();
$this->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));
}
}

View File

@ -0,0 +1,191 @@
<?php
namespace Tests\Unit\Storage;
use Tests\Support\Storage\StorageTestCase;
/**
* Optional live S3 integration tests.
*
* Runs only when .env (or environment) has:
* FILE_STORAGE_DRIVER=s3, AWS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
*
* @internal
* @group s3-integration
*/
final class S3StorageIntegrationTest extends StorageTestCase
{
private ?string $uploadedPolicyKey = null;
private ?string $uploadedEndorsementKey = null;
protected function tearDown(): void
{
if ($this->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'));
}
}

View File

@ -0,0 +1 @@
%PDF-1.4 endorsement original test

View File

@ -0,0 +1 @@
%PDF-1.4 endorsement completion test