feat(email): follow up after post-onboarding AI consent (#596)
## What Adds a lifecycle email sent **2 days after a user records AI consent**, but only when that consent was given **outside of onboarding** — i.e. more than 3 days after sign-up. This targets users who deliberately opted into AI later (e.g. via the billing toggle / transactions banner gated by the `AiConsentSettings` flag), not those who consented during initial setup. ## How - **`email:ai-consent-follow-up`** scheduled command (daily, 10:15 Europe/Madrid) selects active consents accepted exactly 2 days ago, skips onboarding-era ones (`accepted_at <= created_at + 3 days`), and queues a job per user. - **`SendAiConsentFollowUpEmailJob`** sends the mailable and records a `UserMailLog`. Dedup, locale and the queued-email plumbing all reuse the existing drip system — **no migration**. - Email is localised automatically via the user's `HasLocalePreference`, like every other drip email. ## Design notes - **Scheduled command, not a 2-day delayed job** — survives worker restarts and respects consent revoked in the meantime. - **Exact "2 days ago" window**, matching the existing paywall drip. If the scheduler misses a day that cohort isn't retried; kept consistent with the other drips on purpose. - Copy is a first draft — open to wording tweaks. ## Tests `tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php` — 9 tests covering the happy path, onboarding exclusion (incl. the exact-3-days boundary), timing window, revoked consent, the disabled-drip gate, single-send + mail log, no-resend, and locale rendering. All green, Pint clean.
This commit is contained in:
parent
291cfbe261
commit
934d834ab3
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Database\Factories\AiConsentFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
@ -9,6 +10,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property Carbon $accepted_at
|
||||
* @property ?Carbon $revoked_at
|
||||
*/
|
||||
class AiConsent extends Model
|
||||
{
|
||||
/** @use HasFactory<AiConsentFactory> */
|
||||
|
|
|
|||
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', ['category_source' => 'ai'])">
|
||||
{{ __('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,135 @@
|
|||
<?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');
|
||||
expect($html)->toContain('category_source=ai');
|
||||
});
|
||||
|
||||
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