diff --git a/app/Console/Commands/ProcessTransferBillingCommand.php b/app/Console/Commands/ProcessTransferBillingCommand.php index ce438fd..6a569f0 100644 --- a/app/Console/Commands/ProcessTransferBillingCommand.php +++ b/app/Console/Commands/ProcessTransferBillingCommand.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Services\Transfer\TransferBillingService; +use App\Services\Transfer\TransferRecipientMailService; use Illuminate\Console\Command; class ProcessTransferBillingCommand extends Command @@ -11,15 +12,17 @@ class ProcessTransferBillingCommand extends Command protected $description = 'Renew monthly transfer storage billing and delete transfers past the grace period'; - public function handle(TransferBillingService $billing): int + public function handle(TransferBillingService $billing, TransferRecipientMailService $recipients): int { $result = $billing->processDueRenewals(); + $recipientEmails = $recipients->processDueMilestones(); $this->info(sprintf( - 'Transfer billing: %d renewed, %d entered grace, %d deleted.', + 'Transfer billing: %d renewed, %d entered grace, %d deleted, %d recipient reminders sent.', $result['renewed'], $result['graced'], $result['deleted'], + $recipientEmails, )); return self::SUCCESS; diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index bd579cd..33cc25e 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -6,6 +6,7 @@ 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; @@ -41,10 +42,14 @@ 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']), ]); } @@ -58,9 +63,14 @@ 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, 'password' => 'nullable|string|min:4|max:64', 'files' => 'nullable|array', 'files.*' => $fileRules, @@ -94,9 +104,14 @@ 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')) { + $success .= ' A download link was emailed to '.$transfer->recipient_email.'.'; + } + return redirect() ->route('transfer.transfers.show', $transfer) - ->with('success', 'Transfer created. Share the link or QR code with recipients.'); + ->with('success', $success); } public function show(Transfer $transfer): View diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index 68a8954..70d2a1c 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -23,6 +23,9 @@ class Transfer extends Model 'qr_code_id', 'title', 'message', + 'recipient_email', + 'recipient_email_milestones', + 'recipient_milestones_sent', 'password_hash', 'retention_days', 'expires_at', @@ -40,6 +43,8 @@ class Transfer extends Model 'paid_until' => 'datetime', 'grace_ends_at' => 'datetime', 'last_billed_at' => 'datetime', + 'recipient_email_milestones' => 'array', + 'recipient_milestones_sent' => 'array', 'downloads_total' => 'integer', 'storage_bytes' => 'integer', ]; @@ -149,4 +154,24 @@ class Transfer extends Model ->orWhere('grace_ends_at', '>', now()); }); } + + public function wantsRecipientMilestone(string $milestone): bool + { + return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true); + } + + public function recipientMilestoneSent(string $milestone): bool + { + return in_array($milestone, (array) ($this->recipient_milestones_sent ?? []), true); + } + + public function markRecipientMilestoneSent(string $milestone): void + { + $sent = (array) ($this->recipient_milestones_sent ?? []); + $sent[] = $milestone; + + $this->forceFill([ + 'recipient_milestones_sent' => array_values(array_unique($sent)), + ])->save(); + } } diff --git a/app/Notifications/TransferRecipientNotification.php b/app/Notifications/TransferRecipientNotification.php new file mode 100644 index 0000000..670e58e --- /dev/null +++ b/app/Notifications/TransferRecipientNotification.php @@ -0,0 +1,63 @@ +transfer->loadMissing(['files', 'qrCode', 'user']); + $publicUrl = $transfer->qrCode?->publicUrl() ?? ''; + + return (new MailMessage()) + ->subject($this->subjectLine()) + ->view('mail.notifications.transfer-recipient', [ + 'transfer' => $transfer, + 'publicUrl' => $publicUrl, + 'milestone' => $this->milestone, + 'senderName' => $transfer->user?->name, + 'daysRemaining' => $this->daysRemaining(), + ]); + } + + private function subjectLine(): string + { + return match ($this->milestone) { + 'created' => $this->transfer->user?->name + ? "{$this->transfer->user->name} shared files with you" + : "Files shared with you: {$this->transfer->title}", + 'grace' => "Download soon โ€” {$this->transfer->title} needs payment", + '1' => "Last day to download: {$this->transfer->title}", + '3' => "3 days left to download: {$this->transfer->title}", + '7' => "Download reminder: {$this->transfer->title}", + default => "Download reminder: {$this->transfer->title}", + }; + } + + private function daysRemaining(): ?int + { + if (! is_numeric($this->milestone)) { + return null; + } + + return (int) $this->milestone; + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 5519fc4..9220715 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -105,7 +105,7 @@ class AfiaService What Ladill Transfer does and where things live: - Overview (Dashboard): active transfers, storage used, downloads in the last 30 days, transfers expiring soon, wallet balance. - - Transfers: list all file shares; create a new transfer (upload one or more files, title, optional message, retention period, optional password). + - Transfers: list all file shares; create a new transfer (upload files, optional recipient email with milestone reminders, optional password). - After creating a transfer: users get a share link and downloadable QR code; recipients scan ladill.com/q/{code} or open the link. - Files: browse all stored files across transfers with sizes and download counts. - Analytics: download trends, top transfers, recent download activity. diff --git a/app/Services/Transfer/TransferBillingService.php b/app/Services/Transfer/TransferBillingService.php index 3b85cce..15b6952 100644 --- a/app/Services/Transfer/TransferBillingService.php +++ b/app/Services/Transfer/TransferBillingService.php @@ -102,6 +102,8 @@ class TransferBillingService 'grace_ends_at' => $graceEndsAt, 'expires_at' => $graceEndsAt, ]); + + app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(), 'grace'); } public function processDueRenewals(): array diff --git a/app/Services/Transfer/TransferRecipientMailService.php b/app/Services/Transfer/TransferRecipientMailService.php new file mode 100644 index 0000000..8ae7cd1 --- /dev/null +++ b/app/Services/Transfer/TransferRecipientMailService.php @@ -0,0 +1,99 @@ + */ + public function allowedMilestones(): array + { + 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)) { + return false; + } + + if ($transfer->recipientMilestoneSent($milestone)) { + return false; + } + + Notification::route('mail', $email)->notify( + new TransferRecipientNotification($transfer->loadMissing(['files', 'qrCode', 'user']), $milestone), + ); + + $transfer->markRecipientMilestoneSent($milestone); + + 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 bfd3389..885c99f 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -47,10 +47,24 @@ class TransferService $passwordHash = Hash::make((string) $data['password']); } + $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_milestones_sent' => [], 'password_hash' => $passwordHash, 'retention_days' => $billingPeriodDays, 'status' => Transfer::STATUS_ACTIVE, @@ -83,7 +97,10 @@ class TransferService $transfer = $transfer->fresh(['files', 'qrCode']); app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user); - return $transfer->fresh(['files', 'qrCode']); + $transfer = $transfer->fresh(['files', 'qrCode']); + app(TransferRecipientMailService::class)->notifyRecipient($transfer, 'created'); + + return $transfer; }); } diff --git a/config/transfer.php b/config/transfer.php index 6685e82..3086d99 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -29,6 +29,17 @@ 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'], + // Legacy env keys (billing is monthly until cancelled by non-payment + grace). 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), 'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30), diff --git a/database/migrations/2026_06_09_140000_add_recipient_email_to_transfers.php b/database/migrations/2026_06_09_140000_add_recipient_email_to_transfers.php new file mode 100644 index 0000000..a082bfb --- /dev/null +++ b/database/migrations/2026_06_09_140000_add_recipient_email_to_transfers.php @@ -0,0 +1,24 @@ +string('recipient_email')->nullable()->after('message'); + $table->json('recipient_email_milestones')->nullable()->after('recipient_email'); + $table->json('recipient_milestones_sent')->nullable()->after('recipient_email_milestones'); + }); + } + + public function down(): void + { + Schema::table('transfers', function (Blueprint $table) { + $table->dropColumn(['recipient_email', 'recipient_email_milestones', 'recipient_milestones_sent']); + }); + } +}; diff --git a/resources/views/mail/notifications/layout.blade.php b/resources/views/mail/notifications/layout.blade.php new file mode 100644 index 0000000..e844145 --- /dev/null +++ b/resources/views/mail/notifications/layout.blade.php @@ -0,0 +1,402 @@ + + + + + + + {{ $subject ?? 'Ladill Notification' }} + + + + + + + diff --git a/resources/views/mail/notifications/transfer-recipient.blade.php b/resources/views/mail/notifications/transfer-recipient.blade.php new file mode 100644 index 0000000..59fac53 --- /dev/null +++ b/resources/views/mail/notifications/transfer-recipient.blade.php @@ -0,0 +1,76 @@ +@extends('mail.notifications.layout') + +@section('content') + @php + $fileCount = $transfer->files->count(); + $availableUntil = $transfer->isInGracePeriod() + ? $transfer->grace_ends_at + : $transfer->paid_until; + @endphp + +
+

๐Ÿ“ File transfer

+

{{ $transfer->title }}

+
+ + @if($milestone === 'created') +

You have files to download

+ + @elseif($milestone === 'grace') +

Download before these files are removed

+ + @else +

Reminder: download your files

+ + @endif + + @if($transfer->message) + + @endif + + @if($transfer->isPasswordProtected()) + + @endif + + + + + +
+ +
+ + + + @if($availableUntil && $milestone !== 'grace') + + @endif +@endsection diff --git a/resources/views/transfer/transfers/create.blade.php b/resources/views/transfer/transfers/create.blade.php index 1dcf0f8..f895b83 100644 --- a/resources/views/transfer/transfers/create.blade.php +++ b/resources/views/transfer/transfers/create.blade.php @@ -51,6 +51,27 @@ @error('files.*')

{{ $message }}

@enderror +
+ + + @error('recipient_email')

{{ $message }}

@enderror +

Email milestones

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

{{ $message }}

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

{{ $message }}

@enderror +
+

Monthly storage billing

GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.

diff --git a/resources/views/transfer/transfers/show.blade.php b/resources/views/transfer/transfers/show.blade.php index 5689b37..6ba84bf 100644 --- a/resources/views/transfer/transfers/show.blade.php +++ b/resources/views/transfer/transfers/show.blade.php @@ -35,6 +35,16 @@
{{ session('success') }}
@endif + @if($transfer->recipient_email) +
+ Recipient: {{ $transfer->recipient_email }} + @if(is_array($transfer->recipient_email_milestones) && $transfer->recipient_email_milestones !== []) + ยท + Notified on {{ implode(', ', array_map(fn ($m) => $m === 'created' ? 'share' : $m, $transfer->recipient_email_milestones)) }} + @endif +
+ @endif +

Share link

diff --git a/tests/Feature/TransferRecipientMailTest.php b/tests/Feature/TransferRecipientMailTest.php new file mode 100644 index 0000000..a510bf7 --- /dev/null +++ b/tests/Feature/TransferRecipientMailTest.php @@ -0,0 +1,78 @@ +fakeTransferBillingApi(); + + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Sender', + 'email' => 'sender+'.uniqid().'@example.com', + ]); + + $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(); + + Notification::assertSentOnDemand( + TransferRecipientNotification::class, + fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'recipient@example.com', + ); + } + + public function test_recipient_day_milestone_is_sent_once(): void + { + Notification::fake(); + + $user = 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); + } +}