diff --git a/.env.example b/.env.example index eba5650..5c8700a 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,11 @@ MAIL_FROM_NAME="${APP_NAME}" AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 +AWS_DEFAULT_REGION=ap-southeast-1 AWS_BUCKET= +AWS_UPLOAD_PREFIX=csv-uploads +AWS_PRESIGN_TTL_MINUTES=10 +AWS_S3_UPLOAD_NOTIFICATION_QUEUE_URL= AWS_USE_PATH_STYLE_ENDPOINT=false PUSHER_APP_ID=csv_upload diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55c7384..b45a3d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,11 +40,6 @@ jobs: SESSION_DRIVER: array TELESCOPE_ENABLED: false - strategy: - fail-fast: false - matrix: - php-version: ["8.1", "8.2", "8.3"] - steps: - name: Checkout code uses: actions/checkout@v4 @@ -52,7 +47,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php-version }} + php-version: "8.3" extensions: mbstring, pdo_mysql, dom, pcntl, posix, bcmath coverage: none tools: composer:v2 @@ -61,9 +56,9 @@ jobs: uses: actions/cache@v4 with: path: vendor - key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('composer.lock') }} + key: ${{ runner.os }}-php-8.3-${{ hashFiles('composer.lock') }} restore-keys: | - ${{ runner.os }}-php-${{ matrix.php-version }}- + ${{ runner.os }}-php-8.3- - name: Install PHP dependencies run: composer install --no-interaction --no-progress --prefer-dist diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md deleted file mode 100644 index 655e1c3..0000000 --- a/.kiro/steering/product.md +++ /dev/null @@ -1,17 +0,0 @@ -# Product - -CSV Upload is a Laravel web application for uploading, processing, and storing CSV product data. Users upload CSV files through a web interface; the app processes them in background queue jobs and streams real-time progress back to the browser via WebSockets. - -## Core Capabilities - -- CSV file upload with validation (csv, xlsx, xls) -- Background batch processing using Laravel job batches (chunks of 1000 rows) -- `updateOrCreate` upsert logic keyed on `unique_key` for the `products` table -- Real-time progress tracking via WebSocket broadcasts on the `csv-uploads` channel -- File status lifecycle: Pending → Processing → Completed / Failed -- Upload history listing with pagination - -## Domain Models - -- `File` — tracks each upload (name, size, status, linked batch ID) -- `Product` — the target data model populated/updated from CSV rows diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md deleted file mode 100644 index 5a4093f..0000000 --- a/.kiro/steering/structure.md +++ /dev/null @@ -1,49 +0,0 @@ -# Project Structure - -Standard Laravel 10 layout with the following conventions: - -``` -app/ - Console/ # Scheduled commands (Kernel.php) - Enums/ # PHP 8.1 backed enums (e.g. FileStatus: int) - Events/ # Broadcastable events (extend BaseCsvUploadEvent) - Http/ - Controllers/ # Thin controllers — delegate to Services - Middleware/ # Standard Laravel middleware - Requests/ # Form Request classes for validation - Jobs/ # Queueable jobs (implement ShouldQueue + Batchable) - Listeners/ # Event subscribers (CSVUploadEventSubscriber) - Models/ # Eloquent models - Providers/ # Service providers (events registered in EventServiceProvider) - Services/ # Business logic layer (FileService, JobBatchService) - -database/ - migrations/ # Schema migrations (anonymous class style) - factories/ # Model factories - seeders/ - -resources/ - views/ # Blade templates - js/ # app.js (Echo/Pusher setup), bootstrap.js - css/ # app.css (Tailwind entry) - -routes/ - web.php # Only two routes: GET / and POST /upload - api.php # Unused currently - channels.php # Broadcast channel auth - -config/ # Standard Laravel config files + websockets.php -docker/ # Sail-compatible Dockerfiles for PHP 8.0–8.3 -``` - -## Conventions - -- Controllers are thin — validation via Form Requests, logic in Services -- Services are injected via constructor DI (no facades in service classes) -- Events broadcast on public channels; use `broadcastAs()` to set event name and `broadcastWith()` to control payload -- Event listeners are registered as subscribers (`$subscribe` array in `EventServiceProvider`), not individual `$listen` mappings -- Jobs use `Batchable` trait; always check `$this->batch()?->cancelled()` at the start of `handle()` -- CSV rows are chunked into batches of 1000 before dispatching jobs -- Enums are used for model status fields with `$casts` in the model -- Migrations use anonymous classes (`return new class extends Migration`) -- PSR-4 autoloading: `App\` → `app/`, `Tests\` → `tests/` diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md deleted file mode 100644 index 9b7d1b5..0000000 --- a/.kiro/steering/tech.md +++ /dev/null @@ -1,56 +0,0 @@ -# Tech Stack - -## Backend -- PHP 8.1+, Laravel 10 -- MySQL (primary database) -- Redis (queue driver via `predis/predis`) -- Laravel Horizon (queue monitoring dashboard) -- Laravel Sanctum (API auth scaffolding, not actively used in current routes) -- `beyondcode/laravel-websockets` — self-hosted WebSocket server (Pusher-compatible, port 6001) -- `pusher/pusher-php-server` — broadcasting driver - -## Frontend -- Blade templates -- Tailwind CSS 4 (via PostCSS) -- Vite (asset bundler) -- Laravel Echo + pusher-js (WebSocket client) -- Axios - -## Testing -- PHPUnit 10 (`phpunit.xml` at project root) -- Mockery for mocking -- Laravel Pint for code style (PSR-12) - -## Common Commands - -```bash -# Install dependencies -composer install -npm install - -# Build frontend assets -npm run build # production -npm run dev # dev server (Vite HMR) - -# Laravel -php artisan serve # start dev HTTP server (port 8000) -php artisan migrate # run database migrations -php artisan migrate:fresh # drop all tables and re-run migrations -php artisan key:generate # generate app key - -# Queue & WebSockets (run in separate terminals) -php artisan queue:work # process queued jobs -php artisan horizon # start Horizon queue manager -php artisan websockets:serve # start WebSocket server (port 6001) - -# Testing & code style -php artisan test # run PHPUnit test suite -./vendor/bin/pint # fix code style with Laravel Pint -``` - -## Environment -Key `.env` variables: -- `DB_*` — MySQL connection -- `REDIS_*` — Redis connection -- `PUSHER_APP_ID`, `PUSHER_APP_KEY`, `PUSHER_APP_SECRET` — used by both the WebSocket server and Echo client -- `BROADCAST_DRIVER=pusher`, `QUEUE_CONNECTION=redis` diff --git a/app/Console/Commands/PollS3UploadNotifications.php b/app/Console/Commands/PollS3UploadNotifications.php new file mode 100644 index 0000000..1c770cf --- /dev/null +++ b/app/Console/Commands/PollS3UploadNotifications.php @@ -0,0 +1,176 @@ +error('AWS_S3_UPLOAD_NOTIFICATION_QUEUE_URL is not configured.'); + + return self::FAILURE; + } + + $client = $this->sqsClient(); + + do { + $messages = $this->receiveMessages($client, $queueUrl); + + foreach ($messages as $message) { + if ($this->processMessage($message)) { + $client->deleteMessage([ + 'QueueUrl' => $queueUrl, + 'ReceiptHandle' => $message['ReceiptHandle'], + ]); + } + } + } while (! $this->option('once')); + + return self::SUCCESS; + } + + private function sqsClient(): SqsClient + { + $config = [ + 'version' => 'latest', + 'region' => config('services.aws.region'), + ]; + + if (config('services.aws.key') && config('services.aws.secret')) { + $config['credentials'] = [ + 'key' => config('services.aws.key'), + 'secret' => config('services.aws.secret'), + ]; + } + + return new SqsClient($config); + } + + private function receiveMessages(SqsClient $client, string $queueUrl): array + { + $result = $client->receiveMessage([ + 'QueueUrl' => $queueUrl, + 'MaxNumberOfMessages' => 10, + 'WaitTimeSeconds' => 20, + 'MessageAttributeNames' => ['All'], + ]); + + return $result->get('Messages') ?? []; + } + + private function processMessage(array $message): bool + { + try { + $records = $this->extractRecords((string) ($message['Body'] ?? '')); + + foreach ($records as $record) { + $this->processRecord($record); + } + + return true; + } catch (\Throwable $e) { + Log::error('Failed to process S3 upload notification: '.$e->getMessage(), [ + 'exception' => $e, + 'message_id' => $message['MessageId'] ?? null, + ]); + + return false; + } + } + + private function extractRecords(string $body): array + { + $payload = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + + if (isset($payload['Message']) && is_string($payload['Message'])) { + $payload = json_decode($payload['Message'], true, flags: JSON_THROW_ON_ERROR); + } + + return $payload['Records'] ?? []; + } + + private function processRecord(array $record): void + { + $eventName = (string) ($record['eventName'] ?? ''); + + if (! str_starts_with($eventName, 'ObjectCreated:')) { + return; + } + + $key = urldecode(str_replace('+', ' ', (string) data_get($record, 's3.object.key'))); + $prefix = trim((string) config('services.aws.upload_prefix', 'csv-uploads'), '/').'/'; + + if ($key === '' || ! str_starts_with($key, $prefix)) { + return; + } + + $file = $this->findFileForKey($key, $prefix); + + if (! $file) { + Log::warning('S3 upload notification did not match a file record.', ['key' => $key]); + + return; + } + + if (in_array($file->status, [FileStatus::Processing, FileStatus::Completed], true)) { + return; + } + + $this->fileService->processStoredFile($file); + } + + private function findFileForKey(string $key, string $prefix): ?File + { + $file = File::where('storage_disk', 's3') + ->where('storage_path', $key) + ->first(); + + if ($file) { + return $file; + } + + $relativeKey = substr($key, strlen($prefix)); + $segments = explode('/', $relativeKey, 2); + $fileId = filter_var($segments[0] ?? null, FILTER_VALIDATE_INT); + + if (! $fileId) { + return null; + } + + $file = File::where('storage_disk', 's3')->find($fileId); + + if (! $file || $file->status !== FileStatus::Pending || $file->job_batch_id !== null) { + return null; + } + + Log::info('S3 upload key differed from presigned storage path; using notification key.', [ + 'file_id' => $file->id, + 'expected_key' => $file->storage_path, + 'actual_key' => $key, + ]); + + $file->update(['storage_path' => $key]); + + return $file->refresh(); + } +} diff --git a/app/Events/CsvFileUploaded.php b/app/Events/CsvFileUploaded.php index 4fd86e9..8220c6f 100644 --- a/app/Events/CsvFileUploaded.php +++ b/app/Events/CsvFileUploaded.php @@ -4,8 +4,6 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -18,8 +16,10 @@ public function __construct( public string $fileName, public string $batchId, public string $fileId, - public string $uploadedAt - ) {} + public string $uploadedAt, + public string $status = 'Pending' + ) { + } public function broadcastOn(): array { @@ -40,7 +40,7 @@ public function broadcastWith(): array 'batchId' => $this->batchId, 'fileId' => $this->fileId, 'uploadedAt' => $this->uploadedAt, - 'status' => 'Pending' + 'status' => $this->status, ]; } } diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php index 0a9f2bc..0fc1e40 100644 --- a/app/Http/Controllers/FileController.php +++ b/app/Http/Controllers/FileController.php @@ -4,7 +4,10 @@ use App\Events\CsvFileUploaded; use App\Http\Requests\FileUploadRequest; +use App\Http\Requests\PresignedUploadRequest; +use App\Models\File; use App\Services\FileService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Log; use Illuminate\View\View; @@ -13,12 +16,11 @@ class FileController extends Controller { public function __construct( protected FileService $fileService - ) {} + ) { + } /** * Display a listing of uploaded files. - * - * @return View */ public function index(): View { @@ -29,9 +31,6 @@ public function index(): View /** * Store a newly uploaded file and dispatch processing. - * - * @param FileUploadRequest $request - * @return RedirectResponse */ public function store(FileUploadRequest $request): RedirectResponse { @@ -49,9 +48,35 @@ public function store(FileUploadRequest $request): RedirectResponse return redirect()->route('files.index')->with('success-msg', 'File Upload Success!'); } catch (\Throwable $e) { - Log::error('File upload failed: ' . $e->getMessage(), ['exception' => $e]); + Log::error('File upload failed: '.$e->getMessage(), ['exception' => $e]); return redirect()->route('files.index')->with('error-msg', 'File upload failed. Please try again.'); } } + + public function presign(PresignedUploadRequest $request): JsonResponse + { + try { + $upload = $this->fileService->createPresignedUpload( + (string) $request->validated('name'), + (int) $request->validated('size'), + $request->validated('type') + ); + + return response()->json($upload); + } catch (\Throwable $e) { + Log::error('Presigned upload URL generation failed: '.$e->getMessage(), ['exception' => $e]); + + return response()->json([ + 'message' => 'Could not prepare the upload. Please try again.', + ], 500); + } + } + + public function cancelUpload(File $file): JsonResponse + { + $this->fileService->cancelPendingUpload($file); + + return response()->json(['ok' => true]); + } } diff --git a/app/Http/Requests/PresignedUploadRequest.php b/app/Http/Requests/PresignedUploadRequest.php new file mode 100644 index 0000000..bcf6388 --- /dev/null +++ b/app/Http/Requests/PresignedUploadRequest.php @@ -0,0 +1,33 @@ + ['required', 'string', 'max:255'], + 'size' => ['required', 'integer', 'min:1', 'max:52428800'], + 'type' => ['nullable', 'string', 'max:255'], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $extension = strtolower(pathinfo((string) $this->input('name'), PATHINFO_EXTENSION)); + + if (! in_array($extension, ['csv'], true)) { + $validator->errors()->add('name', 'The file must be a CSV file.'); + } + }); + } +} diff --git a/app/Models/File.php b/app/Models/File.php index e6b40ae..8e3f55e 100644 --- a/app/Models/File.php +++ b/app/Models/File.php @@ -19,6 +19,8 @@ class File extends Model 'name', 'name_og', 'file_size', + 'storage_disk', + 'storage_path', 'status', 'publish_datetime', 'job_batch_id', diff --git a/app/Services/FileService.php b/app/Services/FileService.php index 91f1481..ae76628 100644 --- a/app/Services/FileService.php +++ b/app/Services/FileService.php @@ -3,22 +3,24 @@ namespace App\Services; use App\Enums\FileStatus; +use App\Events\CsvFileUploaded; use App\Models\File; +use Aws\S3\S3Client; use Illuminate\Bus\Batch; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class FileService { public function __construct( protected JobBatchService $jobBatchService - ) {} + ) { + } /** * Retrieve paginated file records, newest first. - * - * @param int $perPage - * @return LengthAwarePaginator */ public function getFiles(int $perPage = 15): LengthAwarePaginator { @@ -27,9 +29,6 @@ public function getFiles(int $perPage = 15): LengthAwarePaginator /** * Process an uploaded CSV and dispatch batch jobs. - * - * @param UploadedFile $uploadedFile - * @return File */ public function processAndStoreFile(UploadedFile $uploadedFile): File { @@ -42,13 +41,13 @@ public function processAndStoreFile(UploadedFile $uploadedFile): File /** * Process the uploaded file into structured data. * - * @param UploadedFile $uploadedFile The uploaded file to be processed + * @param UploadedFile $uploadedFile The uploaded file to be processed * @return array Returns an array containing csv_name, csv_name_og, csv_size, and data */ protected function processFile(UploadedFile $uploadedFile): array { $csv_name_og = $uploadedFile->getClientOriginalName(); - $csv_name = $csv_name_og . '_' . uniqid() . '_' . time(); + $csv_name = $csv_name_og.'_'.uniqid().'_'.time(); $csv_size = $uploadedFile->getSize(); $data = file($uploadedFile->getPathname()); @@ -57,11 +56,97 @@ protected function processFile(UploadedFile $uploadedFile): array return compact('csv_name', 'csv_name_og', 'csv_size', 'data'); } + public function createPresignedUpload(string $originalName, int $size, string $contentType = null): array + { + $file = File::create([ + 'name' => $this->uniqueStoredName($originalName), + 'name_og' => $originalName, + 'file_size' => $size, + 'storage_disk' => 's3', + 'status' => FileStatus::Pending, + ]); + + try { + $path = $this->buildUploadPath($file, $originalName); + $file->update(['storage_path' => $path]); + + $expiresAt = now()->addMinutes((int) config('services.aws.presign_ttl_minutes', 10)); + $contentType = $contentType ?: 'text/csv'; + $upload = $this->temporaryUploadUrl($path, $expiresAt, $contentType); + } catch (\Throwable $e) { + $file->delete(); + + throw $e; + } + + return [ + 'fileId' => $file->id, + 'key' => $path, + 'url' => $upload['url'], + 'headers' => $upload['headers'], + 'expiresAt' => $expiresAt->toISOString(), + 'file' => [ + 'id' => $file->id, + 'name' => $file->name_og, + 'status' => $file->status->label(), + 'createdAt' => $file->created_at->toISOString(), + ], + ]; + } + + public function processStoredFile(File $file): File + { + if (! $file->storage_path) { + throw new \RuntimeException("File {$file->id} does not have a storage path."); + } + + $contents = Storage::disk($file->storage_disk ?: 's3')->get($file->storage_path); + + if (! is_string($contents)) { + throw new \RuntimeException("File {$file->id} could not be read from storage."); + } + + $data = preg_split('/\r\n|\r|\n/', $contents); + $data = array_values(array_filter($data, static fn (?string $line): bool => $line !== null && $line !== '')); + $data = $this->removeNonUTF8Characters($data); + + $batch = $this->jobBatchService->dispatchFileProcessing([ + 'csv_name' => $file->name, + 'csv_name_og' => $file->name_og, + 'csv_size' => $file->file_size, + 'data' => $data, + ]); + + $file->update([ + 'job_batch_id' => $batch->id, + 'status' => FileStatus::Processing, + ]); + + CsvFileUploaded::dispatch( + $file->name_og, + $batch->id, + (string) $file->id, + $file->created_at->toISOString(), + FileStatus::Processing->label() + ); + + return $file->refresh(); + } + + public function cancelPendingUpload(File $file): void + { + if ($file->status !== FileStatus::Pending || $file->job_batch_id !== null) { + return; + } + + $file->delete(); + } + /** * Stores the file record in the database using mass assignment. * - * @param Batch $batch The dispatched batch. - * @param array $fileData Processed file metadata. + * @param Batch $batch The dispatched batch. + * @param array $fileData Processed file metadata. * @return File The saved file record. */ protected function storeFileRecord(Batch $batch, array $fileData): File @@ -70,15 +155,74 @@ protected function storeFileRecord(Batch $batch, array $fileData): File 'name' => $fileData['csv_name'], 'name_og' => $fileData['csv_name_og'], 'file_size' => $fileData['csv_size'], + 'storage_disk' => 'local', 'status' => FileStatus::Pending, 'job_batch_id' => $batch->id, ]); } + private function uniqueStoredName(string $originalName): string + { + return $originalName.'_'.uniqid().'_'.time(); + } + + private function buildUploadPath(File $file, string $originalName): string + { + $prefix = trim((string) config('services.aws.upload_prefix', 'csv-uploads'), '/'); + $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)) ?: 'csv'; + $baseName = pathinfo($originalName, PATHINFO_FILENAME); + $safeName = Str::slug($baseName) ?: 'upload'; + + return "{$prefix}/{$file->id}/{$safeName}.{$extension}"; + } + + private function temporaryUploadUrl(string $path, \DateTimeInterface $expiresAt, string $contentType): array + { + $disk = Storage::disk('s3'); + + if (method_exists($disk, 'temporaryUploadUrl')) { + return $disk->temporaryUploadUrl($path, $expiresAt); + } + + $config = [ + 'version' => 'latest', + 'region' => config('filesystems.disks.s3.region'), + 'use_path_style_endpoint' => (bool) config('filesystems.disks.s3.use_path_style_endpoint'), + ]; + + if (config('filesystems.disks.s3.key') && config('filesystems.disks.s3.secret')) { + $config['credentials'] = [ + 'key' => config('filesystems.disks.s3.key'), + 'secret' => config('filesystems.disks.s3.secret'), + ]; + } + + if (config('filesystems.disks.s3.endpoint')) { + $config['endpoint'] = config('filesystems.disks.s3.endpoint'); + } + + $client = new S3Client($config); + + $command = $client->getCommand('PutObject', [ + 'Bucket' => config('filesystems.disks.s3.bucket'), + 'Key' => $path, + 'ContentType' => $contentType, + ]); + + $request = $client->createPresignedRequest($command, $expiresAt); + + return [ + 'url' => (string) $request->getUri(), + 'headers' => [ + 'Content-Type' => $contentType, + ], + ]; + } + /** * Removes any non-UTF8 characters from the given data. * - * @param array|string $data The data to sanitize. + * @param array|string $data The data to sanitize. * @return array|string The sanitized data. */ private function removeNonUTF8Characters(array|string $data): array|string diff --git a/composer.json b/composer.json index 3f014be..65ee836 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "php": "^8.1", "ext-pcntl": "*", "ext-posix": "*", + "aws/aws-sdk-php": "^3.235", "beyondcode/laravel-websockets": "^1.14", "guzzlehttp/guzzle": "^7.2", "illuminate/contracts": "*", @@ -18,6 +19,7 @@ "laravel/horizon": "^5.21", "laravel/sanctum": "^3.2", "laravel/tinker": "^2.8", + "league/flysystem-aws-s3-v3": "^3.0", "predis/predis": "^2.2", "pusher/pusher-php-server": "^7.2" }, diff --git a/composer.lock b/composer.lock index 5985558..e1fe9f1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dcb14549d95e1c050a2a7a1ceeeb850e", + "content-hash": "86b5abef09fef35deb17dca9878e456a", "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.379.10", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "e742805410f6aaf7a910b07915ed012d90d9e069" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e742805410f6aaf7a910b07915ed012d90d9e069", + "reference": "e742805410f6aaf7a910b07915ed012d90d9e069", + "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.8.0", + "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.379.10" + }, + "time": "2026-04-30T18:07:43+00:00" + }, { "name": "beyondcode/laravel-websockets", "version": "1.14.1", @@ -2079,6 +2230,61 @@ ], "time": "2023-10-05T20:15:05+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.32.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.295.10", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + }, + "time": "2026-02-25T16:46:44+00:00" + }, { "name": "league/flysystem-local", "version": "3.16.0", @@ -2296,6 +2502,72 @@ ], "time": "2023-06-21T08:46:11+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "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.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-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.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, { "name": "nesbot/carbon", "version": "2.71.0", @@ -4855,6 +5127,76 @@ ], "time": "2023-05-23T14:45:45+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/dcd8f96bcdc0f128ec406c765cc066c6035d1be3", + "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, { "name": "symfony/finder", "version": "v6.3.5", @@ -9452,7 +9794,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -9460,10 +9802,10 @@ "ext-pcntl": "*", "ext-posix": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "ext-pcntl": "8.0", "ext-posix": "8.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.9.0" } diff --git a/config/services.php b/config/services.php index 0ace530..c718a6e 100644 --- a/config/services.php +++ b/config/services.php @@ -31,4 +31,13 @@ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'aws' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'upload_prefix' => env('AWS_UPLOAD_PREFIX', 'csv-uploads'), + 'presign_ttl_minutes' => env('AWS_PRESIGN_TTL_MINUTES', 10), + 's3_upload_notification_queue_url' => env('AWS_S3_UPLOAD_NOTIFICATION_QUEUE_URL'), + ], + ]; diff --git a/database/migrations/2026_05_01_000000_add_s3_upload_fields_to_files_table.php b/database/migrations/2026_05_01_000000_add_s3_upload_fields_to_files_table.php new file mode 100644 index 0000000..e7f963f --- /dev/null +++ b/database/migrations/2026_05_01_000000_add_s3_upload_fields_to_files_table.php @@ -0,0 +1,35 @@ +dropForeign(['job_batch_id']); + }); + + Schema::table('files', function (Blueprint $table) { + $table->string('storage_disk', 50)->default('s3')->after('file_size'); + $table->string('storage_path', 1024)->nullable()->unique()->after('storage_disk'); + $table->string('job_batch_id', 255)->nullable()->change(); + + $table->foreign('job_batch_id')->references('id')->on('job_batches'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropForeign(['job_batch_id']); + $table->dropUnique(['storage_path']); + $table->dropColumn(['storage_disk', 'storage_path']); + $table->string('job_batch_id', 255)->nullable(false)->change(); + + $table->foreign('job_batch_id')->references('id')->on('job_batches'); + }); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index c5fd802..d3b9eac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: laravel: build: - context: ./docker/8.2 + context: ./docker/8.3 dockerfile: Dockerfile args: WWWGROUP: ${WWWGROUP:-1000} @@ -31,8 +31,10 @@ services: networks: - sail depends_on: - - mysql - - redis + mysql: + condition: service_healthy + redis: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost"] retries: 3 diff --git a/docker/8.2/Dockerfile b/docker/8.2/Dockerfile deleted file mode 100644 index 3f200ea..0000000 --- a/docker/8.2/Dockerfile +++ /dev/null @@ -1,63 +0,0 @@ -FROM ubuntu:22.04 - -LABEL maintainer="Taylor Otwell" - -ARG WWWGROUP -ARG NODE_VERSION=18 -ARG POSTGRES_VERSION=15 - -WORKDIR /var/www/html - -ENV DEBIAN_FRONTEND noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN apt-get update \ - && mkdir -p /etc/apt/keyrings \ - && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 dnsutils librsvg2-bin fswatch \ - && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y php8.2-cli php8.2-dev \ - php8.2-pgsql php8.2-sqlite3 php8.2-gd php8.2-imagick \ - php8.2-curl \ - php8.2-imap php8.2-mysql php8.2-mbstring \ - php8.2-xml php8.2-zip php8.2-bcmath php8.2-soap \ - php8.2-intl php8.2-readline \ - php8.2-ldap \ - php8.2-msgpack php8.2-igbinary php8.2-redis php8.2-swoole \ - php8.2-memcached php8.2-pcov php8.2-xdebug \ - && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm \ - && npm install -g pnpm \ - && npm install -g bun \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ - && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y yarn \ - && apt-get install -y mysql-client \ - && apt-get install -y postgresql-client-$POSTGRES_VERSION \ - && apt-get -y autoremove \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.2 - -RUN groupadd --force -g $WWWGROUP sail -RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail - -COPY start-container /usr/local/bin/start-container -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY php.ini /etc/php/8.2/cli/conf.d/99-sail.ini -RUN chmod +x /usr/local/bin/start-container - -EXPOSE 8000 6001 - -ENTRYPOINT ["start-container"] diff --git a/docker/8.2/start-container b/docker/8.2/start-container deleted file mode 100644 index b864399..0000000 --- a/docker/8.2/start-container +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -if [ ! -z "$WWWUSER" ]; then - usermod -u $WWWUSER sail -fi - -if [ ! -d /.composer ]; then - mkdir /.composer -fi - -chmod -R ugo+rw /.composer - -if [ $# -gt 0 ]; then - exec gosu $WWWUSER "$@" -else - exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf -fi diff --git a/docker/8.3/Dockerfile b/docker/8.3/Dockerfile new file mode 100644 index 0000000..2226c1e --- /dev/null +++ b/docker/8.3/Dockerfile @@ -0,0 +1,72 @@ +FROM ubuntu:24.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=18 +ARG POSTGRES_VERSION=16 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND noninteractive +ENV TZ=UTC + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt_update() { \ + for attempt in 1 2 3 4 5; do \ + apt-get update -o Acquire::Retries=5 -o APT::Update::Error-Mode=any && return 0; \ + sleep $((attempt * 5)); \ + done; \ + apt-get update -o Acquire::Retries=5 -o APT::Update::Error-Mode=any; \ + } \ + && apt_install() { \ + apt-get install -y --no-install-recommends "$@"; \ + } \ + && apt_update \ + && mkdir -p /etc/apt/keyrings \ + && apt_install gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev dnsutils librsvg2-bin fswatch \ + && apt_install php8.3-cli php8.3-dev \ + php8.3-pgsql php8.3-sqlite3 php8.3-gd php8.3-imagick \ + php8.3-curl \ + php8.3-imap php8.3-mysql php8.3-mbstring \ + php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ + php8.3-intl php8.3-readline \ + php8.3-ldap \ + php8.3-msgpack php8.3-igbinary php8.3-redis \ + php8.3-memcached php8.3-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt_update \ + && apt_install nodejs \ + && npm install -g npm \ + && npm install -g pnpm \ + && npm install -g bun \ + && curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] https://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt_update \ + && apt_install yarn \ + && apt_install mysql-client \ + && apt_install postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 + +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 8000 6001 + +ENTRYPOINT ["start-container"] diff --git a/docker/8.2/php.ini b/docker/8.3/php.ini similarity index 100% rename from docker/8.2/php.ini rename to docker/8.3/php.ini diff --git a/docker/8.3/start-container b/docker/8.3/start-container new file mode 100755 index 0000000..6d3dc77 --- /dev/null +++ b/docker/8.3/start-container @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +wait_for_service() { + local name="$1" + local host="$2" + local port="$3" + local attempts="${4:-60}" + + if [ -z "$host" ] || [ -z "$port" ]; then + return + fi + + echo "Waiting for $name at $host:$port..." + + for i in $(seq 1 "$attempts"); do + if timeout 1 bash -c "/dev/null; then + echo "$name is available." + return + fi + + sleep 1 + done + + echo "Timed out waiting for $name at $host:$port." +} + +if [ $# -gt 0 ]; then + exec gosu $WWWUSER "$@" +else + wait_for_service "MySQL" "${DB_HOST:-mysql}" "${DB_PORT:-3306}" + wait_for_service "Redis" "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}" + + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.2/supervisord.conf b/docker/8.3/supervisord.conf similarity index 75% rename from docker/8.2/supervisord.conf rename to docker/8.3/supervisord.conf index bd496b9..471883f 100644 --- a/docker/8.2/supervisord.conf +++ b/docker/8.3/supervisord.conf @@ -25,6 +25,18 @@ autostart=true autorestart=true stopasgroup=true +[program:s3-upload-notifications] +command=/usr/bin/php /var/www/html/artisan s3:poll-upload-notifications +user=sail +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +autostart=true +autorestart=true +stopasgroup=true + [program:websocket] command=/usr/bin/php /var/www/html/artisan websockets:serve --host=0.0.0.0 user=sail diff --git a/resources/js/app.js b/resources/js/app.js index e7d5eb0..4637abe 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,6 +3,10 @@ import "./bootstrap"; // File input change handler const csv_input = document.querySelector("#csv_input"); const alert_close = document.querySelectorAll(".alert_close"); +const uploadForm = csv_input?.closest("form"); +const uploadButton = uploadForm?.querySelector('button[type="submit"]'); +const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; +let uploadInProgress = false; csv_input?.addEventListener("change", () => { if (csv_input.files.length) { @@ -11,6 +15,44 @@ csv_input?.addEventListener("change", () => { } }); +uploadForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + + if (uploadInProgress) { + return; + } + + const file = csv_input?.files?.[0]; + let upload = null; + + if (!file) { + showUploadAlert("Please choose a CSV file first.", "error"); + return; + } + + uploadInProgress = true; + setUploadState(true, "Preparing..."); + + try { + upload = await requestPresignedUpload(file); + + setUploadState(true, "Uploading..."); + await uploadToS3(upload, file); + + upsertPendingFileRow(upload.file); + showUploadAlert("File uploaded. Processing will start after AWS confirms the upload.", "success"); + uploadForm.reset(); + document.querySelector(".upload-container p").innerHTML = "Click or drop file here"; + } catch (error) { + console.error("Upload failed:", error); + await cancelPendingUpload(upload); + showUploadAlert(error.message || "Upload failed. Please try again.", "error"); + } finally { + uploadInProgress = false; + setUploadState(false, "Upload"); + } +}); + alert_close.forEach((element) => { element.addEventListener("click", (event) => { let alerts = document.querySelectorAll("div[role=alert]"); @@ -79,11 +121,19 @@ function addNewFileToTable(event) { noFilesRow.closest("tr").remove(); } - // Check if file already exists to avoid duplicates + const pendingCell = document.getElementById(`file-id-${event.fileId}`); + if (pendingCell) { + pendingCell.id = `job-id-${event.batchId}`; + pendingCell.innerHTML = event.status; + pendingCell.className = ""; + replayPendingBatchEvent(event.batchId); + return; // File already in table + } + const existingRow = document.getElementById(`job-id-${event.batchId}`); if (existingRow) { console.log("⚠️ File already in table, skipping"); - return; // File already in table + return; } const tr = document.createElement("tr"); @@ -110,17 +160,139 @@ function addNewFileToTable(event) { tbody.insertBefore(tr, tbody.firstChild); console.log("✅ Row added to table for batch:", event.batchId); - // Replay any buffered events for this batch that arrived before the row existed - if (window._pendingBatchEvents?.[event.batchId]) { - const pending = window._pendingBatchEvents[event.batchId]; + replayPendingBatchEvent(event.batchId); +} + +async function requestPresignedUpload(file) { + const response = await fetch(uploadForm.dataset.presignUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRF-TOKEN": csrfToken, + }, + body: JSON.stringify({ + name: file.name, + size: file.size, + type: file.type || "text/csv", + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + const validationMessage = Object.values(error.errors ?? {}) + .flat() + .shift(); + + throw new Error(validationMessage || error.message || "Could not prepare the upload."); + } + + return response.json(); +} + +async function uploadToS3(upload, file) { + const response = await fetch(upload.url, { + method: "PUT", + headers: upload.headers ?? {}, + body: file, + }); + + if (!response.ok) { + throw new Error("The direct upload to S3 failed."); + } +} + +async function cancelPendingUpload(upload) { + if (!upload?.fileId || !uploadForm.dataset.cancelUrlTemplate) { + return; + } + + const url = uploadForm.dataset.cancelUrlTemplate.replace("__FILE_ID__", upload.fileId); + + await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "X-CSRF-TOKEN": csrfToken, + }, + }).catch((error) => { + console.warn("Could not cancel pending upload:", error); + }); +} + +function upsertPendingFileRow(file) { + const tbody = document.querySelector("tbody"); + + if (!tbody || document.getElementById(`file-id-${file.id}`)) { + return; + } + + const noFilesRow = tbody.querySelector('td[colspan="3"]'); + + if (noFilesRow) { + noFilesRow.closest("tr").remove(); + } + + const tr = document.createElement("tr"); + const uploadedAt = new Date(file.createdAt); + const formattedDate = uploadedAt.toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + + tr.innerHTML = ` +