wip
This commit is contained in:
@@ -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,
|
||||
|
||||
112
apps/start/src/routes/unsubscribe.tsx
Normal file
112
apps/start/src/routes/unsubscribe.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<LoginNavbar />
|
||||
<div className="flex-1 center-center px-4">
|
||||
<div className="max-w-md w-full text-center space-y-4">
|
||||
<div className="text-6xl mb-4">✓</div>
|
||||
<h1 className="text-2xl font-bold">Unsubscribed</h1>
|
||||
<p className="text-muted-foreground">
|
||||
You've been unsubscribed from {categoryName} emails.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You won't receive any more {categoryName.toLowerCase()} emails from
|
||||
us.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<LoginNavbar />
|
||||
<div className="flex-1 center-center px-4">
|
||||
<div className="max-w-md w-full space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold">Unsubscribe</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Unsubscribe from {categoryName} emails?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You'll stop receiving {categoryName.toLowerCase()} emails sent to{' '}
|
||||
<span className="font-mono text-xs">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleUnsubscribe}
|
||||
disabled={isUnsubscribing}
|
||||
className="w-full bg-black text-white py-3 px-4 rounded-md font-medium hover:bg-black/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof db.organization.findMany<typeof orgQuery>>
|
||||
>[number];
|
||||
|
||||
type OnboardingContext = {
|
||||
org: OrgWithCreator;
|
||||
user: NonNullable<OrgWithCreator['createdBy']>;
|
||||
};
|
||||
|
||||
type OnboardingEmail<T extends EmailTemplate = EmailTemplate> = {
|
||||
day: number;
|
||||
template: T;
|
||||
shouldSend?: (ctx: OnboardingContext) => Promise<boolean | 'complete'>;
|
||||
data: (ctx: OnboardingContext) => EmailData<T>;
|
||||
};
|
||||
|
||||
// Helper to create type-safe email entries with correlated template/data types
|
||||
function email<T extends EmailTemplate>(config: OnboardingEmail<T>) {
|
||||
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<CronQueuePayload>) {
|
||||
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;
|
||||
|
||||
// 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++;
|
||||
// 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' },
|
||||
});
|
||||
orgsCompleted++;
|
||||
logger.info(
|
||||
`Completed onboarding for organization ${org.id} (all emails sent)`,
|
||||
);
|
||||
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`;
|
||||
const nextEmail = ONBOARDING_EMAILS[nextEmailIndex];
|
||||
if (!nextEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Check if enough days have passed
|
||||
if (daysSinceOrgCreation < nextEmail.day) {
|
||||
orgsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check shouldSend callback if defined
|
||||
if (nextEmail.shouldSend) {
|
||||
const result = await nextEmail.shouldSend({ org, user });
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Update onboarding to the template name we just sent
|
||||
await db.organization.update({
|
||||
where: { id: org.id },
|
||||
data: { onboarding: nextEmail.template },
|
||||
});
|
||||
|
||||
emailsSent++;
|
||||
logger.info(`Sent onboarding email ${user.onboarding} to user ${user.id} for org ${org.id}`);
|
||||
|
||||
if (nextOnboardingState > 5) {
|
||||
usersCompleted++;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user