Simplify recipient emails and add live upload progress on create.
Deploy Ladill Transfer / deploy (push) Successful in 47s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 13:25:43 +00:00
co-authored by Cursor
parent c53191a41b
commit 4b616a7f04
11 changed files with 287 additions and 227 deletions
@@ -4,7 +4,6 @@ namespace App\Console\Commands;
use App\Services\Transfer\TransferBillingService; use App\Services\Transfer\TransferBillingService;
use App\Services\Transfer\TransferOwnerMailService; use App\Services\Transfer\TransferOwnerMailService;
use App\Services\Transfer\TransferRecipientMailService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ProcessTransferBillingCommand extends Command class ProcessTransferBillingCommand extends Command
@@ -15,19 +14,16 @@ class ProcessTransferBillingCommand extends Command
public function handle( public function handle(
TransferBillingService $billing, TransferBillingService $billing,
TransferRecipientMailService $recipients,
TransferOwnerMailService $owners, TransferOwnerMailService $owners,
): int { ): int {
$result = $billing->processDueRenewals(); $result = $billing->processDueRenewals();
$recipientEmails = $recipients->processDueMilestones();
$ownerEmails = $owners->processGraceReminders(); $ownerEmails = $owners->processGraceReminders();
$this->info(sprintf( $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['renewed'],
$result['graced'], $result['graced'],
$result['deleted'], $result['deleted'],
$recipientEmails,
$ownerEmails, $ownerEmails,
)); ));
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use App\Models\Transfer; use App\Models\Transfer;
use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter; use App\Services\Qr\QrPdfExporter;
use App\Services\Transfer\TransferRecipientMailService;
use App\Services\Transfer\TransferService; use App\Services\Transfer\TransferService;
use App\Services\Upload\ChunkedUploadService; use App\Services\Upload\ChunkedUploadService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -42,14 +41,10 @@ class TransferController extends Controller
public function create(): View public function create(): View
{ {
$mailService = app(TransferRecipientMailService::class);
return view('transfer.transfers.create', [ return view('transfer.transfers.create', [
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
'gracePeriodDays' => (int) config('transfer.grace_period_days', 15), '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); $fileRules[] = 'max:'.(int) ceil($maxBytes / 1024);
} }
$allowedMilestones = implode(',', app(TransferRecipientMailService::class)->allowedMilestones());
$data = $request->validate([ $data = $request->validate([
'title' => 'required|string|max:120', 'title' => 'required|string|max:120',
'message' => 'nullable|string|max:2000', 'message' => 'nullable|string|max:2000',
'recipient_email' => 'nullable|email|max:255|required_with:email_milestones', 'recipient_email' => 'nullable|email|max:255',
'email_milestones' => 'nullable|array',
'email_milestones.*' => 'in:'.$allowedMilestones,
'password' => 'nullable|string|min:4|max:64', 'password' => 'nullable|string|min:4|max:64',
'files' => 'nullable|array', 'files' => 'nullable|array',
'files.*' => $fileRules, 'files.*' => $fileRules,
@@ -105,7 +96,7 @@ class TransferController extends Controller
} }
$success = 'Transfer created. Share the link or QR code with recipients.'; $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.'.'; $success .= ' A download link was emailed to '.$transfer->recipient_email.'.';
} }
@@ -4,96 +4,32 @@ namespace App\Services\Transfer;
use App\Models\Transfer; use App\Models\Transfer;
use App\Notifications\TransferRecipientNotification; use App\Notifications\TransferRecipientNotification;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Notification; 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 class TransferRecipientMailService
{ {
/** @return list<string> */ public function notifyRecipient(Transfer $transfer, string $milestone = 'created'): bool
public function allowedMilestones(): array
{ {
return array_keys((array) config('transfer.recipient_email_milestones', [])); if ($milestone !== 'created') {
}
/** @return array<string, string> */
public function milestoneOptions(): array
{
return (array) config('transfer.recipient_email_milestones', []);
}
/** @param list<string> $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)) {
return false; return false;
} }
if ($transfer->recipientMilestoneSent($milestone)) { $email = trim((string) $transfer->recipient_email);
if ($email === '') {
return false;
}
if ($transfer->recipientMilestoneSent('created')) {
return false; return false;
} }
Notification::route('mail', $email)->notify( 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; 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<string> */
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;
}
} }
+1 -10
View File
@@ -48,22 +48,13 @@ class TransferService
} }
$recipientEmail = trim((string) ($data['recipient_email'] ?? '')); $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([ $transfer = Transfer::create([
'user_id' => $user->id, 'user_id' => $user->id,
'title' => trim((string) $data['title']), 'title' => trim((string) $data['title']),
'message' => isset($data['message']) ? trim((string) $data['message']) : null, 'message' => isset($data['message']) ? trim((string) $data['message']) : null,
'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null, 'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null,
'recipient_email_milestones' => $recipientEmail !== '' ? $recipientMilestones : null, 'recipient_email_milestones' => $recipientEmail !== '' ? ['created'] : null,
'recipient_milestones_sent' => [], 'recipient_milestones_sent' => [],
'owner_milestones_sent' => [], 'owner_milestones_sent' => [],
'password_hash' => $passwordHash, 'password_hash' => $passwordHash,
-11
View File
@@ -29,17 +29,6 @@ return [
// Days to keep files after a failed renewal before automatic deletion. // Days to keep files after a failed renewal before automatic deletion.
'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15), '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 emails when a transfer enters grace and before files are deleted.
'owner_grace_email_milestones' => ['grace_entered', '7', '3', '1'], 'owner_grace_email_milestones' => ['grace_entered', '7', '3', '1'],
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "ladill-qr-plus", "name": "ladill-transfer",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
+35
View File
@@ -15,6 +15,41 @@
display: none !important; 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) */ /* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */
@media (max-width: 1023px) { @media (max-width: 1023px) {
.mobile-action-bar { .mobile-action-bar {
+163 -47
View File
@@ -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 = {}) => ({ Alpine.data('transferCreateForm', (config = {}) => ({
selectedFiles: [], fileItems: [],
uploading: false,
submitting: false, submitting: false,
uploadProgress: 0,
uploadLabel: '',
prepared: false, 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) { onFilesChange(event) {
this.selectedFiles = Array.from(event.target.files || []); const newFiles = Array.from(event.target.files || []);
this.prepared = false; 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) { async submit(event) {
@@ -322,66 +455,49 @@ Alpine.data('transferCreateForm', (config = {}) => ({
event.preventDefault(); event.preventDefault();
const form = event.target; const form = event.target;
const fileInput = form.querySelector('#files'); 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.'); window.alert('Add at least one file to share.');
return; return;
} }
this.uploading = true; if (this.fileItems.some((item) => item.status === 'error')) {
this.uploadProgress = 0; window.alert('Remove failed uploads or choose the files again.');
const smallFiles = []; 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;
}
try {
form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove()); form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove());
for (let index = 0; index < files.length; index++) { for (const item of this.fileItems) {
const file = files[index]; if (!item.uploadId) {
this.uploadLabel = `Uploading ${file.name} (${index + 1}/${files.length})…`; continue;
}
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'); const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.type = 'hidden';
hidden.name = 'upload_ids[]'; hidden.name = 'upload_ids[]';
hidden.value = result.uploadId; hidden.value = item.uploadId;
form.appendChild(hidden); form.appendChild(hidden);
} else {
smallFiles.push(file);
}
} }
if (fileInput && smallFiles.length) { this.syncFileInput(fileInput);
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.submitting = true;
this.prepared = true; this.prepared = true;
form.submit(); form.submit();
} catch (error) {
this.uploading = false;
this.submitting = false;
this.prepared = false;
window.alert(error?.message || 'Upload failed. Please try again.');
}
}, },
})); }));
@@ -38,15 +38,48 @@
<label for="files" class="block text-sm font-medium text-slate-700">Files</label> <label for="files" class="block text-sm font-medium text-slate-700">Files</label>
<input type="file" id="files" multiple required @change="onFilesChange" <input type="file" id="files" multiple required @change="onFilesChange"
class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100"> class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100">
<p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Large files upload in small chunks no practical size limit.</p> <p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Large files upload in the background with live progress.</p>
<template x-if="uploading"> <div x-show="fileItems.length" x-cloak class="mt-3 space-y-2">
<div class="mt-2"> <template x-for="item in fileItems" :key="item.id">
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-100"> <div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5">
<div class="h-full rounded-full bg-indigo-600 transition-all" :style="`width: ${uploadProgress}%`"></div> <div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-slate-800" x-text="item.name"></p>
<p class="text-xs text-slate-500" x-text="formatBytes(item.size)"></p>
</div> </div>
<p class="mt-1 text-xs text-slate-500" x-text="uploadLabel"></p> <span class="shrink-0 text-xs font-medium"
:class="{
'text-indigo-600': item.status === 'uploading' || item.status === 'pending',
'text-emerald-600': item.status === 'ready',
'text-red-600': item.status === 'error',
}"
x-text="item.status === 'uploading' ? `${item.progress}%` : (item.status === 'pending' ? 'Waiting…' : (item.status === 'ready' ? 'Ready' : 'Failed'))"></span>
<button type="button"
@click="removeFileItem(item.id)"
:disabled="item.status === 'uploading'"
class="shrink-0 rounded p-0.5 text-slate-400 hover:bg-white hover:text-rose-600 disabled:opacity-40"
title="Remove">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<div x-show="item.status === 'uploading'" class="mt-2">
<div class="upload-progress-track">
<div class="upload-progress-bar" :style="`width: ${item.progress}%`"></div>
</div>
</div>
<p x-show="item.status === 'error'" class="mt-1 text-xs text-red-600" x-text="item.error"></p>
</div> </div>
</template> </template>
<div x-show="uploading" class="rounded-xl border border-indigo-100 bg-indigo-50/70 px-3 py-2">
<div class="flex items-center justify-between gap-2 text-xs text-indigo-800">
<span class="font-medium">Uploading files…</span>
<span x-text="`${overallProgress}%`"></span>
</div>
<div class="upload-progress-track mt-2">
<div class="upload-progress-bar" :style="`width: ${overallProgress}%`"></div>
</div>
</div>
</div>
@error('files')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror @error('files')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
@error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror @error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div> </div>
@@ -57,19 +90,7 @@
placeholder="friend@example.com" 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"> 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')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror @error('recipient_email')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
<p class="mt-2 text-xs font-medium text-slate-600">Email milestones</p> <p class="mt-1 text-xs text-slate-500">A download link is emailed immediately when you create the transfer.</p>
<div class="mt-2 space-y-2">
@foreach($emailMilestones as $id => $label)
<label class="flex items-start gap-2 text-sm text-slate-600">
<input type="checkbox" name="email_milestones[]" value="{{ $id }}"
@checked(in_array($id, old('email_milestones', $defaultEmailMilestones), true))
class="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>{{ $label }}</span>
</label>
@endforeach
</div>
@error('email_milestones')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
@error('email_milestones.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div> </div>
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600"> <div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
@@ -38,9 +38,9 @@
@if($transfer->recipient_email) @if($transfer->recipient_email)
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600"> <div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
Recipient: <span class="font-medium text-slate-900">{{ $transfer->recipient_email }}</span> Recipient: <span class="font-medium text-slate-900">{{ $transfer->recipient_email }}</span>
@if(is_array($transfer->recipient_email_milestones) && $transfer->recipient_email_milestones !== []) @if($transfer->recipientMilestoneSent('created'))
<span class="text-slate-400">·</span> <span class="text-slate-400">·</span>
Notified on {{ implode(', ', array_map(fn ($m) => $m === 'created' ? 'share' : $m, $transfer->recipient_email_milestones)) }} Download link emailed
@endif @endif
</div> </div>
@endif @endif
+18 -33
View File
@@ -2,10 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Transfer;
use App\Models\User;
use App\Notifications\TransferRecipientNotification; use App\Notifications\TransferRecipientNotification;
use App\Services\Transfer\TransferRecipientMailService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
@@ -19,22 +16,17 @@ class TransferRecipientMailTest extends TestCase
use FakesTransferBilling; use FakesTransferBilling;
use RefreshDatabase; 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(); Notification::fake();
Storage::fake('qr'); Storage::fake('qr');
$this->fakeTransferBillingApi(); $this->fakeTransferBillingApi();
$user = User::create([ $user = $this->makeUser();
'public_id' => (string) Str::uuid(),
'name' => 'Sender',
'email' => 'sender+'.uniqid().'@example.com',
]);
$this->actingAs($user)->post(route('transfer.transfers.store'), [ $this->actingAs($user)->post(route('transfer.transfers.store'), [
'title' => 'Project files', 'title' => 'Project files',
'recipient_email' => 'recipient@example.com', 'recipient_email' => 'recipient@example.com',
'email_milestones' => ['created'],
'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')], 'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')],
])->assertRedirect(); ])->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(); 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(), 'public_id' => (string) Str::uuid(),
'name' => 'Sender', 'name' => 'Sender',
'email' => 'sender+'.uniqid().'@example.com', '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);
} }
} }