From 56f1c5e89434a745bb7e7f57cc903cd422a536bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 20 Jan 2026 12:34:56 +0100 Subject: [PATCH 1/5] wip --- apps/worker/package.json | 1 + apps/worker/src/boot-cron.ts | 5 + apps/worker/src/jobs/cron.onboarding.ts | 180 ++++++++++++++++++ apps/worker/src/jobs/cron.ts | 4 + .../migrations/20260120110632_/migration.sql | 2 + packages/db/prisma/schema.prisma | 1 + packages/email/onboarding-emails.md | 104 ++++++++++ packages/email/src/emails/index.tsx | 40 ++++ .../src/emails/onboarding-dashboards.tsx | 49 +++++ .../src/emails/onboarding-replace-stack.tsx | 37 ++++ .../src/emails/onboarding-trial-ending.tsx | 66 +++++++ .../email/src/emails/onboarding-welcome.tsx | 43 +++++ .../src/emails/onboarding-what-to-track.tsx | 41 ++++ packages/queue/src/queues.ts | 7 +- packages/trpc/src/routers/onboarding.ts | 15 ++ pnpm-lock.yaml | 10 +- 16 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 apps/worker/src/jobs/cron.onboarding.ts create mode 100644 packages/db/prisma/migrations/20260120110632_/migration.sql create mode 100644 packages/email/onboarding-emails.md create mode 100644 packages/email/src/emails/onboarding-dashboards.tsx create mode 100644 packages/email/src/emails/onboarding-replace-stack.tsx create mode 100644 packages/email/src/emails/onboarding-trial-ending.tsx create mode 100644 packages/email/src/emails/onboarding-welcome.tsx create mode 100644 packages/email/src/emails/onboarding-what-to-track.tsx diff --git a/apps/worker/package.json b/apps/worker/package.json index c8ae019e3..ecd9bbc38 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -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", diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index a67c1e7f7..4c0342ec3 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -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') { diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts new file mode 100644 index 000000000..f13a0ce46 --- /dev/null +++ b/apps/worker/src/jobs/cron.onboarding.ts @@ -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) { + 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, + }); +} diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts index eee51b161..135e174df 100644 --- a/apps/worker/src/jobs/cron.ts +++ b/apps/worker/src/jobs/cron.ts @@ -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) { case 'insightsDaily': { return await insightsDailyJob(job); } + case 'onboarding': { + return await onboardingJob(job); + } } } diff --git a/packages/db/prisma/migrations/20260120110632_/migration.sql b/packages/db/prisma/migrations/20260120110632_/migration.sql new file mode 100644 index 000000000..73984e66b --- /dev/null +++ b/packages/db/prisma/migrations/20260120110632_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."users" ADD COLUMN "onboarding" INTEGER; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7131231e6..0a41b7fd3 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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[] diff --git a/packages/email/onboarding-emails.md b/packages/email/onboarding-emails.md new file mode 100644 index 000000000..56a4b523f --- /dev/null +++ b/packages/email/onboarding-emails.md @@ -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 \ No newline at end of file diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx index f2c47b9ff..74006631e 100644 --- a/packages/email/src/emails/index.tsx +++ b/packages/email/src/emails/index.tsx @@ -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; diff --git a/packages/email/src/emails/onboarding-dashboards.tsx b/packages/email/src/emails/onboarding-dashboards.tsx new file mode 100644 index 000000000..902c7f167 --- /dev/null +++ b/packages/email/src/emails/onboarding-dashboards.tsx @@ -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; +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 ( + + Hi{firstName ? ` ${firstName}` : ''}, + + 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. + + Create your first dashboard + + Carl + + ); +} diff --git a/packages/email/src/emails/onboarding-replace-stack.tsx b/packages/email/src/emails/onboarding-replace-stack.tsx new file mode 100644 index 000000000..1e2487198 --- /dev/null +++ b/packages/email/src/emails/onboarding-replace-stack.tsx @@ -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; +export default OnboardingReplaceStack; +export function OnboardingReplaceStack({ + firstName, +}: Props) { + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + 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 + + ); +} diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx new file mode 100644 index 000000000..3c8ef0be7 --- /dev/null +++ b/packages/email/src/emails/onboarding-trial-ending.tsx @@ -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; +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 ( + + Hi{firstName ? ` ${firstName}` : ''}, + 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 + {recommendedPlan ? ` and based on your usage we recommend ${recommendedPlan}` : ''} + . + + + 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 + + ); +} diff --git a/packages/email/src/emails/onboarding-welcome.tsx b/packages/email/src/emails/onboarding-welcome.tsx new file mode 100644 index 000000000..5929b6bcf --- /dev/null +++ b/packages/email/src/emails/onboarding-welcome.tsx @@ -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; +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 ( + + Hi{firstName ? ` ${firstName}` : ''}, + 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 + + ); +} diff --git a/packages/email/src/emails/onboarding-what-to-track.tsx b/packages/email/src/emails/onboarding-what-to-track.tsx new file mode 100644 index 000000000..ac94a15a1 --- /dev/null +++ b/packages/email/src/emails/onboarding-what-to-track.tsx @@ -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; +export default OnboardingWhatToTrack; +export function OnboardingWhatToTrack({ + firstName, +}: Props) { + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + 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 + + ); +} diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 19076abc7..93df0925d 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -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'; diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts index 071b5a7ec..616dce5bf 100644 --- a/packages/trpc/src/routers/onboarding.ts +++ b/packages/trpc/src/routers/onboarding.ts @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3106017d..66f793fe1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 From a58761e8d720f87e3b5b495e528ea0c6937f3200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 21 Jan 2026 08:25:32 +0100 Subject: [PATCH 2/5] wip --- apps/start/src/routeTree.gen.ts | 21 ++ apps/start/src/routes/unsubscribe.tsx | 112 ++++++ apps/worker/package.json | 1 + apps/worker/src/jobs/cron.onboarding.ts | 341 +++++++++++------- packages/constants/index.ts | 7 + .../migrations/20260120110632_/migration.sql | 2 - .../migration.sql | 3 + .../migration.sql | 12 + packages/db/prisma/schema.prisma | 12 +- packages/email/package.json | 1 + packages/email/src/components/button.tsx | 23 ++ packages/email/src/components/list.tsx | 13 + packages/email/src/emails/index.tsx | 39 +- .../src/emails/onboarding-dashboards.tsx | 29 +- .../src/emails/onboarding-feature-request.tsx | 39 ++ .../src/emails/onboarding-replace-stack.tsx | 37 -- .../src/emails/onboarding-trial-ended.tsx | 55 +++ .../src/emails/onboarding-trial-ending.tsx | 26 +- .../email/src/emails/onboarding-welcome.tsx | 47 ++- .../src/emails/onboarding-what-to-track.tsx | 34 +- packages/email/src/index.tsx | 51 ++- packages/email/src/unsubscribe.ts | 28 ++ packages/payments/src/prices.ts | 32 ++ packages/queue/src/queues.ts | 15 - packages/trpc/src/root.ts | 2 + packages/trpc/src/routers/auth.ts | 1 - packages/trpc/src/routers/email.ts | 40 ++ packages/trpc/src/routers/onboarding.ts | 24 +- pnpm-lock.yaml | 12 +- 29 files changed, 769 insertions(+), 290 deletions(-) create mode 100644 apps/start/src/routes/unsubscribe.tsx delete mode 100644 packages/db/prisma/migrations/20260120110632_/migration.sql create mode 100644 packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql create mode 100644 packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql create mode 100644 packages/email/src/components/button.tsx create mode 100644 packages/email/src/components/list.tsx create mode 100644 packages/email/src/emails/onboarding-feature-request.tsx delete mode 100644 packages/email/src/emails/onboarding-replace-stack.tsx create mode 100644 packages/email/src/emails/onboarding-trial-ended.tsx create mode 100644 packages/email/src/unsubscribe.ts create mode 100644 packages/trpc/src/routers/email.ts diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 5d5144014..2cbd6bdd6 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRouteImport } from './routes/__root' +import { Route as UnsubscribeRouteImport } from './routes/unsubscribe' import { Route as StepsRouteImport } from './routes/_steps' import { Route as PublicRouteImport } from './routes/_public' import { Route as LoginRouteImport } from './routes/_login' @@ -102,6 +103,11 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute( '/_app/$organizationId/$projectId/profiles/$profileId', )() +const UnsubscribeRoute = UnsubscribeRouteImport.update({ + id: '/unsubscribe', + path: '/unsubscribe', + getParentRoute: () => rootRouteImport, +} as any) const StepsRoute = StepsRouteImport.update({ id: '/_steps', getParentRoute: () => rootRouteImport, @@ -525,6 +531,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/unsubscribe': typeof UnsubscribeRoute '/$organizationId': typeof AppOrganizationIdRouteWithChildren '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute @@ -591,6 +598,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/unsubscribe': typeof UnsubscribeRoute '/login': typeof LoginLoginRoute '/reset-password': typeof LoginResetPasswordRoute '/onboarding': typeof PublicOnboardingRoute @@ -653,6 +661,7 @@ export interface FileRoutesById { '/_login': typeof LoginRouteWithChildren '/_public': typeof PublicRouteWithChildren '/_steps': typeof StepsRouteWithChildren + '/unsubscribe': typeof UnsubscribeRoute '/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren '/_login/login': typeof LoginLoginRoute '/_login/reset-password': typeof LoginResetPasswordRoute @@ -728,6 +737,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/unsubscribe' | '/$organizationId' | '/login' | '/reset-password' @@ -794,6 +804,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/unsubscribe' | '/login' | '/reset-password' | '/onboarding' @@ -855,6 +866,7 @@ export interface FileRouteTypes { | '/_login' | '/_public' | '/_steps' + | '/unsubscribe' | '/_app/$organizationId' | '/_login/login' | '/_login/reset-password' @@ -933,6 +945,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRouteWithChildren PublicRoute: typeof PublicRouteWithChildren StepsRoute: typeof StepsRouteWithChildren + UnsubscribeRoute: typeof UnsubscribeRoute ApiConfigRoute: typeof ApiConfigRoute ApiHealthcheckRoute: typeof ApiHealthcheckRoute WidgetCounterRoute: typeof WidgetCounterRoute @@ -945,6 +958,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/unsubscribe': { + id: '/unsubscribe' + path: '/unsubscribe' + fullPath: '/unsubscribe' + preLoaderRoute: typeof UnsubscribeRouteImport + parentRoute: typeof rootRouteImport + } '/_steps': { id: '/_steps' path: '' @@ -1872,6 +1892,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRouteWithChildren, PublicRoute: PublicRouteWithChildren, StepsRoute: StepsRouteWithChildren, + UnsubscribeRoute: UnsubscribeRoute, ApiConfigRoute: ApiConfigRoute, ApiHealthcheckRoute: ApiHealthcheckRoute, WidgetCounterRoute: WidgetCounterRoute, diff --git a/apps/start/src/routes/unsubscribe.tsx b/apps/start/src/routes/unsubscribe.tsx new file mode 100644 index 000000000..1fbcbdee9 --- /dev/null +++ b/apps/start/src/routes/unsubscribe.tsx @@ -0,0 +1,112 @@ +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { LoginNavbar } from '@/components/login-navbar'; +import { useTRPC } from '@/integrations/trpc/react'; +import { emailCategories } from '@openpanel/constants'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { useState } from 'react'; +import { z } from 'zod'; + +const unsubscribeSearchSchema = z.object({ + email: z.string().email(), + category: z.string(), + token: z.string(), +}); + +export const Route = createFileRoute('/unsubscribe')({ + component: RouteComponent, + validateSearch: unsubscribeSearchSchema, + pendingComponent: FullPageLoadingState, +}); + +function RouteComponent() { + const search = useSearch({ from: '/unsubscribe' }); + const { email, category, token } = search; + const trpc = useTRPC(); + const [isUnsubscribing, setIsUnsubscribing] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + const unsubscribeMutation = trpc.email.unsubscribe.useMutation({ + onSuccess: () => { + setIsSuccess(true); + setIsUnsubscribing(false); + }, + onError: (err) => { + setError(err.message || 'Failed to unsubscribe'); + setIsUnsubscribing(false); + }, + }); + + const handleUnsubscribe = () => { + setIsUnsubscribing(true); + setError(null); + unsubscribeMutation.mutate({ email, category, token }); + }; + + const categoryName = + emailCategories[category as keyof typeof emailCategories] || category; + + if (isSuccess) { + return ( +
+ +
+
+
+

Unsubscribed

+

+ You've been unsubscribed from {categoryName} emails. +

+

+ You won't receive any more {categoryName.toLowerCase()} emails from + us. +

+
+
+
+ ); + } + + return ( +
+ +
+
+
+

Unsubscribe

+

+ Unsubscribe from {categoryName} emails? +

+

+ You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '} + {email} +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + Cancel + +
+
+
+
+ ); +} diff --git a/apps/worker/package.json b/apps/worker/package.json index ecd9bbc38..7c3c15722 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -20,6 +20,7 @@ "@openpanel/json": "workspace:*", "@openpanel/logger": "workspace:*", "@openpanel/importer": "workspace:*", + "@openpanel/payments": "workspace:*", "@openpanel/queue": "workspace:*", "@openpanel/redis": "workspace:*", "bullmq": "^5.63.0", diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts index f13a0ce46..b21eb0eee 100644 --- a/apps/worker/src/jobs/cron.onboarding.ts +++ b/apps/worker/src/jobs/cron.onboarding.ts @@ -1,180 +1,265 @@ -import { differenceInDays } from 'date-fns'; import type { Job } from 'bullmq'; +import { differenceInDays } from 'date-fns'; import { db } from '@openpanel/db'; -import { sendEmail } from '@openpanel/email'; +import { + type EmailData, + type EmailTemplate, + sendEmail, +} from '@openpanel/email'; import type { CronQueuePayload } from '@openpanel/queue'; +import { getRecommendedPlan } from '@openpanel/payments'; 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 +// Types for the onboarding email system +const orgQuery = { + include: { + createdBy: { + select: { + id: true, + email: true, + firstName: true, + deletedAt: true, + }, + }, + }, +} as const; + +type OrgWithCreator = Awaited< + ReturnType> +>[number]; + +type OnboardingContext = { + org: OrgWithCreator; + user: NonNullable; +}; + +type OnboardingEmail = { + day: number; + template: T; + shouldSend?: (ctx: OnboardingContext) => Promise; + data: (ctx: OnboardingContext) => EmailData; +}; + +// Helper to create type-safe email entries with correlated template/data types +function email(config: OnboardingEmail) { + return config; +} + +const getters = { + firstName: (ctx: OnboardingContext) => ctx.user.firstName || undefined, + organizationName: (ctx: OnboardingContext) => ctx.org.name, + dashboardUrl: (ctx: OnboardingContext) => { + return `${process.env.DASHBOARD_URL}/${ctx.org.id}`; + }, + billingUrl: (ctx: OnboardingContext) => { + return `${process.env.DASHBOARD_URL}/${ctx.org.id}/billing`; + }, + recommendedPlan: (ctx: OnboardingContext) => { + return getRecommendedPlan( + ctx.org.subscriptionPeriodEventsCount, + (plan) => + `${plan.formattedEvents} events per month for ${plan.formattedPrice}`, + ); + }, } as const; +// Declarative email schedule - easy to add, remove, or reorder +const ONBOARDING_EMAILS = [ + email({ + day: 0, + template: 'onboarding-welcome', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + dashboardUrl: getters.dashboardUrl(ctx), + }), + }), + email({ + day: 2, + template: 'onboarding-what-to-track', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + }), + }), + email({ + day: 6, + template: 'onboarding-dashboards', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + dashboardUrl: getters.dashboardUrl(ctx), + }), + }), + email({ + day: 14, + template: 'onboarding-featue-request', + data: (ctx) => ({ + firstName: getters.firstName(ctx), + }), + }), + email({ + day: 26, + template: 'onboarding-trial-ending', + shouldSend: async ({ org }) => { + if (org.subscriptionStatus === 'active') { + return 'complete'; + } + return true; + }, + data: (ctx) => { + return { + firstName: getters.firstName(ctx), + organizationName: getters.organizationName(ctx), + billingUrl: getters.billingUrl(ctx), + recommendedPlan: getters.recommendedPlan(ctx), + }; + }, + }), + email({ + day: 30, + template: 'onboarding-trial-ended', + shouldSend: async ({ org }) => { + if (org.subscriptionStatus === 'active') { + return 'complete'; + } + return true; + }, + data: (ctx) => { + return { + firstName: getters.firstName(ctx), + billingUrl: getters.billingUrl(ctx), + recommendedPlan: getters.recommendedPlan(ctx), + }; + }, + }), +]; + export async function onboardingJob(job: Job) { logger.info('Starting onboarding email job'); - // Fetch organizations with their creators who are in onboarding - const organizations = await db.organization.findMany({ + // Fetch organizations that are in onboarding (not completed) + const orgs = await db.organization.findMany({ where: { - createdByUserId: { - not: null, + onboarding: { + not: 'completed', }, + deleteAt: null, createdBy: { - onboarding: { - not: null, - gte: 1, - lte: 5, - }, deletedAt: null, }, }, - include: { - createdBy: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - onboarding: true, - }, - }, - }, + ...orgQuery, }); - logger.info(`Found ${organizations.length} organizations with creators in onboarding`); + logger.info(`Found ${orgs.length} organizations in onboarding`); let emailsSent = 0; - let usersCompleted = 0; - let usersSkipped = 0; + let orgsCompleted = 0; + let orgsSkipped = 0; - for (const org of organizations) { - if (!org.createdBy || !org.createdByUserId) { + for (const org of orgs) { + // Skip if no creator or creator is deleted + if (!org.createdBy || org.createdBy.deletedAt) { + orgsSkipped++; continue; } const user = org.createdBy; + const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt); - // 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 }, + // Find the next email to send + // If org.onboarding is empty string, they haven't received any email yet + const lastSentIndex = org.onboarding + ? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding) + : -1; + const nextEmailIndex = lastSentIndex + 1; + + // No more emails to send + if (nextEmailIndex >= ONBOARDING_EMAILS.length) { + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: 'completed' }, }); - usersCompleted++; - logger.info(`Stopped onboarding for user ${user.id} (active subscription)`); + orgsCompleted++; + logger.info( + `Completed onboarding for organization ${org.id} (all emails sent)`, + ); continue; } - if (!user.onboarding || user.onboarding < 1 || user.onboarding > 5) { + const nextEmail = ONBOARDING_EMAILS[nextEmailIndex]; + if (!nextEmail) { 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++; + // Check if enough days have passed + if (daysSinceOrgCreation < nextEmail.day) { + orgsSkipped++; 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`; + // Check shouldSend callback if defined + if (nextEmail.shouldSend) { + const result = await nextEmail.shouldSend({ org, user }); - 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; - } + if (result === 'complete') { + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: 'completed' }, + }); + orgsCompleted++; + logger.info( + `Completed onboarding for organization ${org.id} (shouldSend returned complete)`, + ); + continue; } - // Increment onboarding state - const nextOnboardingState = user.onboarding + 1; - await db.user.update({ - where: { id: user.id }, - data: { - onboarding: nextOnboardingState > 5 ? null : nextOnboardingState, - }, + if (result === false) { + orgsSkipped++; + continue; + } + } + + try { + const emailData = nextEmail.data({ org, user }); + + await sendEmail(nextEmail.template, { + to: user.email, + data: emailData as never, }); - emailsSent++; - logger.info(`Sent onboarding email ${user.onboarding} to user ${user.id} for org ${org.id}`); + // Update onboarding to the template name we just sent + await db.organization.update({ + where: { id: org.id }, + data: { onboarding: nextEmail.template }, + }); - if (nextOnboardingState > 5) { - usersCompleted++; - } + emailsSent++; + logger.info( + `Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`, + ); } catch (error) { - logger.error(`Failed to send onboarding email to user ${user.id}`, { - error, - onboardingStep: user.onboarding, - organizationId: org.id, - }); + logger.error( + `Failed to send onboarding email to organization ${org.id}`, + { + error, + template: nextEmail.template, + }, + ); } } logger.info('Completed onboarding email job', { - totalOrganizations: organizations.length, + totalOrgs: orgs.length, emailsSent, - usersCompleted, - usersSkipped, + orgsCompleted, + orgsSkipped, }); + + return { + totalOrgs: orgs.length, + emailsSent, + orgsCompleted, + orgsSkipped, + }; } diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 27cf4119f..077697a45 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -508,6 +508,13 @@ export function getCountry(code?: string) { return countries[code as keyof typeof countries]; } +export const emailCategories = { + onboarding: 'Onboarding', + billing: 'Billing', +} as const; + +export type EmailCategory = keyof typeof emailCategories; + export const chartColors = [ { main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' }, { main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' }, diff --git a/packages/db/prisma/migrations/20260120110632_/migration.sql b/packages/db/prisma/migrations/20260120110632_/migration.sql deleted file mode 100644 index 73984e66b..000000000 --- a/packages/db/prisma/migrations/20260120110632_/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "public"."users" ADD COLUMN "onboarding" INTEGER; diff --git a/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql new file mode 100644 index 000000000..bcd49719a --- /dev/null +++ b/packages/db/prisma/migrations/20260120230539_onboarding_to_organization/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."organizations" +ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed'; \ No newline at end of file diff --git a/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql new file mode 100644 index 000000000..b2c9b5847 --- /dev/null +++ b/packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "public"."email_unsubscribes" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "category" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_unsubscribes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "email_unsubscribes_email_category_key" ON "public"."email_unsubscribes"("email", "category"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0a41b7fd3..a9457a096 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -62,6 +62,7 @@ model Organization { integrations Integration[] invites Invite[] timezone String? + onboarding String @default("completed") // 'completed' or template name for next email // Subscription subscriptionId String? @@ -94,7 +95,6 @@ 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[] @@ -611,3 +611,13 @@ model InsightEvent { @@index([insightId, createdAt]) @@map("insight_events") } + +model EmailUnsubscribe { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + email String + category String + createdAt DateTime @default(now()) + + @@unique([email, category]) + @@map("email_unsubscribes") +} diff --git a/packages/email/package.json b/packages/email/package.json index a5664bcb0..27ae2e2ea 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@openpanel/db": "workspace:*", "@react-email/components": "^0.5.6", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/email/src/components/button.tsx b/packages/email/src/components/button.tsx new file mode 100644 index 000000000..e376fb259 --- /dev/null +++ b/packages/email/src/components/button.tsx @@ -0,0 +1,23 @@ +import { Button as EmailButton } from '@react-email/components'; + +export function Button({ + href, + children, + style, +}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) { + return ( + + {children} + + ); +} diff --git a/packages/email/src/components/list.tsx b/packages/email/src/components/list.tsx new file mode 100644 index 000000000..dbbd9afd9 --- /dev/null +++ b/packages/email/src/components/list.tsx @@ -0,0 +1,13 @@ +import { Text } from '@react-email/components'; + +export function List({ items }: { items: React.ReactNode[] }) { + return ( +
    + {items.map((node, index) => ( +
  • + {node} +
  • + ))} +
+ ); +} diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx index 74006631e..b6f7abbbe 100644 --- a/packages/email/src/emails/index.tsx +++ b/packages/email/src/emails/index.tsx @@ -3,22 +3,23 @@ import { EmailInvite, zEmailInvite } from './email-invite'; 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 OnboardingFeatureRequest, { + zOnboardingFeatureRequest, +} from './onboarding-feature-request'; +import OnboardingTrialEnded, { + zOnboardingTrialEnded, +} from './onboarding-trial-ended'; import OnboardingTrialEnding, { zOnboardingTrialEnding, } from './onboarding-trial-ending'; +import OnboardingWelcome, { zOnboardingWelcome } from './onboarding-welcome'; +import OnboardingWhatToTrack, { + zOnboardingWhatToTrack, +} from './onboarding-what-to-track'; +import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon'; export const templates = { invite: { @@ -38,31 +39,43 @@ export const templates = { 'Your trial is ending soon', Component: TrailEndingSoon, schema: zTrailEndingSoon, + category: 'billing' as const, }, 'onboarding-welcome': { subject: () => "You're in", Component: OnboardingWelcome, schema: zOnboardingWelcome, + category: 'onboarding' as const, }, 'onboarding-what-to-track': { subject: () => "What's actually worth tracking", Component: OnboardingWhatToTrack, schema: zOnboardingWhatToTrack, + category: 'onboarding' as const, }, 'onboarding-dashboards': { subject: () => 'The part most people skip', Component: OnboardingDashboards, schema: zOnboardingDashboards, + category: 'onboarding' as const, }, - 'onboarding-replace-stack': { + 'onboarding-featue-request': { subject: () => 'One provider to rule them all', - Component: OnboardingReplaceStack, - schema: zOnboardingReplaceStack, + Component: OnboardingFeatureRequest, + schema: zOnboardingFeatureRequest, + category: 'onboarding' as const, }, 'onboarding-trial-ending': { subject: () => 'Your trial ends in a few days', Component: OnboardingTrialEnding, schema: zOnboardingTrialEnding, + category: 'onboarding' as const, + }, + 'onboarding-trial-ended': { + subject: () => 'Your trial has ended', + Component: OnboardingTrialEnded, + schema: zOnboardingTrialEnded, + category: 'onboarding' as const, }, } as const; diff --git a/packages/email/src/emails/onboarding-dashboards.tsx b/packages/email/src/emails/onboarding-dashboards.tsx index 902c7f167..d49e23fdd 100644 --- a/packages/email/src/emails/onboarding-dashboards.tsx +++ b/packages/email/src/emails/onboarding-dashboards.tsx @@ -2,6 +2,7 @@ import { Link, Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; import { Layout } from '../components/layout'; +import { List } from '../components/list'; export const zOnboardingDashboards = z.object({ firstName: z.string().optional(), @@ -30,20 +31,34 @@ export function OnboardingDashboards({ 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. - Create your first dashboard + Best regards, +
+ Carl
- Carl + + Dashboard + ); } diff --git a/packages/email/src/emails/onboarding-feature-request.tsx b/packages/email/src/emails/onboarding-feature-request.tsx new file mode 100644 index 000000000..a0a8b289b --- /dev/null +++ b/packages/email/src/emails/onboarding-feature-request.tsx @@ -0,0 +1,39 @@ +import { Link, Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Layout } from '../components/layout'; + +export const zOnboardingFeatureRequest = z.object({ + firstName: z.string().optional(), +}); + +export type Props = z.infer; +export default OnboardingFeatureRequest; +export function OnboardingFeatureRequest({ firstName }: Props) { + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + OpenPanel aims to be the one stop shop for all your analytics needs. + + + We have already in a very short time become one of the most popular + open-source analytics platforms out there and we're working hard to add + more features to make it the best analytics platform. + + + Do you feel like you're missing a feature that's important to you? If + that's the case, please reply here or go to our feedback board and add + your request there. + + + Feedback board + + + Best regards, +
+ Carl +
+
+ ); +} diff --git a/packages/email/src/emails/onboarding-replace-stack.tsx b/packages/email/src/emails/onboarding-replace-stack.tsx deleted file mode 100644 index 1e2487198..000000000 --- a/packages/email/src/emails/onboarding-replace-stack.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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; -export default OnboardingReplaceStack; -export function OnboardingReplaceStack({ - firstName, -}: Props) { - return ( - - Hi{firstName ? ` ${firstName}` : ''}, - - 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 - - ); -} diff --git a/packages/email/src/emails/onboarding-trial-ended.tsx b/packages/email/src/emails/onboarding-trial-ended.tsx new file mode 100644 index 000000000..8b61db3d7 --- /dev/null +++ b/packages/email/src/emails/onboarding-trial-ended.tsx @@ -0,0 +1,55 @@ +import { Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Button } from '../components/button'; +import { Layout } from '../components/layout'; + +export const zOnboardingTrialEnded = z.object({ + firstName: z.string().optional(), + billingUrl: z.string(), + recommendedPlan: z.string().optional(), +}); + +export type Props = z.infer; +export default OnboardingTrialEnded; +export function OnboardingTrialEnded({ + firstName, + 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-ended'); + + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + Your OpenPanel trial has ended. + + Your tracking is still running in the background, but you won't be able + to see any new data until you upgrade. All your dashboards, reports, and + event history are still there waiting for you. + + + Important: If you don't upgrade within 30 days, your workspace and + projects will be permanently deleted. + + + To keep your data and continue using OpenPanel, upgrade to a paid plan.{' '} + {recommendedPlan + ? `Based on your usage we recommend upgrading to the ${recommendedPlan}` + : 'Plans start at $2.50/month'} + . + + + If you have any questions or something's holding you back, just reply to + this email. + + + + + Carl + + ); +} diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx index 3c8ef0be7..aa7e4fef0 100644 --- a/packages/email/src/emails/onboarding-trial-ending.tsx +++ b/packages/email/src/emails/onboarding-trial-ending.tsx @@ -1,6 +1,7 @@ -import { Button, Link, Text } from '@react-email/components'; +import { Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; +import { Button } from '../components/button'; import { Layout } from '../components/layout'; export const zOnboardingTrialEnding = z.object({ @@ -33,32 +34,21 @@ export function OnboardingTrialEnding({ event history) stays intact. - 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}` : ''} + To continue using OpenPanel, you'll need to upgrade to a paid plan.{' '} + {recommendedPlan + ? `Based on your usage we recommend upgrading to the ${recommendedPlan} plan` + : 'Plans start at $2.50/month'} . - If something's holding you back, I'd like to hear about it. Just - reply. + 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 diff --git a/packages/email/src/emails/onboarding-welcome.tsx b/packages/email/src/emails/onboarding-welcome.tsx index 5929b6bcf..727fee0f7 100644 --- a/packages/email/src/emails/onboarding-welcome.tsx +++ b/packages/email/src/emails/onboarding-welcome.tsx @@ -1,7 +1,8 @@ -import { Link, Text } from '@react-email/components'; +import { Heading, Link, Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; import { Layout } from '../components/layout'; +import { List } from '../components/list'; export const zOnboardingWelcome = z.object({ firstName: z.string().optional(), @@ -10,34 +11,42 @@ export const zOnboardingWelcome = z.object({ export type Props = z.infer; 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'); - +export function OnboardingWelcome({ firstName }: Props) { return ( Hi{firstName ? ` ${firstName}` : ''}, Thanks for trying OpenPanel. - We built OpenPanel because most analytics tools are either too expensive, - too complicated, or both. OpenPanel is different. + 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. + We hope you find OpenPanel useful and if you have any questions, + regarding tracking or how to import your existing events, just reach + out. We're here to help. + To get started, you can: + + Install tracking script + , + + Start tracking your events + , + ]} + /> - If you can't find your provider just reach out and we'll help you out. + Best regards, +
+ Carl
- Reach out if you have any questions. I answer all emails. - Carl
); } diff --git a/packages/email/src/emails/onboarding-what-to-track.tsx b/packages/email/src/emails/onboarding-what-to-track.tsx index ac94a15a1..556dde509 100644 --- a/packages/email/src/emails/onboarding-what-to-track.tsx +++ b/packages/email/src/emails/onboarding-what-to-track.tsx @@ -2,6 +2,7 @@ import { Text } from '@react-email/components'; import React from 'react'; import { z } from 'zod'; import { Layout } from '../components/layout'; +import { List } from '../components/list'; export const zOnboardingWhatToTrack = z.object({ firstName: z.string().optional(), @@ -9,33 +10,34 @@ export const zOnboardingWhatToTrack = z.object({ export type Props = z.infer; export default OnboardingWhatToTrack; -export function OnboardingWhatToTrack({ - firstName, -}: Props) { +export function OnboardingWhatToTrack({ firstName }: Props) { return ( Hi{firstName ? ` ${firstName}` : ''}, - 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. + Tracking can be overwhelming at first, and that's why its important to + focus on what's matters. For most products, that's something like: - For most products, that's something like: - - Signups + - - The first meaningful action (create something, send something, buy - something) + Start small and incrementally add more events as you go is usually the + best approach. - - 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, or have any + questions, just reply here. - If you're not sure whether something's worth tracking, just ask. I'm - happy to look at your setup. + Best regards, +
+ Carl
- Carl
); } diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx index e61bcd58e..f1bedd347 100644 --- a/packages/email/src/index.tsx +++ b/packages/email/src/index.tsx @@ -2,34 +2,72 @@ import React from 'react'; import { Resend } from 'resend'; import type { z } from 'zod'; +import { db } from '@openpanel/db'; import { type TemplateKey, type Templates, templates } from './emails'; +import { getUnsubscribeUrl } from './unsubscribe'; + +export * from './unsubscribe'; const FROM = process.env.EMAIL_SENDER ?? 'hello@openpanel.dev'; +export type EmailData = z.infer; +export type EmailTemplate = keyof Templates; + export async function sendEmail( - template: T, + templateKey: T, options: { - to: string | string[]; + to: string; data: z.infer; }, ) { const { to, data } = options; - const { subject, Component, schema } = templates[template]; - const props = schema.safeParse(data); + const template = templates[templateKey]; + const props = template.schema.safeParse(data); if (!props.success) { console.error('Failed to parse data', props.error); return null; } + // Check if user has unsubscribed from this category (only for non-transactional emails) + if ('category' in template && template.category) { + const unsubscribed = await db.emailUnsubscribe.findUnique({ + where: { + email_category: { + email: to, + category: template.category, + }, + }, + }); + + if (unsubscribed) { + console.log( + `Skipping email to ${to} - unsubscribed from ${template.category}`, + ); + return null; + } + } + if (!process.env.RESEND_API_KEY) { console.log('No RESEND_API_KEY found, here is the data'); - console.log(data); + console.log('Template:', template); + // @ts-expect-error - TODO: fix this + console.log('Subject: ', subject(props.data)); + console.log('To: ', to); + console.log('Data: ', JSON.stringify(data, null, 2)); return null; } const resend = new Resend(process.env.RESEND_API_KEY); + // Build headers for unsubscribe (only for non-transactional emails) + const headers: Record = {}; + if ('category' in template && template.category) { + const unsubscribeUrl = getUnsubscribeUrl(to, template.category); + headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`; + headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; + } + try { const res = await resend.emails.send({ from: FROM, @@ -38,12 +76,11 @@ export async function sendEmail( subject: subject(props.data), // @ts-expect-error - TODO: fix this react: , + headers: Object.keys(headers).length > 0 ? headers : undefined, }); - if (res.error) { throw new Error(res.error.message); } - return res; } catch (error) { console.error('Failed to send email', error); diff --git a/packages/email/src/unsubscribe.ts b/packages/email/src/unsubscribe.ts new file mode 100644 index 000000000..baeef6fd5 --- /dev/null +++ b/packages/email/src/unsubscribe.ts @@ -0,0 +1,28 @@ +import { createHmac } from 'crypto'; + +const SECRET = + process.env.UNSUBSCRIBE_SECRET || + process.env.COOKIE_SECRET || + process.env.SECRET || + 'default-secret-change-in-production'; + +export function generateUnsubscribeToken(email: string, category: string): string { + const data = `${email}:${category}`; + return createHmac('sha256', SECRET).update(data).digest('hex'); +} + +export function verifyUnsubscribeToken( + email: string, + category: string, + token: string, +): boolean { + const expectedToken = generateUnsubscribeToken(email, category); + return token === expectedToken; +} + +export function getUnsubscribeUrl(email: string, category: string): string { + const token = generateUnsubscribeToken(email, category); + const params = new URLSearchParams({ email, category, token }); + const dashboardUrl = process.env.DASHBOARD_URL || 'http://localhost:3000'; + return `${dashboardUrl}/unsubscribe?${params.toString()}`; +} diff --git a/packages/payments/src/prices.ts b/packages/payments/src/prices.ts index dbe728b4f..6e3fb719f 100644 --- a/packages/payments/src/prices.ts +++ b/packages/payments/src/prices.ts @@ -1,5 +1,11 @@ export type { ProductPrice } from '@polar-sh/sdk/models/components/productprice.js'; +function formatEventsCount(events: number) { + return new Intl.NumberFormat('en-gb', { + notation: 'compact', + }).format(events); +} + export type IPrice = { price: number; events: number; @@ -39,3 +45,29 @@ export const FREE_PRODUCT_IDS = [ 'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod '036efa2a-b3b4-4c75-b24a-9cac6bb8893b', // Sandbox ]; + +export function getRecommendedPlan( + monthlyEvents: number | undefined | null, + cb: ( + options: { + formattedEvents: string; + formattedPrice: string; + } & IPrice, + ) => T, +): T | undefined { + if (!monthlyEvents) { + return undefined; + } + const price = PRICING.find((price) => price.events >= monthlyEvents); + if (!price) { + return undefined; + } + return cb({ + ...price, + formattedEvents: formatEventsCount(price.events), + formattedPrice: Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(price.price), + }); +} diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 93df0925d..32b21a4cf 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -259,18 +259,3 @@ export const insightsQueue = new Queue( }, }, ); - -export function addTrialEndingSoonJob(organizationId: string, delay: number) { - return miscQueue.add( - 'misc', - { - type: 'trialEndingSoon', - payload: { - organizationId, - }, - }, - { - delay, - }, - ); -} diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 88c19045c..068a321dd 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -3,6 +3,7 @@ import { chartRouter } from './routers/chart'; import { chatRouter } from './routers/chat'; import { clientRouter } from './routers/client'; import { dashboardRouter } from './routers/dashboard'; +import { emailRouter } from './routers/email'; import { eventRouter } from './routers/event'; import { importRouter } from './routers/import'; import { insightRouter } from './routers/insight'; @@ -51,6 +52,7 @@ export const appRouter = createTRPCRouter({ chat: chatRouter, insight: insightRouter, widget: widgetRouter, + email: emailRouter, }); // export type definition of API diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index fa22019ad..912b6a87f 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -353,7 +353,6 @@ export const authRouter = createTRPCRouter({ .input(zSignInShare) .mutation(async ({ input, ctx }) => { const { password, shareId, shareType = 'overview' } = input; - let share: { password: string | null; public: boolean } | null = null; let cookieName = ''; diff --git a/packages/trpc/src/routers/email.ts b/packages/trpc/src/routers/email.ts new file mode 100644 index 000000000..5e149d5d0 --- /dev/null +++ b/packages/trpc/src/routers/email.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { db } from '@openpanel/db'; +import { verifyUnsubscribeToken } from '@openpanel/email'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const emailRouter = createTRPCRouter({ + unsubscribe: publicProcedure + .input( + z.object({ + email: z.string().email(), + category: z.string(), + token: z.string(), + }), + ) + .mutation(async ({ input }) => { + const { email, category, token } = input; + + // Verify token + if (!verifyUnsubscribeToken(email, category, token)) { + throw new Error('Invalid unsubscribe link'); + } + + // Upsert the unsubscribe record + await db.emailUnsubscribe.upsert({ + where: { + email_category: { + email, + category, + }, + }, + create: { + email, + category, + }, + update: {}, + }); + + return { success: true }; + }), +}); diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts index 616dce5bf..a49b54359 100644 --- a/packages/trpc/src/routers/onboarding.ts +++ b/packages/trpc/src/routers/onboarding.ts @@ -8,7 +8,6 @@ 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( @@ -22,13 +21,6 @@ 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), @@ -37,24 +29,10 @@ async function createOrGetOrganization( subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS), subscriptionStatus: 'trialing', timezone: input.timezone, + onboarding: 'onboarding-welcome', }, }); - // 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, - 1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9, - ); - } - return organization; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66f793fe1..6fb2d4d7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ overrides: patchedDependencies: nuqs: - hash: w6thjv3pgywfrbh4sblczc6qpy + hash: 4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e 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=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) + 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) prisma-error-enum: specifier: ^0.1.3 version: 0.1.3 @@ -880,6 +880,9 @@ importers: '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger + '@openpanel/payments': + specifier: workspace:* + version: link:../../packages/payments '@openpanel/queue': specifier: workspace:* version: link:../../packages/queue @@ -1136,6 +1139,9 @@ importers: packages/email: dependencies: + '@openpanel/db': + specifier: workspace:* + version: link:../db '@react-email/components': specifier: ^0.5.6 version: 0.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -33178,7 +33184,7 @@ snapshots: dependencies: esm-env: esm-env-runtime@0.1.1 - 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): + 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): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3 From 3fa1a5429ea1f738fe3794d3a70c454d007d0c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 21 Jan 2026 11:21:40 +0100 Subject: [PATCH 3/5] wip --- .../components/auth/share-enter-password.tsx | 71 ++++------ .../start/src/components/public-page-card.tsx | 51 ++++++++ apps/start/src/routeTree.gen.ts | 110 ++++++++++++++++ ...tionId.profile._tabs.email-preferences.tsx | 123 ++++++++++++++++++ ...pp.$organizationId.profile._tabs.index.tsx | 96 ++++++++++++++ .../_app.$organizationId.profile._tabs.tsx | 55 ++++++++ apps/start/src/routes/unsubscribe.tsx | 107 ++++++--------- apps/worker/src/boot-cron.ts | 2 +- apps/worker/src/boot-workers.ts | 18 ++- apps/worker/src/jobs/cron.onboarding.ts | 19 ++- packages/constants/index.ts | 6 +- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 2 +- packages/email/src/components/footer.tsx | 22 ++-- packages/email/src/components/layout.tsx | 8 +- packages/email/src/emails/email-invite.tsx | 5 +- .../email/src/emails/email-reset-password.tsx | 7 +- packages/email/src/emails/index.tsx | 3 - .../src/emails/onboarding-dashboards.tsx | 5 +- .../src/emails/onboarding-feature-request.tsx | 7 +- .../src/emails/onboarding-trial-ended.tsx | 3 +- .../src/emails/onboarding-trial-ending.tsx | 3 +- .../email/src/emails/onboarding-welcome.tsx | 7 +- .../src/emails/onboarding-what-to-track.tsx | 7 +- .../email/src/emails/trial-ending-soon.tsx | 3 +- packages/email/src/index.tsx | 12 +- packages/trpc/src/routers/email.ts | 77 ++++++++++- packages/trpc/src/routers/onboarding.ts | 2 +- 28 files changed, 661 insertions(+), 172 deletions(-) create mode 100644 apps/start/src/components/public-page-card.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.profile._tabs.tsx create mode 100644 packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql diff --git a/apps/start/src/components/auth/share-enter-password.tsx b/apps/start/src/components/auth/share-enter-password.tsx index b25dae612..9f1e7ce7a 100644 --- a/apps/start/src/components/auth/share-enter-password.tsx +++ b/apps/start/src/components/auth/share-enter-password.tsx @@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { LogoSquare } from '../logo'; +import { PublicPageCard } from '../public-page-card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; @@ -43,54 +43,27 @@ export function ShareEnterPassword({ }); }); + const typeLabel = + shareType === 'dashboard' + ? 'Dashboard' + : shareType === 'report' + ? 'Report' + : 'Overview'; + return ( -
-
-
- -
- {shareType === 'dashboard' - ? 'Dashboard is locked' - : shareType === 'report' - ? 'Report is locked' - : 'Overview is locked'} -
-
- Please enter correct password to access this{' '} - {shareType === 'dashboard' - ? 'dashboard' - : shareType === 'report' - ? 'report' - : 'overview'} -
-
-
- - -
-
-
-

- Powered by{' '} - - OpenPanel.dev - -

-

- The best web and product analytics tool out there (our honest - opinion). -

-

- - Try it for free today! - -

-
-
+ +
+ + +
+
); } diff --git a/apps/start/src/components/public-page-card.tsx b/apps/start/src/components/public-page-card.tsx new file mode 100644 index 000000000..400513ec0 --- /dev/null +++ b/apps/start/src/components/public-page-card.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; +import { LoginNavbar } from './login-navbar'; +import { LogoSquare } from './logo'; + +interface PublicPageCardProps { + title: string; + description?: ReactNode; + children?: ReactNode; + showFooter?: boolean; +} + +export function PublicPageCard({ + title, + description, + children, + showFooter = true, +}: PublicPageCardProps) { + return ( +
+ +
+
+
+ +
{title}
+ {description && ( +
+ {description} +
+ )} +
+ {!!children &&
{children}
} +
+ {showFooter && ( +
+

+ Powered by{' '} + + OpenPanel.dev + + {' · '} + + Try it for free today! + +

+
+ )} +
+
+ ); +} diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 2cbd6bdd6..705b7c2dc 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as AppOrganizationIdProjectIdRouteImport } from './routes/_app.$o import { Route as AppOrganizationIdProjectIdIndexRouteImport } from './routes/_app.$organizationId.$projectId.index' import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify' import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect' +import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.$organizationId.profile._tabs' import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs' import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs' import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions' @@ -47,8 +48,10 @@ import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_a import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights' import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards' import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat' +import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index' import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index' import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._tabs.index' +import { Route as AppOrganizationIdProfileTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.profile._tabs.email-preferences' import { Route as AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members' import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations' import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed' @@ -81,6 +84,9 @@ import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } f import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events' +const AppOrganizationIdProfileRouteImport = createFileRoute( + '/_app/$organizationId/profile', +)() const AppOrganizationIdMembersRouteImport = createFileRoute( '/_app/$organizationId/members', )() @@ -174,6 +180,12 @@ const AppOrganizationIdRoute = AppOrganizationIdRouteImport.update({ path: '/$organizationId', getParentRoute: () => AppRoute, } as any) +const AppOrganizationIdProfileRoute = + AppOrganizationIdProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AppOrganizationIdRoute, + } as any) const AppOrganizationIdMembersRoute = AppOrganizationIdMembersRouteImport.update({ id: '/members', @@ -271,6 +283,11 @@ const StepsOnboardingProjectIdConnectRoute = path: '/onboarding/$projectId/connect', getParentRoute: () => StepsRoute, } as any) +const AppOrganizationIdProfileTabsRoute = + AppOrganizationIdProfileTabsRouteImport.update({ + id: '/_tabs', + getParentRoute: () => AppOrganizationIdProfileRoute, + } as any) const AppOrganizationIdMembersTabsRoute = AppOrganizationIdMembersTabsRouteImport.update({ id: '/_tabs', @@ -335,6 +352,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute = path: '/$profileId', getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute, } as any) +const AppOrganizationIdProfileTabsIndexRoute = + AppOrganizationIdProfileTabsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppOrganizationIdProfileTabsRoute, + } as any) const AppOrganizationIdMembersTabsIndexRoute = AppOrganizationIdMembersTabsIndexRouteImport.update({ id: '/', @@ -347,6 +370,12 @@ const AppOrganizationIdIntegrationsTabsIndexRoute = path: '/', getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute, } as any) +const AppOrganizationIdProfileTabsEmailPreferencesRoute = + AppOrganizationIdProfileTabsEmailPreferencesRouteImport.update({ + id: '/email-preferences', + path: '/email-preferences', + getParentRoute: () => AppOrganizationIdProfileTabsRoute, + } as any) const AppOrganizationIdMembersTabsMembersRoute = AppOrganizationIdMembersTabsMembersRouteImport.update({ id: '/members', @@ -559,6 +588,7 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren + '/$organizationId/profile': typeof AppOrganizationIdProfileTabsRouteWithChildren '/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute @@ -573,8 +603,10 @@ export interface FileRoutesByFullPath { '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute + '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute '/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute + '/$organizationId/profile/': typeof AppOrganizationIdProfileTabsIndexRoute '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute @@ -624,6 +656,7 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute + '/$organizationId/profile': typeof AppOrganizationIdProfileTabsIndexRoute '/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute @@ -638,6 +671,7 @@ export interface FileRoutesByTo { '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute + '/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute @@ -691,6 +725,8 @@ export interface FileRoutesById { '/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren '/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren '/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren + '/_app/$organizationId/profile': typeof AppOrganizationIdProfileRouteWithChildren + '/_app/$organizationId/profile/_tabs': typeof AppOrganizationIdProfileTabsRouteWithChildren '/_steps/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute '/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute '/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute @@ -709,8 +745,10 @@ export interface FileRoutesById { '/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute '/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute '/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute + '/_app/$organizationId/profile/_tabs/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute '/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute '/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute + '/_app/$organizationId/profile/_tabs/': typeof AppOrganizationIdProfileTabsIndexRoute '/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute @@ -765,6 +803,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/sessions' | '/$organizationId/integrations' | '/$organizationId/members' + | '/$organizationId/profile' | '/onboarding/$projectId/connect' | '/onboarding/$projectId/verify' | '/$organizationId/$projectId/' @@ -779,8 +818,10 @@ export interface FileRouteTypes { | '/$organizationId/integrations/installed' | '/$organizationId/members/invitations' | '/$organizationId/members/members' + | '/$organizationId/profile/email-preferences' | '/$organizationId/integrations/' | '/$organizationId/members/' + | '/$organizationId/profile/' | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/stats' @@ -830,6 +871,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/sessions' | '/$organizationId/integrations' | '/$organizationId/members' + | '/$organizationId/profile' | '/onboarding/$projectId/connect' | '/onboarding/$projectId/verify' | '/$organizationId/$projectId' @@ -844,6 +886,7 @@ export interface FileRouteTypes { | '/$organizationId/integrations/installed' | '/$organizationId/members/invitations' | '/$organizationId/members/members' + | '/$organizationId/profile/email-preferences' | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/stats' @@ -896,6 +939,8 @@ export interface FileRouteTypes { | '/_app/$organizationId/integrations/_tabs' | '/_app/$organizationId/members' | '/_app/$organizationId/members/_tabs' + | '/_app/$organizationId/profile' + | '/_app/$organizationId/profile/_tabs' | '/_steps/onboarding/$projectId/connect' | '/_steps/onboarding/$projectId/verify' | '/_app/$organizationId/$projectId/' @@ -914,8 +959,10 @@ export interface FileRouteTypes { | '/_app/$organizationId/integrations/_tabs/installed' | '/_app/$organizationId/members/_tabs/invitations' | '/_app/$organizationId/members/_tabs/members' + | '/_app/$organizationId/profile/_tabs/email-preferences' | '/_app/$organizationId/integrations/_tabs/' | '/_app/$organizationId/members/_tabs/' + | '/_app/$organizationId/profile/_tabs/' | '/_app/$organizationId/$projectId/events/_tabs/conversions' | '/_app/$organizationId/$projectId/events/_tabs/events' | '/_app/$organizationId/$projectId/events/_tabs/stats' @@ -1063,6 +1110,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdRouteImport parentRoute: typeof AppRoute } + '/_app/$organizationId/profile': { + id: '/_app/$organizationId/profile' + path: '/profile' + fullPath: '/$organizationId/profile' + preLoaderRoute: typeof AppOrganizationIdProfileRouteImport + parentRoute: typeof AppOrganizationIdRoute + } '/_app/$organizationId/members': { id: '/_app/$organizationId/members' path: '/members' @@ -1182,6 +1236,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport parentRoute: typeof StepsRoute } + '/_app/$organizationId/profile/_tabs': { + id: '/_app/$organizationId/profile/_tabs' + path: '/profile' + fullPath: '/$organizationId/profile' + preLoaderRoute: typeof AppOrganizationIdProfileTabsRouteImport + parentRoute: typeof AppOrganizationIdProfileRoute + } '/_app/$organizationId/members/_tabs': { id: '/_app/$organizationId/members/_tabs' path: '/members' @@ -1259,6 +1320,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute } + '/_app/$organizationId/profile/_tabs/': { + id: '/_app/$organizationId/profile/_tabs/' + path: '/' + fullPath: '/$organizationId/profile/' + preLoaderRoute: typeof AppOrganizationIdProfileTabsIndexRouteImport + parentRoute: typeof AppOrganizationIdProfileTabsRoute + } '/_app/$organizationId/members/_tabs/': { id: '/_app/$organizationId/members/_tabs/' path: '/' @@ -1273,6 +1341,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute } + '/_app/$organizationId/profile/_tabs/email-preferences': { + id: '/_app/$organizationId/profile/_tabs/email-preferences' + path: '/email-preferences' + fullPath: '/$organizationId/profile/email-preferences' + preLoaderRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRouteImport + parentRoute: typeof AppOrganizationIdProfileTabsRoute + } '/_app/$organizationId/members/_tabs/members': { id: '/_app/$organizationId/members/_tabs/members' path: '/members' @@ -1817,6 +1892,39 @@ const AppOrganizationIdMembersRouteWithChildren = AppOrganizationIdMembersRouteChildren, ) +interface AppOrganizationIdProfileTabsRouteChildren { + AppOrganizationIdProfileTabsEmailPreferencesRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRoute + AppOrganizationIdProfileTabsIndexRoute: typeof AppOrganizationIdProfileTabsIndexRoute +} + +const AppOrganizationIdProfileTabsRouteChildren: AppOrganizationIdProfileTabsRouteChildren = + { + AppOrganizationIdProfileTabsEmailPreferencesRoute: + AppOrganizationIdProfileTabsEmailPreferencesRoute, + AppOrganizationIdProfileTabsIndexRoute: + AppOrganizationIdProfileTabsIndexRoute, + } + +const AppOrganizationIdProfileTabsRouteWithChildren = + AppOrganizationIdProfileTabsRoute._addFileChildren( + AppOrganizationIdProfileTabsRouteChildren, + ) + +interface AppOrganizationIdProfileRouteChildren { + AppOrganizationIdProfileTabsRoute: typeof AppOrganizationIdProfileTabsRouteWithChildren +} + +const AppOrganizationIdProfileRouteChildren: AppOrganizationIdProfileRouteChildren = + { + AppOrganizationIdProfileTabsRoute: + AppOrganizationIdProfileTabsRouteWithChildren, + } + +const AppOrganizationIdProfileRouteWithChildren = + AppOrganizationIdProfileRoute._addFileChildren( + AppOrganizationIdProfileRouteChildren, + ) + interface AppOrganizationIdRouteChildren { AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute @@ -1824,6 +1932,7 @@ interface AppOrganizationIdRouteChildren { AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren + AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren } const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = { @@ -1834,6 +1943,7 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = { AppOrganizationIdIntegrationsRoute: AppOrganizationIdIntegrationsRouteWithChildren, AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren, + AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren, } const AppOrganizationIdRouteWithChildren = diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx new file mode 100644 index 000000000..d9b9430df --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx @@ -0,0 +1,123 @@ +import { WithLabel } from '@/components/forms/input-with-label'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; +import { useTRPC } from '@/integrations/trpc/react'; +import { handleError } from '@/integrations/trpc/react'; +import { emailCategories } from '@openpanel/constants'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { SaveIcon } from 'lucide-react'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const validator = z.object({ + categories: z.record(z.string(), z.boolean()), +}); + +type IForm = z.infer; + +export const Route = createFileRoute( + '/_app/$organizationId/profile/_tabs/email-preferences', +)({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const preferencesQuery = useSuspenseQuery( + trpc.email.getPreferences.queryOptions(), + ); + + const { control, handleSubmit, formState, reset } = useForm({ + defaultValues: { + categories: preferencesQuery.data, + }, + }); + + const mutation = useMutation( + trpc.email.updatePreferences.mutationOptions({ + onSuccess: async () => { + toast('Email preferences updated', { + description: 'Your email preferences have been saved.', + }); + await queryClient.invalidateQueries( + trpc.email.getPreferences.pathFilter(), + ); + // Reset form with fresh data after refetch + const freshData = await queryClient.fetchQuery( + trpc.email.getPreferences.queryOptions(), + ); + reset({ + categories: freshData, + }); + }, + onError: handleError, + }), + ); + + return ( +
{ + mutation.mutate(values); + })} + > + + + Email Preferences + + +

+ Choose which types of emails you want to receive. Uncheck a category + to stop receiving those emails. +

+ +
+ {Object.entries(emailCategories).map(([category, label]) => ( + ( +
+
+
{label}
+
+ {category === 'onboarding' && + 'Get started tips and guidance emails'} + {category === 'billing' && + 'Subscription updates and payment reminders'} +
+
+ +
+ )} + /> + ))} +
+ + +
+
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx new file mode 100644 index 000000000..9791abed6 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx @@ -0,0 +1,96 @@ +import { InputWithLabel } from '@/components/forms/input-with-label'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { SaveIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const validator = z.object({ + firstName: z.string(), + lastName: z.string(), +}); + +type IForm = z.infer; + +export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const session = useSuspenseQuery(trpc.auth.session.queryOptions()); + const user = session.data?.user; + + const { register, handleSubmit, formState, reset } = useForm({ + defaultValues: { + firstName: user?.firstName ?? '', + lastName: user?.lastName ?? '', + }, + }); + + const mutation = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + toast('Profile updated', { + description: 'Your profile has been updated.', + }); + queryClient.invalidateQueries(trpc.auth.session.pathFilter()); + reset({ + firstName: data.firstName ?? '', + lastName: data.lastName ?? '', + }); + }, + onError: handleError, + }), + ); + + if (!user) { + return null; + } + + return ( +
{ + mutation.mutate(values); + })} + > + + + Profile + + + + + + + +
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx new file mode 100644 index 000000000..ffcbb9a37 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.tsx @@ -0,0 +1,55 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { ProfileAvatar } from '@/components/profiles/profile-avatar'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { usePageTabs } from '@/hooks/use-page-tabs'; +import { useTRPC } from '@/integrations/trpc/react'; +import { getProfileName } from '@/utils/getters'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({ + component: Component, + pendingComponent: FullPageLoadingState, +}); + +function Component() { + const router = useRouter(); + const { activeTab, tabs } = usePageTabs([ + { + id: '/$organizationId/profile', + label: 'Profile', + }, + { id: 'email-preferences', label: 'Email preferences' }, + ]); + + const handleTabChange = (tabId: string) => { + router.navigate({ + from: Route.fullPath, + to: tabId, + }); + }; + + return ( + + + + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + + ); +} diff --git a/apps/start/src/routes/unsubscribe.tsx b/apps/start/src/routes/unsubscribe.tsx index 1fbcbdee9..d33be89f6 100644 --- a/apps/start/src/routes/unsubscribe.tsx +++ b/apps/start/src/routes/unsubscribe.tsx @@ -1,8 +1,9 @@ -import { FullPageEmptyState } from '@/components/full-page-empty-state'; import FullPageLoadingState from '@/components/full-page-loading-state'; -import { LoginNavbar } from '@/components/login-navbar'; +import { PublicPageCard } from '@/components/public-page-card'; +import { Button, LinkButton } from '@/components/ui/button'; import { useTRPC } from '@/integrations/trpc/react'; import { emailCategories } from '@openpanel/constants'; +import { useMutation } from '@tanstack/react-query'; import { createFileRoute, useSearch } from '@tanstack/react-router'; import { useState } from 'react'; import { z } from 'zod'; @@ -27,16 +28,18 @@ function RouteComponent() { const [isSuccess, setIsSuccess] = useState(false); const [error, setError] = useState(null); - const unsubscribeMutation = trpc.email.unsubscribe.useMutation({ - onSuccess: () => { - setIsSuccess(true); - setIsUnsubscribing(false); - }, - onError: (err) => { - setError(err.message || 'Failed to unsubscribe'); - setIsUnsubscribing(false); - }, - }); + const unsubscribeMutation = useMutation( + trpc.email.unsubscribe.mutationOptions({ + onSuccess: () => { + setIsSuccess(true); + setIsUnsubscribing(false); + }, + onError: (err) => { + setError(err.message || 'Failed to unsubscribe'); + setIsUnsubscribing(false); + }, + }), + ); const handleUnsubscribe = () => { setIsUnsubscribing(true); @@ -49,64 +52,38 @@ function RouteComponent() { if (isSuccess) { return ( -
- -
-
-
-

Unsubscribed

-

- You've been unsubscribed from {categoryName} emails. -

-

- You won't receive any more {categoryName.toLowerCase()} emails from - us. -

-
-
-
+ ); } return ( -
- -
-
-
-

Unsubscribe

-

- Unsubscribe from {categoryName} emails? -

-

- You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '} - {email} -

-
- - {error && ( -
- {error} -
- )} - -
- - - Cancel - + + Unsubscribe from {categoryName} emails? You'll stop receiving{' '} + {categoryName.toLowerCase()} emails sent to  + {email} + + } + > +
+ {error && ( +
+ {error}
-
+ )} + + + Cancel +
-
+ ); } diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index 4c0342ec3..9eb558c68 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -42,7 +42,7 @@ export async function bootCron() { { name: 'onboarding', type: 'onboarding', - pattern: '0 10 * * *', + pattern: '0 * * * *', }, ]; diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts index 4b739afcf..6d96dd61f 100644 --- a/apps/worker/src/boot-workers.ts +++ b/apps/worker/src/boot-workers.ts @@ -281,10 +281,20 @@ export async function bootWorkers() { eventName: string, evtOrExitCodeOrError: number | string | Error, ) { - logger.info('Starting graceful shutdown', { - code: evtOrExitCodeOrError, - eventName, - }); + // Log the actual error details for unhandled rejections/exceptions + if (evtOrExitCodeOrError instanceof Error) { + logger.error('Unhandled error triggered shutdown', { + eventName, + message: evtOrExitCodeOrError.message, + stack: evtOrExitCodeOrError.stack, + name: evtOrExitCodeOrError.name, + }); + } else { + logger.info('Starting graceful shutdown', { + code: evtOrExitCodeOrError, + eventName, + }); + } try { const time = performance.now(); diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts index b21eb0eee..fe195c773 100644 --- a/apps/worker/src/jobs/cron.onboarding.ts +++ b/apps/worker/src/jobs/cron.onboarding.ts @@ -135,14 +135,16 @@ const ONBOARDING_EMAILS = [ ]; export async function onboardingJob(job: Job) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } + logger.info('Starting onboarding email job'); // Fetch organizations that are in onboarding (not completed) const orgs = await db.organization.findMany({ where: { - onboarding: { - not: 'completed', - }, + OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }], deleteAt: null, createdBy: { deletedAt: null, @@ -168,7 +170,7 @@ export async function onboardingJob(job: Job) { const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt); // Find the next email to send - // If org.onboarding is empty string, they haven't received any email yet + // If org.onboarding is null or empty string, they haven't received any email yet const lastSentIndex = org.onboarding ? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding) : -1; @@ -192,6 +194,15 @@ export async function onboardingJob(job: Job) { continue; } + logger.info( + `Checking if enough days have passed for organization ${org.id}`, + { + daysSinceOrgCreation, + nextEmailDay: nextEmail.day, + orgCreatedAt: org.createdAt, + today: new Date(), + }, + ); // Check if enough days have passed if (daysSinceOrgCreation < nextEmail.day) { orgsSkipped++; diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 077697a45..8169f0bd3 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -3,10 +3,7 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns'; export const DEFAULT_ASPECT_RATIO = 0.5625; export const NOT_SET_VALUE = '(not set)'; -export const RESERVED_EVENT_NAMES = [ - 'session_start', - 'session_end', -] as const; +export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const; export const timeWindows = { '30min': { @@ -510,7 +507,6 @@ export function getCountry(code?: string) { export const emailCategories = { onboarding: 'Onboarding', - billing: 'Billing', } as const; export type EmailCategory = keyof typeof emailCategories; diff --git a/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql new file mode 100644 index 000000000..cb115cd4d --- /dev/null +++ b/packages/db/prisma/migrations/20260121093831_nullable_onboaridng/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a9457a096..784f94fb0 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -62,7 +62,7 @@ model Organization { integrations Integration[] invites Invite[] timezone String? - onboarding String @default("completed") // 'completed' or template name for next email + onboarding String? @default("completed") // Subscription subscriptionId String? diff --git a/packages/email/src/components/footer.tsx b/packages/email/src/components/footer.tsx index ad4dd2136..3beead858 100644 --- a/packages/email/src/components/footer.tsx +++ b/packages/email/src/components/footer.tsx @@ -11,7 +11,7 @@ import React from 'react'; const baseUrl = 'https://openpanel.dev'; -export function Footer() { +export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) { return ( <>
@@ -71,15 +71,17 @@ export function Footer() { - {/* - - Notification preferences - - */} + {unsubscribeUrl && ( + + + Notification preferences + + + )} ); diff --git a/packages/email/src/components/layout.tsx b/packages/email/src/components/layout.tsx index dbf6879c0..6900b31c4 100644 --- a/packages/email/src/components/layout.tsx +++ b/packages/email/src/components/layout.tsx @@ -7,15 +7,15 @@ import { Section, Tailwind, } from '@react-email/components'; -// biome-ignore lint/style/useImportType: resend needs React -import React from 'react'; +import type React from 'react'; import { Footer } from './footer'; type Props = { children: React.ReactNode; + unsubscribeUrl?: string; }; -export function Layout({ children }: Props) { +export function Layout({ children, unsubscribeUrl }: Props) { return ( @@ -57,7 +57,7 @@ export function Layout({ children }: Props) { />
{children}
-
+
diff --git a/packages/email/src/emails/email-invite.tsx b/packages/email/src/emails/email-invite.tsx index f3e662621..472de8706 100644 --- a/packages/email/src/emails/email-invite.tsx +++ b/packages/email/src/emails/email-invite.tsx @@ -13,9 +13,10 @@ export default EmailInvite; export function EmailInvite({ organizationName = 'Acme Co', url = 'https://openpanel.dev', -}: Props) { + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { return ( - + You've been invited to join {organizationName}! If you don't have an account yet, click the button below to create one diff --git a/packages/email/src/emails/email-reset-password.tsx b/packages/email/src/emails/email-reset-password.tsx index 56a6fe762..cf6136f12 100644 --- a/packages/email/src/emails/email-reset-password.tsx +++ b/packages/email/src/emails/email-reset-password.tsx @@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({ export type Props = z.infer; export default EmailResetPassword; -export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) { +export function EmailResetPassword({ + url = 'https://openpanel.dev', + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { return ( - + You have requested to reset your password. Follow the link below to reset your password: diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx index b6f7abbbe..c2c21b21e 100644 --- a/packages/email/src/emails/index.tsx +++ b/packages/email/src/emails/index.tsx @@ -39,7 +39,6 @@ export const templates = { 'Your trial is ending soon', Component: TrailEndingSoon, schema: zTrailEndingSoon, - category: 'billing' as const, }, 'onboarding-welcome': { subject: () => "You're in", @@ -69,13 +68,11 @@ export const templates = { subject: () => 'Your trial ends in a few days', Component: OnboardingTrialEnding, schema: zOnboardingTrialEnding, - category: 'onboarding' as const, }, 'onboarding-trial-ended': { subject: () => 'Your trial has ended', Component: OnboardingTrialEnded, schema: zOnboardingTrialEnded, - category: 'onboarding' as const, }, } as const; diff --git a/packages/email/src/emails/onboarding-dashboards.tsx b/packages/email/src/emails/onboarding-dashboards.tsx index d49e23fdd..02ae98a48 100644 --- a/packages/email/src/emails/onboarding-dashboards.tsx +++ b/packages/email/src/emails/onboarding-dashboards.tsx @@ -14,14 +14,15 @@ export default OnboardingDashboards; export function OnboardingDashboards({ firstName, dashboardUrl = 'https://dashboard.openpanel.dev', -}: Props) { + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { 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 ( - + Hi{firstName ? ` ${firstName}` : ''}, Tracking events is the easy part. The value comes from actually looking diff --git a/packages/email/src/emails/onboarding-feature-request.tsx b/packages/email/src/emails/onboarding-feature-request.tsx index a0a8b289b..6cc10c20c 100644 --- a/packages/email/src/emails/onboarding-feature-request.tsx +++ b/packages/email/src/emails/onboarding-feature-request.tsx @@ -9,9 +9,12 @@ export const zOnboardingFeatureRequest = z.object({ export type Props = z.infer; export default OnboardingFeatureRequest; -export function OnboardingFeatureRequest({ firstName }: Props) { +export function OnboardingFeatureRequest({ + firstName, + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { return ( - + Hi{firstName ? ` ${firstName}` : ''}, OpenPanel aims to be the one stop shop for all your analytics needs. diff --git a/packages/email/src/emails/onboarding-trial-ended.tsx b/packages/email/src/emails/onboarding-trial-ended.tsx index 8b61db3d7..3a5e3187f 100644 --- a/packages/email/src/emails/onboarding-trial-ended.tsx +++ b/packages/email/src/emails/onboarding-trial-ended.tsx @@ -16,7 +16,8 @@ export function OnboardingTrialEnded({ firstName, billingUrl = 'https://dashboard.openpanel.dev', recommendedPlan, -}: Props) { + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { const newUrl = new URL(billingUrl); newUrl.searchParams.set('utm_source', 'email'); newUrl.searchParams.set('utm_medium', 'email'); diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx index aa7e4fef0..9a91d8094 100644 --- a/packages/email/src/emails/onboarding-trial-ending.tsx +++ b/packages/email/src/emails/onboarding-trial-ending.tsx @@ -18,7 +18,8 @@ export function OnboardingTrialEnding({ organizationName = 'your organization', billingUrl = 'https://dashboard.openpanel.dev', recommendedPlan, -}: Props) { + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { const newUrl = new URL(billingUrl); newUrl.searchParams.set('utm_source', 'email'); newUrl.searchParams.set('utm_medium', 'email'); diff --git a/packages/email/src/emails/onboarding-welcome.tsx b/packages/email/src/emails/onboarding-welcome.tsx index 727fee0f7..b2faf278e 100644 --- a/packages/email/src/emails/onboarding-welcome.tsx +++ b/packages/email/src/emails/onboarding-welcome.tsx @@ -11,9 +11,12 @@ export const zOnboardingWelcome = z.object({ export type Props = z.infer; export default OnboardingWelcome; -export function OnboardingWelcome({ firstName }: Props) { +export function OnboardingWelcome({ + firstName, + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { return ( - + Hi{firstName ? ` ${firstName}` : ''}, Thanks for trying OpenPanel. diff --git a/packages/email/src/emails/onboarding-what-to-track.tsx b/packages/email/src/emails/onboarding-what-to-track.tsx index 556dde509..ce3f8ecba 100644 --- a/packages/email/src/emails/onboarding-what-to-track.tsx +++ b/packages/email/src/emails/onboarding-what-to-track.tsx @@ -10,9 +10,12 @@ export const zOnboardingWhatToTrack = z.object({ export type Props = z.infer; export default OnboardingWhatToTrack; -export function OnboardingWhatToTrack({ firstName }: Props) { +export function OnboardingWhatToTrack({ + firstName, + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { return ( - + Hi{firstName ? ` ${firstName}` : ''}, Tracking can be overwhelming at first, and that's why its important to diff --git a/packages/email/src/emails/trial-ending-soon.tsx b/packages/email/src/emails/trial-ending-soon.tsx index 77e7e3ae9..bf6e7034a 100644 --- a/packages/email/src/emails/trial-ending-soon.tsx +++ b/packages/email/src/emails/trial-ending-soon.tsx @@ -13,7 +13,8 @@ export default TrailEndingSoon; export function TrailEndingSoon({ organizationName = 'Acme Co', url = 'https://openpanel.dev', -}: Props) { + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { const newUrl = new URL(url); newUrl.searchParams.set('utm_source', 'email'); newUrl.searchParams.set('utm_medium', 'email'); diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx index f1bedd347..bba633af4 100644 --- a/packages/email/src/index.tsx +++ b/packages/email/src/index.tsx @@ -29,7 +29,6 @@ export async function sendEmail( return null; } - // Check if user has unsubscribed from this category (only for non-transactional emails) if ('category' in template && template.category) { const unsubscribed = await db.emailUnsubscribe.findUnique({ where: { @@ -51,8 +50,7 @@ export async function sendEmail( if (!process.env.RESEND_API_KEY) { console.log('No RESEND_API_KEY found, here is the data'); console.log('Template:', template); - // @ts-expect-error - TODO: fix this - console.log('Subject: ', subject(props.data)); + console.log('Subject: ', template.subject(props.data as any)); console.log('To: ', to); console.log('Data: ', JSON.stringify(data, null, 2)); return null; @@ -60,10 +58,10 @@ export async function sendEmail( const resend = new Resend(process.env.RESEND_API_KEY); - // Build headers for unsubscribe (only for non-transactional emails) const headers: Record = {}; if ('category' in template && template.category) { const unsubscribeUrl = getUnsubscribeUrl(to, template.category); + (data as any).unsubscribeUrl = unsubscribeUrl; headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`; headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; } @@ -72,10 +70,8 @@ export async function sendEmail( const res = await resend.emails.send({ from: FROM, to, - // @ts-expect-error - TODO: fix this - subject: subject(props.data), - // @ts-expect-error - TODO: fix this - react: , + subject: template.subject(props.data as any), + react: , headers: Object.keys(headers).length > 0 ? headers : undefined, }); if (res.error) { diff --git a/packages/trpc/src/routers/email.ts b/packages/trpc/src/routers/email.ts index 5e149d5d0..8bb95f666 100644 --- a/packages/trpc/src/routers/email.ts +++ b/packages/trpc/src/routers/email.ts @@ -1,7 +1,8 @@ -import { z } from 'zod'; +import { emailCategories } from '@openpanel/constants'; import { db } from '@openpanel/db'; import { verifyUnsubscribeToken } from '@openpanel/email'; -import { createTRPCRouter, publicProcedure } from '../trpc'; +import { z } from 'zod'; +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; export const emailRouter = createTRPCRouter({ unsubscribe: publicProcedure @@ -35,6 +36,78 @@ export const emailRouter = createTRPCRouter({ update: {}, }); + return { success: true }; + }), + + getPreferences: protectedProcedure.query(async ({ ctx }) => { + if (!ctx.session.userId || !ctx.session.user?.email) { + throw new Error('User not authenticated'); + } + + const email = ctx.session.user.email; + + // Get all unsubscribe records for this user + const unsubscribes = await db.emailUnsubscribe.findMany({ + where: { + email, + }, + select: { + category: true, + }, + }); + + const unsubscribedCategories = new Set(unsubscribes.map((u) => u.category)); + + // Return object with all categories, true = subscribed (not unsubscribed) + const preferences: Record = {}; + for (const [category] of Object.entries(emailCategories)) { + preferences[category] = !unsubscribedCategories.has(category); + } + + return preferences; + }), + + updatePreferences: protectedProcedure + .input( + z.object({ + categories: z.record(z.string(), z.boolean()), + }), + ) + .mutation(async ({ input, ctx }) => { + if (!ctx.session.userId || !ctx.session.user?.email) { + throw new Error('User not authenticated'); + } + + const email = ctx.session.user.email; + + // Process each category + for (const [category, subscribed] of Object.entries(input.categories)) { + if (subscribed) { + // User wants to subscribe - delete unsubscribe record if exists + await db.emailUnsubscribe.deleteMany({ + where: { + email, + category, + }, + }); + } else { + // User wants to unsubscribe - upsert unsubscribe record + await db.emailUnsubscribe.upsert({ + where: { + email_category: { + email, + category, + }, + }, + create: { + email, + category, + }, + update: {}, + }); + } + } + return { success: true }; }), }); diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts index a49b54359..12494bc5e 100644 --- a/packages/trpc/src/routers/onboarding.ts +++ b/packages/trpc/src/routers/onboarding.ts @@ -29,7 +29,7 @@ async function createOrGetOrganization( subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS), subscriptionStatus: 'trialing', timezone: input.timezone, - onboarding: 'onboarding-welcome', + onboarding: '', }, }); From f9b1ec50388f04340ea437fc315122f069e6f7af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 21 Jan 2026 15:31:24 +0100 Subject: [PATCH 4/5] fix coderabbit comments --- apps/start/src/hooks/use-cookie-store.tsx | 2 +- apps/start/src/modals/create-invite.tsx | 2 +- ...tionId.profile._tabs.email-preferences.tsx | 22 ++++++++++++++++--- apps/worker/src/jobs/cron.onboarding.ts | 2 +- packages/email/onboarding-emails.md | 2 +- packages/email/src/components/button.tsx | 1 + packages/email/src/emails/index.tsx | 2 +- .../src/emails/onboarding-trial-ended.tsx | 2 +- .../src/emails/onboarding-trial-ending.tsx | 4 ++-- packages/email/src/index.tsx | 2 +- packages/email/src/unsubscribe.ts | 15 +++++++++++-- packages/trpc/src/routers/email.ts | 3 ++- 12 files changed, 44 insertions(+), 15 deletions(-) diff --git a/apps/start/src/hooks/use-cookie-store.tsx b/apps/start/src/hooks/use-cookie-store.tsx index 43f8b6ea8..366bba15d 100644 --- a/apps/start/src/hooks/use-cookie-store.tsx +++ b/apps/start/src/hooks/use-cookie-store.tsx @@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' }) }); // Called in __root.tsx beforeLoad hook to get cookies from the server -// And recieved with useRouteContext in the client +// And received with useRouteContext in the client export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() => pick(VALID_COOKIES, getCookies()), ); diff --git a/apps/start/src/modals/create-invite.tsx b/apps/start/src/modals/create-invite.tsx index 83572f5be..e05e228a6 100644 --- a/apps/start/src/modals/create-invite.tsx +++ b/apps/start/src/modals/create-invite.tsx @@ -102,7 +102,7 @@ export default function CreateInvite() {
Invite a user - Invite users to your organization. They will recieve an email + Invite users to your organization. They will receive an email will instructions.
diff --git a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx index d9b9430df..f4fbb29e6 100644 --- a/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx +++ b/apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx @@ -20,6 +20,22 @@ const validator = z.object({ type IForm = z.infer; +/** + * Build explicit boolean values for every key in emailCategories. + * Uses saved preferences when available, falling back to true (opted-in). + */ +function buildCategoryDefaults( + savedPreferences?: Record, +): Record { + return Object.keys(emailCategories).reduce( + (acc, category) => { + acc[category] = savedPreferences?.[category] ?? true; + return acc; + }, + {} as Record, + ); +} + export const Route = createFileRoute( '/_app/$organizationId/profile/_tabs/email-preferences', )({ @@ -37,7 +53,7 @@ function Component() { const { control, handleSubmit, formState, reset } = useForm({ defaultValues: { - categories: preferencesQuery.data, + categories: buildCategoryDefaults(preferencesQuery.data), }, }); @@ -55,7 +71,7 @@ function Component() { trpc.email.getPreferences.queryOptions(), ); reset({ - categories: freshData, + categories: buildCategoryDefaults(freshData), }); }, onError: handleError, @@ -96,7 +112,7 @@ function Component() {
diff --git a/apps/worker/src/jobs/cron.onboarding.ts b/apps/worker/src/jobs/cron.onboarding.ts index fe195c773..d2e61aaaa 100644 --- a/apps/worker/src/jobs/cron.onboarding.ts +++ b/apps/worker/src/jobs/cron.onboarding.ts @@ -92,7 +92,7 @@ const ONBOARDING_EMAILS = [ }), email({ day: 14, - template: 'onboarding-featue-request', + template: 'onboarding-feature-request', data: (ctx) => ({ firstName: getters.firstName(ctx), }), diff --git a/packages/email/onboarding-emails.md b/packages/email/onboarding-emails.md index 56a4b523f..dcac48108 100644 --- a/packages/email/onboarding-emails.md +++ b/packages/email/onboarding-emails.md @@ -99,6 +99,6 @@ If OpenPanel has been useful, upgrading just keeps it going. Plans start at $2.5 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. +Your project will receive events for the next 30 days, if you haven't upgraded by then we'll remove your workspace and projects. Carl \ No newline at end of file diff --git a/packages/email/src/components/button.tsx b/packages/email/src/components/button.tsx index e376fb259..563844d64 100644 --- a/packages/email/src/components/button.tsx +++ b/packages/email/src/components/button.tsx @@ -1,4 +1,5 @@ import { Button as EmailButton } from '@react-email/components'; +import type * as React from 'react'; export function Button({ href, diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx index c2c21b21e..a5d435591 100644 --- a/packages/email/src/emails/index.tsx +++ b/packages/email/src/emails/index.tsx @@ -58,7 +58,7 @@ export const templates = { schema: zOnboardingDashboards, category: 'onboarding' as const, }, - 'onboarding-featue-request': { + 'onboarding-feature-request': { subject: () => 'One provider to rule them all', Component: OnboardingFeatureRequest, schema: zOnboardingFeatureRequest, diff --git a/packages/email/src/emails/onboarding-trial-ended.tsx b/packages/email/src/emails/onboarding-trial-ended.tsx index 3a5e3187f..14be5b802 100644 --- a/packages/email/src/emails/onboarding-trial-ended.tsx +++ b/packages/email/src/emails/onboarding-trial-ended.tsx @@ -24,7 +24,7 @@ export function OnboardingTrialEnded({ newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended'); return ( - + Hi{firstName ? ` ${firstName}` : ''}, Your OpenPanel trial has ended. diff --git a/packages/email/src/emails/onboarding-trial-ending.tsx b/packages/email/src/emails/onboarding-trial-ending.tsx index 9a91d8094..236d3c883 100644 --- a/packages/email/src/emails/onboarding-trial-ending.tsx +++ b/packages/email/src/emails/onboarding-trial-ending.tsx @@ -26,7 +26,7 @@ export function OnboardingTrialEnding({ newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending'); return ( - + Hi{firstName ? ` ${firstName}` : ''}, Quick heads up: your OpenPanel trial ends soon. @@ -45,7 +45,7 @@ export function OnboardingTrialEnding({ 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 + Your project will receive events for the next 30 days, if you haven't upgraded by then we'll remove your workspace and projects. diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx index bba633af4..4367408ad 100644 --- a/packages/email/src/index.tsx +++ b/packages/email/src/index.tsx @@ -61,7 +61,7 @@ export async function sendEmail( const headers: Record = {}; if ('category' in template && template.category) { const unsubscribeUrl = getUnsubscribeUrl(to, template.category); - (data as any).unsubscribeUrl = unsubscribeUrl; + (props.data as any).unsubscribeUrl = unsubscribeUrl; headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`; headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; } diff --git a/packages/email/src/unsubscribe.ts b/packages/email/src/unsubscribe.ts index baeef6fd5..1a6ced129 100644 --- a/packages/email/src/unsubscribe.ts +++ b/packages/email/src/unsubscribe.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'crypto'; +import { createHmac, timingSafeEqual } from 'crypto'; const SECRET = process.env.UNSUBSCRIBE_SECRET || @@ -17,7 +17,18 @@ export function verifyUnsubscribeToken( token: string, ): boolean { const expectedToken = generateUnsubscribeToken(email, category); - return token === expectedToken; + const tokenBuffer = Buffer.from(token, 'hex'); + const expectedBuffer = Buffer.from(expectedToken, 'hex'); + + // Handle length mismatch safely to avoid timing leaks + if (tokenBuffer.length !== expectedBuffer.length) { + // Compare against zero-filled buffer of same length as token to maintain constant time + const zeroBuffer = Buffer.alloc(tokenBuffer.length); + timingSafeEqual(tokenBuffer, zeroBuffer); + return false; + } + + return timingSafeEqual(tokenBuffer, expectedBuffer); } export function getUnsubscribeUrl(email: string, category: string): string { diff --git a/packages/trpc/src/routers/email.ts b/packages/trpc/src/routers/email.ts index 8bb95f666..d912cb0a5 100644 --- a/packages/trpc/src/routers/email.ts +++ b/packages/trpc/src/routers/email.ts @@ -2,6 +2,7 @@ import { emailCategories } from '@openpanel/constants'; import { db } from '@openpanel/db'; import { verifyUnsubscribeToken } from '@openpanel/email'; import { z } from 'zod'; +import { TRPCBadRequestError } from '../errors'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; export const emailRouter = createTRPCRouter({ @@ -18,7 +19,7 @@ export const emailRouter = createTRPCRouter({ // Verify token if (!verifyUnsubscribeToken(email, category, token)) { - throw new Error('Invalid unsubscribe link'); + throw TRPCBadRequestError('Invalid unsubscribe link'); } // Upsert the unsubscribe record From 12e8c9beaa286ddbf3d15c6788af0988bd517559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 21 Jan 2026 19:50:21 +0100 Subject: [PATCH 5/5] remove template --- packages/email/onboarding-emails.md | 104 ---------------------------- 1 file changed, 104 deletions(-) delete mode 100644 packages/email/onboarding-emails.md diff --git a/packages/email/onboarding-emails.md b/packages/email/onboarding-emails.md deleted file mode 100644 index dcac48108..000000000 --- a/packages/email/onboarding-emails.md +++ /dev/null @@ -1,104 +0,0 @@ -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 receive events for the next 30 days, if you haven't upgraded by then we'll remove your workspace and projects. - -Carl \ No newline at end of file