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
17 changes: 9 additions & 8 deletions .github/workflows/onboarding-reminders.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
name: Onboarding reminders cron

# Pings the cloud backend every 6 hours to send drip emails to users
# who registered 24h+ ago but never created a connector. The endpoint
# Pings the cloud backend every hour. The endpoint
# (`/api/cron/onboarding-reminders`) is idempotent — re-running within
# a window won't double-send because per-user counters and timing
# checks are persisted in the users table.
# a window won't double-send because per-user counters, flags and timing
# checks are persisted.
#
# Schedule: */6 means 00:00, 06:00, 12:00, 18:00 UTC. We don't tie this
# to local business hours because the email itself isn't time-sensitive
# (users register globally and the drip lands ~ a day later anyway).
# Hourly because the first nudge goes out an hour after sign-up, to people
# whose workspace is still empty (how to connect an app from the chat or the
# dashboard). The other passes (trial lifecycle, win-back, licence
# re-verification, release of deleted workspaces' licences) are gated by their
# own windows and flags, so running them more often sends nothing twice.

on:
schedule:
- cron: "0 */6 * * *"
- cron: "0 * * * *"
workflow_dispatch:
inputs:
target_url:
Expand Down
146 changes: 141 additions & 5 deletions packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ describe('OnboardingCronService — trial status transition', () => {
update: jest.fn(),
},
license: {
findMany: jest.fn().mockImplementation(async () => {
calls.push('lifecycle');
findMany: jest.fn().mockImplementation(async (args: any) => {
calls.push(args?.where?.status === 'active' ? 'lifecycle' : 'winback');
return [];
}),
updateMany: jest.fn().mockImplementation(async () => {
Expand All @@ -183,7 +183,7 @@ describe('OnboardingCronService — trial status transition', () => {
expect(prisma.license.updateMany.mock.calls[0][0].data).toEqual({ status: 'expired' });
// The "your trial has ended" email selects on status='active', so the
// flip must come after it or the email would never be sent.
expect(calls).toEqual(['lifecycle', 'mark-expired']);
expect(calls).toEqual(['lifecycle', 'winback', 'mark-expired']);
});
});

Expand Down Expand Up @@ -281,10 +281,20 @@ describe('OnboardingCronService — onboarding pass', () => {
);
});

it('waits the usual 24h for a user with no AI client yet', async () => {
it('nudges a user with no AI client an hour after sign-up, without naming one', async () => {
const { service, email } = makeService({ candidates: [user({})] });
await service.run();
expect(email.sendOnboardingReminderEmail).not.toHaveBeenCalled();
expect(email.sendOnboardingReminderEmail).toHaveBeenCalledWith('u1@example.com', 'Ada', 1, undefined);
});

it('asks the database only for users who signed up at least an hour ago', async () => {
const { service, prisma } = makeService({ candidates: [] });
const before = Date.now();
await service.run();
const where = prisma.user.findMany.mock.calls[0][0].where;
const ageMs = before - where.createdAt.lte.getTime();
expect(ageMs).toBeGreaterThanOrEqual(HOUR - 1000);
expect(ageMs).toBeLessThan(HOUR + 60_000);
});

it('still reminds a user who pressed Skip on /welcome with an empty workspace', async () => {
Expand All @@ -309,3 +319,129 @@ describe('OnboardingCronService — onboarding pass', () => {
);
});
});

describe('OnboardingCronService — trial win-back', () => {
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date('2026-11-20T10:00:00Z').getTime();
const out = () => ({ examined: 0, winbackOffers: 0, winbackHelp: 0, skipped: 0 });

function makeService(opts: {
endedDaysAgo: number;
calls?: number;
paid?: number;
flagged?: boolean;
optedOut?: boolean;
sendOk?: boolean;
}) {
const prisma = {
license: {
findMany: jest.fn().mockResolvedValue([
{ organizationId: 'org-1', expiresAt: new Date(NOW - opts.endedDaysAgo * DAY) },
]),
count: jest.fn().mockResolvedValue(opts.paid ?? 0),
},
orgSettings: {
findUnique: jest.fn().mockResolvedValue(opts.flagged ? { id: 'f' } : null),
upsert: jest.fn().mockResolvedValue({}),
},
organizationMember: {
findMany: jest.fn().mockResolvedValue([
{ user: { email: 'admin@example.com', name: 'Ada', emailMarketingOptOut: !!opts.optedOut } },
]),
},
toolInvocation: { count: jest.fn().mockResolvedValue(opts.calls ?? 0) },
} as any;
const email = { sendTrialWinbackEmail: jest.fn().mockResolvedValue(opts.sendOk ?? true) } as any;
const service = new OnboardingCronService(prisma, email, makeLicense(), makeRelease());
const run = async () => {
const o = out();
await (service as any).runWinbackPass(NOW, o);
return o;
};
return { run, prisma, email };
}

it('only looks at trials that ended on or after the switch from the licence site', async () => {
const { run, prisma } = makeService({ endedDaysAgo: 8, calls: 3 });
await run();
const where = prisma.license.findMany.mock.calls[0][0].where;
expect(where.plan).toBe('trial');
expect(where.expiresAt.gte).toEqual(new Date('2026-10-06T00:00:00Z'));
});

it('a week after, offers 30% to a workspace that used the product', async () => {
const { run, email, prisma } = makeService({ endedDaysAgo: 8, calls: 12 });
const o = await run();
expect(email.sendTrialWinbackEmail).toHaveBeenCalledWith('admin@example.com', 'Ada', {
kind: 'discount',
percentOff: 30,
promoCode: 'START30',
endedAgo: 'week',
successfulCalls: 12,
});
expect(o.winbackOffers).toBe(1);
expect(prisma.orgSettings.upsert.mock.calls[0][0].where.organizationId_key.key).toBe('trial_email_winback7');
});

it('a month after, offers 50%', async () => {
const { run, email } = makeService({ endedDaysAgo: 31, calls: 2 });
await run();
expect(email.sendTrialWinbackEmail.mock.calls[0][2]).toMatchObject({
kind: 'discount',
percentOff: 50,
promoCode: 'WINBACK50',
endedAgo: 'month',
});
});

it('a week after, sends the how-to without a discount to a workspace that never made a call', async () => {
const { run, email } = makeService({ endedDaysAgo: 8, calls: 0 });
const o = await run();
expect(email.sendTrialWinbackEmail.mock.calls[0][2]).toEqual({ kind: 'help' });
expect(o.winbackHelp).toBe(1);
});

it('sends nothing more a month after to a workspace that never made a call', async () => {
const { run, email, prisma } = makeService({ endedDaysAgo: 31, calls: 0 });
await run();
expect(email.sendTrialWinbackEmail).not.toHaveBeenCalled();
expect(prisma.orgSettings.upsert).toHaveBeenCalledTimes(1);
});

it('sends nothing outside a stage window, nor twice, nor after a purchase', async () => {
for (const opts of [
{ endedDaysAgo: 20, calls: 5 },
{ endedDaysAgo: 40, calls: 5 },
{ endedDaysAgo: 8, calls: 5, flagged: true },
{ endedDaysAgo: 8, calls: 5, paid: 1 },
]) {
const { run, email } = makeService(opts);
await run();
expect(email.sendTrialWinbackEmail).not.toHaveBeenCalled();
}
});

it('respects the marketing opt-out, and closes the stage', async () => {
const { run, email, prisma } = makeService({ endedDaysAgo: 8, calls: 5, optedOut: true });
await run();
expect(email.sendTrialWinbackEmail).not.toHaveBeenCalled();
expect(prisma.orgSettings.upsert).toHaveBeenCalledTimes(1);
});

it('retries on the next run when the email could not be sent', async () => {
const { run, prisma } = makeService({ endedDaysAgo: 8, calls: 5, sendOk: false });
await run();
expect(prisma.orgSettings.upsert).not.toHaveBeenCalled();
});

it('takes the codes from the environment when set', async () => {
process.env.WINBACK_FIRST_PROMO_CODE = 'SPRING30';
try {
const { run, email } = makeService({ endedDaysAgo: 8, calls: 1 });
await run();
expect(email.sendTrialWinbackEmail.mock.calls[0][2].promoCode).toBe('SPRING30');
} finally {
delete process.env.WINBACK_FIRST_PROMO_CODE;
}
});
});
Loading
Loading