Add recipient email and milestone notifications on transfer create.
Deploy Ladill Transfer / deploy (push) Successful in 44s
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user