Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/backend/db_patches/0201_AddUniqueConstraintToOidcSub.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@

DO
$$
BEGIN
IF register_patch('AddUniqueConstraintToOidcSub.sql', 'yoganandanpandiyan', 'Unique constraint', '2025-10-13') THEN
BEGIN
-- Clean up empty string values in oidc_sub column by setting them to NULL
-- This prevents empty strings from conflicting with the unique constraint
UPDATE users
SET oidc_sub = NULL
WHERE oidc_sub = '';

-- Handle duplicate oidc_sub values by appending a unique suffix. ex., '_1', '_2', etc.
WITH duplicates AS (
SELECT
user_id,
oidc_sub,
ROW_NUMBER() OVER (PARTITION BY oidc_sub ORDER BY user_id) AS rn
FROM users
WHERE oidc_sub IS NOT NULL
)
UPDATE users u
SET oidc_sub = u.oidc_sub || '_' || d.rn
FROM duplicates d
WHERE u.user_id = d.user_id
AND d.rn > 1;

-- Add unique constraint to prevent duplicate oidc_sub values in the future
ALTER TABLE users
ADD CONSTRAINT unique_oidc_sub UNIQUE (oidc_sub);
END;
END IF;
END;
$$
LANGUAGE plpgsql;
26 changes: 18 additions & 8 deletions apps/backend/src/auth/OAuthAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,16 +179,26 @@ export class OAuthAuthorization extends UserAuthorization {
''
);

const roleID = this.getUserRole(newUser);
if (newUser.email) {
const roleID = this.getUserRole({
id: newUser.id,
email: newUser.email,
});

await this.userDataSource.addUserRole({
userID: newUser.id,
roleID,
});
await this.userDataSource.addUserRole({
userID: newUser.id,
roleID,
});

if (roleID === UserRole.USER_OFFICER) {
logger.logInfo('Initial User Officer created', {
email: newUser.email,
if (roleID === UserRole.USER_OFFICER) {
logger.logInfo('Initial User Officer created', {
email: newUser.email,
userID: newUser.id,
});
}
} else {
logger.logInfo('User created without email, cannot assign role', {
userID: newUser.id,
});
}

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/datasources/UserDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface UserDataSource {
oauth_issuer: string,
gender: string,
birthdate: Date,
institution: number,
institution: number | undefined,
department: string,
position: string,
email: string,
Expand Down
44 changes: 42 additions & 2 deletions apps/backend/src/datasources/mockups/UserDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const basicDummyUser = new BasicUserDetails(
false,
'test@email.com',
'',
'',
''
);

Expand All @@ -43,6 +44,7 @@ export const basicDummyUserNotOnProposal = new BasicUserDetails(
false,
'test@email.com',
'',
'',
''
);

Expand Down Expand Up @@ -300,6 +302,7 @@ export class UserDataSourceMock implements UserDataSource {
false,
'test@email.com',
'',
'',
''
);
}
Expand Down Expand Up @@ -483,8 +486,45 @@ export class UserDataSourceMock implements UserDataSource {
return true;
}

async create(firstname: string, lastname: string) {
return dummyUser;
async create(
user_title: string | undefined,
firstname: string,
lastname: string,
username: string,
preferredname: string | undefined,
oidc_sub: string,
oauth_refreshtoken: string,
oauth_issuer: string,
gender: string,
birthdate: Date,
institution: number | undefined,
department: string,
position: string,
email: string,
telephone: string
) {
return new User(
10,
user_title || 'unspecified',
firstname,
lastname,
username,
preferredname || '',
oidc_sub,
oauth_refreshtoken,
oauth_issuer,
gender || 'unspecified',
birthdate,
institution || 1,
'Test institution',
department,
position,
email,
telephone,
false,
new Date().toISOString(),
new Date().toISOString()
);
}

async ensureDummyUserExists(userId: number): Promise<User> {
Expand Down
128 changes: 73 additions & 55 deletions apps/backend/src/datasources/postgres/UserDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from '../../resolvers/mutations/UpdateUserMutation';
import { UsersArgs } from '../../resolvers/queries/UsersQuery';
import { UserDataSource } from '../UserDataSource';
import database from './database';
import database, { isUniqueConstraintError } from './database';
import {
CountryRecord,
InstitutionRecord,
Expand Down Expand Up @@ -90,31 +90,40 @@ export default class PostgresUserDataSource implements UserDataSource {
oidcSub,
oauthRefreshToken,
oauthIssuer,
username,
} = user;

const [userRecord]: UserRecord[] = await database
.update({
firstname,
user_title,
lastname,
preferredname,
gender,
birthdate,
institution_id: institutionId,
department,
position,
email,
telephone,
placeholder,
oidc_sub: oidcSub,
oauth_refresh_token: oauthRefreshToken,
oauth_issuer: oauthIssuer,
})
.from('users')
.where('user_id', user.id)
.returning(['*']);

return createUserObject(userRecord);
try {
const [userRecord]: UserRecord[] = await database
.update({
firstname,
user_title,
lastname,
preferredname,
gender,
birthdate,
institution_id: institutionId,
department,
position,
email,
telephone,
placeholder,
oidc_sub: oidcSub,
oauth_refresh_token: oauthRefreshToken,
oauth_issuer: oauthIssuer,
username,
})
.from('users')
.where('user_id', user.id)
.returning(['*']);

return createUserObject(userRecord);
} catch (error) {
if (isUniqueConstraintError(error)) {
throw new GraphQLError('User already exists');
}
throw new GraphQLError('Could not update user. Check your Inputs.');
}
}
async updateUserByOidcSub(
args: UpdateUserByOidcSubArgs
Expand All @@ -135,34 +144,40 @@ export default class PostgresUserDataSource implements UserDataSource {
oauthRefreshToken,
oauthIssuer,
} = args;

const [userRecord]: UserRecord[] = await database
.update({
firstname,
user_title,
lastname,
preferredname,
gender,
birthdate,
institution_id: institutionId,
department,
position,
email,
telephone,
placeholder,
oauth_refresh_token: oauthRefreshToken,
oauth_issuer: oauthIssuer,
updated_at: new Date(),
})
.from('users')
.where('oidc_sub', args.oidcSub)
.returning(['*']);

if (!userRecord) {
return null;
try {
const [userRecord]: UserRecord[] = await database
.update({
firstname,
user_title,
lastname,
preferredname,
gender,
birthdate,
institution_id: institutionId,
department,
position,
email,
telephone,
placeholder,
oauth_refresh_token: oauthRefreshToken,
oauth_issuer: oauthIssuer,
updated_at: new Date(),
})
.from('users')
.where('oidc_sub', args.oidcSub)
.returning(['*']);

if (!userRecord) {
return null;
}

return createUserObject(userRecord);
} catch (error) {
if (isUniqueConstraintError(error)) {
throw new GraphQLError('User already exists');
}
throw new GraphQLError('Could not create user. Check your Inputs.');
}

return createUserObject(userRecord);
}

async createInviteUser(args: CreateUserByEmailInviteArgs): Promise<number> {
Expand All @@ -175,7 +190,6 @@ export default class PostgresUserDataSource implements UserDataSource {
lastname,
username: email,
preferredname: firstname,
oidc_sub: '',
oauth_refresh_token: '',
oauth_issuer: '',
gender: '',
Expand Down Expand Up @@ -363,7 +377,6 @@ export default class PostgresUserDataSource implements UserDataSource {
.then((user: UserRecord) => (!user ? null : createUserObject(user)));
}

// NOTE: This is used in the OAuthAuthorization only where we upsert users returned from Auth server.
async create(
user_title: string | undefined,
firstname: string,
Expand All @@ -375,7 +388,7 @@ export default class PostgresUserDataSource implements UserDataSource {
oauth_issuer: string,
gender: string,
birthdate: Date,
institution_id: number,
institution_id: number | undefined,
department: string,
position: string,
email: string,
Expand Down Expand Up @@ -407,6 +420,12 @@ export default class PostgresUserDataSource implements UserDataSource {
}

return createUserObject(user[0]);
})
.catch((error) => {
if (isUniqueConstraintError(error)) {
throw new GraphQLError('User already exists');
}
throw new GraphQLError('Could not update user. Check your Inputs.');
});
}

Expand Down Expand Up @@ -460,7 +479,6 @@ export default class PostgresUserDataSource implements UserDataSource {
lastname: '',
username: userId.toString(),
preferredname: '',
oidc_sub: '',
oauth_refresh_token: '',
gender: '',
birthdate: '2000-01-01',
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/datasources/postgres/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@ if (process.env.DATABASE_LOG_QUERIES === '1') {
});
}

export function isUniqueConstraintError(error: any) {
return 'code' in error && error.code === '23505';
}

export default db;
17 changes: 9 additions & 8 deletions apps/backend/src/datasources/postgres/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,26 +245,26 @@ export interface TemplateRecord {

export interface UserRecord {
readonly user_id: number;
readonly user_title: string;
readonly user_title: string | null;
readonly firstname: string;
readonly lastname: string;
readonly username: string;
readonly preferredname: string;
readonly username: string | null;
readonly preferredname: string | null;
readonly oidc_sub: string | null;
readonly oauth_refresh_token: string | null;
readonly oauth_issuer: string | null;
readonly gender: string;
readonly birthdate: Date;
readonly department: string;
readonly position: string;
readonly email: string;
readonly email: string | null;
readonly telephone: string;
readonly created_at: Date;
readonly updated_at: Date;
readonly full_count: number;
readonly institution_id: number;
readonly institution: string;
readonly placeholder: boolean;
readonly institution_id: number | null;
readonly institution: string | null;
readonly placeholder: boolean | null;
}

export interface VisitRegistrationRecord {
Expand Down Expand Up @@ -1002,7 +1002,8 @@ export const createBasicUserObject = (
user.placeholder,
user.email,
user.country,
user.user_title
user.user_title,
user.oidc_sub
);
};

Expand Down
Loading
Loading