Openstatus
www.openstatus.dev
1import { z } from "zod";
2
3import { and, eq, isNotNull } from "@openstatus/db";
4import {
5 invitation,
6 maintenance,
7 pageSubscriber,
8 selectWorkspaceSchema,
9 statusReportUpdate,
10} from "@openstatus/db/src/schema";
11import { EmailClient } from "@openstatus/emails";
12import { TRPCError } from "@trpc/server";
13import { env } from "../../env";
14import {
15 createTRPCRouter,
16 protectedProcedure,
17 publicProcedure,
18} from "../../trpc";
19
20const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY });
21
22export const emailRouter = createTRPCRouter({
23 sendStatusReport: protectedProcedure
24 .input(z.object({ id: z.number() }))
25 .mutation(async (opts) => {
26 const limits = opts.ctx.workspace.limits;
27
28 if (limits["status-subscribers"]) {
29 const _statusReportUpdate =
30 await opts.ctx.db.query.statusReportUpdate.findFirst({
31 where: eq(statusReportUpdate.id, opts.input.id),
32 with: {
33 statusReport: {
34 with: {
35 statusReportsToPageComponents: {
36 with: {
37 pageComponent: true,
38 },
39 },
40 page: {
41 with: {
42 pageSubscribers: {
43 where: isNotNull(pageSubscriber.acceptedAt),
44 },
45 },
46 },
47 },
48 },
49 },
50 });
51
52 if (!_statusReportUpdate) return;
53 if (!_statusReportUpdate.statusReport.page) return;
54 if (
55 _statusReportUpdate.statusReport.page.workspaceId !==
56 opts.ctx.workspace.id
57 )
58 return;
59 const validSubscribers =
60 _statusReportUpdate.statusReport.page.pageSubscribers.filter(
61 (s): s is typeof s & { token: string } =>
62 s.token !== null &&
63 s.acceptedAt !== null &&
64 s.unsubscribedAt === null,
65 );
66 if (!validSubscribers.length) return;
67
68 await emailClient.sendStatusReportUpdate({
69 subscribers: validSubscribers.map((subscriber) => ({
70 email: subscriber.email,
71 token: subscriber.token,
72 })),
73 pageTitle: _statusReportUpdate.statusReport.page.title,
74 pageSlug: _statusReportUpdate.statusReport.page.slug,
75 customDomain: _statusReportUpdate.statusReport.page.customDomain,
76 reportTitle: _statusReportUpdate.statusReport.title,
77 status: _statusReportUpdate.status,
78 message: _statusReportUpdate.message,
79 date: new Date(_statusReportUpdate.date).toISOString(),
80 pageComponents:
81 _statusReportUpdate.statusReport.statusReportsToPageComponents.map(
82 (i) => i.pageComponent.name,
83 ),
84 });
85 }
86 }),
87 sendMaintenance: protectedProcedure
88 .input(z.object({ id: z.number() }))
89 .mutation(async (opts) => {
90 const limits = opts.ctx.workspace.limits;
91
92 if (limits["status-subscribers"]) {
93 const _maintenance = await opts.ctx.db.query.maintenance.findFirst({
94 where: and(
95 eq(maintenance.id, opts.input.id),
96 eq(maintenance.workspaceId, opts.ctx.workspace.id),
97 ),
98 with: {
99 maintenancesToPageComponents: {
100 with: {
101 pageComponent: true,
102 },
103 },
104 page: {
105 with: {
106 pageSubscribers: {
107 where: isNotNull(pageSubscriber.acceptedAt),
108 },
109 },
110 },
111 },
112 });
113
114 if (!_maintenance) return;
115 if (!_maintenance.page) return;
116 const validSubscribers = _maintenance.page.pageSubscribers.filter(
117 (s): s is typeof s & { token: string } =>
118 s.token !== null &&
119 s.acceptedAt !== null &&
120 s.unsubscribedAt === null,
121 );
122 if (!validSubscribers.length) return;
123
124 await emailClient.sendStatusReportUpdate({
125 subscribers: validSubscribers.map((subscriber) => ({
126 email: subscriber.email,
127 token: subscriber.token,
128 })),
129 pageTitle: _maintenance.page.title,
130 pageSlug: _maintenance.page.slug,
131 customDomain: _maintenance.page.customDomain,
132 reportTitle: _maintenance.title,
133 status: "maintenance",
134 message: _maintenance.message,
135 date: new Date(_maintenance.from).toISOString(),
136 pageComponents: _maintenance.maintenancesToPageComponents.map(
137 (i) => i.pageComponent.name,
138 ),
139 });
140 }
141 }),
142 sendTeamInvitation: protectedProcedure
143 .input(z.object({ id: z.number(), baseUrl: z.string().optional() }))
144 .mutation(async (opts) => {
145 const limits = opts.ctx.workspace.limits;
146
147 if (limits.members === "Unlimited" || limits.members > 1) {
148 const _invitation = await opts.ctx.db.query.invitation.findFirst({
149 where: and(
150 eq(invitation.id, opts.input.id),
151 eq(invitation.workspaceId, opts.ctx.workspace.id),
152 ),
153 });
154
155 if (!_invitation) return;
156
157 await emailClient.sendTeamInvitation({
158 to: _invitation.email,
159 token: _invitation.token,
160 invitedBy: `${opts.ctx.user.email}`,
161 workspaceName: opts.ctx.workspace.name || "OpenStatus",
162 baseUrl: opts.input.baseUrl,
163 });
164 }
165 }),
166
167 sendPageSubscription: publicProcedure
168 .input(z.object({ id: z.number() }))
169 .mutation(async (opts) => {
170 const _pageSubscriber = await opts.ctx.db.query.pageSubscriber.findFirst({
171 where: eq(pageSubscriber.id, opts.input.id),
172 with: {
173 page: {
174 with: {
175 workspace: true,
176 },
177 },
178 },
179 });
180
181 if (!_pageSubscriber || !_pageSubscriber.token) {
182 throw new TRPCError({
183 code: "NOT_FOUND",
184 message: "Page subscriber not found",
185 });
186 }
187
188 const workspace = selectWorkspaceSchema.safeParse(
189 _pageSubscriber.page.workspace,
190 );
191
192 if (!workspace.success) {
193 throw new TRPCError({
194 code: "NOT_FOUND",
195 message: "Workspace not found",
196 });
197 }
198 if (!workspace.data.limits["status-subscribers"]) {
199 throw new TRPCError({
200 code: "FORBIDDEN",
201 message: "Upgrade to use status subscribers",
202 });
203 }
204
205 const link = _pageSubscriber.page.customDomain
206 ? `https://${_pageSubscriber.page.customDomain}/verify/${_pageSubscriber.token}`
207 : `https://${_pageSubscriber.page.slug}.openstatus.dev/verify/${_pageSubscriber.token}`;
208
209 await emailClient.sendPageSubscription({
210 to: _pageSubscriber.email,
211 page: _pageSubscriber.page.title,
212 link,
213 });
214 }),
215});