Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/rules/backend/lifecycle-emails.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ paths:

- Send only through `MAIL_LIFECYCLE_MAILER`, test sends included; never fall back to the transactional mailer. Everything lifecycle exists only in SaaS mode (`LifecycleEmailAvailability`).
- Every candidate query must: exclude `users.lifecycle_emails_unsubscribed_at`; skip accounts nudged in the last 3 days (`quietPeriodStart()`); filter in SQL before `LIMIT`, never in PHP after it; and anchor its trigger on a timestamp old data can't move (never `updated_at` unless also bounded by a creation date after `enabled_at`). Marketing-flavoured nudges also require `users.marketing_opted_in_at`.
- A template's `enabled_at` is the cold-start cutoff; never backdate it.
- A template's `enabled_at` is the cold-start cutoff; never backdate it. The one exception is `LAPSED_PAYER`, which on purpose reaches accounts that lapsed before it was enabled, within a fixed 18-month lookback anchored on the end of the last sold date.
- Every candidate query skips suspended accounts (`accounts.suspended_at`).
- Unsubscribe is a button on `/emails/unsubscribe/:token`, never unsubscribe-on-GET (link scanners prefetch).
- `account_lifecycle_nudges` is claim-then-send: only transient failures (network, HTTP 5xx/429) release the claim and stop the sweep; any other rejection keeps the claim so one bad address never stalls it. Nudges are sent synchronously, not via `BaseMail`, so failures can be classified.
- Stripe nudges filter by signup timezone (`StripeConnectTimezones`); don't use `organizer_stripe_platforms.stripe_account_details->country`, because unfinished Express accounts report the platform default (`IE`).
1 change: 1 addition & 0 deletions backend/app/DomainObjects/Enums/LifecycleNudgeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ enum LifecycleNudgeType
case STRIPE_ONBOARDING_INCOMPLETE;
case EVENT_DRAFT_STALE;
case LIVE_EVENT_NO_SALES;
case LAPSED_PAYER;
}
2 changes: 2 additions & 0 deletions backend/app/Helper/Url.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class Url

public const EVENT_DASHBOARD = 'app.frontend_urls.event_dashboard';

public const ORGANIZER_EVENTS = 'app.frontend_urls.organizer_events';

public const WELCOME = 'app.frontend_urls.welcome';

public const LIFECYCLE_EMAIL_UNSUBSCRIBE = 'app.frontend_urls.lifecycle_email_unsubscribe';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace HiEvents\Repository\DTO\LifecycleEmail;

use HiEvents\DataTransferObjects\BaseDataObject;

class LapsedPayerNudgeCandidateDTO extends BaseDataObject
{
public function __construct(
public int $accountId,
public int $userId,
public int $eventId,
public int $organizerId,
public string $eventTitle,
public bool $isEventArchived,
public string $lastSoldDateEndedAt,
public string $email,
public string $firstName,
) {}
}
115 changes: 115 additions & 0 deletions backend/app/Repository/Eloquent/AccountLifecycleNudgeRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
use HiEvents\DomainObjects\Status\AccountDeletionRequestStatus;
use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\DomainObjects\Status\OrderPaymentStatus;
use HiEvents\DomainObjects\Status\OrderRefundStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Models\AccountLifecycleNudge;
use HiEvents\Repository\DTO\LifecycleEmail\AccountOwnerNudgeCandidateDTO;
use HiEvents\Repository\DTO\LifecycleEmail\EventNudgeCandidateDTO;
use HiEvents\Repository\DTO\LifecycleEmail\LapsedPayerNudgeCandidateDTO;
use HiEvents\Repository\DTO\LifecycleEmail\LifecycleNudgeSendCountsDTO;
use HiEvents\Repository\DTO\LifecycleEmail\StripeOnboardingNudgeCandidateDTO;
use HiEvents\Repository\Interfaces\AccountLifecycleNudgeRepositoryInterface;
Expand Down Expand Up @@ -53,6 +56,7 @@ public function findNoEventCandidates(
AND u.lifecycle_emails_unsubscribed_at IS NULL
WHERE a.deleted_at IS NULL
AND a.account_verified_at IS NOT NULL
AND a.suspended_at IS NULL
AND a.created_at >= ?
AND a.created_at <= ?
AND NOT EXISTS (SELECT 1 FROM events e WHERE e.account_id = a.id)
Expand Down Expand Up @@ -111,6 +115,7 @@ public function findStripeOnboardingCandidates(
JOIN organizers o ON o.id = fpp.organizer_id AND o.deleted_at IS NULL
JOIN accounts a ON a.id = o.account_id AND a.deleted_at IS NULL
AND a.account_verified_at IS NOT NULL
AND a.suspended_at IS NULL
JOIN account_users au ON au.account_id = a.id AND au.is_account_owner = true
AND au.status = 'ACTIVE' AND au.deleted_at IS NULL
JOIN users u ON u.id = au.user_id AND u.deleted_at IS NULL
Expand Down Expand Up @@ -162,6 +167,7 @@ public function findDraftEventCandidates(
FROM events e
JOIN accounts a ON a.id = e.account_id AND a.deleted_at IS NULL
AND a.account_verified_at IS NOT NULL
AND a.suspended_at IS NULL
JOIN account_users au ON au.account_id = a.id AND au.is_account_owner = true
AND au.status = 'ACTIVE' AND au.deleted_at IS NULL
JOIN users u ON u.id = au.user_id AND u.deleted_at IS NULL
Expand Down Expand Up @@ -213,6 +219,7 @@ public function findLiveEventWithoutSalesCandidates(
FROM events e
JOIN accounts a ON a.id = e.account_id AND a.deleted_at IS NULL
AND a.account_verified_at IS NOT NULL
AND a.suspended_at IS NULL
JOIN account_users au ON au.account_id = a.id AND au.is_account_owner = true
AND au.status = 'ACTIVE' AND au.deleted_at IS NULL
JOIN users u ON u.id = au.user_id AND u.deleted_at IS NULL
Expand Down Expand Up @@ -268,6 +275,114 @@ public function findLiveEventWithoutSalesCandidates(
));
}

public function findLapsedPayerCandidates(
CarbonInterface $lapsedAfter,
CarbonInterface $lapsedBefore,
CarbonInterface $upcomingAfter,
int $limit,
): Collection {
$rows = $this->db->select(<<<'SQL'
WITH paid_sales AS (
SELECT e.account_id, e.id AS event_id, e.organizer_id, e.title, e.status AS event_status,
eo.status AS occurrence_status, COALESCE(eo.end_date, eo.start_date) AS ended_at,
LOWER(o.email) AS buyer_email
FROM orders o
JOIN events e ON e.id = o.event_id AND e.deleted_at IS NULL
JOIN organizers og ON og.id = e.organizer_id AND og.deleted_at IS NULL
JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL
JOIN event_occurrences eo ON eo.id = oi.event_occurrence_id AND eo.deleted_at IS NULL
WHERE o.status = :completed
AND o.payment_status = :payment_received
AND o.refund_status IS DISTINCT FROM :refunded
AND o.deleted_at IS NULL
AND o.is_manually_created = false
AND o.payment_provider IS NOT NULL
AND o.total_gross - COALESCE(o.total_refunded, 0) > 0
AND COALESCE(eo.end_date, eo.start_date) >= :lapsed_after
AND NOT EXISTS (SELECT 1 FROM account_users bau
JOIN users bu ON bu.id = bau.user_id
WHERE bau.account_id = e.account_id AND LOWER(bu.email) = LOWER(o.email))
AND NOT EXISTS (SELECT 1 FROM organizers bog
WHERE bog.account_id = e.account_id AND LOWER(bog.email) = LOWER(o.email))
),
last_sold_date AS (
SELECT DISTINCT ON (account_id) *
FROM paid_sales
WHERE account_id IN (SELECT account_id FROM paid_sales
GROUP BY account_id HAVING COUNT(DISTINCT buyer_email) >= 2)
ORDER BY account_id, ended_at DESC
)
SELECT * FROM (
SELECT DISTINCT ON (a.id)
a.id AS account_id, lsd.event_id, lsd.organizer_id, lsd.title, lsd.event_status, lsd.ended_at,
u.id AS user_id, u.email, u.first_name
FROM last_sold_date lsd
JOIN accounts a ON a.id = lsd.account_id AND a.deleted_at IS NULL
AND a.account_verified_at IS NOT NULL
AND a.suspended_at IS NULL
JOIN account_users au ON au.account_id = a.id AND au.is_account_owner = true
AND au.status = 'ACTIVE' AND au.deleted_at IS NULL
JOIN users u ON u.id = au.user_id AND u.deleted_at IS NULL
AND u.lifecycle_emails_unsubscribed_at IS NULL
WHERE lsd.ended_at <= :lapsed_before
AND lsd.event_status <> :pending_review
AND lsd.occurrence_status = :active
AND NOT EXISTS (SELECT 1 FROM events ne
WHERE ne.account_id = a.id AND ne.created_at > :created_since)
AND NOT EXISTS (SELECT 1 FROM event_occurrences reo
JOIN events re ON re.id = reo.event_id AND re.deleted_at IS NULL
WHERE re.account_id = a.id AND reo.deleted_at IS NULL
AND reo.status = :recent_active
AND COALESCE(reo.end_date, reo.start_date) > :ran_since)
AND NOT EXISTS (SELECT 1 FROM event_occurrences ueo
JOIN events ue ON ue.id = ueo.event_id AND ue.deleted_at IS NULL
WHERE ue.account_id = a.id AND ueo.deleted_at IS NULL
AND COALESCE(ueo.end_date, ueo.start_date) >= :upcoming_after)
AND NOT EXISTS (SELECT 1 FROM account_deletion_requests adr
WHERE adr.account_id = a.id AND adr.status = :deletion_requested)
AND NOT EXISTS (SELECT 1 FROM account_lifecycle_nudges n
WHERE n.account_id = a.id
AND (n.nudge_type = :nudge_type OR n.created_at > :quiet_since))
AND NOT EXISTS (SELECT 1 FROM account_lifecycle_nudges sn
JOIN account_users sau ON sau.account_id = sn.account_id
AND sau.is_account_owner = true
WHERE sau.user_id = u.id AND sn.nudge_type = :owner_nudge_type)
ORDER BY a.id
) candidates
ORDER BY ended_at DESC
LIMIT :limit
SQL, [
'completed' => OrderStatus::COMPLETED->name,
'payment_received' => OrderPaymentStatus::PAYMENT_RECEIVED->name,
'refunded' => OrderRefundStatus::REFUNDED->name,
'lapsed_after' => $lapsedAfter->toDateTimeString(),
'lapsed_before' => $lapsedBefore->toDateTimeString(),
'pending_review' => EventStatus::PENDING_MANUAL_REVIEW->name,
'active' => EventOccurrenceStatus::ACTIVE->name,
'created_since' => $lapsedBefore->toDateTimeString(),
'recent_active' => EventOccurrenceStatus::ACTIVE->name,
'ran_since' => $lapsedBefore->toDateTimeString(),
'upcoming_after' => $upcomingAfter->toDateTimeString(),
'deletion_requested' => AccountDeletionRequestStatus::REQUESTED->name,
'nudge_type' => LifecycleNudgeType::LAPSED_PAYER->name,
'quiet_since' => $this->quietPeriodStart(),
'owner_nudge_type' => LifecycleNudgeType::LAPSED_PAYER->name,
'limit' => $limit,
]);

return collect($rows)->map(static fn (object $row) => new LapsedPayerNudgeCandidateDTO(
accountId: (int) $row->account_id,
userId: (int) $row->user_id,
eventId: (int) $row->event_id,
organizerId: (int) $row->organizer_id,
eventTitle: $row->title,
isEventArchived: $row->event_status === EventStatus::ARCHIVED->name,
lastSoldDateEndedAt: (string) $row->ended_at,
email: $row->email,
firstName: $row->first_name,
));
}

public function claim(int $accountId, LifecycleNudgeType $nudgeType, ?int $eventId = null): bool
{
return $this->db->table('account_lifecycle_nudges')->insertOrIgnore([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use HiEvents\DomainObjects\Enums\LifecycleNudgeType;
use HiEvents\Repository\DTO\LifecycleEmail\AccountOwnerNudgeCandidateDTO;
use HiEvents\Repository\DTO\LifecycleEmail\EventNudgeCandidateDTO;
use HiEvents\Repository\DTO\LifecycleEmail\LapsedPayerNudgeCandidateDTO;
use HiEvents\Repository\DTO\LifecycleEmail\LifecycleNudgeSendCountsDTO;
use HiEvents\Repository\DTO\LifecycleEmail\StripeOnboardingNudgeCandidateDTO;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -57,6 +58,16 @@ public function findLiveEventWithoutSalesCandidates(
int $limit,
): Collection;

/**
* @return Collection<int, LapsedPayerNudgeCandidateDTO>
*/
public function findLapsedPayerCandidates(
CarbonInterface $lapsedAfter,
CarbonInterface $lapsedBefore,
CarbonInterface $upcomingAfter,
int $limit,
): Collection;

public function claim(int $accountId, LifecycleNudgeType $nudgeType, ?int $eventId = null): bool;

public function release(int $accountId, LifecycleNudgeType $nudgeType): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class AdminLifecycleEmailResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var 'NO_EVENT_CREATED'|'STRIPE_ONBOARDING_INCOMPLETE'|'EVENT_DRAFT_STALE'|'LIVE_EVENT_NO_SALES' */
/** @var 'NO_EVENT_CREATED'|'STRIPE_ONBOARDING_INCOMPLETE'|'EVENT_DRAFT_STALE'|'LIVE_EVENT_NO_SALES'|'LAPSED_PAYER' */
'nudge_type' => $this->template->nudgeType->name,
'subject' => $this->template->subject,
'body' => $this->template->body,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,20 @@ private function validate(string $subject, string $body): void
private function contextFor(LifecycleEmailRecipientDTO $recipient): array
{
return [
'first_name' => $recipient->firstName,
'first_name' => $this->normaliseNameCase($recipient->firstName),
...$recipient->context,
];
}

private function normaliseNameCase(string $name): string
{
if ($name !== mb_strtoupper($name) && $name !== mb_strtolower($name)) {
return $name;
}

return mb_convert_case($name, MB_CASE_TITLE);
}

private function toDTO(LifecycleNudgeType $nudgeType, ?LifecycleEmailTemplateDomainObject $saved): LifecycleEmailTemplateDTO
{
if ($saved === null) {
Expand Down
113 changes: 113 additions & 0 deletions backend/app/Services/Domain/LifecycleEmail/Nudges/LapsedPayerNudge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace HiEvents\Services\Domain\LifecycleEmail\Nudges;

use Carbon\CarbonInterface;
use HiEvents\DomainObjects\Enums\LifecycleNudgeType;
use HiEvents\Helper\Url;
use HiEvents\Repository\DTO\LifecycleEmail\LapsedPayerNudgeCandidateDTO;
use HiEvents\Repository\Interfaces\AccountLifecycleNudgeRepositoryInterface;
use HiEvents\Services\Domain\LifecycleEmail\DTO\LifecycleEmailRecipientDTO;
use HiEvents\Services\Domain\LifecycleEmail\DTO\LifecycleEmailTemplateDTO;
use HiEvents\Services\Domain\LifecycleEmail\DTO\LifecycleNudgeCandidateDTO;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

class LapsedPayerNudge implements LifecycleNudgeInterface
{
private const LAPSED_FOR_DAYS = 60;

private const LOOKBACK_MONTHS = 18;

public function __construct(
private readonly AccountLifecycleNudgeRepositoryInterface $nudgeRepository,
) {}

public function type(): LifecycleNudgeType
{
return LifecycleNudgeType::LAPSED_PAYER;
}

public function findCandidates(CarbonInterface $triggeredAfter, int $limit): Collection
{
return $this->nudgeRepository
->findLapsedPayerCandidates(
lapsedAfter: Carbon::now()->subMonths(self::LOOKBACK_MONTHS),
lapsedBefore: Carbon::now()->subDays(self::LAPSED_FOR_DAYS),
upcomingAfter: Carbon::now(),
limit: $limit,
)
->map(static fn (LapsedPayerNudgeCandidateDTO $candidate) => new LifecycleNudgeCandidateDTO(
accountId: $candidate->accountId,
email: $candidate->email,
recipient: new LifecycleEmailRecipientDTO(
firstName: $candidate->firstName,
ctaUrl: sprintf(
Url::getFrontEndUrlFromConfig(Url::ORGANIZER_EVENTS),
$candidate->organizerId,
$candidate->isEventArchived ? 'archived' : 'ended',
),
context: [
'event' => ['title' => $candidate->eventTitle],
'months_since_last_event' => (int) Carbon::parse($candidate->lastSoldDateEndedAt)->diffInMonths(Carbon::now()),
],
accountId: $candidate->accountId,
userId: $candidate->userId,
),
eventId: $candidate->eventId,
));
}

public function defaultTemplate(): LifecycleEmailTemplateDTO
{
return new LifecycleEmailTemplateDTO(
nudgeType: $this->type(),
subject: 'Planning your next event?',
body: <<<'HTML'
<p>Hi {{ first_name }},</p>
<p>I hope your last event went well. It's been a little while, so I wanted to ask: are you planning another one?</p>
<p>If you are, you don't have to start from scratch. Copy your last event from your past events page and change the dates, and your tickets and settings come with it.</p>
{% if months_since_last_event >= 3 %}
<p>We've also added a few things since then to make things easier, like a box office for selling on the door, checkout add-ons, and faster check-in. Reserved seating is also available. Just let me know and I'll switch it on for you.</p>
{% endif %}
<p>If anything got in the way last time, just reply and let me know.</p>
HTML,
ctaLabel: 'See your past events',
);
}

public function tokens(): array
{
return [
[
'token' => '{{ first_name }}',
'description' => __('The account owner\'s first name'),
'example' => 'Alex',
],
[
'token' => '{{ event.title }}',
'description' => __('The title of the event with the account\'s most recent paid sale'),
'example' => 'Summer Rooftop Sessions',
],
[
'token' => '{{ months_since_last_event }}',
'description' => __('Whole months since that event\'s last sold date ended'),
'example' => '4',
],
];
}

public function sampleRecipient(): LifecycleEmailRecipientDTO
{
return new LifecycleEmailRecipientDTO(
firstName: 'Alex',
ctaUrl: config('app.frontend_url'),
context: [
'event' => ['title' => 'Summer Rooftop Sessions'],
'months_since_last_event' => 4,
],
);
}
}
Loading
Loading