feat(email): send trial ending soon mails

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-03-30 20:58:17 +02:00
parent 0f0bb13107
commit a9c664dcfb
12 changed files with 1143 additions and 123 deletions

View File

@@ -28,6 +28,7 @@ COPY apps/worker/package.json ./apps/worker/
# Packages
COPY packages/db/package.json ./packages/db/
COPY packages/json/package.json ./packages/json/
COPY packages/email/package.json ./packages/email/
COPY packages/redis/package.json ./packages/redis/
COPY packages/queue/package.json ./packages/queue/
COPY packages/logger/package.json ./packages/logger/
@@ -73,6 +74,7 @@ COPY --from=build /app/apps/worker ./apps/worker
# Packages
COPY --from=build /app/packages/db ./packages/db
COPY --from=build /app/packages/json ./packages/json
COPY --from=build /app/packages/email ./packages/email
COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/queue ./packages/queue

View File

@@ -18,6 +18,7 @@
"@openpanel/logger": "workspace:*",
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",
"@openpanel/email": "workspace:*",
"bullmq": "^5.8.7",
"express": "^4.18.2",
"prom-client": "^15.1.3",

View File

@@ -4,6 +4,7 @@ import { Worker } from 'bullmq';
import {
cronQueue,
eventsQueue,
miscQueue,
notificationQueue,
sessionsQueue,
} from '@openpanel/queue';
@@ -13,6 +14,7 @@ import { performance } from 'node:perf_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
import { cronJob } from './jobs/cron';
import { eventsJob } from './jobs/events';
import { miscJob } from './jobs/misc';
import { notificationJob } from './jobs/notification';
import { sessionsJob } from './jobs/sessions';
import { logger } from './utils/logger';
@@ -35,12 +37,14 @@ export async function bootWorkers() {
notificationJob,
workerOptions,
);
const miscWorker = new Worker(miscQueue.name, miscJob, workerOptions);
const workers = [
sessionsWorker,
eventsWorker,
cronWorker,
notificationWorker,
miscWorker,
];
workers.forEach((worker) => {
@@ -105,12 +109,7 @@ export async function bootWorkers() {
try {
const time = performance.now();
await waitForQueueToEmpty(cronQueue);
await Promise.all([
cronWorker.close(),
eventsWorker.close(),
sessionsWorker.close(),
notificationWorker.close(),
]);
await Promise.all(workers.map((worker) => worker.close()));
logger.info('workers closed successfully', {
elapsed: performance.now() - time,
});

View File

@@ -7,6 +7,7 @@ import { createInitialSalts } from '@openpanel/db';
import {
cronQueue,
eventsQueue,
miscQueue,
notificationQueue,
sessionsQueue,
} from '@openpanel/queue';
@@ -36,6 +37,7 @@ async function start() {
new BullMQAdapter(sessionsQueue),
new BullMQAdapter(cronQueue),
new BullMQAdapter(notificationQueue),
new BullMQAdapter(miscQueue),
],
serverAdapter: serverAdapter,
});

View File

@@ -0,0 +1,50 @@
import { db } from '@openpanel/db';
import { sendEmail } from '@openpanel/email';
import type { MiscQueuePayloadTrialEndingSoon } from '@openpanel/queue';
import type { Job } from 'bullmq';
export async function trialEndingSoonJob(
job: Job<MiscQueuePayloadTrialEndingSoon>,
) {
const { organizationId } = job.data.payload;
const organization = await db.organization.findUnique({
where: {
id: organizationId,
},
include: {
createdBy: {
select: {
email: true,
},
},
projects: {
select: {
id: true,
},
},
},
});
if (!organization) {
return;
}
const project = organization.projects[0];
if (!organization.createdBy?.email) {
return;
}
if (!project) {
return;
}
return sendEmail('trial-ending-soon', {
to: organization.createdBy?.email,
data: {
organizationName: organization.name,
url: `https://dashboard.openpanel.dev/${organization.id}/${project.id}/settings/organization?tab=billing`,
},
});
}

View File

@@ -0,0 +1,13 @@
import type { Job } from 'bullmq';
import type { MiscQueuePayloadTrialEndingSoon } from '@openpanel/queue';
import { trialEndingSoonJob } from './misc.trail-ending-soon';
export async function miscJob(job: Job<MiscQueuePayloadTrialEndingSoon>) {
switch (job.data.type) {
case 'trialEndingSoon': {
return await trialEndingSoonJob(job);
}
}
}

View File

@@ -22,8 +22,8 @@ export function Footer() {
<br />
<Row>
<Column className="align-middle w-[40px]">
<Row className="mt-4">
<Column className="w-8">
<Link href="https://git.new/openpanel">
<Img
src={`${baseUrl}/icons/github.png`}
@@ -33,7 +33,7 @@ export function Footer() {
/>
</Link>
</Column>
<Column className="align-middle w-[40px]">
<Column className="w-8">
<Link href="https://x.com/openpaneldev">
<Img
src={`${baseUrl}/icons/x.png`}
@@ -43,8 +43,7 @@ export function Footer() {
/>
</Link>
</Column>
<Column className="align-middle">
<Column className="w-8">
<Link href="https://go.openpanel.dev/discord">
<Img
src={`${baseUrl}/icons/discord.png`}
@@ -54,8 +53,7 @@ export function Footer() {
/>
</Link>
</Column>
<Column className="align-middle">
<Column className="w-auto">
<Link href="mailto:hello@openpanel.dev">
<Img
src={`${baseUrl}/icons/email.png`}

View File

@@ -3,6 +3,7 @@ import { EmailInvite, zEmailInvite } from './email-invite';
import EmailResetPassword, {
zEmailResetPassword,
} from './email-reset-password';
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
export const templates = {
invite: {
@@ -17,6 +18,12 @@ export const templates = {
Component: EmailResetPassword,
schema: zEmailResetPassword,
},
'trial-ending-soon': {
subject: (data: z.infer<typeof zTrailEndingSoon>) =>
'Your trial is ending soon',
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
},
} as const;
export type Templates = typeof templates;

View File

@@ -0,0 +1,73 @@
import { Button, Hr, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zTrailEndingSoon = z.object({
url: z.string(),
organizationName: z.string(),
});
export type Props = z.infer<typeof zTrailEndingSoon>;
export default TrailEndingSoon;
export function TrailEndingSoon({
organizationName = 'Acme Co',
url = 'https://openpanel.dev',
}: Props) {
const newUrl = new URL(url);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'trial-ending-soon');
return (
<Layout>
<Text>Your trial period is ending soon for {organizationName}!</Text>
<Text>
When your trial ends, you'll still receive incoming events but you won't
be able to see them in the dashboard until you upgrade.
</Text>
<Text>
<Link href={newUrl.toString()}>Upgrade to a paid plan</Link>
</Text>
<Hr />
<Text style={{ fontWeight: 'bold' }}>
Discover what you can do with OpenPanel:
</Text>
<Text>
🎯 <strong>Create Custom Funnels</strong> - Track user progression
through your key conversion paths and identify where users drop off
</Text>
<Text>
📈 <strong>User Retention Analysis</strong> - Understand how well you're
keeping users engaged over time with beautiful retention graphs
</Text>
<Text>
🗺️ <strong>User Journey Mapping</strong> - Follow individual user paths
through your application to understand their behavior and optimize their
experience
</Text>
<Text>
🔬 <strong>A/B Testing Analysis</strong> - Measure the impact of your
product changes with detailed conversion metrics and statistical
significance
</Text>
<Text>
📊 <strong>Custom Event Tracking</strong> - Track any user interaction
that matters to your business with our flexible event system
</Text>
<Text>
<Button
href={newUrl.toString()}
style={{
backgroundColor: '#0070f3',
color: 'white',
padding: '12px 20px',
borderRadius: '5px',
textDecoration: 'none',
}}
>
Upgrade Now to Unlock All Features
</Button>
</Text>
</Layout>
);
}

View File

@@ -76,6 +76,15 @@ export type CronQueuePayload =
| CronQueuePayloadPing
| CronQueuePayloadProject;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';
payload: {
organizationId: string;
};
};
export type MiscQueuePayload = MiscQueuePayloadTrialEndingSoon;
export type CronQueueType = CronQueuePayload['type'];
export const eventsQueue = new Queue<EventsQueuePayload>('events', {
@@ -107,6 +116,13 @@ export const cronQueue = new Queue<CronQueuePayload>('cron', {
},
});
export const miscQueue = new Queue<MiscQueuePayload>('misc', {
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
},
});
export type NotificationQueuePayload = {
type: 'sendNotification';
payload: {
@@ -123,3 +139,18 @@ export const notificationQueue = new Queue<NotificationQueuePayload>(
},
},
);
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
return miscQueue.add(
'misc',
{
type: 'trialEndingSoon',
payload: {
organizationId,
},
},
{
delay,
},
);
}

View File

@@ -8,6 +8,7 @@ import { zOnboardingProject } from '@openpanel/validation';
import { hashPassword } from '@openpanel/common/server';
import { addDays } from 'date-fns';
import { addTrialEndingSoonJob, miscQueue } from '../../../queue';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
async function createOrGetOrganization(
@@ -18,16 +19,27 @@ async function createOrGetOrganization(
return await getOrganizationBySlug(input.organizationId);
}
const TRIAL_DURATION_IN_DAYS = 30;
if (input.organization) {
return db.organization.create({
const organization = await db.organization.create({
data: {
id: await getId('organization', input.organization),
name: input.organization,
createdByUserId: user.id,
subscriptionEndsAt: addDays(new Date(), 30),
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
subscriptionStatus: 'trialing',
},
});
if (!process.env.SELF_HOSTED) {
await addTrialEndingSoonJob(
organization.id,
1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9,
);
}
return organization;
}
return null;

1048
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff