Openstatus
www.openstatus.dev
1import { TRPCError } from "@trpc/server";
2import { z } from "zod";
3
4import { Events } from "@openstatus/analytics";
5import { type SQL, and, db, eq, gte, isNull } from "@openstatus/db";
6import {
7 insertInvitationSchema,
8 invitation,
9 selectInvitationSchema,
10 selectWorkspaceSchema,
11 usersToWorkspaces,
12 workspace,
13} from "@openstatus/db/src/schema";
14
15import { createTRPCRouter, protectedProcedure } from "../trpc";
16
17export const invitationRouter = createTRPCRouter({
18 create: protectedProcedure
19 .meta({ track: Events.InviteUser, trackProps: ["email"] })
20 .input(insertInvitationSchema.pick({ email: true }))
21 .mutation(async (opts) => {
22 const { email } = opts.input;
23
24 const _members = opts.ctx.workspace.limits.members;
25 const membersLimit = _members === "Unlimited" ? 420 : _members;
26
27 const usersToWorkspacesNumbers = (
28 await opts.ctx.db.query.usersToWorkspaces.findMany({
29 where: eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id),
30 })
31 ).length;
32
33 const openInvitationsNumbers = (
34 await opts.ctx.db.query.invitation.findMany({
35 where: and(
36 eq(invitation.workspaceId, opts.ctx.workspace.id),
37 gte(invitation.expiresAt, new Date()),
38 isNull(invitation.acceptedAt),
39 ),
40 })
41 ).length;
42
43 // the user has reached the limits
44 if (usersToWorkspacesNumbers + openInvitationsNumbers >= membersLimit) {
45 throw new TRPCError({
46 code: "FORBIDDEN",
47 message: "You reached your member limits.",
48 });
49 }
50
51 const expiresAt = new Date();
52 expiresAt.setDate(expiresAt.getDate() + 7);
53
54 const token = crypto.randomUUID();
55
56 const _invitation = await opts.ctx.db
57 .insert(invitation)
58 .values({ email, expiresAt, token, workspaceId: opts.ctx.workspace.id })
59 .returning()
60 .get();
61
62 if (process.env.NODE_ENV === "development") {
63 console.log(
64 `>>>> Invitation token: http://localhost:3000/invite?token=${token} <<<< `,
65 );
66 }
67
68 return _invitation;
69 }),
70
71 delete: protectedProcedure
72 .input(z.object({ id: z.number() }))
73 .meta({ track: Events.DeleteInvite })
74 .mutation(async (opts) => {
75 await opts.ctx.db
76 .delete(invitation)
77 .where(
78 and(
79 eq(invitation.id, opts.input.id),
80 eq(invitation.workspaceId, opts.ctx.workspace.id),
81 ),
82 )
83 .run();
84 }),
85
86 list: protectedProcedure.query(async (opts) => {
87 const whereConditions: SQL[] = [
88 eq(invitation.workspaceId, opts.ctx.workspace.id),
89 gte(invitation.expiresAt, new Date()),
90 isNull(invitation.acceptedAt),
91 ];
92
93 const result = await opts.ctx.db.query.invitation.findMany({
94 where: and(...whereConditions),
95 });
96
97 return result;
98 }),
99
100 get: protectedProcedure
101 .input(z.object({ token: z.string().nullable() }))
102 .query(async (opts) => {
103 if (!opts.ctx.user.email) {
104 throw new TRPCError({
105 code: "UNAUTHORIZED",
106 message: "You are not authorized to access this resource.",
107 });
108 }
109
110 if (!opts.input.token) {
111 throw new TRPCError({
112 code: "BAD_REQUEST",
113 message: "Token is required.",
114 });
115 }
116
117 const result = await opts.ctx.db.query.invitation.findFirst({
118 where: and(
119 eq(invitation.token, opts.input.token),
120 isNull(invitation.acceptedAt),
121 gte(invitation.expiresAt, new Date()),
122 eq(invitation.email, opts.ctx.user.email),
123 ),
124 with: {
125 workspace: true,
126 },
127 });
128
129 if (!result) {
130 throw new TRPCError({
131 code: "NOT_FOUND",
132 message: "Invitation not found.",
133 });
134 }
135
136 return selectInvitationSchema
137 .extend({
138 workspace: selectWorkspaceSchema,
139 })
140 .parse(result);
141 }),
142
143 accept: protectedProcedure
144 .input(z.object({ id: z.number() }))
145 .mutation(async (opts) => {
146 if (!opts.ctx.user.email) {
147 throw new TRPCError({
148 code: "UNAUTHORIZED",
149 message: "You are not authorized to access this resource.",
150 });
151 }
152
153 const _invitation = await opts.ctx.db.query.invitation.findFirst({
154 where: and(
155 eq(invitation.id, opts.input.id),
156 eq(invitation.email, opts.ctx.user.email),
157 isNull(invitation.acceptedAt),
158 gte(invitation.expiresAt, new Date()),
159 ),
160 with: {
161 workspace: true,
162 },
163 });
164
165 if (!_invitation) {
166 throw new TRPCError({
167 code: "NOT_FOUND",
168 message: "Invitation not found.",
169 });
170 }
171
172 if (_invitation.acceptedAt) {
173 throw new TRPCError({
174 code: "BAD_REQUEST",
175 message: "Invitation already accepted.",
176 });
177 }
178
179 const result = await db.transaction(async (tx) => {
180 await tx
181 .update(invitation)
182 .set({
183 acceptedAt: new Date(),
184 })
185 .where(eq(invitation.id, opts.input.id))
186 .run();
187
188 await tx
189 .insert(usersToWorkspaces)
190 .values({
191 userId: opts.ctx.user.id,
192 workspaceId: _invitation.workspaceId,
193 role: _invitation.role,
194 })
195 .run();
196
197 const _workspace = await tx.query.workspace.findFirst({
198 where: eq(workspace.id, _invitation.workspaceId),
199 });
200
201 return _workspace;
202 });
203
204 return result;
205 }),
206});