From 4b616a7f04af600663856e32aa07d4d96568b6b2 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 8 Jun 2026 13:25:43 +0000 Subject: [PATCH] Simplify recipient emails and add live upload progress on create. Recipients always get the download link immediately when an email is set. Large files upload in the background with per-file and overall progress bars. Co-authored-by: Cursor --- .../ProcessTransferBillingCommand.php | 6 +- .../Transfer/TransferController.php | 13 +- .../Transfer/TransferRecipientMailService.php | 86 +------ app/Services/Transfer/TransferService.php | 11 +- config/transfer.php | 11 - package-lock.json | 2 +- resources/css/app.css | 35 +++ resources/js/app.js | 234 +++++++++++++----- .../views/transfer/transfers/create.blade.php | 61 +++-- .../views/transfer/transfers/show.blade.php | 4 +- tests/Feature/TransferRecipientMailTest.php | 51 ++-- 11 files changed, 287 insertions(+), 227 deletions(-) diff --git a/app/Console/Commands/ProcessTransferBillingCommand.php b/app/Console/Commands/ProcessTransferBillingCommand.php index ff76222..2af5e5a 100644 --- a/app/Console/Commands/ProcessTransferBillingCommand.php +++ b/app/Console/Commands/ProcessTransferBillingCommand.php @@ -4,7 +4,6 @@ namespace App\Console\Commands; use App\Services\Transfer\TransferBillingService; use App\Services\Transfer\TransferOwnerMailService; -use App\Services\Transfer\TransferRecipientMailService; use Illuminate\Console\Command; class ProcessTransferBillingCommand extends Command @@ -15,19 +14,16 @@ class ProcessTransferBillingCommand extends Command public function handle( TransferBillingService $billing, - TransferRecipientMailService $recipients, TransferOwnerMailService $owners, ): int { $result = $billing->processDueRenewals(); - $recipientEmails = $recipients->processDueMilestones(); $ownerEmails = $owners->processGraceReminders(); $this->info(sprintf( - 'Transfer billing: %d renewed, %d entered grace, %d deleted, %d recipient reminders, %d owner grace reminders.', + 'Transfer billing: %d renewed, %d entered grace, %d deleted, %d owner grace reminders.', $result['renewed'], $result['graced'], $result['deleted'], - $recipientEmails, $ownerEmails, )); diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index 33cc25e..541e78f 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -6,7 +6,6 @@ use App\Http\Controllers\Controller; use App\Models\Transfer; use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrPdfExporter; -use App\Services\Transfer\TransferRecipientMailService; use App\Services\Transfer\TransferService; use App\Services\Upload\ChunkedUploadService; use Illuminate\Http\RedirectResponse; @@ -42,14 +41,10 @@ class TransferController extends Controller public function create(): View { - $mailService = app(TransferRecipientMailService::class); - return view('transfer.transfers.create', [ 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), 'gracePeriodDays' => (int) config('transfer.grace_period_days', 15), - 'emailMilestones' => $mailService->milestoneOptions(), - 'defaultEmailMilestones' => (array) config('transfer.default_recipient_email_milestones', ['created']), ]); } @@ -63,14 +58,10 @@ class TransferController extends Controller $fileRules[] = 'max:'.(int) ceil($maxBytes / 1024); } - $allowedMilestones = implode(',', app(TransferRecipientMailService::class)->allowedMilestones()); - $data = $request->validate([ 'title' => 'required|string|max:120', 'message' => 'nullable|string|max:2000', - 'recipient_email' => 'nullable|email|max:255|required_with:email_milestones', - 'email_milestones' => 'nullable|array', - 'email_milestones.*' => 'in:'.$allowedMilestones, + 'recipient_email' => 'nullable|email|max:255', 'password' => 'nullable|string|min:4|max:64', 'files' => 'nullable|array', 'files.*' => $fileRules, @@ -105,7 +96,7 @@ class TransferController extends Controller } $success = 'Transfer created. Share the link or QR code with recipients.'; - if ($transfer->recipient_email && $transfer->wantsRecipientMilestone('created') && $transfer->recipientMilestoneSent('created')) { + if ($transfer->recipient_email && $transfer->recipientMilestoneSent('created')) { $success .= ' A download link was emailed to '.$transfer->recipient_email.'.'; } diff --git a/app/Services/Transfer/TransferRecipientMailService.php b/app/Services/Transfer/TransferRecipientMailService.php index 8ae7cd1..13ee7cb 100644 --- a/app/Services/Transfer/TransferRecipientMailService.php +++ b/app/Services/Transfer/TransferRecipientMailService.php @@ -4,96 +4,32 @@ namespace App\Services\Transfer; use App\Models\Transfer; use App\Notifications\TransferRecipientNotification; -use Carbon\CarbonInterface; use Illuminate\Support\Facades\Notification; -/** Sends milestone emails to transfer recipients (share link + reminders). */ +/** Sends the immediate download-link email to transfer recipients. */ class TransferRecipientMailService { - /** @return list */ - public function allowedMilestones(): array + public function notifyRecipient(Transfer $transfer, string $milestone = 'created'): bool { - return array_keys((array) config('transfer.recipient_email_milestones', [])); - } - - /** @return array */ - public function milestoneOptions(): array - { - return (array) config('transfer.recipient_email_milestones', []); - } - - /** @param list $milestones */ - public function normalizeMilestones(array $milestones): array - { - $allowed = $this->allowedMilestones(); - - return array_values(array_unique(array_filter( - $milestones, - static fn ($milestone) => in_array((string) $milestone, $allowed, true), - ))); - } - - public function notifyRecipient(Transfer $transfer, string $milestone): bool - { - $email = trim((string) $transfer->recipient_email); - if ($email === '' || ! $transfer->wantsRecipientMilestone($milestone)) { + if ($milestone !== 'created') { return false; } - if ($transfer->recipientMilestoneSent($milestone)) { + $email = trim((string) $transfer->recipient_email); + if ($email === '') { + return false; + } + + if ($transfer->recipientMilestoneSent('created')) { return false; } Notification::route('mail', $email)->notify( - new TransferRecipientNotification($transfer->loadMissing(['files', 'qrCode', 'user']), $milestone), + new TransferRecipientNotification($transfer->loadMissing(['files', 'qrCode', 'user']), 'created'), ); - $transfer->markRecipientMilestoneSent($milestone); + $transfer->markRecipientMilestoneSent('created'); return true; } - - public function processDueMilestones(): int - { - $sent = 0; - - Transfer::query() - ->accessible() - ->whereNotNull('recipient_email') - ->whereNotNull('recipient_email_milestones') - ->with(['files', 'qrCode', 'user']) - ->chunkById(100, function ($transfers) use (&$sent) { - foreach ($transfers as $transfer) { - foreach ($this->dueDayMilestones($transfer) as $milestone) { - if ($this->notifyRecipient($transfer, $milestone)) { - $sent++; - } - } - } - }); - - return $sent; - } - - /** @return list */ - private function dueDayMilestones(Transfer $transfer): array - { - if (! $transfer->paid_until instanceof CarbonInterface || $transfer->paid_until->isPast()) { - return []; - } - - $daysRemaining = (int) now()->startOfDay()->diffInDays( - $transfer->paid_until->copy()->startOfDay(), - false, - ); - - $due = []; - foreach (['7', '3', '1'] as $milestone) { - if ($daysRemaining === (int) $milestone && $transfer->wantsRecipientMilestone($milestone)) { - $due[] = $milestone; - } - } - - return $due; - } } diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php index fea9cdb..9b12d0e 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -48,22 +48,13 @@ class TransferService } $recipientEmail = trim((string) ($data['recipient_email'] ?? '')); - $recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones( - is_array($data['email_milestones'] ?? null) ? $data['email_milestones'] : [], - ); - - if ($recipientEmail !== '' && $recipientMilestones === []) { - $recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones( - (array) config('transfer.default_recipient_email_milestones', ['created']), - ); - } $transfer = Transfer::create([ 'user_id' => $user->id, 'title' => trim((string) $data['title']), 'message' => isset($data['message']) ? trim((string) $data['message']) : null, 'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null, - 'recipient_email_milestones' => $recipientEmail !== '' ? $recipientMilestones : null, + 'recipient_email_milestones' => $recipientEmail !== '' ? ['created'] : null, 'recipient_milestones_sent' => [], 'owner_milestones_sent' => [], 'password_hash' => $passwordHash, diff --git a/config/transfer.php b/config/transfer.php index 15addc9..401cbcf 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -29,17 +29,6 @@ return [ // Days to keep files after a failed renewal before automatic deletion. 'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15), - // Recipient email milestones (checkboxes on the create transfer form). - 'recipient_email_milestones' => [ - 'created' => 'Send download link immediately', - '7' => '7 days before files become unavailable', - '3' => '3 days before files become unavailable', - '1' => '1 day before files become unavailable', - 'grace' => 'When files enter the payment grace period', - ], - - 'default_recipient_email_milestones' => ['created'], - // Owner emails when a transfer enters grace and before files are deleted. 'owner_grace_email_milestones' => ['grace_entered', '7', '3', '1'], diff --git a/package-lock.json b/package-lock.json index 0f544b4..960724f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "ladill-qr-plus", + "name": "ladill-transfer", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/css/app.css b/resources/css/app.css index 371c9e5..2b1b6ec 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -15,6 +15,41 @@ display: none !important; } +@keyframes upload-progress-shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +.upload-progress-track { + height: 0.375rem; + width: 100%; + overflow: hidden; + border-radius: 9999px; + background-color: rgb(226 232 240); +} + +.upload-progress-bar { + position: relative; + height: 100%; + border-radius: 9999px; + background: linear-gradient(90deg, #6366f1 0%, #4f46e5 100%); + transition: width 0.25s ease; + overflow: hidden; +} + +.upload-progress-bar::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.35), transparent); + animation: upload-progress-shimmer 1.4s ease-in-out infinite; +} + /* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */ @media (max-width: 1023px) { .mobile-action-bar { diff --git a/resources/js/app.js b/resources/js/app.js index 7f3fe90..85c7ea5 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -301,17 +301,150 @@ Alpine.data('topbarSearch', (config = {}) => ({ }, })); +function formatUploadBytes(bytes) { + if (bytes >= 1048576) { + return `${(bytes / 1048576).toFixed(1)} MB`; + } + if (bytes >= 1024) { + return `${Math.round(bytes / 1024)} KB`; + } + + return `${bytes} B`; +} + Alpine.data('transferCreateForm', (config = {}) => ({ - selectedFiles: [], - uploading: false, + fileItems: [], submitting: false, - uploadProgress: 0, - uploadLabel: '', prepared: false, + _uploadQueue: Promise.resolve(), + + get uploading() { + return this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending'); + }, + + get overallProgress() { + const items = this.fileItems.filter((item) => item.status !== 'error'); + if (!items.length) { + return 0; + } + + const total = items.reduce((sum, item) => sum + item.size, 0); + const done = items.reduce((sum, item) => { + if (item.status === 'ready') { + return sum + item.size; + } + if (item.status === 'uploading') { + return sum + (item.size * (item.progress || 0) / 100); + } + + return sum; + }, 0); + + return total ? Math.round((done / total) * 100) : 0; + }, + + formatBytes(bytes) { + return formatUploadBytes(bytes); + }, onFilesChange(event) { - this.selectedFiles = Array.from(event.target.files || []); + const newFiles = Array.from(event.target.files || []); this.prepared = false; + + const prevByKey = new Map(this.fileItems.map((item) => [item.key, item])); + this.fileItems = []; + + for (const file of newFiles) { + const key = `${file.name}:${file.size}:${file.lastModified}`; + const prev = prevByKey.get(key); + if (prev && (prev.status === 'uploading' || prev.status === 'ready')) { + this.fileItems.push(prev); + continue; + } + + const item = { + id: (crypto.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random()}`, + key, + file, + name: file.name, + size: file.size, + status: shouldUseChunkedUpload(file.size) ? 'pending' : 'ready', + progress: 0, + uploadId: prev?.uploadId || null, + error: null, + }; + this.fileItems.push(item); + + if (item.status === 'pending') { + this.queueUpload(item); + } + } + + this.syncFileInput(event.target); + }, + + queueUpload(item) { + this._uploadQueue = this._uploadQueue + .then(() => this.uploadFileItem(item)) + .catch(() => {}); + }, + + async uploadFileItem(item) { + if (item.status !== 'pending') { + return; + } + + item.status = 'uploading'; + item.progress = 0; + + try { + const result = await uploadFileChunked(item.file, { + initUrl: config.initUrl, + chunkUrl: config.chunkUrl, + finalizeUrl: config.finalizeUrl, + csrfToken: config.csrfToken, + onProgress: (percent) => { + item.progress = percent; + }, + }); + + item.uploadId = result.uploadId; + item.status = 'ready'; + item.progress = 100; + } catch (error) { + item.status = 'error'; + item.error = error?.message || 'Upload failed.'; + } + }, + + removeFileItem(id) { + const item = this.fileItems.find((entry) => entry.id === id); + if (!item || item.status === 'uploading') { + return; + } + + this.fileItems = this.fileItems.filter((entry) => entry.id !== id); + this.syncFileInput(document.querySelector('#files')); + }, + + syncFileInput(input) { + if (!input) { + return; + } + + const smallFiles = this.fileItems + .filter((item) => item.status === 'ready' && item.file && !shouldUseChunkedUpload(item.size)) + .map((item) => item.file); + + if (smallFiles.length) { + const dataTransfer = new DataTransfer(); + smallFiles.forEach((file) => dataTransfer.items.add(file)); + input.files = dataTransfer.files; + input.required = true; + } else { + input.removeAttribute('required'); + input.value = ''; + } }, async submit(event) { @@ -322,66 +455,49 @@ Alpine.data('transferCreateForm', (config = {}) => ({ event.preventDefault(); const form = event.target; const fileInput = form.querySelector('#files'); - const files = this.selectedFiles.length - ? this.selectedFiles - : Array.from(fileInput?.files || []); - if (!files.length) { + if (!this.fileItems.length) { window.alert('Add at least one file to share.'); return; } - this.uploading = true; - this.uploadProgress = 0; - const smallFiles = []; - - try { - form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove()); - - for (let index = 0; index < files.length; index++) { - const file = files[index]; - this.uploadLabel = `Uploading ${file.name} (${index + 1}/${files.length})…`; - - if (shouldUseChunkedUpload(file.size)) { - const result = await uploadFileChunked(file, { - initUrl: config.initUrl, - chunkUrl: config.chunkUrl, - finalizeUrl: config.finalizeUrl, - csrfToken: config.csrfToken, - onProgress: (percent) => { - this.uploadProgress = percent; - }, - }); - - const hidden = document.createElement('input'); - hidden.type = 'hidden'; - hidden.name = 'upload_ids[]'; - hidden.value = result.uploadId; - form.appendChild(hidden); - } else { - smallFiles.push(file); - } - } - - if (fileInput && smallFiles.length) { - const dataTransfer = new DataTransfer(); - smallFiles.forEach((file) => dataTransfer.items.add(file)); - fileInput.files = dataTransfer.files; - } else if (fileInput) { - fileInput.removeAttribute('required'); - fileInput.value = ''; - } - - this.uploading = false; - this.submitting = true; - this.prepared = true; - form.submit(); - } catch (error) { - this.uploading = false; - this.submitting = false; - this.prepared = false; - window.alert(error?.message || 'Upload failed. Please try again.'); + if (this.fileItems.some((item) => item.status === 'error')) { + window.alert('Remove failed uploads or choose the files again.'); + return; } + + if (this.uploading) { + await this._uploadQueue; + } + + if (this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending')) { + window.alert('Please wait for uploads to finish.'); + return; + } + + if (!this.fileItems.some((item) => item.status === 'ready')) { + window.alert('Add at least one file to share.'); + return; + } + + form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove()); + + for (const item of this.fileItems) { + if (!item.uploadId) { + continue; + } + + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = 'upload_ids[]'; + hidden.value = item.uploadId; + form.appendChild(hidden); + } + + this.syncFileInput(fileInput); + this.submitting = true; + this.prepared = true; + form.submit(); }, })); diff --git a/resources/views/transfer/transfers/create.blade.php b/resources/views/transfer/transfers/create.blade.php index f895b83..32c70ee 100644 --- a/resources/views/transfer/transfers/create.blade.php +++ b/resources/views/transfer/transfers/create.blade.php @@ -38,15 +38,48 @@ -

Up to {{ $maxFiles }} files. Large files upload in small chunks — no practical size limit.

- + @error('files')

{{ $message }}

@enderror @error('files.*')

{{ $message }}

@enderror @@ -57,19 +90,7 @@ placeholder="friend@example.com" class="mt-1 block w-full rounded-xl border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"> @error('recipient_email')

{{ $message }}

@enderror -

Email milestones

-
- @foreach($emailMilestones as $id => $label) - - @endforeach -
- @error('email_milestones')

{{ $message }}

@enderror - @error('email_milestones.*')

{{ $message }}

@enderror +

A download link is emailed immediately when you create the transfer.

diff --git a/resources/views/transfer/transfers/show.blade.php b/resources/views/transfer/transfers/show.blade.php index 6ba84bf..65b1a1c 100644 --- a/resources/views/transfer/transfers/show.blade.php +++ b/resources/views/transfer/transfers/show.blade.php @@ -38,9 +38,9 @@ @if($transfer->recipient_email)
Recipient: {{ $transfer->recipient_email }} - @if(is_array($transfer->recipient_email_milestones) && $transfer->recipient_email_milestones !== []) + @if($transfer->recipientMilestoneSent('created')) · - Notified on {{ implode(', ', array_map(fn ($m) => $m === 'created' ? 'share' : $m, $transfer->recipient_email_milestones)) }} + Download link emailed @endif
@endif diff --git a/tests/Feature/TransferRecipientMailTest.php b/tests/Feature/TransferRecipientMailTest.php index a510bf7..3ba3a22 100644 --- a/tests/Feature/TransferRecipientMailTest.php +++ b/tests/Feature/TransferRecipientMailTest.php @@ -2,10 +2,7 @@ namespace Tests\Feature; -use App\Models\Transfer; -use App\Models\User; use App\Notifications\TransferRecipientNotification; -use App\Services\Transfer\TransferRecipientMailService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Notification; @@ -19,22 +16,17 @@ class TransferRecipientMailTest extends TestCase use FakesTransferBilling; use RefreshDatabase; - public function test_create_transfer_emails_recipient_when_created_milestone_selected(): void + public function test_create_transfer_emails_recipient_immediately(): void { Notification::fake(); Storage::fake('qr'); $this->fakeTransferBillingApi(); - $user = User::create([ - 'public_id' => (string) Str::uuid(), - 'name' => 'Sender', - 'email' => 'sender+'.uniqid().'@example.com', - ]); + $user = $this->makeUser(); $this->actingAs($user)->post(route('transfer.transfers.store'), [ 'title' => 'Project files', 'recipient_email' => 'recipient@example.com', - 'email_milestones' => ['created'], 'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')], ])->assertRedirect(); @@ -44,35 +36,28 @@ class TransferRecipientMailTest extends TestCase ); } - public function test_recipient_day_milestone_is_sent_once(): void + public function test_create_transfer_without_recipient_email_sends_no_notification(): void { Notification::fake(); + Storage::fake('qr'); + $this->fakeTransferBillingApi(); - $user = User::create([ + $user = $this->makeUser(); + + $this->actingAs($user)->post(route('transfer.transfers.store'), [ + 'title' => 'Project files', + 'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')], + ])->assertRedirect(); + + Notification::assertNothingSent(); + } + + private function makeUser() + { + return \App\Models\User::create([ 'public_id' => (string) Str::uuid(), 'name' => 'Sender', 'email' => 'sender+'.uniqid().'@example.com', ]); - - $transfer = Transfer::create([ - 'user_id' => $user->id, - 'title' => 'Reminder test', - 'recipient_email' => 'recipient@example.com', - 'recipient_email_milestones' => ['7'], - 'recipient_milestones_sent' => [], - 'retention_days' => 30, - 'paid_until' => now()->addDays(7)->startOfDay(), - 'status' => Transfer::STATUS_ACTIVE, - 'storage_bytes' => 1024, - ]); - - $service = app(TransferRecipientMailService::class); - $this->assertSame(1, $service->processDueMilestones()); - $this->assertSame(0, $service->processDueMilestones()); - - $transfer->refresh(); - $this->assertContains('7', $transfer->recipient_milestones_sent ?? []); - - Notification::assertSentOnDemand(TransferRecipientNotification::class, 1); } }