diff --git a/apps/web/src/lib/organizations/organizations.test.ts b/apps/web/src/lib/organizations/organizations.test.ts index a67d50a5c0..78e6ef36f9 100644 --- a/apps/web/src/lib/organizations/organizations.test.ts +++ b/apps/web/src/lib/organizations/organizations.test.ts @@ -7,6 +7,7 @@ import { organization_membership_removals, organization_seats_purchases, organization_user_limits, + kilocode_users, } from '@kilocode/db/schema'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { eq, and } from 'drizzle-orm'; @@ -1597,6 +1598,55 @@ describe('Organizations', () => { expect(storedInvitation?.accepted_at).not.toBeNull(); }); + test('disables the personal account when the account was created after the invite', async () => { + const owner = await insertTestUser(); + const invitee = await insertTestUser(); + const organization = await createOrganization('Test Org', owner.id); + + const invitation = await inviteUserToOrganization( + organization.id, + owner.id, + invitee.google_user_email, + 'member' + ); + // Simulate a brand-new account created to accept an already-pending invite. + await db + .update(organization_invitations) + .set({ created_at: sql`NOW() - INTERVAL '1 hour'` }) + .where(eq(organization_invitations.id, invitation.id)); + + const result = await acceptOrganizationInvite(invitee.id, invitation.token); + expect(result.success).toBe(true); + + const updatedInvitee = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, invitee.id), + }); + expect(updatedInvitee?.personal_account_disabled).toBe(true); + }); + + test('leaves the personal account untouched when the account predates the invite', async () => { + const owner = await insertTestUser(); + const invitee = await insertTestUser(); + const organization = await createOrganization('Test Org', owner.id); + + // The invitation is created after the invitee's account, matching an + // existing user who is later invited to an organization. + const invitation = await inviteUserToOrganization( + organization.id, + owner.id, + invitee.google_user_email, + 'member' + ); + + const result = await acceptOrganizationInvite(invitee.id, invitation.token); + expect(result.success).toBe(true); + + const updatedInvitee = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, invitee.id), + }); + expect(updatedInvitee?.personal_account_disabled).toBe(false); + }); + test('rejects accepting a pre-existing invitation into a child organization', async () => { const owner = await insertTestUser(); const invitee = await insertTestUser({ google_user_email: 'legacy@example.com' }); diff --git a/apps/web/src/lib/organizations/organizations.ts b/apps/web/src/lib/organizations/organizations.ts index 25ba7c4e59..c78788c645 100644 --- a/apps/web/src/lib/organizations/organizations.ts +++ b/apps/web/src/lib/organizations/organizations.ts @@ -692,6 +692,7 @@ export async function acceptOrganizationInvite( .select({ email: kilocode_users.google_user_email, normalizedEmail: kilocode_users.normalized_email, + createdAt: kilocode_users.created_at, }) .from(kilocode_users) .where(eq(kilocode_users.id, userId)) @@ -781,6 +782,19 @@ export async function acceptOrganizationInvite( invited_by: invitation.invited_by, }); + // If the invitation predates the account, the account was created after + // (i.e. because of) a pending invite: this is a brand-new user joining an + // organization via invite, so disable their personal account. Existing + // users — whose account predates the invitation — keep their value. + const accountCreatedForInvite = + new Date(invitation.created_at).getTime() < new Date(acceptingUser.createdAt).getTime(); + if (accountCreatedForInvite) { + await tx + .update(kilocode_users) + .set({ personal_account_disabled: true }) + .where(eq(kilocode_users.id, userId)); + } + // Clear any previous removal record so the user isn't treated as "removed" // by subsequent webhook events (Subscription Lifecycle 2) await tx