{"record":{"id":"2eb1bc88ffa82076","repo":"koala73/worldmonitor","slug":"too-many-emails","errorCode":"TOO_MANY_EMAILS","errorMessage":"TOO_MANY_EMAILS","messagePattern":"TOO_MANY_EMAILS","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/businessSeats.ts","lineNumber":157,"sourceCode":"\n    // Serialize concurrent inviteSeats / removeSeat calls for this Business\n    // subscription so the cap check below cannot be bypassed by a race.\n    await touchBusinessSeatLock(ctx, businessSub.dodoSubscriptionId, now);\n\n    const normalizedOwnerEmail = ownerEmail.toLowerCase();\n    // Dedupe: the existingByEmail check below only guards against emails\n    // that already had a grant BEFORE this call — a duplicate within the\n    // SAME args.emails array would otherwise slip past it (neither\n    // occurrence is in existingGrants yet) and create two grant rows for\n    // one invitee.\n    const emails = Array.from(\n      new Set(args.emails.map((e) => e.trim().toLowerCase()).filter((e) => e.length > 0)),\n    );\n    if (emails.length === 0) {\n      throw new ConvexError({ kind: \"NO_EMAILS_PROVIDED\" });\n    }\n    if (emails.length > MAX_SEATS) {\n      throw new ConvexError({ kind: \"TOO_MANY_EMAILS\" });\n    }\n\n    // Check existing grants for duplicates (active or pending).\n    const existingGrants = await ctx.db\n      .query(\"businessProGrants\")\n      .withIndex(\"by_businessSubscriptionId\", (q) =>\n        q.eq(\"businessSubscriptionId\", businessSub.dodoSubscriptionId),\n      )\n      .collect();\n    const existingByEmail = new Map(\n      existingGrants.map((g) => [g.inviteeEmail, g]),\n    );\n\n    // Separate new invites from duplicates before the cap check so a duplicate\n    // re-invite is idempotent even when the cap is full.\n    const newEmails: string[] = [];\n    const results: Array<{\n      email: string;","sourceCodeStart":139,"sourceCodeEnd":175,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/payments/businessSeats.ts#L139-L175","documentation":"Thrown by `inviteSeats` when the normalized (deduped, non-blank) emails count exceeds `MAX_SEATS` (4). Because the cap is checked AFTER dedup, genuine duplicates do not consume slots, but distinct addresses beyond 4 are rejected. Object-typed ConvexError (`{ kind: \"TOO_MANY_EMAILS\" }`).","triggerScenarios":"Calling `inviteSeats` with more than 4 distinct valid emails, e.g. 5+ teammates in one call.","commonSituations":"Owner tries to invite the whole team at once; pasting a list of 5+ addresses; UI didn't enforce the 4-seat maximum client-side.","solutions":["Reduce the list to at most 4 distinct emails per call.","Enforce the `MAX_SEATS = 4` limit in the UI before submit.","Note pending invites also count against the cap; revoke unused pending invites to free slots if needed."],"exampleFix":"// before\nawait inviteSeats({ emails: fiveEmails });\n\n// after\nconst MAX_SEATS = 4;\nconst emails = uniq(emailsInput).slice(0, MAX_SEATS);\nif (emailsInput.length > MAX_SEATS) showToast(\"Max 4 seats per invite.\");\nawait inviteSeats({ emails });","handlingStrategy":"validation","validationCode":"const MAX_SEATS = 4;\nconst emails = Array.from(new Set(rawEmails.map(e => e.trim().toLowerCase()).filter(Boolean)));\nif (emails.length > MAX_SEATS) {\n  showToast(`Max ${MAX_SEATS} seats per invite.`);\n  return;\n}\nawait inviteSeats({ emails });","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Enforce the 4-seat cap in the UI.","Remember pending invites also count toward the cap; revoke unused ones to free slots."],"tags":["validation","convex","business-seats","billing","limit"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}