Skip to content

Commit 152c7f7

Browse files
add team-invite and feedback onboarding emails
New cron routes that send conditional emails: - Day 5: invite your team (skipped if workspace already has members) - Day 14: one-question feedback request
1 parent 292b0b2 commit 152c7f7

File tree

6 files changed

+201
-1
lines changed

6 files changed

+201
-1
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { NextRequest } from "next/server";
2+
3+
import { and, gte, lte } from "@openstatus/db";
4+
import { db } from "@openstatus/db/src/db";
5+
import { user } from "@openstatus/db/src/schema";
6+
import { FeedbackEmail, sendEmail } from "@openstatus/emails";
7+
8+
export async function GET(request: NextRequest) {
9+
const authHeader = request.headers.get("authorization");
10+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
11+
return new Response("Unauthorized", { status: 401 });
12+
}
13+
14+
const date1 = new Date();
15+
date1.setDate(date1.getDate() - 15);
16+
const date2 = new Date();
17+
date2.setDate(date2.getDate() - 14);
18+
19+
const users = await db
20+
.select({ email: user.email })
21+
.from(user)
22+
.where(and(gte(user.createdAt, date1), lte(user.createdAt, date2)))
23+
.all();
24+
25+
let sent = 0;
26+
for (const u of users) {
27+
if (!u.email || u.email.trim() === "") continue;
28+
29+
await sendEmail({
30+
from: "Thibault from OpenStatus <thibault@openstatus.dev>",
31+
subject: "One quick question",
32+
to: [u.email],
33+
react: FeedbackEmail(),
34+
});
35+
sent++;
36+
}
37+
38+
return Response.json({ success: true, sent });
39+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { NextRequest } from "next/server";
2+
3+
import { and, count, eq, gte, inArray, lte } from "@openstatus/db";
4+
import { db } from "@openstatus/db/src/db";
5+
import { invitation, user, usersToWorkspaces } from "@openstatus/db/src/schema";
6+
import { sendEmail, TeamInviteReminderEmail } from "@openstatus/emails";
7+
8+
export async function GET(request: NextRequest) {
9+
const authHeader = request.headers.get("authorization");
10+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
11+
return new Response("Unauthorized", { status: 401 });
12+
}
13+
14+
const date1 = new Date();
15+
date1.setDate(date1.getDate() - 6);
16+
const date2 = new Date();
17+
date2.setDate(date2.getDate() - 5);
18+
19+
const users = await db
20+
.select({
21+
email: user.email,
22+
workspaceId: usersToWorkspaces.workspaceId,
23+
})
24+
.from(user)
25+
.innerJoin(usersToWorkspaces, eq(user.id, usersToWorkspaces.userId))
26+
.where(and(gte(user.createdAt, date1), lte(user.createdAt, date2)))
27+
.all();
28+
29+
const workspaceIds = [...new Set(users.map((u) => u.workspaceId).filter(Boolean))];
30+
31+
if (workspaceIds.length === 0) {
32+
return Response.json({ success: true, sent: 0 });
33+
}
34+
35+
const workspaceMemberCounts = await db
36+
.select({
37+
workspaceId: usersToWorkspaces.workspaceId,
38+
memberCount: count(usersToWorkspaces.userId),
39+
})
40+
.from(usersToWorkspaces)
41+
.where(inArray(usersToWorkspaces.workspaceId, workspaceIds))
42+
.groupBy(usersToWorkspaces.workspaceId)
43+
.all();
44+
45+
const workspacesWithInvitations = await db
46+
.select({ workspaceId: invitation.workspaceId })
47+
.from(invitation)
48+
.where(inArray(invitation.workspaceId, workspaceIds))
49+
.groupBy(invitation.workspaceId)
50+
.all();
51+
52+
const hasTeamActivity = new Set<number>();
53+
54+
for (const row of workspaceMemberCounts) {
55+
if (row.memberCount > 1) {
56+
hasTeamActivity.add(row.workspaceId);
57+
}
58+
}
59+
for (const row of workspacesWithInvitations) {
60+
hasTeamActivity.add(row.workspaceId);
61+
}
62+
63+
let sent = 0;
64+
for (const u of users) {
65+
if (!u.email) continue;
66+
if (u.workspaceId && hasTeamActivity.has(u.workspaceId)) continue;
67+
68+
await sendEmail({
69+
from: "Thibault from OpenStatus <thibault@openstatus.dev>",
70+
subject: "Incidents are a team sport",
71+
to: [u.email],
72+
react: TeamInviteReminderEmail(),
73+
});
74+
sent++;
75+
}
76+
77+
return Response.json({ success: true, sent });
78+
}

apps/web/vercel.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22
"crons": [
33
{
44
"path": "/api/internal/email",
5-
"schedule": "00 17 * * *"
5+
"schedule": "13 17 * * *"
6+
},
7+
{
8+
"path": "/api/internal/email/team-invite",
9+
"schedule": "12 14 * * *"
10+
},
11+
{
12+
"path": "/api/internal/email/feedback",
13+
"schedule": "30 15 * * *"
614
}
715
]
816
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/** @jsxImportSource react */
2+
3+
import { Body, Head, Html, Preview } from "@react-email/components";
4+
5+
const FeedbackEmail = () => {
6+
return (
7+
<Html>
8+
<Head>
9+
<title>One quick question</title>
10+
</Head>
11+
<Preview>What's the one thing you'd change about OpenStatus?</Preview>
12+
<Body>
13+
Hey
14+
<br />
15+
<br />
16+
You've been on OpenStatus for about two weeks now. One quick question:
17+
<br />
18+
<br />
19+
What's the one thing you wish OpenStatus did differently?
20+
<br />
21+
<br />
22+
No survey, no form — just hit reply. I read every response and it
23+
genuinely shapes what we build next.
24+
<br />
25+
<br />
26+
Thibault Le Ouay Ducasse, co-founder of OpenStatus
27+
<br />
28+
</Body>
29+
</Html>
30+
);
31+
};
32+
33+
export default FeedbackEmail;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/** @jsxImportSource react */
2+
3+
import { Body, Head, Html, Preview } from "@react-email/components";
4+
5+
const TeamInviteReminderEmail = () => {
6+
return (
7+
<Html>
8+
<Head>
9+
<title>Incidents are a team sport</title>
10+
</Head>
11+
<Preview>Invite your team so everyone can manage status updates</Preview>
12+
<Body>
13+
Hey
14+
<br />
15+
<br />
16+
When something goes down at 2am, you don't want to be the only one who
17+
can update the status page.
18+
<br />
19+
<br />
20+
👉{" "}
21+
<a href="https://app.openstatus.dev/settings/general?ref=email-team-invite">
22+
Invite your team
23+
</a>
24+
<br />
25+
<br />
26+
Everyone gets access to monitors, incidents, and status pages — so
27+
whoever is on-call can respond without waiting on you.
28+
<br />
29+
<br />
30+
Hit reply if you have questions — happy to help.
31+
<br />
32+
<br />
33+
Thibault Le Ouay Ducasse, co-founder of OpenStatus
34+
<br />
35+
</Body>
36+
</Html>
37+
);
38+
};
39+
40+
export default TeamInviteReminderEmail;

packages/emails/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
export { default as FeedbackEmail } from "../emails/feedback";
12
export { default as FollowUpEmail } from "../emails/followup";
23
export { default as SlackFeedbackEmail } from "../emails/slack-feedback";
34
export { default as SubscribeEmail } from "../emails/subscribe";
5+
export { default as TeamInviteReminderEmail } from "../emails/team-invite-reminder";
46
export { default as WelcomeEmail } from "../emails/welcome";
57
export { default as TeamInvitationEmail } from "../emails/team-invitation";
68
export { default as MonitorPausedEmail } from "../emails/monitor-paused";

0 commit comments

Comments
 (0)