feat: add check for existing organization members to prevent duplicates during member creation

This commit is contained in:
Rotimi Best
2026-03-12 05:31:46 +01:00
parent 71f18149b2
commit a42ae50bca
4 changed files with 53 additions and 17 deletions
+22 -15
View File
@@ -1,16 +1,17 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import { getProfileByEmail, getProfileById, updateProfile } from '@cio/db/queries/auth';
import { getLicenseStatus } from '@api/services/license';
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
import type { TProfile } from '@cio/db/types';
import { import {
createOrganizationMember, createOrganizationMember,
getFirstOrganization, getFirstOrganization,
getOrganizationByProfileId getOrganizationByProfileId,
hasOrgMemberByProfileIdOrEmail
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import { env } from '@api/config/env'; import { getProfileByEmail, getProfileById, updateProfile } from '@cio/db/queries/auth';
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import type { TProfile } from '@cio/db/types';
import { env } from '@api/config/env';
import { getLicenseStatus } from '@api/services/license';
export type GetAccountDataResult = { export type GetAccountDataResult = {
profile: TProfile; profile: TProfile;
@@ -35,17 +36,23 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404); throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404);
} }
// Self-hosted: auto-add user as student to the single org if they are not a member // Self-hosted: auto-add user as student to the single org if they are not a member.
// Skip if they already have membership (by profileId) or a pending invite (by email).
if (env.PUBLIC_IS_SELFHOSTED === 'true' && organizations.length === 0) { if (env.PUBLIC_IS_SELFHOSTED === 'true' && organizations.length === 0) {
const firstOrg = await getFirstOrganization(); const firstOrg = await getFirstOrganization();
if (firstOrg) { if (firstOrg) {
await createOrganizationMember({ const alreadyInOrg = await hasOrgMemberByProfileIdOrEmail(firstOrg.id, userId, profile.email ?? undefined);
organizationId: firstOrg.id,
profileId: userId, if (!alreadyInOrg) {
roleId: ROLE.STUDENT, await createOrganizationMember({
verified: true organizationId: firstOrg.id,
}); profileId: userId,
organizations = await getOrganizationByProfileId(userId); roleId: ROLE.STUDENT,
verified: true
});
organizations = await getOrganizationByProfileId(userId);
}
} }
} }
@@ -380,7 +380,7 @@
</Button> </Button>
{/snippet} {/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Content align="start" class="w-44 p-3"> <Popover.Content align="start" class="z-250! w-44 p-3">
<div class="grid grid-cols-5 gap-2"> <div class="grid grid-cols-5 gap-2">
{#each TAG_COLOR_OPTIONS as color (color)} {#each TAG_COLOR_OPTIONS as color (color)}
<Button <Button
@@ -165,6 +165,31 @@ export const checkEmailExistsInOrg = async (orgId: string, email: string): Promi
return result.length > 0; return result.length > 0;
}; };
/**
* Checks if an org has a member matching the given profileId or email.
* Used to avoid creating duplicate members (e.g. when user has a pending invite).
*/
export const hasOrgMemberByProfileIdOrEmail = async (
orgId: string,
profileId: string,
email: string | null | undefined
): Promise<boolean> => {
const conditions = [eq(schema.organizationmember.organizationId, orgId)];
const profileOrEmailMatch = or(
eq(schema.organizationmember.profileId, profileId),
...(email ? [eq(schema.organizationmember.email, email.toLowerCase().trim())] : [])
);
const result = await db
.select({ id: schema.organizationmember.id })
.from(schema.organizationmember)
.where(and(...conditions, profileOrEmailMatch))
.limit(1);
return result.length > 0;
};
/** /**
* Checks which emails already exist as team members in an organization (bulk check) * Checks which emails already exist as team members in an organization (bulk check)
* Checks both organizationmember.email and profile.email (via organizationmember.profileId) * Checks both organizationmember.email and profile.email (via organizationmember.profileId)
+5 -1
View File
@@ -14,6 +14,7 @@ import {
text, text,
timestamp, timestamp,
unique, unique,
uniqueIndex,
uuid, uuid,
varchar varchar
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
@@ -1467,7 +1468,10 @@ export const organizationmember = pgTable(
}), }),
index('idx_organizationmember_profile_id').on(table.profileId), index('idx_organizationmember_profile_id').on(table.profileId),
index('idx_organizationmember_organization_id').on(table.organizationId), index('idx_organizationmember_organization_id').on(table.organizationId),
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId) index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId),
uniqueIndex('organizationmember_org_profile_unique')
.on(table.organizationId, table.profileId)
.where(sql`${table.profileId} IS NOT NULL`)
] ]
); );