diff --git a/apps/backend/db_patches/0201_AddUniqueConstraintToOidcSub.sql b/apps/backend/db_patches/0201_AddUniqueConstraintToOidcSub.sql new file mode 100644 index 0000000000..c635d88294 --- /dev/null +++ b/apps/backend/db_patches/0201_AddUniqueConstraintToOidcSub.sql @@ -0,0 +1,65 @@ + +DO +$$ +DECLARE + duplicate_count INTEGER; + duplicate_details TEXT; +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 = ''; + + -- Check for duplicate oidc_sub values and throw error if any are found + SELECT COUNT(*) + INTO duplicate_count + FROM ( + SELECT oidc_sub + FROM users + WHERE oidc_sub IS NOT NULL + GROUP BY oidc_sub + HAVING COUNT(*) > 1 + ) duplicates; + + IF duplicate_count > 0 THEN + WITH duplicate_groups AS ( + SELECT + oidc_sub, + STRING_AGG(user_id::TEXT, ', ' ORDER BY user_id) AS user_ids + FROM users + WHERE oidc_sub IS NOT NULL + AND oidc_sub IN ( + SELECT oidc_sub + FROM users + WHERE oidc_sub IS NOT NULL + GROUP BY oidc_sub + HAVING COUNT(*) > 1 + ) + GROUP BY oidc_sub + ) + SELECT STRING_AGG( + FORMAT('oidc_sub: "%s" (user_ids: %s)', oidc_sub, user_ids), + E'\n' + ) + INTO duplicate_details + FROM duplicate_groups; + + RAISE EXCEPTION 'Cannot add unique constraint to oidc_sub column. Found % duplicate oidc_sub value(s) that must be manually resolved: +% + +Please manually fix these duplicate values before running this migration again. +OIDC subject identifiers should be unique as they come from identity providers.', + duplicate_count, duplicate_details; + END IF; + + -- 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; diff --git a/apps/backend/src/auth/OAuthAuthorization.ts b/apps/backend/src/auth/OAuthAuthorization.ts index 888c7a9712..e643217b54 100644 --- a/apps/backend/src/auth/OAuthAuthorization.ts +++ b/apps/backend/src/auth/OAuthAuthorization.ts @@ -16,7 +16,7 @@ import { SettingsId } from '../models/Settings'; import { AuthJwtPayload, User, UserRole } from '../models/User'; import { UserAuthorization } from './UserAuthorization'; -interface UserinfoResponseWithInssitution extends UserinfoResponse { +interface UserinfoResponseWithInstitution extends UserinfoResponse { institution_ror_id?: string; institution_name?: string; institution_country?: string; @@ -85,11 +85,11 @@ export class OAuthAuthorization extends UserAuthorization { }); } - private async getUserInstitutionId( - userInfo: UserinfoResponseWithInssitution + public async getOrCreateUserInstitution( + userInfo: UserinfoResponseWithInstitution ) { if (!userInfo.institution_name || !userInfo.institution_country) { - return undefined; + return null; } let institution = userInfo.institution_ror_id @@ -119,7 +119,7 @@ export class OAuthAuthorization extends UserAuthorization { await this.adminDataSource.createInstitution(newInstitution); } - return institution?.id; + return institution; } private async upsertUser( @@ -127,7 +127,7 @@ export class OAuthAuthorization extends UserAuthorization { tokenSet: ValidTokenSet ): Promise { const client = await OpenIdClient.getInstance(); - const institutionId = await this.getUserInstitutionId(userInfo); + const institution = await this.getOrCreateUserInstitution(userInfo); const userWithOAuthSubMatch = await this.userDataSource.getByOIDCSub( userInfo.sub ); @@ -152,7 +152,7 @@ export class OAuthAuthorization extends UserAuthorization { oauthIssuer: client.issuer.metadata.issuer, oauthRefreshToken: tokenSet.refresh_token ?? '', oidcSub: userInfo.sub, - institutionId: institutionId ?? user.institutionId, + institutionId: institution?.id ?? user.institutionId, position: userInfo.position as string, preferredname: userInfo.preferred_username, telephone: userInfo.phone_number, @@ -172,7 +172,7 @@ export class OAuthAuthorization extends UserAuthorization { client.issuer.metadata.issuer, userInfo.gender ?? 'unspecified', new Date(), - institutionId ?? 1, + institution?.id ?? 1, '', (userInfo.position as string) ?? '', userInfo.email, diff --git a/apps/backend/src/auth/StfcUserAuthorization.ts b/apps/backend/src/auth/StfcUserAuthorization.ts index e56ae10bd4..7675a94fb9 100644 --- a/apps/backend/src/auth/StfcUserAuthorization.ts +++ b/apps/backend/src/auth/StfcUserAuthorization.ts @@ -17,6 +17,7 @@ import { toStfcBasicPersonDetails, } from '../datasources/stfc/StfcUserDataSource'; import { createUOWSClient } from '../datasources/stfc/UOWSClient'; +import { Institution } from '../models/Institution'; import { Instrument } from '../models/Instrument'; import { Rejection, rejection } from '../models/Rejection'; import { Role, Roles } from '../models/Role'; @@ -347,4 +348,12 @@ export class StfcUserAuthorization extends UserAuthorization { async canBeAssignedToFap(userId: number): Promise { return true; } + + getOrCreateUserInstitution(userInfo: { + institution_ror_id?: string; + institution_name?: string; + institution_country?: string; + }): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/apps/backend/src/auth/UserAuthorization.ts b/apps/backend/src/auth/UserAuthorization.ts index 0d6ac8b760..e2bd4aaa0b 100644 --- a/apps/backend/src/auth/UserAuthorization.ts +++ b/apps/backend/src/auth/UserAuthorization.ts @@ -7,6 +7,7 @@ import { InternalReviewDataSource } from '../datasources/InternalReviewDataSourc import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; +import { Institution } from '../models/Institution'; import { Rejection } from '../models/Rejection'; import { Role, Roles } from '../models/Role'; import { AuthJwtPayload, User, UserWithRole } from '../models/User'; @@ -191,6 +192,12 @@ export abstract class UserAuthorization { iss: string | null ): Promise; + abstract getOrCreateUserInstitution(userInfo: { + institution_ror_id?: string; + institution_name?: string; + institution_country?: string; + }): Promise; + abstract logout(token: AuthJwtPayload): Promise; abstract isExternalTokenValid( diff --git a/apps/backend/src/auth/mockups/UserAuthorization.ts b/apps/backend/src/auth/mockups/UserAuthorization.ts index 031ac79018..c313816c73 100644 --- a/apps/backend/src/auth/mockups/UserAuthorization.ts +++ b/apps/backend/src/auth/mockups/UserAuthorization.ts @@ -1,13 +1,78 @@ import 'reflect-metadata'; import { injectable } from 'tsyringe'; +import { dummyInstitution } from '../../datasources/mockups/AdminDataSource'; import { dummyUser } from '../../datasources/mockups/UserDataSource'; +import { Institution } from '../../models/Institution'; import { Role } from '../../models/Role'; import { AuthJwtPayload, User } from '../../models/User'; import { UserAuthorization } from '../UserAuthorization'; @injectable() export class UserAuthorizationMock extends UserAuthorization { + // Mock institution data for testing + private mockInstitutions: Institution[] = [ + dummyInstitution, + new Institution( + 3, + 'Dummy Research Institute', + 3, + 'https://ror.org/dummy001' + ), + new Institution(4, 'Test University', 4, 'https://ror.org/dummy002'), + new Institution(5, 'Mock Academic Center', 4, 'https://ror.org/dummy003'), + ]; + + async getOrCreateUserInstitution(userInfo: { + institution_ror_id?: string; + institution_name?: string; + institution_country?: string; + }): Promise { + // Return default institution if all fields are empty or unspecified + if ( + (!userInfo.institution_name || userInfo.institution_name.trim() === '') && + (!userInfo.institution_ror_id || + userInfo.institution_ror_id.trim() === '') + ) { + return this.mockInstitutions[0]; // Return dummyInstitution as default + } + + // Try to find existing institution by ROR ID first (most reliable) + if (userInfo.institution_ror_id) { + const existingByRor = this.mockInstitutions.find( + (inst) => inst.rorId === userInfo.institution_ror_id + ); + if (existingByRor) { + return existingByRor; + } + } + + // Try to find existing institution by name (case-insensitive) + if (userInfo.institution_name) { + const existingByName = this.mockInstitutions.find( + (inst) => + inst.name.toLowerCase() === userInfo.institution_name?.toLowerCase() + ); + if (existingByName) { + return existingByName; + } + } + + // Create new institution if not found + const newId = Math.max(...this.mockInstitutions.map((i) => i.id)) + 1; + + const newInstitution = new Institution( + newId, + userInfo.institution_name ?? 'Unknown Institution', + 1, + userInfo.institution_ror_id + ); + + // Add to mock data for subsequent calls + this.mockInstitutions.push(newInstitution); + + return newInstitution; + } async externalTokenLogin( token: string, _redirectUri: string diff --git a/apps/backend/src/datasources/mockups/UserDataSource.ts b/apps/backend/src/datasources/mockups/UserDataSource.ts index 566b1d1057..7e49445120 100644 --- a/apps/backend/src/datasources/mockups/UserDataSource.ts +++ b/apps/backend/src/datasources/mockups/UserDataSource.ts @@ -28,6 +28,7 @@ export const basicDummyUser = new BasicUserDetails( false, 'test@email.com', '', + '', '' ); @@ -43,6 +44,7 @@ export const basicDummyUserNotOnProposal = new BasicUserDetails( false, 'test@email.com', '', + '', '' ); @@ -259,8 +261,21 @@ export class UserDataSourceMock implements UserDataSource { async addUserRole(args: AddUserRoleArgs): Promise { return true; } + // Mock user storage for testing upsertUserByOidcSub + private mockUsers: User[] = [ + dummyUser, + dummyUserNotOnProposal, + dummyUserOfficer, + dummyPlaceHolderUser, + ]; + async getByOIDCSub(oidcSub: string): Promise { - return dummyUser; + // Check if user exists with this OIDC sub + const existingUser = this.mockUsers.find( + (user) => user.oidcSub === oidcSub + ); + + return existingUser || null; } async createInviteUser(args: CreateUserByEmailInviteArgs): Promise { return 5; @@ -300,6 +315,7 @@ export class UserDataSourceMock implements UserDataSource { false, 'test@email.com', '', + '', '' ); } @@ -483,8 +499,53 @@ 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_refresh_token: string, + oauth_issuer: string, + gender: string, + birthdate: Date, + institution_id: number, + department: string, + position: string, + email: string, + telephone: string + ) { + // Generate a new user ID + const newId = Math.max(...this.mockUsers.map((u) => u.id)) + 1; + + const newUser = new User( + newId, + user_title || 'unspecified', + firstname, + lastname, + username, + preferredname || '', + oidc_sub, + oauth_refresh_token, + oauth_issuer, + gender || 'unspecified', + birthdate, + institution_id || 1, + 'Test institution', + department, + position, + email, + telephone, + false, + new Date().toISOString(), + new Date().toISOString() + ); + + // Add to mock users collection + this.mockUsers.push(newUser); + + return newUser; } async ensureDummyUserExists(userId: number): Promise { diff --git a/apps/backend/src/datasources/postgres/UserDataSource.ts b/apps/backend/src/datasources/postgres/UserDataSource.ts index be3e9ddb25..b61884d6c2 100644 --- a/apps/backend/src/datasources/postgres/UserDataSource.ts +++ b/apps/backend/src/datasources/postgres/UserDataSource.ts @@ -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, @@ -90,32 +90,42 @@ 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 ): Promise { @@ -135,34 +145,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 { @@ -175,7 +191,6 @@ export default class PostgresUserDataSource implements UserDataSource { lastname, username: email, preferredname: firstname, - oidc_sub: '', oauth_refresh_token: '', oauth_issuer: '', gender: '', @@ -260,7 +275,9 @@ export default class PostgresUserDataSource implements UserDataSource { .join('institutions as i', { 'u.institution_id': 'i.institution_id' }) .where('user_id', id) .first() - .then((user: UserRecord) => (!user ? null : createUserObject(user))); + .then((user: UserRecord) => { + return !user ? null : createUserObject(user); + }); } async getUserWithInstitution(id: number): Promise<{ @@ -363,7 +380,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, @@ -407,6 +423,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.'); }); } @@ -460,7 +482,6 @@ export default class PostgresUserDataSource implements UserDataSource { lastname: '', username: userId.toString(), preferredname: '', - oidc_sub: '', oauth_refresh_token: '', gender: '', birthdate: '2000-01-01', @@ -718,6 +739,7 @@ export default class PostgresUserDataSource implements UserDataSource { users.map((user) => createBasicUserObject(user)) ); } + async createInstitution( name: string, countryId: number | null = null, diff --git a/apps/backend/src/datasources/postgres/database.ts b/apps/backend/src/datasources/postgres/database.ts index 1525b1bafc..52cf7d8754 100644 --- a/apps/backend/src/datasources/postgres/database.ts +++ b/apps/backend/src/datasources/postgres/database.ts @@ -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; diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 7927e0770d..d3abb964b8 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -1002,7 +1002,8 @@ export const createBasicUserObject = ( user.placeholder, user.email, user.country, - user.user_title + user.user_title, + user.oidc_sub ); }; diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts index ff133f1bfd..82031914b1 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts @@ -107,7 +107,8 @@ export function toEssBasicUserDetails( false, stfcUser.email ?? '', stfcUser.country ?? '', - stfcUser.title ?? '' + stfcUser.title ?? '', + '' ); } diff --git a/apps/backend/src/models/User.ts b/apps/backend/src/models/User.ts index c1cb4e38b5..42b8382fc3 100644 --- a/apps/backend/src/models/User.ts +++ b/apps/backend/src/models/User.ts @@ -91,7 +91,8 @@ export class BasicUserDetails { public placeholder: boolean, public email: string, public country: string, - public title: string + public user_title: string, + public oidc_sub: string | null ) {} } diff --git a/apps/backend/src/mutations/UserMutations.spec.ts b/apps/backend/src/mutations/UserMutations.spec.ts index 24d0fd2452..f4b13ad824 100644 --- a/apps/backend/src/mutations/UserMutations.spec.ts +++ b/apps/backend/src/mutations/UserMutations.spec.ts @@ -12,7 +12,7 @@ import { } from '../datasources/mockups/UserDataSource'; import { EmailInviteResponse } from '../models/EmailInviteResponse'; import { isRejection, Rejection } from '../models/Rejection'; -import { AuthJwtPayload, UserRole } from '../models/User'; +import { AuthJwtPayload, User, UserRole } from '../models/User'; import { verifyToken } from '../utils/jwt'; import UserMutations from './UserMutations'; @@ -366,3 +366,131 @@ describe('updateUserByOidcSub', () => { expect((result as typeof dummyUser).department).toBe(dummyUser.department); }); }); + +describe('upsertUserByOidcSub', () => { + test('A user can be created if OIDC sub does not exist', async () => { + const newOidcSub = 'new-unique-oidc-sub'; + const result = await userMutations.upsertUserByOidcSub( + dummyUserOfficerWithRole, + { + oidcSub: newOidcSub, + firstName: 'New', + lastName: 'User', + email: 'new.user@example.com', + userTitle: null, + username: null, + preferredName: null, + gender: null, + birthDate: null, + institutionRoRId: '', + department: null, + position: '', + telephone: null, + institutionName: '', + institutionCountry: '', + } + ); + // Check if the result has the oidcsub + expect(isRejection(result)).toBe(false); + expect((result as User).oidcSub).toBe(newOidcSub); + }); + test('A user will be updated if OIDC sub exists', async () => { + const result = await userMutations.upsertUserByOidcSub( + dummyUserOfficerWithRole, + { + oidcSub: dummyUser.oidcSub as string, + firstName: 'UpsertedJane', + lastName: 'UpsertedDoe', + email: 'upserted.jane.doe@example.com', + userTitle: null, + username: null, + preferredName: null, + gender: null, + birthDate: null, + institutionRoRId: '', + department: null, + position: '', + telephone: null, + institutionName: '', + institutionCountry: '', + } + ); + + // Check if the result has the oidcsub + expect(isRejection(result)).toBe(false); + expect((result as User).oidcSub).toBe(dummyUser.oidcSub); + }); + + test('A new user can be created where the institution will be fetched from institutionRoRId', async () => { + const newOidcSub = 'user-with-existing-institution-ror'; + const existingRorId = 'https://ror.org/dummy001'; // Dummy Research Institute from our mock + + const result = await userMutations.upsertUserByOidcSub( + dummyUserOfficerWithRole, + { + oidcSub: newOidcSub, + firstName: 'John', + lastName: 'Scientist', + email: 'john.scientist@dummy-research.org', + userTitle: 'Dr.', + username: 'jscientist', + preferredName: 'Johnny', + gender: 'male', + birthDate: '1985-05-15', + institutionRoRId: existingRorId, // This should find Dummy Research Institute in our mock + institutionName: 'CERN', // This should match the existing institution + institutionCountry: 'Switzerland', + department: 'Physics Department', + position: 'Senior Researcher', + telephone: '+41-22-767-6111', + } + ); + + expect(isRejection(result)).toBe(false); + const createdUser = result as User; + + // Should use existing institution (Dummy Research Institute has ID 3 in our mock) + expect(createdUser.institutionId).toBe(3); + expect(createdUser.firstname).toBe('John'); + expect(createdUser.lastname).toBe('Scientist'); + expect(createdUser.email).toBe('john.scientist@dummy-research.org'); + expect(createdUser.oidcSub).toBe(newOidcSub); + }); + + test('A new user can be created where the institution will be created newly for the new institutionRoRId', async () => { + const newOidcSub = 'user-with-new-institution-ror'; + const newRorId = 'https://ror.org/05a28rw58'; // New ROR ID not in our mock + + const result = await userMutations.upsertUserByOidcSub( + dummyUserOfficerWithRole, + { + oidcSub: newOidcSub, + firstName: 'Maria', + lastName: 'Researcher', + email: 'maria.researcher@newinstitute.edu', + userTitle: 'Prof.', + username: 'mresearcher', + preferredName: 'Maria', + gender: 'female', + birthDate: '1980-03-22', + institutionRoRId: newRorId, // This ROR ID doesn't exist in mock + institutionName: 'New Research Institute', + institutionCountry: 'Germany', + department: 'Materials Science', + position: 'Principal Investigator', + telephone: '+49-30-12345678', + } + ); + + expect(isRejection(result)).toBe(false); + const createdUser = result as User; + + // Should create new institution and assign it + // The new institution should have ID 6 (next available in our mock) + expect(createdUser.institutionId).toBe(6); + expect(createdUser.firstname).toBe('Maria'); + expect(createdUser.lastname).toBe('Researcher'); + expect(createdUser.email).toBe('maria.researcher@newinstitute.edu'); + expect(createdUser.oidcSub).toBe(newOidcSub); + }); +}); diff --git a/apps/backend/src/mutations/UserMutations.ts b/apps/backend/src/mutations/UserMutations.ts index d621a22c72..6a7a0604d1 100644 --- a/apps/backend/src/mutations/UserMutations.ts +++ b/apps/backend/src/mutations/UserMutations.ts @@ -8,11 +8,13 @@ import { updateUserValidationBackendSchema, } from '@user-office-software/duo-validation'; import * as bcrypt from 'bcryptjs'; +import { DateTime } from 'luxon'; import { inject, injectable } from 'tsyringe'; import { Args } from 'type-graphql'; import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; +import { AdminDataSource } from '../datasources/AdminDataSource'; import { RedeemCodesDataSource } from '../datasources/RedeemCodesDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { Authorized, EventBus, ValidateArgs } from '../decorators'; @@ -34,6 +36,7 @@ import { UpdateUserByOidcSubArgs, UpdateUserByIdArgs, } from '../resolvers/mutations/UpdateUserMutation'; +import { UpsertUserByOidcSubArgs } from '../resolvers/mutations/UpsertUserMutation'; import { signToken, verifyToken } from '../utils/jwt'; import { ApolloServerErrorCodeExtended } from '../utils/utilTypes'; @@ -42,6 +45,7 @@ export default class UserMutations { constructor( @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, @inject(Tokens.UserDataSource) private dataSource: UserDataSource, + @inject(Tokens.AdminDataSource) private adminDataSource: AdminDataSource, @inject(Tokens.RedeemCodesDataSource) private redeemCodeDataSource: RedeemCodesDataSource ) {} @@ -507,4 +511,95 @@ export default class UserMutations { ): Promise { return this.dataSource.setUserNotPlaceholder(id); } + + @Authorized([Roles.USER_OFFICER]) + async upsertUserByOidcSub( + agent: UserWithRole | null, + args: UpsertUserByOidcSubArgs + ) { + const { + userTitle, + firstName, + lastName, + username, + preferredName, + oidcSub, + gender, + birthDate, + institutionRoRId, + institutionName, + institutionCountry, + department, + position, + email, + telephone, + } = args; + + const userWithOAuthSubMatch = await this.dataSource.getByOIDCSub(oidcSub); + + let formattedBirthDate: DateTime | null = null; + formattedBirthDate = birthDate ? DateTime.fromISO(birthDate) : null; + if (formattedBirthDate && !formattedBirthDate.isValid) { + return rejection('Invalid birth date format', { birthDate, args }); + } + + const institution = await this.userAuth.getOrCreateUserInstitution({ + institution_ror_id: institutionRoRId, + institution_name: institutionName, + institution_country: institutionCountry, + }); + + if (!institution) { + return rejection('Invalid Input for the Institution', { + institutionRoRId, + args, + }); + } + + if (userWithOAuthSubMatch) { + const updatedUser = await this.dataSource.update({ + ...userWithOAuthSubMatch, + birthdate: formattedBirthDate?.toJSDate(), + department: department ?? undefined, + email, + firstname: firstName, + username: username ?? undefined, + gender: gender ?? undefined, + lastname: lastName, + oidcSub: oidcSub, + institutionId: institution.id, + position: position, + preferredname: preferredName ?? undefined, + telephone: telephone ?? undefined, + user_title: userTitle ?? undefined, + }); + + return updatedUser; + } else { + const newUser = await this.dataSource.create( + userTitle ?? '', + firstName, + lastName, + username ?? '', + preferredName ?? '', + oidcSub, + '', + '', + gender ?? '', + formattedBirthDate?.toJSDate() ?? new Date(), + institution.id, + department ?? '', + position, + email, + telephone ?? '' + ); + + await this.dataSource.addUserRole({ + userID: newUser.id, + roleID: UserRole.USER, + }); + + return newUser; + } + } } diff --git a/apps/backend/src/queries/UserQueries.ts b/apps/backend/src/queries/UserQueries.ts index e6825399c6..f35b9c3fea 100644 --- a/apps/backend/src/queries/UserQueries.ts +++ b/apps/backend/src/queries/UserQueries.ts @@ -73,7 +73,8 @@ export default class UserQueries { user.placeholder, user.email, user.country, - user.title + user.user_title, + user.oidc_sub ); } else { return null; @@ -107,7 +108,8 @@ export default class UserQueries { user.placeholder, user.email, user.country, - user.title + user.user_title, + user.oidc_sub ); } diff --git a/apps/backend/src/resolvers/mutations/UpsertUserMutation.ts b/apps/backend/src/resolvers/mutations/UpsertUserMutation.ts new file mode 100644 index 0000000000..9ddab35e3c --- /dev/null +++ b/apps/backend/src/resolvers/mutations/UpsertUserMutation.ts @@ -0,0 +1,62 @@ +import { Args, ArgsType, Ctx, Field, Mutation, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { User } from '../types/User'; +@ArgsType() +export class UpsertUserByOidcSubArgs { + @Field(() => String, { nullable: true }) + public userTitle: string | null; + + @Field(() => String) + public firstName: string; + + @Field(() => String) + public lastName: string; + + @Field(() => String, { nullable: true }) + public username: string | null; + + @Field(() => String, { nullable: true }) + public preferredName: string | null; + + @Field(() => String) + public oidcSub: string; + + @Field(() => String, { nullable: true }) + public gender: string | null; + + @Field(() => String, { nullable: true }) + public birthDate: string | null; + + @Field(() => String) + public institutionRoRId: string; + + @Field(() => String) + public institutionName: string; + + @Field(() => String) + public institutionCountry: string; + + @Field(() => String, { nullable: true }) + public department: string | null; + + @Field(() => String) + public position: string; + + @Field(() => String) + public email: string; + + @Field(() => String, { nullable: true }) + public telephone: string | null; +} + +@Resolver() +export class UpsertUserByOidcSubMutation { + @Mutation(() => User) + upsertUserByOidcSub( + @Args() input: UpsertUserByOidcSubArgs, + @Ctx() context: ResolverContext + ) { + return context.mutations.user.upsertUserByOidcSub(context.user, input); + } +} diff --git a/apps/backend/src/resolvers/types/BasicUserDetails.ts b/apps/backend/src/resolvers/types/BasicUserDetails.ts index b05aed8433..373a0b7a42 100644 --- a/apps/backend/src/resolvers/types/BasicUserDetails.ts +++ b/apps/backend/src/resolvers/types/BasicUserDetails.ts @@ -44,6 +44,9 @@ export class BasicUserDetails implements Partial { @Field({ nullable: true }) public country: string; + + @Field(() => String, { nullable: true }) + public oidc_sub?: string | null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/backend/src/statusActionEngine/statusActionUtils.ts b/apps/backend/src/statusActionEngine/statusActionUtils.ts index fdf1467741..1fa7af8e38 100644 --- a/apps/backend/src/statusActionEngine/statusActionUtils.ts +++ b/apps/backend/src/statusActionEngine/statusActionUtils.ts @@ -463,6 +463,7 @@ export const getOtherAndFormatOutputForEmailSending = async ( true, otherEmail, '', + '', '' ); diff --git a/apps/frontend/src/graphql/user/fragment.basicUserInformation.graphql b/apps/frontend/src/graphql/user/fragment.basicUserInformation.graphql index 48498dbe86..d0fb90376f 100644 --- a/apps/frontend/src/graphql/user/fragment.basicUserInformation.graphql +++ b/apps/frontend/src/graphql/user/fragment.basicUserInformation.graphql @@ -10,4 +10,5 @@ fragment basicUserDetails on BasicUserDetails { placeholder email country + oidc_sub } diff --git a/apps/frontend/src/graphql/user/upsertUserByOidcSub.graphql b/apps/frontend/src/graphql/user/upsertUserByOidcSub.graphql new file mode 100644 index 0000000000..ef0aeb201d --- /dev/null +++ b/apps/frontend/src/graphql/user/upsertUserByOidcSub.graphql @@ -0,0 +1,37 @@ +mutation upsertUserByOidcSub( + $userTitle: String + $firstName: String! + $lastName: String! + $username: String + $preferredName: String + $oidcSub: String! + $gender: String + $birthDate: String + $institutionRoRId: String! + $institutionName: String! + $institutionCountry: String! + $department: String + $position: String! + $email: String! + $telephone: String +) { + upsertUserByOidcSub( + userTitle: $userTitle + firstName: $firstName + lastName: $lastName + username: $username + preferredName: $preferredName + oidcSub: $oidcSub + gender: $gender + birthDate: $birthDate + institutionRoRId: $institutionRoRId + institutionName: $institutionName + institutionCountry: $institutionCountry + department: $department + position: $position + email: $email + telephone: $telephone + ) { + id + } +} diff --git a/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts b/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts index 6cc3ad8969..85d672084f 100644 --- a/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts +++ b/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts @@ -34,6 +34,7 @@ function createRegistrationStub( position: '', email: '', country: '', + oidc_sub: '', }, questionary: { isCompleted: false,