This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-20 12:34:56 +01:00
parent 6e997e62f1
commit 56f1c5e894
16 changed files with 601 additions and 4 deletions

View File

@@ -23,6 +23,7 @@
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",
"bullmq": "^5.63.0",
"date-fns": "^3.3.1",
"express": "^4.18.2",
"groupmq": "catalog:",
"prom-client": "^15.1.3",

View File

@@ -39,6 +39,11 @@ export async function bootCron() {
type: 'insightsDaily',
pattern: '0 2 * * *',
},
{
name: 'onboarding',
type: 'onboarding',
pattern: '0 10 * * *',
},
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {

View File

@@ -0,0 +1,180 @@
import { differenceInDays } from 'date-fns';
import type { Job } from 'bullmq';
import { db } from '@openpanel/db';
import { sendEmail } from '@openpanel/email';
import type { CronQueuePayload } from '@openpanel/queue';
import { logger } from '../utils/logger';
const EMAIL_SCHEDULE = {
1: 0, // Welcome email - Day 0
2: 2, // What to track - Day 2
3: 6, // Dashboards - Day 6
4: 14, // Replace stack - Day 14
5: 26, // Trial ending - Day 26
} as const;
export async function onboardingJob(job: Job<CronQueuePayload>) {
logger.info('Starting onboarding email job');
// Fetch organizations with their creators who are in onboarding
const organizations = await db.organization.findMany({
where: {
createdByUserId: {
not: null,
},
createdBy: {
onboarding: {
not: null,
gte: 1,
lte: 5,
},
deletedAt: null,
},
},
include: {
createdBy: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
onboarding: true,
},
},
},
});
logger.info(`Found ${organizations.length} organizations with creators in onboarding`);
let emailsSent = 0;
let usersCompleted = 0;
let usersSkipped = 0;
for (const org of organizations) {
if (!org.createdBy || !org.createdByUserId) {
continue;
}
const user = org.createdBy;
// Check if organization has active subscription
if (org.subscriptionStatus === 'active') {
// Stop onboarding for users with active subscriptions
await db.user.update({
where: { id: user.id },
data: { onboarding: null },
});
usersCompleted++;
logger.info(`Stopped onboarding for user ${user.id} (active subscription)`);
continue;
}
if (!user.onboarding || user.onboarding < 1 || user.onboarding > 5) {
continue;
}
// Use organization creation date instead of user registration date
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
const requiredDays = EMAIL_SCHEDULE[user.onboarding as keyof typeof EMAIL_SCHEDULE];
if (daysSinceOrgCreation < requiredDays) {
usersSkipped++;
continue;
}
const dashboardUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}`;
const billingUrl = `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL || 'https://dashboard.openpanel.dev'}/${org.id}/billing`;
try {
// Send appropriate email based on onboarding step
switch (user.onboarding) {
case 1: {
// Welcome email
await sendEmail('onboarding-welcome', {
to: user.email,
data: {
firstName: user.firstName || undefined,
dashboardUrl,
},
});
break;
}
case 2: {
// What to track email
await sendEmail('onboarding-what-to-track', {
to: user.email,
data: {
firstName: user.firstName || undefined,
},
});
break;
}
case 3: {
// Dashboards email
await sendEmail('onboarding-dashboards', {
to: user.email,
data: {
firstName: user.firstName || undefined,
dashboardUrl,
},
});
break;
}
case 4: {
// Replace stack email
await sendEmail('onboarding-replace-stack', {
to: user.email,
data: {
firstName: user.firstName || undefined,
},
});
break;
}
case 5: {
// Trial ending email
await sendEmail('onboarding-trial-ending', {
to: user.email,
data: {
firstName: user.firstName || undefined,
organizationName: org.name,
billingUrl,
recommendedPlan: undefined, // TODO: Calculate based on usage
},
});
break;
}
}
// Increment onboarding state
const nextOnboardingState = user.onboarding + 1;
await db.user.update({
where: { id: user.id },
data: {
onboarding: nextOnboardingState > 5 ? null : nextOnboardingState,
},
});
emailsSent++;
logger.info(`Sent onboarding email ${user.onboarding} to user ${user.id} for org ${org.id}`);
if (nextOnboardingState > 5) {
usersCompleted++;
}
} catch (error) {
logger.error(`Failed to send onboarding email to user ${user.id}`, {
error,
onboardingStep: user.onboarding,
organizationId: org.id,
});
}
}
logger.info('Completed onboarding email job', {
totalOrganizations: organizations.length,
emailsSent,
usersCompleted,
usersSkipped,
});
}

View File

@@ -4,6 +4,7 @@ import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects';
import { onboardingJob } from './cron.onboarding';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
import { insightsDailyJob } from './insights';
@@ -31,5 +32,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'insightsDaily': {
return await insightsDailyJob(job);
}
case 'onboarding': {
return await onboardingJob(job);
}
}
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."users" ADD COLUMN "onboarding" INTEGER;

View File

@@ -94,6 +94,7 @@ model User {
email String @unique
firstName String?
lastName String?
onboarding Int? // null = disabled/completed, 1-5 = next email step
createdOrganizations Organization[] @relation("organizationCreatedBy")
subscriptions Organization[] @relation("subscriptionCreatedBy")
membership Member[]

View File

@@ -0,0 +1,104 @@
These emails have good bones but yeah, they have that ChatGPT sheen. The structure is too neat, the bullet points feel mechanical, and phrases like "really opens up" and "usually clicks" are dead giveaways.
Here's my pass at them:
---
**Email 1 - Welcome (Day 0)**
Subject: You're in
Hi,
Thanks for trying OpenPanel.
We built OpenPanel because most analytics tools are either too expensive, too complicated, or both. OpenPanel is different.
If you already have setup your tracking you should see your dashboard getting filled up. If you come from another provider and want to import your old events you can do that in our project settings.
If you can't find your provider just reach out and we'll help you out.
Reach out if you have any questions. I answer all emails.
Carl
---
**Email 2 - What to track (Day 2)**
Subject: What's actually worth tracking
Hi,
Track the moments that tell you whether your product is working. Track things that matters to your product the most and then you can easily create funnels or conversions reports to understand what happening.
For most products, that's something like:
- Signups
- The first meaningful action (create something, send something, buy something)
- Return visits
You don't need 50 events. Five good ones will tell you more than fifty random ones.
If you're not sure whether something's worth tracking, just ask. I'm happy to look at your setup.
Carl
---
**Email 3 - Dashboards (Day 6)**
Subject: The part most people skip
Hi,
Tracking events is the easy part. The value comes from actually looking at them.
If you haven't yet, try building a simple dashboard. Pick one thing you care about and visualize it. Could be:
- How many people sign up and then actually do something
- Where users drop off in a flow (funnel)
- Which pages lead to conversions (entry page -> CTA)
This is usually when people go from "I have analytics" to "I understand what's happening." It's a different feeling.
Takes maybe 10 minutes to set up. Worth it.
Carl
---
**Email 4 - Replace the stack (Day 14)**
Subject: One provider to rule them all
Hi,
A lot of people who sign up are using multiple tools: something for traffic, something for product analytics and something else for seeing raw events.
OpenPanel can replace that whole setup.
If you're still thinking of web analytics and product analytics as separate things, try combining them in a single dashboard. Traffic sources on top, user behavior below. That view tends to be more useful than either one alone.
OpenPanel should be able to replace all of them, you can just reach out if you feel like something is missing.
Carl
---
**Email 5 - Trial ending (Day 26)**
Subject: Your trial ends in a few days
Hi,
Quick heads up: your OpenPanel trial ends soon.
Your tracking will keep working, but you won't be able to see new data until you upgrade. Everything you've built so far (dashboards, reports, event history) stays intact.
If OpenPanel has been useful, upgrading just keeps it going. Plans start at $2.50/month and based on your usage we recommend {xxxx}.
If something's holding you back, I'd like to hear about it. Just reply.
Your project will recieve events for the next 30 days, if you haven't upgraded by then we'll remove your workspace and projects.
Carl

View File

@@ -4,6 +4,21 @@ import EmailResetPassword, {
zEmailResetPassword,
} from './email-reset-password';
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
import OnboardingWelcome, {
zOnboardingWelcome,
} from './onboarding-welcome';
import OnboardingWhatToTrack, {
zOnboardingWhatToTrack,
} from './onboarding-what-to-track';
import OnboardingDashboards, {
zOnboardingDashboards,
} from './onboarding-dashboards';
import OnboardingReplaceStack, {
zOnboardingReplaceStack,
} from './onboarding-replace-stack';
import OnboardingTrialEnding, {
zOnboardingTrialEnding,
} from './onboarding-trial-ending';
export const templates = {
invite: {
@@ -24,6 +39,31 @@ export const templates = {
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
},
'onboarding-welcome': {
subject: () => "You're in",
Component: OnboardingWelcome,
schema: zOnboardingWelcome,
},
'onboarding-what-to-track': {
subject: () => "What's actually worth tracking",
Component: OnboardingWhatToTrack,
schema: zOnboardingWhatToTrack,
},
'onboarding-dashboards': {
subject: () => 'The part most people skip',
Component: OnboardingDashboards,
schema: zOnboardingDashboards,
},
'onboarding-replace-stack': {
subject: () => 'One provider to rule them all',
Component: OnboardingReplaceStack,
schema: zOnboardingReplaceStack,
},
'onboarding-trial-ending': {
subject: () => 'Your trial ends in a few days',
Component: OnboardingTrialEnding,
schema: zOnboardingTrialEnding,
},
} as const;
export type Templates = typeof templates;

View File

@@ -0,0 +1,49 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingDashboards = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingDashboards>;
export default OnboardingDashboards;
export function OnboardingDashboards({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
}: Props) {
const newUrl = new URL(dashboardUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-dashboards');
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking events is the easy part. The value comes from actually looking
at them.
</Text>
<Text>
If you haven't yet, try building a simple dashboard. Pick one thing you
care about and visualize it. Could be:
</Text>
<Text>
- How many people sign up and then actually do something
</Text>
<Text>- Where users drop off in a flow (funnel)</Text>
<Text>- Which pages lead to conversions (entry page → CTA)</Text>
<Text>
This is usually when people go from "I have analytics" to "I understand
what's happening." It's a different feeling.
</Text>
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
<Text>
<Link href={newUrl.toString()}>Create your first dashboard</Link>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,37 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingReplaceStack = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingReplaceStack>;
export default OnboardingReplaceStack;
export function OnboardingReplaceStack({
firstName,
}: Props) {
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
A lot of people who sign up are using multiple tools: something for
traffic, something for product analytics and something else for seeing
raw events.
</Text>
<Text>OpenPanel can replace that whole setup.</Text>
<Text>
If you're still thinking of web analytics and product analytics as
separate things, try combining them in a single dashboard. Traffic
sources on top, user behavior below. That view tends to be more useful
than either one alone.
</Text>
<Text>
OpenPanel should be able to replace all of them, you can just reach out
if you feel like something is missing.
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,66 @@
import { Button, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnding = z.object({
firstName: z.string().optional(),
organizationName: z.string(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnding>;
export default OnboardingTrialEnding;
export function OnboardingTrialEnding({
firstName,
organizationName = 'your organization',
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
}: Props) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending');
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Quick heads up: your OpenPanel trial ends soon.</Text>
<Text>
Your tracking will keep working, but you won't be able to see new data
until you upgrade. Everything you've built so far (dashboards, reports,
event history) stays intact.
</Text>
<Text>
If OpenPanel has been useful, upgrading just keeps it going. Plans
start at $2.50/month
{recommendedPlan ? ` and based on your usage we recommend ${recommendedPlan}` : ''}
.
</Text>
<Text>
If something's holding you back, I'd like to hear about it. Just
reply.
</Text>
<Text>
Your project will recieve events for the next 30 days, if you haven't
upgraded by then we'll remove your workspace and projects.
</Text>
<Text>
<Button
href={newUrl.toString()}
style={{
backgroundColor: '#0070f3',
color: 'white',
padding: '12px 20px',
borderRadius: '5px',
textDecoration: 'none',
}}
>
Upgrade Now
</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,43 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingWelcome = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingWelcome>;
export default OnboardingWelcome;
export function OnboardingWelcome({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
}: Props) {
const newUrl = new URL(dashboardUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-welcome');
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Thanks for trying OpenPanel.</Text>
<Text>
We built OpenPanel because most analytics tools are either too expensive,
too complicated, or both. OpenPanel is different.
</Text>
<Text>
If you already have setup your tracking you should see your dashboard
getting filled up. If you come from another provider and want to import
your old events you can do that in our{' '}
<Link href={newUrl.toString()}>project settings</Link>.
</Text>
<Text>
If you can't find your provider just reach out and we'll help you out.
</Text>
<Text>Reach out if you have any questions. I answer all emails.</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -0,0 +1,41 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingWhatToTrack = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
export function OnboardingWhatToTrack({
firstName,
}: Props) {
return (
<Layout>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Track the moments that tell you whether your product is working. Track
things that matters to your product the most and then you can easily
create funnels or conversions reports to understand what happening.
</Text>
<Text>For most products, that's something like:</Text>
<Text>- Signups</Text>
<Text>
- The first meaningful action (create something, send something, buy
something)
</Text>
<Text>- Return visits</Text>
<Text>
You don't need 50 events. Five good ones will tell you more than fifty
random ones.
</Text>
<Text>
If you're not sure whether something's worth tracking, just ask. I'm
happy to look at your setup.
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -115,6 +115,10 @@ export type CronQueuePayloadInsightsDaily = {
type: 'insightsDaily';
payload: undefined;
};
export type CronQueuePayloadOnboarding = {
type: 'onboarding';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -122,7 +126,8 @@ export type CronQueuePayload =
| CronQueuePayloadFlushProfiles
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily;
| CronQueuePayloadInsightsDaily
| CronQueuePayloadOnboarding;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';

View File

@@ -22,6 +22,13 @@ async function createOrGetOrganization(
const TRIAL_DURATION_IN_DAYS = 30;
if (input.organization) {
// Check if this is the user's first organization
const existingOrgCount = await db.organization.count({
where: {
createdByUserId: user.id,
},
});
const organization = await db.organization.create({
data: {
id: await getId('organization', input.organization),
@@ -33,6 +40,14 @@ async function createOrGetOrganization(
},
});
// Set onboarding = 1 for first organization creation
if (existingOrgCount === 0 && user.onboarding === null) {
await db.user.update({
where: { id: user.id },
data: { onboarding: 1 },
});
}
if (!process.env.SELF_HOSTED) {
await addTrialEndingSoonJob(
organization.id,

10
pnpm-lock.yaml generated
View File

@@ -37,7 +37,7 @@ overrides:
patchedDependencies:
nuqs:
hash: 4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e
hash: w6thjv3pgywfrbh4sblczc6qpy
path: patches/nuqs.patch
importers:
@@ -656,7 +656,7 @@ importers:
version: 3.0.1
nuqs:
specifier: ^2.5.2
version: 2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
version: 2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
prisma-error-enum:
specifier: ^0.1.3
version: 0.1.3
@@ -889,6 +889,9 @@ importers:
bullmq:
specifier: ^5.63.0
version: 5.63.0
date-fns:
specifier: ^3.3.1
version: 3.3.1
express:
specifier: ^4.18.2
version: 4.18.2
@@ -16527,6 +16530,7 @@ packages:
tar@6.2.0:
resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
@@ -33174,7 +33178,7 @@ snapshots:
dependencies:
esm-env: esm-env-runtime@0.1.1
nuqs@2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
nuqs@2.5.2(patch_hash=w6thjv3pgywfrbh4sblczc6qpy)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
dependencies:
'@standard-schema/spec': 1.0.0
react: 19.2.3