feat(email): follow up after post-onboarding AI consent
Send a lifecycle email two days after a user opts into AI, but only when the consent was given outside of onboarding (more than three days after sign-up) so we don't email users who consented during their initial setup. A daily scheduled command (email:ai-consent-follow-up) selects active consents accepted two days ago, skips onboarding-era ones, and queues a drip email. Deduplication reuses the existing UserMailLog drip system, so no migration is needed. The email is localised via the user's HasLocalePreference, like the other drip emails.
This commit is contained in:
parent
291cfbe261
commit
47cecf0bee
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Drip\SendAiConsentFollowUpEmailJob;
|
||||
use App\Models\AiConsent;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class SendAiConsentFollowUpEmailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'email:ai-consent-follow-up';
|
||||
|
||||
protected $description = 'Queue the AI consent follow-up email for users who opted into AI two days ago, outside of onboarding';
|
||||
|
||||
/**
|
||||
* Consent given more than this many days after sign-up is treated as a
|
||||
* deliberate, post-onboarding opt-in (the audience for this email).
|
||||
*/
|
||||
private const ONBOARDING_GRACE_DAYS = 3;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! config('mail.drip_emails_enabled')) {
|
||||
$this->info('Drip emails are disabled. Nothing to do.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$queued = 0;
|
||||
|
||||
AiConsent::query()
|
||||
->active()
|
||||
->whereDate('accepted_at', today()->subDays(2))
|
||||
->with('user')
|
||||
->chunkById(100, function (Collection $consents) use (&$queued): void {
|
||||
foreach ($consents as $consent) {
|
||||
$user = $consent->user;
|
||||
|
||||
if ($user === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($consent->accepted_at->lessThanOrEqualTo($user->created_at->copy()->addDays(self::ONBOARDING_GRACE_DAYS))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SendAiConsentFollowUpEmailJob::dispatch($user);
|
||||
$queued++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Queued {$queued} AI consent follow-up email(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,5 +12,6 @@ enum DripEmailType: string
|
|||
case Feedback = 'feedback';
|
||||
case SubscriptionCancelled = 'subscription_cancelled';
|
||||
case PaywallFollowUp = 'paywall_follow_up';
|
||||
case AiConsentFollowUp = 'ai_consent_follow_up';
|
||||
case Update = 'update';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Drip;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Mail\Drip\AiConsentFollowUpEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendAiConsentFollowUpEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::AiConsentFollowUp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->user->aiConsents()->active()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($this->user)->send(new AiConsentFollowUpEmail($this->user));
|
||||
|
||||
UserMailLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'email_type' => DripEmailType::AiConsentFollowUp,
|
||||
'email_identifier' => DripEmailType::AiConsentFollowUp->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Drip;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AiConsentFollowUpEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
from: new Address(
|
||||
config('mail.drip_from.address', 'hi@whisper.money'),
|
||||
config('mail.drip_from.name', 'Álvaro and Víctor'),
|
||||
),
|
||||
subject: __('Putting AI to work on your finances'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.drip.ai-consent-follow-up',
|
||||
with: [
|
||||
'userName' => $this->user->name,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
11
lang/es.json
11
lang/es.json
|
|
@ -2166,5 +2166,14 @@
|
|||
"Connect your Interactive Brokers account using a Flex Web Service token and Query ID.": "Conecta tu cuenta de Interactive Brokers usando un token del Flex Web Service y un Query ID.",
|
||||
"Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.": "Introduce tu token del Flex Web Service y el Query ID para conectar tu cuenta de Interactive Brokers.",
|
||||
"In Client Portal, create an Activity Flex Query including the \"Net Asset Value (NAV)\" and \"Open Positions\" sections, then generate a Flex Web Service token under": "En el Client Portal, crea una Activity Flex Query que incluya las secciones \"Net Asset Value (NAV)\" y \"Open Positions\", y luego genera un token del Flex Web Service en",
|
||||
"Performance & Reports → Flex Queries": "Rendimiento e Informes → Flex Queries"
|
||||
"Performance & Reports → Flex Queries": "Rendimiento e Informes → Flex Queries",
|
||||
"Putting AI to work on your finances": "Ponemos la IA a trabajar en tus finanzas",
|
||||
"Your AI assistant is hard at work, :name": "Tu asistente de IA está trabajando duro, :name",
|
||||
"A couple of days ago you turned on AI-powered insights in Whisper Money. We wanted to check in and show you what it has been doing for you behind the scenes.": "Hace un par de días activaste los análisis con IA en Whisper Money. Queríamos saber cómo te va y enseñarte lo que ha estado haciendo por ti entre bastidores.",
|
||||
"Your transactions, categorised automatically": "Tus transacciones, categorizadas automáticamente",
|
||||
"Instead of sorting every transaction by hand, our AI suggests a category for each one — so your spending breakdown stays accurate and up to date with almost no effort from you.": "En lugar de ordenar cada transacción a mano, nuestra IA sugiere una categoría para cada una, para que el desglose de tus gastos se mantenga preciso y actualizado casi sin esfuerzo por tu parte.",
|
||||
"See your categorised transactions": "Ver tus transacciones categorizadas",
|
||||
"Private by design, always": "Privado por diseño, siempre",
|
||||
"Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.": "Activar la IA nunca cambia quién es el dueño de tus datos: lo eres tú. Solo la usamos para ayudarte a entender tus propias finanzas, y puedes revocar este consentimiento cuando quieras desde tus ajustes.",
|
||||
"If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<x-mail::message>
|
||||
# {{ __('Your AI assistant is hard at work, :name', ['name' => $userName]) }}
|
||||
|
||||
{{ __('A couple of days ago you turned on AI-powered insights in Whisper Money. We wanted to check in and show you what it has been doing for you behind the scenes.') }}
|
||||
|
||||
## {{ __('Your transactions, categorised automatically') }}
|
||||
|
||||
{{ __('Instead of sorting every transaction by hand, our AI suggests a category for each one — so your spending breakdown stays accurate and up to date with almost no effort from you.') }}
|
||||
|
||||
<x-mail::button :url="route('transactions.index')">
|
||||
{{ __('See your categorised transactions') }}
|
||||
</x-mail::button>
|
||||
|
||||
## {{ __('Private by design, always') }}
|
||||
|
||||
{{ __('Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.') }}
|
||||
|
||||
{{ __('If you have any questions, **just reply to this email**. We personally read every message.') }}
|
||||
|
||||
{{ __('Best,') }}<br>
|
||||
{{ __('Víctor & Álvaro') }}<br>
|
||||
{{ __('Founders of Whisper Money') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -12,6 +12,7 @@ Schedule::command('resend:sync-leads')->dailyAt('03:00');
|
|||
Schedule::command('leads:send-invitations --force --limit=150')->dailyAt('09:00');
|
||||
Schedule::command('leads:send-re-invitations --force')->dailyAt('09:00');
|
||||
Schedule::command('email:paywall-follow-up')->dailyAt('10:00')->timezone('Europe/Madrid');
|
||||
Schedule::command('email:ai-consent-follow-up')->dailyAt('10:15')->timezone('Europe/Madrid');
|
||||
Schedule::command('stats:daily-report')->dailyAt('09:00')->timezone('Europe/Madrid');
|
||||
Schedule::command('stats:ai-cohort-report')->monthlyOn(1, '09:00')->timezone('Europe/Madrid');
|
||||
Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Jobs\Drip\SendAiConsentFollowUpEmailJob;
|
||||
use App\Mail\Drip\AiConsentFollowUpEmail;
|
||||
use App\Models\AiConsent;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Creates a user whose AI consent was recorded $acceptedDaysAgo days ago, having
|
||||
* signed up $signedUpDaysAgo days ago.
|
||||
*/
|
||||
function userWithConsent(int $signedUpDaysAgo, int $acceptedDaysAgo): User
|
||||
{
|
||||
$user = User::factory()->create(['created_at' => now()->subDays($signedUpDaysAgo)]);
|
||||
|
||||
$user->aiConsents()->create([
|
||||
'scope' => AiConsent::SCOPE_FINANCE,
|
||||
'version' => (string) config('ai_suggestions.consent_version'),
|
||||
'accepted_at' => now()->subDays($acceptedDaysAgo),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('queues the follow-up for a post-onboarding consent accepted two days ago', function () {
|
||||
Bus::fake();
|
||||
|
||||
$user = userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2);
|
||||
|
||||
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
|
||||
|
||||
Bus::assertDispatched(
|
||||
SendAiConsentFollowUpEmailJob::class,
|
||||
fn (SendAiConsentFollowUpEmailJob $job): bool => $job->user->is($user),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips consent given during onboarding', function () {
|
||||
Bus::fake();
|
||||
|
||||
// Signed up and consented the same day → onboarding, not a deliberate opt-in.
|
||||
userWithConsent(signedUpDaysAgo: 2, acceptedDaysAgo: 2);
|
||||
|
||||
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
|
||||
|
||||
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
|
||||
});
|
||||
|
||||
it('treats consent exactly three days after signup as onboarding', function () {
|
||||
Bus::fake();
|
||||
|
||||
// accepted two days ago, signed up five days ago → exactly 3 days apart (not > 3).
|
||||
userWithConsent(signedUpDaysAgo: 5, acceptedDaysAgo: 2);
|
||||
|
||||
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
|
||||
|
||||
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
|
||||
});
|
||||
|
||||
it('does not queue when the consent was not accepted exactly two days ago', function () {
|
||||
Bus::fake();
|
||||
|
||||
userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 1);
|
||||
userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 3);
|
||||
|
||||
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
|
||||
|
||||
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
|
||||
});
|
||||
|
||||
it('skips revoked consent', function () {
|
||||
Bus::fake();
|
||||
|
||||
$user = userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2);
|
||||
$user->aiConsents()->update(['revoked_at' => now()]);
|
||||
|
||||
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
|
||||
|
||||
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
|
||||
});
|
||||
|
||||
it('does nothing when drip emails are disabled', function () {
|
||||
config(['mail.drip_emails_enabled' => false]);
|
||||
Bus::fake();
|
||||
|
||||
userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2);
|
||||
|
||||
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
|
||||
|
||||
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
|
||||
});
|
||||
|
||||
it('sends the email once and records a mail log', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->has(AiConsent::factory(), 'aiConsents')->create();
|
||||
|
||||
(new SendAiConsentFollowUpEmailJob($user))->handle();
|
||||
|
||||
Mail::assertQueued(AiConsentFollowUpEmail::class);
|
||||
expect($user->hasReceivedEmail(DripEmailType::AiConsentFollowUp))->toBeTrue();
|
||||
expect(UserMailLog::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('renders the email in the user locale', function () {
|
||||
app()->setLocale('es');
|
||||
|
||||
$user = User::factory()->make(['name' => 'Ada']);
|
||||
$html = (new AiConsentFollowUpEmail($user))->render();
|
||||
|
||||
app()->setLocale('en');
|
||||
|
||||
expect($html)->toContain('Tu asistente de IA está trabajando duro');
|
||||
});
|
||||
|
||||
it('does not resend if the user already received it', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->has(AiConsent::factory(), 'aiConsents')->create();
|
||||
UserMailLog::create([
|
||||
'user_id' => $user->id,
|
||||
'email_type' => DripEmailType::AiConsentFollowUp,
|
||||
'email_identifier' => DripEmailType::AiConsentFollowUp->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
(new SendAiConsentFollowUpEmailJob($user))->handle();
|
||||
|
||||
Mail::assertNotQueued(AiConsentFollowUpEmail::class);
|
||||
});
|
||||
Loading…
Reference in New Issue