{"record":{"id":"6b038c9b14950612","repo":"koala73/worldmonitor","slug":"invite-already-used","errorCode":"INVITE_ALREADY_USED","errorMessage":"INVITE_ALREADY_USED","messagePattern":"INVITE_ALREADY_USED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"warning","filePath":"convex/payments/businessSeats.ts","lineNumber":468,"sourceCode":" * grant to `accepted`, stamps `inviteeUserId`, and recomputes the invitee's\n * entitlement. Single-use: accepted/revoked/expired tokens are rejected.\n */\nexport const acceptBusinessInvite = mutation({\n  args: { grantId: v.id(\"businessProGrants\"), token: v.string() },\n  handler: async (ctx, args) => {\n    const userId = await requireUserId(ctx);\n    const identity = await resolveUserIdentity(ctx);\n    const inviteeEmail = identity?.email?.trim().toLowerCase();\n    if (!inviteeEmail) {\n      throw new ConvexError({ kind: \"INVITEE_EMAIL_UNAVAILABLE\" });\n    }\n\n    const grant = await ctx.db.get(args.grantId);\n    if (!grant) {\n      throw new ConvexError({ kind: \"GRANT_NOT_FOUND\" });\n    }\n    if (grant.status !== \"pending\") {\n      throw new ConvexError({ kind: \"INVITE_ALREADY_USED\" });\n    }\n    const now = Date.now();\n    if (grant.expiresAt <= now) {\n      throw new ConvexError({ kind: \"INVITE_EXPIRED\" });\n    }\n    if (!(await verifyBusinessInviteToken(args.grantId, args.token))) {\n      throw new ConvexError({ kind: \"INVALID_INVITE_TOKEN\" });\n    }\n    if (grant.inviteeEmail !== inviteeEmail) {\n      throw new ConvexError({ kind: \"INVITE_EMAIL_MISMATCH\" });\n    }\n    if (!sameDomain(grant.inviteeEmail, inviteeEmail)) {\n      throw new ConvexError({ kind: \"INVITE_EMAIL_MISMATCH\" });\n    }\n    if (!isCorporateDomain(inviteeEmail)) {\n      throw new ConvexError({ kind: \"INVITEE_DOMAIN_NOT_CORPORATE\" });\n    }\n","sourceCodeStart":450,"sourceCodeEnd":486,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/payments/businessSeats.ts#L450-L486","documentation":"`acceptBusinessInvite` is single-use: it only accepts grants whose status is exactly `\"pending\"`. A grant already `\"accepted\"` or `\"revoked\"` is rejected with INVITE_ALREADY_USED. The status check runs before token verification, so a reused link fails fast.","triggerScenarios":"Clicking an accept link a second time after a successful accept; clicking a link for a grant that has since been revoked by the owner.","commonSituations":"User accepts, gets Pro, then clicks the email link again; the owner revoked the seat and the invitee retries the old link; email client pre-fetches the link once.","solutions":["Treat INVITE_ALREADY_USED as success if the current user already holds the Pro entitlement from that grant","Avoid re-using accept links — they are intentionally single-use","If the seat was revoked, ask the owner to issue a fresh invite"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  await convex.mutation(api.payments.businessSeats.acceptBusinessInvite, { grantId, token });\n} catch (err) {\n  if (err.data?.kind === 'INVITE_ALREADY_USED') {\n    // check entitlement; if user already has Pro from this grant, treat as success\n  } else { throw err; }\n}","preventionTips":["Accept links are single-use — don't reuse them","After a successful accept, redirect away so a refresh can't re-submit","Handle email-client link pre-fetch by treating INVITE_ALREADY_USED as benign"],"tags":["payments","business-seats","invites","state-machine"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}