-
Notifications
You must be signed in to change notification settings - Fork 513
Team invitations on user #1200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Team invitations on user #1200
Changes from all commits
1a687c7
8553203
98470d6
73dc1e0
5ec002a
3660ace
ac1b069
1ed5e0e
73c5c26
41ba7d2
3b15b84
51a4dcd
8a8dcac
2bf8062
d7a29d2
07d93e8
053af37
03ee020
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud"; | ||
| import { getItemQuantityForCustomer } from "@/lib/payments"; | ||
| import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; | ||
| import { globalPrismaClient } from "@/prisma-client"; | ||
| import { VerificationCodeType } from "@/generated/prisma/client"; | ||
| import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||
| import { KnownErrors } from "@stackframe/stack-shared"; | ||
| import { adaptSchema, clientOrHigherAuthTypeSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||
|
|
||
| export const POST = createSmartRouteHandler({ | ||
| metadata: { | ||
| summary: "Accept a team invitation by ID", | ||
| description: "Accepts a team invitation for the specified user. The user must have a verified email matching the invitation's recipient email. This marks the invitation as used and adds the user to the team.", | ||
| tags: ["Teams"], | ||
| }, | ||
| request: yupObject({ | ||
| auth: yupObject({ | ||
| type: clientOrHigherAuthTypeSchema, | ||
| tenancy: adaptSchema.defined(), | ||
| user: adaptSchema.optional(), | ||
| }).defined(), | ||
| params: yupObject({ | ||
| id: yupString().uuid().defined(), | ||
| }).defined(), | ||
| query: yupObject({ | ||
| user_id: userIdOrMeSchema.defined(), | ||
| }).defined(), | ||
| }), | ||
| response: yupObject({ | ||
| statusCode: yupNumber().oneOf([200]).defined(), | ||
| bodyType: yupString().oneOf(["json"]).defined(), | ||
| body: yupObject({}).defined(), | ||
| }), | ||
| async handler({ auth, params, query }) { | ||
| const userId = query.user_id; | ||
|
|
||
| if (auth.type === 'client') { | ||
| if (!auth.user) { | ||
| throw new KnownErrors.CannotGetOwnUserWithoutUser(); | ||
| } | ||
| if (userId !== auth.user.id) { | ||
| throw new KnownErrors.CannotGetOwnUserWithoutUser(); | ||
| } | ||
|
|
||
| if (auth.user.restricted_reason) { | ||
| throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(auth.user.restricted_reason); | ||
| } | ||
| } | ||
|
|
||
| // Look up the invitation (verification code) by ID | ||
| const code = await globalPrismaClient.verificationCode.findUnique({ | ||
| where: { | ||
| projectId_branchId_id: { | ||
| projectId: auth.tenancy.project.id, | ||
| branchId: auth.tenancy.branchId, | ||
| id: params.id, | ||
| }, | ||
| type: VerificationCodeType.TEAM_INVITATION, | ||
| usedAt: null, | ||
| expiresAt: { gt: new Date() }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!code) { | ||
| throw new KnownErrors.VerificationCodeNotFound(); | ||
| } | ||
|
|
||
| const invitationData = code.data as { team_id: string }; | ||
| const invitationMethod = code.method as { email: string }; | ||
|
Comment on lines
+69
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unvalidated type casts violate defensive coding guidelines.
🛡️ Proposed fix- const invitationData = code.data as { team_id: string };
- const invitationMethod = code.method as { email: string };
+ const invitationData = code.data as { team_id: string };
+ const teamId = invitationData?.team_id ?? throwErr("invitationData.team_id is undefined — the verification code data is malformed");
+ const invitationMethod = code.method as { email: string };
+ const recipientEmail = invitationMethod?.email ?? throwErr("invitationMethod.email is undefined — the verification code method is malformed");Then replace all subsequent usages of As per coding guidelines: "Code defensively. Prefer 🤖 Prompt for AI Agents |
||
|
|
||
| // Verify that the target user has a verified email matching the invitation's recipient | ||
| const prisma = await getPrismaClientForTenancy(auth.tenancy); | ||
| const matchingChannel = await prisma.contactChannel.findFirst({ | ||
| where: { | ||
| tenancyId: auth.tenancy.id, | ||
| projectUserId: userId, | ||
| type: 'EMAIL', | ||
| isVerified: true, | ||
| value: invitationMethod.email, | ||
| }, | ||
| }); | ||
|
|
||
| if (!matchingChannel) { | ||
| throw new KnownErrors.VerificationCodeNotFound(); | ||
| } | ||
|
N2D4 marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| await retryTransaction(prisma, async (tx) => { | ||
| // Internal project payment checks (same as in the verification code handler) | ||
| if (auth.tenancy.project.id === "internal") { | ||
| const currentMemberCount = await tx.teamMember.count({ | ||
| where: { | ||
| tenancyId: auth.tenancy.id, | ||
| teamId: invitationData.team_id, | ||
| }, | ||
| }); | ||
| const maxDashboardAdmins = await getItemQuantityForCustomer({ | ||
| prisma: tx, | ||
| tenancy: auth.tenancy, | ||
| customerId: invitationData.team_id, | ||
| itemId: "dashboard_admins", | ||
| customerType: "team", | ||
| }); | ||
| if (currentMemberCount + 1 > maxDashboardAdmins) { | ||
| throw new KnownErrors.ItemQuantityInsufficientAmount("dashboard_admins", invitationData.team_id, -1); | ||
| } | ||
| } | ||
|
|
||
| const oldMembership = await tx.teamMember.findUnique({ | ||
| where: { | ||
| tenancyId_projectUserId_teamId: { | ||
| tenancyId: auth.tenancy.id, | ||
| projectUserId: userId, | ||
| teamId: invitationData.team_id, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!oldMembership) { | ||
| await teamMembershipsCrudHandlers.adminCreate({ | ||
| tenancy: auth.tenancy, | ||
| team_id: invitationData.team_id, | ||
| user_id: userId, | ||
| data: {}, | ||
|
N2D4 marked this conversation as resolved.
|
||
| }); | ||
| } | ||
|
|
||
| // Mark the invitation as used inside the transaction to prevent race conditions | ||
| const updated = await globalPrismaClient.verificationCode.updateMany({ | ||
| where: { | ||
| projectId: auth.tenancy.project.id, | ||
| branchId: auth.tenancy.branchId, | ||
| id: params.id, | ||
| usedAt: null, | ||
| }, | ||
| data: { | ||
| usedAt: new Date(), | ||
| }, | ||
| }); | ||
|
|
||
| if (updated.count === 0) { | ||
| throw new KnownErrors.VerificationCodeNotFound(); | ||
| } | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-retryable operations inside retryTransaction cause failuresHigh Severity The Additional Locations (1) |
||
|
|
||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: {}, | ||
| }; | ||
| }, | ||
| }); | ||


Uh oh!
There was an error while loading. Please reload this page.