Skip to content

Commit 05881a3

Browse files
Feature/tfr2 176 integrate welcome email after 24h of sign up (#78)
1 parent cdf5e16 commit 05881a3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2005
-129
lines changed

apps/engine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"standardwebhooks": "^1.0.0",
6666
"superjson": "^2.2.1",
6767
"tailwind-merge": "^2.4.0",
68-
"zod": "^3.23.8",
68+
"zod": "catalog:",
6969
"zod-form-data": "^2.0.2"
7070
},
7171
"devDependencies": {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { serverEnv } from '@/env/server-env';
2+
import { api } from '@ds-project/api/service';
3+
import { sendEmail } from '@ds-project/email';
4+
import type { NextRequest } from 'next/server';
5+
import { NextResponse } from 'next/server';
6+
import { Webhook } from 'standardwebhooks';
7+
8+
/**
9+
* Checks the scheduled emails in the database and sends the emails that are due
10+
*/
11+
12+
export async function POST(request: NextRequest) {
13+
const wh = new Webhook(serverEnv.SERVICE_HOOK_SECRET);
14+
const payload = await request.text();
15+
const headers = Object.fromEntries(request.headers);
16+
17+
// Verify the request is coming from an authorized source
18+
try {
19+
wh.verify(payload, headers);
20+
} catch (error) {
21+
return NextResponse.json(
22+
{ error },
23+
{
24+
status: 401,
25+
}
26+
);
27+
}
28+
29+
const dueEmailJobs = await api.jobs.getDueEmailList();
30+
31+
console.log(`👀 ${dueEmailJobs.length} due email jobs found.`);
32+
// Run all the possible jobs, don't break if one fails. This way we can process all the jobs
33+
await Promise.allSettled(
34+
dueEmailJobs.map(async (job, jobIndex) => {
35+
console.log(
36+
`⚙️ (${jobIndex + 1}/${dueEmailJobs.length}) Processing job ${job.id}.`
37+
);
38+
// Only process jobs of type email
39+
if (job.data?.type !== 'email') {
40+
console.log(
41+
`⏭️ (${jobIndex + 1}/${dueEmailJobs.length}) Skipped job ${job.id}.`
42+
);
43+
return;
44+
}
45+
46+
await sendEmail({
47+
accountId: job.accountId,
48+
subject: job.data.subject,
49+
template: job.data.template,
50+
});
51+
52+
await api.jobs.markCompleted({ id: job.id });
53+
54+
console.log(
55+
`📧 (${jobIndex + 1}/${dueEmailJobs.length}) Email job ${job.id} processed successfully.`
56+
);
57+
})
58+
);
59+
60+
return NextResponse.json(
61+
{},
62+
{
63+
status: 200,
64+
}
65+
);
66+
}

apps/engine/src/app/api/email/route.tsx

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
import type { NextRequest } from 'next/server';
22
import { NextResponse } from 'next/server';
33
import { serverEnv } from '@/env/server-env';
4-
import { SignUpEmail } from '@ds-project/email/src/templates/sign-up';
5-
import { Resend } from '@ds-project/email/src/resend';
6-
import { render } from '@ds-project/email/src/render';
74
import { config } from '@/config';
85
import { Webhook } from 'standardwebhooks';
6+
import { sendEmail } from '@ds-project/email';
97

10-
const resend = new Resend(serverEnv.RESEND_API_KEY);
8+
interface WebhookPayload {
9+
user: {
10+
email: string;
11+
};
12+
email_data: {
13+
token: string;
14+
};
15+
}
1116

17+
/**
18+
* Sends a sign up email with an OTP code so the user can authenticate
19+
* @param request
20+
* @returns
21+
*/
1222
export async function POST(request: NextRequest) {
1323
const wh = new Webhook(serverEnv.SEND_EMAIL_HOOK_SECRET);
1424
const payload = await request.text();
@@ -18,32 +28,20 @@ export async function POST(request: NextRequest) {
1828
const {
1929
user,
2030
email_data: { token },
21-
} = wh.verify(payload, headers) as {
22-
user: {
23-
email: string;
24-
};
25-
email_data: {
26-
token: string;
27-
};
28-
};
29-
30-
const html = await render(
31-
<SignUpEmail
32-
otpCode={token}
33-
staticPathUrl={`${config.pageUrl}/static/email`}
34-
/>
35-
);
31+
} = wh.verify(payload, headers) as WebhookPayload;
3632

37-
const { error } = await resend.emails.send({
38-
from: 'DS Pro <noreply@getds.pro>',
39-
to: [user.email],
33+
// Send OTP email to the user
34+
await sendEmail({
35+
email: user.email,
4036
subject: 'DS Pro - Confirmation Code',
41-
html,
37+
template: {
38+
key: 'verify-otp',
39+
props: {
40+
otpCode: token,
41+
staticPathUrl: `${config.pageUrl}/static/email`,
42+
},
43+
},
4244
});
43-
44-
if (error) {
45-
throw new Error(error.message, { cause: error.name });
46-
}
4745
} catch (error) {
4846
return NextResponse.json(
4947
{ error },

apps/engine/src/app/auth/_actions/auth.action.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
'use server';
22

33
import { z } from 'zod';
4-
import { unprotectedAction } from '@/lib/safe-action';
4+
import { publicAction } from '@/lib/safe-action';
55

6-
export const authAction = unprotectedAction
6+
export const authAction = publicAction
77
.metadata({ actionName: 'authAction' })
88
.schema(
99
z.object({
Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
'use server';
22

33
import { z } from 'zod';
4-
import { unprotectedAction } from '@/lib/safe-action';
4+
import { publicAction } from '@/lib/safe-action';
55
import { zfd } from 'zod-form-data';
6+
import { scheduleOnboardingEmails } from '../_utils/schedule-onboarding-emails';
7+
import { sendEmail } from '@ds-project/email';
8+
import { config } from '@/config';
69

7-
export const verifyOtpAction = unprotectedAction
10+
export const verifyOtpAction = publicAction
811
.metadata({ actionName: 'verifyOtpAction' })
912
.schema(
1013
z.object({
@@ -17,20 +20,53 @@ export const verifyOtpAction = unprotectedAction
1720
})
1821
)
1922
.action(async ({ ctx, parsedInput: { email, token } }) => {
20-
const { error } = await ctx.authClient.auth.verifyOtp({
23+
const { error, data } = await ctx.authClient.auth.verifyOtp({
2124
email,
2225
token,
2326
type: 'email',
2427
});
2528

2629
if (!error) {
27-
return {
28-
ok: true,
29-
};
30+
if (
31+
data.user?.id &&
32+
data.user.email_confirmed_at &&
33+
new Date(data.user.email_confirmed_at).getTime() <
34+
new Date().getTime() + 1000 * 60 * 1 // 1 minute
35+
) {
36+
const result = await ctx.authClient
37+
.from('accounts')
38+
.select('id')
39+
.eq('user_id', data.user.id)
40+
.single();
41+
42+
if (result.error) {
43+
return {
44+
error: result.error.message,
45+
ok: false,
46+
};
47+
}
48+
49+
await sendEmail({
50+
accountId: result.data.id,
51+
subject: 'Welcome to DS Pro',
52+
template: {
53+
key: 'welcome',
54+
props: {
55+
staticPathUrl: `${config.pageUrl}/static/email`,
56+
},
57+
},
58+
scheduledAt: 'in 5 minutes',
59+
});
60+
61+
await scheduleOnboardingEmails(result.data.id);
62+
return {
63+
ok: true,
64+
};
65+
}
3066
}
3167

3268
return {
33-
error: error.message,
69+
error: error?.message ?? 'Error verifying OTP',
3470
ok: false,
3571
};
3672
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { config } from '@/config';
2+
import { api } from '@ds-project/api/service';
3+
4+
export async function scheduleOnboardingEmails(accountId: string) {
5+
await api.jobs.create([
6+
{
7+
type: 'email',
8+
accountId,
9+
dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours
10+
data: {
11+
type: 'email',
12+
subject: 'DS Pro - Ready to sync?',
13+
template: {
14+
key: 'onboarding-1d',
15+
props: {
16+
staticPathUrl: `${config.pageUrl}/static/email`,
17+
},
18+
},
19+
},
20+
},
21+
{
22+
type: 'email',
23+
accountId,
24+
dueDate: new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(), // 72 hours
25+
data: {
26+
type: 'email',
27+
subject: 'DS Pro - The Future of Design Tokens',
28+
template: {
29+
key: 'onboarding-3d',
30+
props: {
31+
staticPathUrl: `${config.pageUrl}/static/email`,
32+
},
33+
},
34+
},
35+
},
36+
]);
37+
}

apps/engine/src/env/server-env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const serverEnv = createEnv({
2323
SENTRY_AUTH_TOKEN: z.string().min(1).optional(),
2424
RESEND_API_KEY: z.string().min(1),
2525
SEND_EMAIL_HOOK_SECRET: z.string().min(1),
26+
SERVICE_HOOK_SECRET: z.string().min(1),
2627
// Feature Flags
2728
ENABLE_RELEASES_FLAG: z.coerce.boolean(),
2829
},

apps/engine/src/lib/safe-action.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const actionClient = createSafeActionClient({
5454
return next({ ctx: { ...ctx, authClient } });
5555
});
5656

57-
export const unprotectedAction = actionClient;
57+
export const publicAction = actionClient;
5858

5959
// Auth client defined by extending the base one.
6060
// Note that the same initialization options and middleware functions of the base client

apps/engine/vercel.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"crons": [
3+
{
4+
"path": "/api/email/cron",
5+
"schedule": "10 10 * * *"
6+
}
7+
]
8+
}

packages/api/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"types": "./dist/react.d.ts",
1919
"default": "./src/react.tsx"
2020
},
21+
"./service": {
22+
"types": "./dist/service.d.ts",
23+
"default": "./src/service.tsx"
24+
},
2125
"./operations": {
2226
"types": "./dist/operations.d.ts",
2327
"default": "./src/operations/index.ts"

packages/api/src/app-router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { accountsRouter } from './router/accounts';
22
import { apiKeysRouter } from './router/api-keys';
33
import { githubRouter } from './router/github';
44
import { integrationsRouter } from './router/integrations';
5+
import { jobsRouter } from './router/jobs';
56
import { projectsRouter } from './router/projects';
67
import { resourcesRouter } from './router/resources';
78
import { usersRouter } from './router/users';
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
1516
resources: resourcesRouter,
1617
projects: projectsRouter,
1718
github: githubRouter,
19+
jobs: jobsRouter,
1820
});
1921

2022
// export type definition of API

packages/api/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
22

33
import type { AppRouter } from './app-router';
44
import { appRouter } from './app-router';
5-
import { createTRPCContext, createCallerFactory } from './trpc';
5+
import { createClientTRPCContext, createCallerFactory } from './trpc';
66

77
/**
88
* Create a server-side caller for the tRPC API
@@ -29,5 +29,9 @@ type RouterInputs = inferRouterInputs<AppRouter>;
2929
**/
3030
type RouterOutputs = inferRouterOutputs<AppRouter>;
3131

32-
export { createTRPCContext, appRouter, createCaller };
32+
export {
33+
createClientTRPCContext as createTRPCContext,
34+
appRouter,
35+
createCaller,
36+
};
3337
export type { AppRouter, RouterInputs, RouterOutputs };

packages/api/src/router/accounts.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import { eq } from '@ds-project/database';
22

3-
import { createTRPCRouter, authenticatedProcedure } from '../trpc';
3+
import {
4+
createTRPCRouter,
5+
authenticatedProcedure,
6+
serviceProcedure,
7+
} from '../trpc';
8+
import { SelectAccountsSchema } from '@ds-project/database/schema';
49

510
export const accountsRouter = createTRPCRouter({
611
getCurrent: authenticatedProcedure.query(({ ctx }) => {
712
return ctx.database.query.Accounts.findFirst({
813
where: (accounts) => eq(accounts.id, ctx.account.id),
914
});
1015
}),
16+
get: serviceProcedure
17+
.input(SelectAccountsSchema.pick({ id: true }))
18+
.query(({ ctx, input }) => {
19+
return ctx.database.query.Accounts.findFirst({
20+
where: (accounts) => eq(accounts.id, input.id),
21+
columns: {
22+
email: true,
23+
id: true,
24+
},
25+
});
26+
}),
1127
});

0 commit comments

Comments
 (0)