Add recipient email and milestone notifications on transfer create.
Deploy Ladill Transfer / deploy (push) Successful in 44s

Recipients can be emailed the download link immediately and at optional
reminders (7/3/1 days before unavailable, or when grace period starts).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 12:38:58 +00:00
co-authored by Cursor
parent 311b5b40bb
commit 7c4673adb6
15 changed files with 851 additions and 5 deletions
@@ -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;
@@ -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
+25
View File
@@ -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();
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Notifications;
use App\Models\Transfer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TransferRecipientNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Transfer $transfer,
private readonly string $milestone,
) {}
public function via(mixed $notifiable): array
{
return ['mail'];
}
public function toMail(mixed $notifiable): MailMessage
{
$transfer = $this->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;
}
}
+1 -1
View File
@@ -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.
@@ -102,6 +102,8 @@ class TransferBillingService
'grace_ends_at' => $graceEndsAt,
'expires_at' => $graceEndsAt,
]);
app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(), 'grace');
}
public function processDueRenewals(): array
@@ -0,0 +1,99 @@
<?php
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). */
class TransferRecipientMailService
{
/** @return list<string> */
public function allowedMilestones(): array
{
return array_keys((array) config('transfer.recipient_email_milestones', []));
}
/** @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;
}
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<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;
}
}
+18 -1
View File
@@ -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;
});
}