Openstatus www.openstatus.dev
at main 206 lines 5.6 kB view raw
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});