1+ import { VerificationCodeType } from "@/generated/prisma/client" ;
12import { ensureTeamExists , ensureTeamMembershipExists , ensureUserTeamPermissionExists } from "@/lib/request-checks" ;
2- import { getPrismaClientForTenancy , retryTransaction } from "@/prisma-client" ;
3+ import { getPrismaClientForTenancy , globalPrismaClient , retryTransaction } from "@/prisma-client" ;
34import { createCrudHandlers } from "@/route-handlers/crud-handler" ;
45import { KnownErrors } from "@stackframe/stack-shared" ;
56import { teamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation" ;
6- import { yupObject , yupString } from "@stackframe/stack-shared/dist/schema-fields" ;
7- import { throwErr } from "@stackframe/stack-shared/dist/utils/errors" ;
7+ import { userIdOrMeSchema , yupObject , yupString } from "@stackframe/stack-shared/dist/schema-fields" ;
8+ import { StatusError , throwErr } from "@stackframe/stack-shared/dist/utils/errors" ;
89import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies" ;
10+ import { teamsCrudHandlers } from "../teams/crud" ;
911import { teamInvitationCodeHandler } from "./accept/verification-code-handler" ;
1012
1113export const teamInvitationsCrudHandlers = createLazyProxy ( ( ) => createCrudHandlers ( teamInvitationCrud , {
1214 querySchema : yupObject ( {
13- team_id : yupString ( ) . uuid ( ) . defined ( ) . meta ( { openapiField : { onlyShowInOperations : [ 'List' ] } } ) ,
15+ team_id : yupString ( ) . uuid ( ) . optional ( ) . meta ( { openapiField : { onlyShowInOperations : [ 'List' , 'Delete' ] , description : 'The team ID to list invitations for. Required unless user_id is provided.' } } ) ,
16+ user_id : userIdOrMeSchema . optional ( ) . meta ( { openapiField : { onlyShowInOperations : [ 'List' ] , description : 'List invitations sent to this user\'s verified emails. Must be "me" for client access. Cannot be combined with team_id.' } } ) ,
1417 } ) ,
1518 paramsSchema : yupObject ( {
1619 id : yupString ( ) . uuid ( ) . defined ( ) ,
1720 } ) ,
1821 onList : async ( { auth, query } ) => {
22+ if ( query . team_id != null && query . user_id != null ) {
23+ throw new StatusError ( StatusError . BadRequest , "Cannot specify both team_id and user_id" ) ;
24+ }
25+ if ( query . team_id == null && query . user_id == null ) {
26+ throw new StatusError ( StatusError . BadRequest , "Must specify either team_id or user_id" ) ;
27+ }
28+
29+ if ( query . user_id != null ) {
30+ // List invitations sent to the user's verified emails
31+ if ( auth . type === 'client' ) {
32+ const currentUserId = auth . user ?. id ?? throwErr ( new KnownErrors . CannotGetOwnUserWithoutUser ( ) ) ;
33+ if ( query . user_id !== currentUserId ) {
34+ throw new KnownErrors . CannotGetOwnUserWithoutUser ( ) ;
35+ }
36+ }
37+
38+ const targetUserId = query . user_id ;
39+
40+ const prisma = await getPrismaClientForTenancy ( auth . tenancy ) ;
41+ const verifiedEmails = await prisma . contactChannel . findMany ( {
42+ where : {
43+ tenancyId : auth . tenancy . id ,
44+ projectUserId : targetUserId ,
45+ type : 'EMAIL' ,
46+ isVerified : true ,
47+ } ,
48+ select : { value : true } ,
49+ } ) ;
50+
51+ if ( verifiedEmails . length === 0 ) {
52+ return { items : [ ] , is_paginated : false } ;
53+ }
54+
55+ const codes = await globalPrismaClient . verificationCode . findMany ( {
56+ where : {
57+ projectId : auth . tenancy . project . id ,
58+ branchId : auth . tenancy . branchId ,
59+ type : VerificationCodeType . TEAM_INVITATION ,
60+ usedAt : null ,
61+ expiresAt : { gt : new Date ( ) } ,
62+ OR : verifiedEmails . map ( ( { value } ) => ( {
63+ method : { path : [ 'email' ] , equals : value } ,
64+ } ) ) ,
65+ } ,
66+ } ) ;
67+
68+ const teamIds = [ ...new Set ( codes . map ( code => {
69+ const data = code . data as { team_id : string } ;
70+ return data . team_id ;
71+ } ) ) ] ;
72+
73+ const teamsMap = new Map < string , string > ( ) ;
74+ for ( const teamId of teamIds ) {
75+ try {
76+ const team = await teamsCrudHandlers . adminRead ( {
77+ tenancy : auth . tenancy ,
78+ team_id : teamId ,
79+ allowedErrorTypes : [ KnownErrors . TeamNotFound ] ,
80+ } ) ;
81+ teamsMap . set ( teamId , team . display_name ) ;
82+ } catch ( e ) {
83+ if ( KnownErrors . TeamNotFound . isInstance ( e ) ) {
84+ // Team may have been deleted since the invitation was created; skip these invitations
85+ continue ;
86+ }
87+ throw e ;
88+ }
89+ }
90+
91+ return {
92+ items : codes
93+ . filter ( code => {
94+ const data = code . data as { team_id : string } ;
95+ return teamsMap . has ( data . team_id ) ;
96+ } )
97+ . map ( code => {
98+ const data = code . data as { team_id : string } ;
99+ const method = code . method as { email : string } ;
100+ return {
101+ id : code . id ,
102+ team_id : data . team_id ,
103+ team_display_name : teamsMap . get ( data . team_id ) ?? throwErr ( "team_display_name should be available after filtering; this should never happen" ) ,
104+ expires_at_millis : code . expiresAt . getTime ( ) ,
105+ recipient_email : method . email ,
106+ } ;
107+ } ) ,
108+ is_paginated : false ,
109+ } ;
110+ }
111+
112+ // List invitations for a specific team (existing behavior)
113+ const teamId = query . team_id ?? throwErr ( "team_id is required when user_id is not provided; this should never happen because of the earlier validation" ) ;
19114 const prisma = await getPrismaClientForTenancy ( auth . tenancy ) ;
20115 return await retryTransaction ( prisma , async ( tx ) => {
21116 if ( auth . type === 'client' ) {
22- // Client can only:
23- // - list invitations in their own team if they have the $read_members AND $invite_members permissions
24117 const currentUserId = auth . user ?. id ?? throwErr ( new KnownErrors . CannotGetOwnUserWithoutUser ( ) ) ;
25118
26- await ensureTeamMembershipExists ( tx , { tenancyId : auth . tenancy . id , teamId : query . team_id , userId : currentUserId } ) ;
119+ await ensureTeamMembershipExists ( tx , { tenancyId : auth . tenancy . id , teamId, userId : currentUserId } ) ;
27120
28121 for ( const permissionId of [ '$read_members' , '$invite_members' ] ) {
29122 await ensureUserTeamPermissionExists ( tx , {
30123 tenancy : auth . tenancy ,
31- teamId : query . team_id ,
124+ teamId,
32125 userId : currentUserId ,
33126 permissionId,
34127 errorType : 'required' ,
35128 recursive : true ,
36129 } ) ;
37130 }
38131 } else {
39- await ensureTeamExists ( tx , { tenancyId : auth . tenancy . id , teamId : query . team_id } ) ;
132+ await ensureTeamExists ( tx , { tenancyId : auth . tenancy . id , teamId } ) ;
40133 }
41134
42135 const allCodes = await teamInvitationCodeHandler . listCodes ( {
43136 tenancy : auth . tenancy ,
44137 dataFilter : {
45138 path : [ 'team_id' ] ,
46- equals : query . team_id ,
139+ equals : teamId ,
47140 } ,
48141 } ) ;
49142
143+ const team = await teamsCrudHandlers . adminRead ( {
144+ tenancy : auth . tenancy ,
145+ team_id : teamId ,
146+ } ) ;
147+ const teamDisplayName = team . display_name ;
148+
50149 return {
51150 items : allCodes . map ( code => ( {
52151 id : code . id ,
53152 team_id : code . data . team_id ,
153+ team_display_name : teamDisplayName ,
54154 expires_at_millis : code . expiresAt . getTime ( ) ,
55155 recipient_email : code . method . email ,
56156 } ) ) ,
@@ -59,26 +159,24 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
59159 } ) ;
60160 } ,
61161 onDelete : async ( { auth, query, params } ) => {
162+ const teamId = query . team_id ?? throwErr ( new StatusError ( StatusError . BadRequest , "team_id is required for deleting a team invitation" ) ) ;
62163 const prisma = await getPrismaClientForTenancy ( auth . tenancy ) ;
63164 await retryTransaction ( prisma , async ( tx ) => {
64165 if ( auth . type === 'client' ) {
65- // Client can only:
66- // - delete invitations in their own team if they have the $remove_members permissions
67-
68166 const currentUserId = auth . user ?. id ?? throwErr ( new KnownErrors . CannotGetOwnUserWithoutUser ( ) ) ;
69167
70- await ensureTeamMembershipExists ( tx , { tenancyId : auth . tenancy . id , teamId : query . team_id , userId : currentUserId } ) ;
168+ await ensureTeamMembershipExists ( tx , { tenancyId : auth . tenancy . id , teamId, userId : currentUserId } ) ;
71169
72170 await ensureUserTeamPermissionExists ( tx , {
73171 tenancy : auth . tenancy ,
74- teamId : query . team_id ,
172+ teamId,
75173 userId : currentUserId ,
76174 permissionId : "$remove_members" ,
77175 errorType : 'required' ,
78176 recursive : true ,
79177 } ) ;
80178 } else {
81- await ensureTeamExists ( tx , { tenancyId : auth . tenancy . id , teamId : query . team_id } ) ;
179+ await ensureTeamExists ( tx , { tenancyId : auth . tenancy . id , teamId } ) ;
82180 }
83181 } ) ;
84182
0 commit comments