From 4bd3e65069122d50de01c047f5bdd37ffc9ccd31 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Wed, 17 Jun 2026 16:07:10 -0700 Subject: [PATCH 01/80] Updated 'users' resolver to include 'role' param, and added findByAffiliationIdAndUserRole and updated search functions in the User model --- CHANGELOG.md | 3 ++ src/models/Plan.ts | 19 +++++++++ src/models/User.ts | 94 ++++++++++++++++++++++++++++++++++++++----- src/resolvers/user.ts | 36 +++++++++++++---- src/schemas/user.ts | 4 +- src/types.ts | 4 ++ 6 files changed, 141 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 649d5ba7..68131c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,9 @@ - added data-migration to fix question JSON so that `"selected": 0` is now `"selected": false` (and `1` -> `true`). ### Updated +- Updated `users` resolver to include `role`, and added a `SORT_FIELD_MAP` [#240] +- Added `findByAffiliationIdAndUserRole` and updated the `User.search` to accept `role` [#240] +- Added `findByUserId` to `Plan` model to find all plans for a given user [#240] - Updated `requestFeedback`, `addTemplate` and `publishTemplateCustomization` resolvers to add a record to the `adminNotifications` table [#570] - Updated `awsConfig` to move SES properties underneath the `ses` property - Updated `uuid` and `@testcontainers/mysql` dependencies diff --git a/src/models/Plan.ts b/src/models/Plan.ts index 0f0cd599..dc7b4c32 100644 --- a/src/models/Plan.ts +++ b/src/models/Plan.ts @@ -890,4 +890,23 @@ export class Plan extends MySqlModel { )) : []; } + + /** + * Fetch the Plans associated with a user + * + * @param reference The caller's reference string for logging purposes' + * @param context The Apollo context object + * @param userId The id of the user whose Plans we want to fetch + * @returns The Plan object or null if it does not exist + */ + static async findByUserId(reference: string, context: MyContext, userId: number): Promise { + const sql = `SELECT * FROM ${this.tableName} WHERE createdById = ?`; + const results = await Plan.query(context, sql, [userId?.toString()], reference); + + return Array.isArray(results) + ? await Promise.all(results.map(async (result) => + await Plan.processResult(context, result) + )) + : []; + } } diff --git a/src/models/User.ts b/src/models/User.ts index 94471001..54d21006 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -98,10 +98,10 @@ export class User extends MySqlModel { this.givenName = capitalizeFirstLetter(this.givenName); this.surName = capitalizeFirstLetter(this.surName); // Set the languageId to the default if it is not a supported language - if (!supportedLanguages.map((l) => l.id).includes(this.languageId)){ + if (!supportedLanguages.map((l) => l.id).includes(this.languageId)) { this.languageId = defaultLanguageId; } - this.orcid = this.orcid? formatORCID(this.orcid) : null; + this.orcid = this.orcid ? formatORCID(this.orcid) : null; } // Verify that the email does not already exist and that the required fields have values @@ -174,11 +174,11 @@ export class User extends MySqlModel { // If the user was found, check the password // TODO: Add logic to lock the account after too many failures - // Otherwise check the password - if (user && await bcrypt.compare(password, user.password)) { - context.logger.debug(prepareObjectForLogs({ id: user.id }), "Successful authCheck"); - return user.id; - } + // Otherwise check the password + if (user && await bcrypt.compare(password, user.password)) { + context.logger.debug(prepareObjectForLogs({ id: user.id }), "Successful authCheck"); + return user.id; + } context.logger.debug("Failed authCheck"); return null; @@ -271,15 +271,81 @@ export class User extends MySqlModel { return response; } + static async findByAffiliationIdAndUserRole( + reference: string, + context: MyContext, + affiliationId: string, + term: string, + role: UserRole, + options: PaginationOptions = User.getDefaultPaginationOptions(), + ): Promise> { + const whereFilters = ['u.affiliationId = ?', 'u.role = ?']; + const values = [affiliationId, role]; + + const searchTerm = (term ?? '').toLowerCase().trim(); + if (!isNullOrUndefined(searchTerm)) { + whereFilters.push(`( + LOWER(u.givenName) LIKE ? OR + LOWER(u.surName) LIKE ? OR + LOWER(ue.email) LIKE ? OR + LOWER(u.orcid) LIKE ?)`); + values.push(`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`); + } + + // Join users with user_emails + const sqlStatement = ` + SELECT u.* FROM users u + LEFT JOIN userEmails ue ON u.id = ue.userId AND ue.isPrimary = 1 + `; + + // Determine the type of pagination being used + let opts; + if (options.type === PaginationType.OFFSET) { + opts = { + ...options, + // Specify the fields available for sorting + availableSortFields: ['u.surName', 'u.givenName', 'u.created', 'ue.email', 'u.orcid', 'u.role', 'u.active', 'u.last_sign_in', 'a.name'], + } as PaginationOptionsForOffsets; + } else { + opts = { + ...options, + // Specify the field we want to use for the cursor (should typically match the sort field) + cursorField: 'CONCAT(ue.email, u.id)', + } as PaginationOptionsForCursors; + } + + // Set the default sort field and order if none was provided + if (isNullOrUndefined(opts.sortField)) opts.sortField = 'u.created'; + if (isNullOrUndefined(opts.sortDir)) opts.sortDir = 'DESC'; + + // Specify the field we want to use for the count + opts.countField = 'u.id'; + + const response: PaginatedQueryResults = await User.queryWithPagination( + context, + sqlStatement, + whereFilters, + '', + values, + opts, + reference, + ); + + context.logger.debug(prepareObjectForLogs({ options, response }), reference); + return response; + } + // Find all the Users that match the search term static async search( reference: string, context: MyContext, term: string, options: PaginationOptions = User.getDefaultPaginationOptions(), + role?: UserRole, ): Promise> { - const whereFilters = []; - const values = []; + const whereFilters: string[] = []; + const values: string[] = []; + // Handle the incoming search term const searchTerm = (term ?? '').toLowerCase().trim(); @@ -293,13 +359,19 @@ export class User extends MySqlModel { values.push(`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`); } + // Add role filter if provided + if (!isNullOrUndefined(role)) { + whereFilters.push('u.role = ?'); + values.push(role); + } + // Determine the type of pagination being used let opts; if (options.type === PaginationType.OFFSET) { opts = { ...options, // Specify the fields available for sorting - availableSortFields: ['u.surName', 'u.givenName', 'u.created', 'ue.email', 'u.orcid'], + availableSortFields: ['u.surName', 'u.givenName', 'u.created', 'ue.email', 'u.orcid', 'u.role', 'u.active', 'u.last_sign_in', 'a.name'], } as PaginationOptionsForOffsets; } else { opts = { @@ -438,7 +510,7 @@ export class User extends MySqlModel { // Add the email to the UserEmail table and send out a 'please confirm' email const userEmail = new UserEmail({ userId: user.id, email: email, isPrimary: true }); - if (!await userEmail.create(context)){ + if (!await userEmail.create(context)) { context.logger.error(prepareObjectForLogs({ userEmail }), `${reference} - unable to add UserEmail`); } diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index a95b4a04..681f2c1f 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -1,8 +1,9 @@ import { Resolvers, UserSearchResults } from "../types"; -import { MyContext} from '../context'; -import { User } from '../models/User'; +import { MyContext } from '../context'; +import { User, UserRole } from '../models/User'; import { UserEmail } from "../models/UserEmail"; import { Affiliation } from '../models/Affiliation'; +import { Plan } from '../models/Plan'; import { isAdmin, isAuthorized, isSuperAdmin } from "../services/authService"; import { AuthenticationError, ForbiddenError, InternalServerError, NotFoundError } from "../utils/graphQLErrors"; import { defaultLanguageId } from "../models/Language"; @@ -13,6 +14,16 @@ import { GraphQLError } from "graphql"; import { PaginationOptionsForCursors, PaginationOptionsForOffsets, PaginationType } from "../types/general"; import { isNullOrUndefined, normaliseDateTime } from "../utils/helpers"; +const SORT_FIELD_MAP: Record = { + name: 'u.surName', + email: 'ue.email', + role: 'u.role', + active: 'u.active', + created: 'u.created', + lastActivity: 'u.last_sign_in', + organization: 'a.name', +}; + export const resolvers: Resolvers = { Query: { // returns the current User @@ -33,18 +44,24 @@ export const resolvers: Resolvers = { // Should only be callable by an Admin. Super returns all users, Admin gets only // the users associated with their affiliationId - users: async (_, { term, paginationOptions }, context): Promise => { + users: async (_, { term, role, paginationOptions }, context): Promise => { const reference = 'users resolver'; + try { + // Map the frontend column id to a SQL field before building opts + if (paginationOptions?.sortField) { + paginationOptions.sortField = SORT_FIELD_MAP[paginationOptions.sortField] ?? 'u.created'; + } + const opts = !isNullOrUndefined(paginationOptions) && paginationOptions.type === PaginationType.OFFSET - ? paginationOptions as PaginationOptionsForOffsets - : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; + ? paginationOptions as PaginationOptionsForOffsets + : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; if (isSuperAdmin(context.token)) { - return await User.search(reference, context, term, opts); + return await User.search(reference, context, term, opts, role as unknown as UserRole,); } else if (isAdmin(context.token)) { - return await User.findByAffiliationId(reference, context, context.token.affiliationId, term, opts); + return await User.findByAffiliationIdAndUserRole(reference, context, context.token.affiliationId, term, role as unknown as UserRole, opts); } // Unauthorized! @@ -464,6 +481,11 @@ export const resolvers: Resolvers = { const primaryEmail = await UserEmail.findPrimaryByUserId('Chained User.email', context, parent.id); return primaryEmail ? primaryEmail.email : null; }, + // Chained resolver to fetch plans associated with the user + plans: async (parent: User, _, context): Promise => { + console.log("***User ID in User.plans resolver: ", parent.id); + return await Plan.findByUserId('Chained User.plans', context, parent.id); + }, last_sign_in: (parent: User) => { return normaliseDateTime(parent.last_sign_in); }, diff --git a/src/schemas/user.ts b/src/schemas/user.ts index e5425153..dad9355b 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -5,7 +5,7 @@ export const typeDefs = gql` "Returns the currently logged in user's information" me: User "Returns all of the users associated with the current admin's affiliation (Super admins get everything)" - users(term: String, paginationOptions: PaginationOptions): UserSearchResults + users(term: String, role: UserRole, paginationOptions: PaginationOptions): UserSearchResults "Returns the specified user (Admin only)" user(userId: Int!): User } @@ -74,6 +74,8 @@ export const typeDefs = gql` role: UserRole! "The user's organizational affiliation" affiliation: Affiliation + "The plans that the user created" + plans: [Plan] "Whether the user has accepted the terms and conditions of having an account" acceptedTerms: Boolean "The user's ORCID" diff --git a/src/types.ts b/src/types.ts index f3188d2a..48abb0ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3954,6 +3954,7 @@ export type QueryUserArgs = { export type QueryUsersArgs = { paginationOptions?: InputMaybe; + role?: InputMaybe; term?: InputMaybe; }; @@ -5269,6 +5270,8 @@ export type User = { notify_on_template_shared?: Maybe; /** The user's ORCID */ orcid?: Maybe; + /** The plans that the user created */ + plans?: Maybe>>; /** The user's role within the DMPTool */ role: UserRole; /** The user's SSO ID */ @@ -8300,6 +8303,7 @@ export type UserResolvers, ParentType, ContextType>; notify_on_template_shared?: Resolver, ParentType, ContextType>; orcid?: Resolver, ParentType, ContextType>; + plans?: Resolver>>, ParentType, ContextType>; role?: Resolver; ssoId?: Resolver, ParentType, ContextType>; surName?: Resolver, ParentType, ContextType>; From b458aa163dc67609aeafe1ffa108d4f6bf7abc55 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Thu, 18 Jun 2026 13:06:16 -0700 Subject: [PATCH 02/80] Added unit tests for updates to models --- CHANGELOG.md | 2 +- src/models/__tests__/Plan.spec.ts | 17 ++ src/models/__tests__/User.spec.ts | 339 +++++++++++++++++++++++++++++- src/resolvers/user.ts | 15 -- 4 files changed, 353 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68131c75..02825f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,7 +92,7 @@ - added data-migration to fix question JSON so that `"selected": 0` is now `"selected": false` (and `1` -> `true`). ### Updated -- Updated `users` resolver to include `role`, and added a `SORT_FIELD_MAP` [#240] +- Updated `users` resolver to include `role` [#240] - Added `findByAffiliationIdAndUserRole` and updated the `User.search` to accept `role` [#240] - Added `findByUserId` to `Plan` model to find all plans for a given user [#240] - Updated `requestFeedback`, `addTemplate` and `publishTemplateCustomization` resolvers to add a record to the `adminNotifications` table [#570] diff --git a/src/models/__tests__/Plan.spec.ts b/src/models/__tests__/Plan.spec.ts index ffc90607..ba9b0afa 100644 --- a/src/models/__tests__/Plan.spec.ts +++ b/src/models/__tests__/Plan.spec.ts @@ -1013,6 +1013,23 @@ describe('findBy Queries', () => { const result = await Plan.findByProjectId('testing', context, projectId); expect(result).toEqual([]); }); + + it('findByUserId should call query with correct params and return the default', async () => { + localQuery.mockResolvedValueOnce([plan]); + const userId = casual.integer(1, 999); + const result = await Plan.findByUserId('testing', context, userId); + const expectedSql = 'SELECT * FROM plans WHERE createdById = ?'; + expect(localQuery).toHaveBeenCalledTimes(1); + expect(localQuery).toHaveBeenLastCalledWith(context, expectedSql, [userId.toString()], 'testing') + expect(result).toEqual([plan]); + }); + + it('findByUserId should return an empty array if it finds no default', async () => { + localQuery.mockResolvedValueOnce([]); + const userId = casual.integer(1, 999); + const result = await Plan.findByUserId('testing', context, userId); + expect(result).toEqual([]); + }); }); describe('publish', () => { diff --git a/src/models/__tests__/User.spec.ts b/src/models/__tests__/User.spec.ts index 64751952..171e8ec0 100644 --- a/src/models/__tests__/User.spec.ts +++ b/src/models/__tests__/User.spec.ts @@ -10,7 +10,7 @@ import { getRandomEnumValue } from '../../__tests__/helpers'; import { logger } from "../../logger"; import { UserEmail } from '../UserEmail'; import { PaginationType } from '../../types/general'; -import {ProjectCollaborator, TemplateCollaborator} from "../Collaborator"; +import { ProjectCollaborator, TemplateCollaborator } from "../Collaborator"; jest.mock('../../context.ts'); jest.mock('../UserEmail'); @@ -195,7 +195,7 @@ describe('Password validation', () => { it('should allow all of the approved special characters', () => { const chars = ['~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '-', "_", '+', '=', '?', ' ']; for (const char of chars) { - const valid = new User({ password: `Abcd3Fgh1jkL${char}`}).validatePassword(); + const valid = new User({ password: `Abcd3Fgh1jkL${char}` }).validatePassword(); expect(valid, `Failed when testing character ${char}`).toBe(true); } }); @@ -246,7 +246,7 @@ describe('Password validation', () => { it('should fail for a new user if it contains special characters that are not allowed', () => { const badChars = ['(', ')', '{', '[', '}', ']', '|', '\\', ':', ';', '"', "'", '<', ',', '>', '.', '/']; for (const char of badChars) { - const valid = new User({ password: `Abcd3Fgh1jkL${char}`}).validatePassword(); + const valid = new User({ password: `Abcd3Fgh1jkL${char}` }).validatePassword(); expect(valid, `Failed when testing character ${char}`).toBe(false); } }); @@ -290,7 +290,7 @@ describe('authCheck', () => { const email = casual.email; const password = 'Abcd3Fgh1jkL$'; (UserEmail.findByEmail as jest.Mock).mockResolvedValue([ - new UserEmail({ userId: 12345, isPrimary: true, isConfirmed: true, email: email}) + new UserEmail({ userId: 12345, isPrimary: true, isConfirmed: true, email: email }) ]); mockQuery.mockResolvedValueOnce([mockUser]); @@ -1056,3 +1056,334 @@ describe('findByOrcid', () => { expect(result).toEqual(null); }); }); + +describe('findByAffiliationIdAndUserRole', () => { + let context; + let mockPaginatedResults; + + const makeUser = (id: number, role: UserRole) => + new User({ + id, + affiliationId: 'affil-1', + givenName: id === 1 ? 'Alice' : 'Bob', + surName: id === 1 ? 'Smith' : 'Jones', + password: 'password', + role, + languageId: defaultLanguageId, + acceptedTerms: true, + }); + + beforeEach(async () => { + jest.resetAllMocks(); + context = await buildMockContextWithToken(logger); + + mockPaginatedResults = { + items: [makeUser(1, UserRole.RESEARCHER), makeUser(2, UserRole.RESEARCHER)], + totalCount: 2, + hasNextPage: false, + hasPreviousPage: false, + pageInfo: {}, + }; + + jest.spyOn(User, 'queryWithPagination').mockResolvedValue(mockPaginatedResults); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls queryWithPagination and returns users for a given affiliationId and role', async () => { + const result = await User.findByAffiliationIdAndUserRole( + 'testRef', + context, + 'affil-1', + '', + UserRole.RESEARCHER, + { type: PaginationType.OFFSET }, + ); + + expect(User.queryWithPagination).toHaveBeenCalledTimes(1); + expect(result.items.length).toBe(2); + expect(result.items[0].givenName).toBe('Alice'); + expect(result.items[1].givenName).toBe('Bob'); + expect(result.totalCount).toBe(2); + }); + + it('filters by ADMIN role', async () => { + const adminUser = makeUser(3, UserRole.ADMIN); + (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ + items: [adminUser], + totalCount: 1, + hasNextPage: false, + hasPreviousPage: false, + pageInfo: {}, + }); + + const result = await User.findByAffiliationIdAndUserRole( + 'testRef', + context, + 'affil-1', + '', + UserRole.ADMIN, + { type: PaginationType.OFFSET }, + ); + + const [, , whereFilters, , values] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(whereFilters).toContain('u.role = ?'); + expect(values).toContain(UserRole.ADMIN); + expect(result.items.length).toBe(1); + }); + + it('passes the search term through to the query values', async () => { + await User.findByAffiliationIdAndUserRole( + 'testRef', + context, + 'affil-1', + 'alice', + UserRole.RESEARCHER, + { type: PaginationType.OFFSET }, + ); + + const [, , whereFilters, , values] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(whereFilters.some((f: string) => f.includes('LOWER(u.givenName) LIKE ?'))).toBe(true); + expect(values.some((v: string) => v.includes('alice'))).toBe(true); + }); + + it('uses OFFSET pagination options when type is OFFSET', async () => { + await User.findByAffiliationIdAndUserRole( + 'testRef', + context, + 'affil-1', + '', + UserRole.RESEARCHER, + { type: PaginationType.OFFSET, sortField: 'u.surName', sortDir: 'ASC' }, + ); + + const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(opts.availableSortFields).toBeDefined(); + expect(opts.availableSortFields).toContain('u.surName'); + expect(opts.sortField).toBe('u.surName'); + expect(opts.sortDir).toBe('ASC'); + }); + + it('uses CURSOR pagination options when type is CURSOR', async () => { + await User.findByAffiliationIdAndUserRole( + 'testRef', + context, + 'affil-1', + '', + UserRole.RESEARCHER, + { type: PaginationType.CURSOR }, + ); + + const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(opts.cursorField).toBeDefined(); + expect(opts.availableSortFields).toBeUndefined(); + }); + + it('applies default sort field and direction when none are provided', async () => { + await User.findByAffiliationIdAndUserRole( + 'testRef', + context, + 'affil-1', + '', + UserRole.RESEARCHER, + { type: PaginationType.OFFSET }, + ); + + const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(opts.sortField).toBe('u.created'); + expect(opts.sortDir).toBe('DESC'); + }); + + it('returns empty results when no users match', async () => { + (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ + items: [], + totalCount: 0, + hasNextPage: false, + hasPreviousPage: false, + pageInfo: {}, + }); + + const result = await User.findByAffiliationIdAndUserRole( + 'testRef', + context, + 'affil-99', + '', + UserRole.ADMIN, + { type: PaginationType.OFFSET }, + ); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + }); +}); + +describe('search', () => { + let context; + let mockPaginatedResults; + + const makeUser = (id: number, role: UserRole) => + new User({ + id, + affiliationId: casual.url, + givenName: id === 1 ? 'Alice' : 'Bob', + surName: id === 1 ? 'Smith' : 'Jones', + password: 'password', + role, + languageId: defaultLanguageId, + acceptedTerms: true, + }); + + beforeEach(async () => { + jest.resetAllMocks(); + context = await buildMockContextWithToken(logger); + + mockPaginatedResults = { + items: [makeUser(1, UserRole.RESEARCHER), makeUser(2, UserRole.RESEARCHER)], + totalCount: 2, + hasNextPage: false, + hasPreviousPage: false, + pageInfo: {}, + }; + + jest.spyOn(User, 'queryWithPagination').mockResolvedValue(mockPaginatedResults); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls queryWithPagination and returns matching users', async () => { + const result = await User.search( + 'testRef', + context, + 'alice', + { type: PaginationType.OFFSET }, + ); + + expect(User.queryWithPagination).toHaveBeenCalledTimes(1); + expect(result.items.length).toBe(2); + expect(result.totalCount).toBe(2); + }); + + it('passes the search term into whereFilters and values', async () => { + await User.search('testRef', context, 'alice', { type: PaginationType.OFFSET }); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const whereFilters = callArgs[2]; + const values = callArgs[4]; + + expect(whereFilters.some((f: string) => f.includes('LOWER(u.givenName) LIKE ?'))).toBe(true); + expect(whereFilters.some((f: string) => f.includes('LOWER(a.searchName) LIKE ?'))).toBe(true); + expect(values.every((v: string) => v === '%alice%')).toBe(true); + expect(values.length).toBe(5); // one per LIKE clause + }); + + it('adds role filter to whereFilters and values when role is provided', async () => { + await User.search('testRef', context, '', { type: PaginationType.OFFSET }, UserRole.ADMIN); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const whereFilters = callArgs[2]; + const values = callArgs[4]; + + expect(whereFilters).toContain('u.role = ?'); + expect(values).toContain(UserRole.ADMIN); + }); + + it('does not add role filter when role is not provided', async () => { + await User.search('testRef', context, '', { type: PaginationType.OFFSET }); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const whereFilters = callArgs[2]; + + expect(whereFilters.every((f: string) => !f.includes('u.role = ?'))).toBe(true); + }); + + it('includes both term and role filters when both are provided', async () => { + await User.search('testRef', context, 'alice', { type: PaginationType.OFFSET }, UserRole.ADMIN); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const whereFilters = callArgs[2]; + const values = callArgs[4]; + + expect(whereFilters.some((f: string) => f.includes('LOWER(u.givenName) LIKE ?'))).toBe(true); + expect(whereFilters).toContain('u.role = ?'); + expect(values).toContain('%alice%'); + expect(values).toContain(UserRole.ADMIN); + }); + + it('uses OFFSET pagination and sets availableSortFields', async () => { + await User.search( + 'testRef', + context, + '', + { type: PaginationType.OFFSET, sortField: 'u.surName', sortDir: 'ASC' }, + ); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const opts = callArgs[5]; + + expect(opts.availableSortFields).toBeDefined(); + expect(opts.availableSortFields).toContain('u.surName'); + expect(opts.availableSortFields).toContain('a.name'); + expect(opts.cursorField).toBeUndefined(); + expect(opts.sortField).toBe('u.surName'); + expect(opts.sortDir).toBe('ASC'); + }); + + it('uses CURSOR pagination and sets cursorField', async () => { + await User.search('testRef', context, '', { type: PaginationType.CURSOR }); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const opts = callArgs[5]; + + expect(opts.cursorField).toBe('CONCAT(ue.email, u.id)'); + expect(opts.availableSortFields).toBeUndefined(); + }); + + it('applies default sort field and direction when none are provided', async () => { + await User.search('testRef', context, '', { type: PaginationType.OFFSET }); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const opts = callArgs[5]; + + expect(opts.sortField).toBe('u.created'); + expect(opts.sortDir).toBe('DESC'); + }); + + it('sets countField to u.id', async () => { + await User.search('testRef', context, '', { type: PaginationType.OFFSET }); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const opts = callArgs[5]; + + expect(opts.countField).toBe('u.id'); + }); + + it('returns empty results when no users match', async () => { + (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ + items: [], + totalCount: 0, + hasNextPage: false, + hasPreviousPage: false, + pageInfo: {}, + }); + + const result = await User.search('testRef', context, 'nobody', { type: PaginationType.OFFSET }); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('handles an empty search term without adding whereFilters', async () => { + await User.search('testRef', context, '', { type: PaginationType.OFFSET }); + + const callArgs = (User.queryWithPagination as jest.Mock).mock.calls[0]; + const whereFilters = callArgs[2]; + + // empty string is still truthy for isNullOrUndefined, so the LIKE block still runs — + // but the values will all be '%%' (match everything), not zero filters + expect(whereFilters.some((f: string) => f.includes('LOWER(u.givenName) LIKE ?'))).toBe(true); + }); +}); diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 681f2c1f..369d4aec 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -14,16 +14,6 @@ import { GraphQLError } from "graphql"; import { PaginationOptionsForCursors, PaginationOptionsForOffsets, PaginationType } from "../types/general"; import { isNullOrUndefined, normaliseDateTime } from "../utils/helpers"; -const SORT_FIELD_MAP: Record = { - name: 'u.surName', - email: 'ue.email', - role: 'u.role', - active: 'u.active', - created: 'u.created', - lastActivity: 'u.last_sign_in', - organization: 'a.name', -}; - export const resolvers: Resolvers = { Query: { // returns the current User @@ -48,11 +38,6 @@ export const resolvers: Resolvers = { const reference = 'users resolver'; try { - // Map the frontend column id to a SQL field before building opts - if (paginationOptions?.sortField) { - paginationOptions.sortField = SORT_FIELD_MAP[paginationOptions.sortField] ?? 'u.created'; - } - const opts = !isNullOrUndefined(paginationOptions) && paginationOptions.type === PaginationType.OFFSET ? paginationOptions as PaginationOptionsForOffsets : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; From 0af20b4fe469a5c30476631b1707f89f6cf19366 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Thu, 18 Jun 2026 13:13:31 -0700 Subject: [PATCH 03/80] Removed console.log --- src/resolvers/user.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 369d4aec..483cbbaa 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -468,7 +468,6 @@ export const resolvers: Resolvers = { }, // Chained resolver to fetch plans associated with the user plans: async (parent: User, _, context): Promise => { - console.log("***User ID in User.plans resolver: ", parent.id); return await Plan.findByUserId('Chained User.plans', context, parent.id); }, last_sign_in: (parent: User) => { From 5bd67a38639262c34547a23421497d190ec28cc2 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Thu, 18 Jun 2026 13:57:26 -0700 Subject: [PATCH 04/80] Fixed issue with 'findByAffiliationIdAndUserRole' resolver --- src/models/User.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/models/User.ts b/src/models/User.ts index 54d21006..c8ab6965 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -279,8 +279,14 @@ export class User extends MySqlModel { role: UserRole, options: PaginationOptions = User.getDefaultPaginationOptions(), ): Promise> { - const whereFilters = ['u.affiliationId = ?', 'u.role = ?']; - const values = [affiliationId, role]; + const whereFilters = ['u.affiliationId = ?']; + const values = [affiliationId]; + + // Only filter by role if one was provided + if (!isNullOrUndefined(role)) { + whereFilters.push('u.role = ?'); + values.push(role); + } const searchTerm = (term ?? '').toLowerCase().trim(); if (!isNullOrUndefined(searchTerm)) { @@ -320,7 +326,6 @@ export class User extends MySqlModel { // Specify the field we want to use for the count opts.countField = 'u.id'; - const response: PaginatedQueryResults = await User.queryWithPagination( context, sqlStatement, @@ -346,7 +351,6 @@ export class User extends MySqlModel { const whereFilters: string[] = []; const values: string[] = []; - // Handle the incoming search term const searchTerm = (term ?? '').toLowerCase().trim(); if (!isNullOrUndefined(searchTerm)) { From bce8a9815cc51d1b4313f484fab86bb89bd574f3 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Thu, 18 Jun 2026 14:20:50 -0700 Subject: [PATCH 05/80] removed findByAffiliationIdAndUserRole since it was almost identical to findByAffiliationId --- CHANGELOG.md | 2 +- src/models/User.ts | 76 +------- src/models/__tests__/User.spec.ts | 312 +++++++++++------------------- src/resolvers/user.ts | 2 +- 4 files changed, 125 insertions(+), 267 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9827dd5b..881f5663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,7 @@ ### Updated - Updated `users` resolver to include `role` [#240] -- Added `findByAffiliationIdAndUserRole` and updated the `User.search` to accept `role` [#240] +- Added `findByAffiliationId` and `search` to pass in `role` as optional [#240] - Added `findByUserId` to `Plan` model to find all plans for a given user [#240] - Updated Trivy scripts to ignore the entire `docker/` directory - Updated Localstack startup file to remove unused lambda function and SQS. diff --git a/src/models/User.ts b/src/models/User.ts index c8ab6965..09cf9df4 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -213,10 +213,15 @@ export class User extends MySqlModel { affiliationId: string, term: string, options: PaginationOptions = User.getDefaultPaginationOptions(), + role?: UserRole ): Promise> { const whereFilters = ['u.affiliationId = ?']; const values = [affiliationId]; + if (!isNullOrUndefined(role)) { + whereFilters.push('u.role = ?'); + values.push(role); + } // Handle the incoming search term const searchTerm = (term ?? '').toLowerCase().trim(); if (!isNullOrUndefined(searchTerm)) { @@ -228,76 +233,6 @@ export class User extends MySqlModel { values.push(`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`); } - // Join users with user_emails - const sqlStatement = ` - SELECT u.* FROM users u - LEFT JOIN userEmails ue ON u.id = ue.userId AND ue.isPrimary = 1 - `; - - // Determine the type of pagination being used - let opts; - if (options.type === PaginationType.OFFSET) { - opts = { - ...options, - // Specify the fields available for sorting - availableSortFields: ['u.surName', 'u.givenName', 'u.created', 'ue.email', 'u.orcid'], - } as PaginationOptionsForOffsets; - } else { - opts = { - ...options, - // Specify the field we want to use for the cursor (should typically match the sort field) - cursorField: 'CONCAT(ue.email, u.id)', - } as PaginationOptionsForCursors; - } - - // Set the default sort field and order if none was provided - if (isNullOrUndefined(opts.sortField)) opts.sortField = 'u.created'; - if (isNullOrUndefined(opts.sortDir)) opts.sortDir = 'DESC'; - - // Specify the field we want to use for the count - opts.countField = 'u.id'; - - const response: PaginatedQueryResults = await User.queryWithPagination( - context, - sqlStatement, - whereFilters, - '', - values, - opts, - reference, - ); - - context.logger.debug(prepareObjectForLogs({ options, response }), reference); - return response; - } - - static async findByAffiliationIdAndUserRole( - reference: string, - context: MyContext, - affiliationId: string, - term: string, - role: UserRole, - options: PaginationOptions = User.getDefaultPaginationOptions(), - ): Promise> { - const whereFilters = ['u.affiliationId = ?']; - const values = [affiliationId]; - - // Only filter by role if one was provided - if (!isNullOrUndefined(role)) { - whereFilters.push('u.role = ?'); - values.push(role); - } - - const searchTerm = (term ?? '').toLowerCase().trim(); - if (!isNullOrUndefined(searchTerm)) { - whereFilters.push(`( - LOWER(u.givenName) LIKE ? OR - LOWER(u.surName) LIKE ? OR - LOWER(ue.email) LIKE ? OR - LOWER(u.orcid) LIKE ?)`); - values.push(`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`); - } - // Join users with user_emails const sqlStatement = ` SELECT u.* FROM users u @@ -326,6 +261,7 @@ export class User extends MySqlModel { // Specify the field we want to use for the count opts.countField = 'u.id'; + const response: PaginatedQueryResults = await User.queryWithPagination( context, sqlStatement, diff --git a/src/models/__tests__/User.spec.ts b/src/models/__tests__/User.spec.ts index 171e8ec0..833446e8 100644 --- a/src/models/__tests__/User.spec.ts +++ b/src/models/__tests__/User.spec.ts @@ -855,55 +855,139 @@ describe('findByAffiliationId', () => { let context; let mockPaginatedResults; + const makeUser = (id: number, role: UserRole) => + new User({ + id, + affiliationId: 'affil-1', + givenName: id === 1 ? 'Alice' : 'Bob', + surName: id === 1 ? 'Smith' : 'Jones', + password: 'password', + role, + languageId: defaultLanguageId, + acceptedTerms: true, + }); + beforeEach(async () => { jest.resetAllMocks(); context = await buildMockContextWithToken(logger); mockPaginatedResults = { - items: [ - new User({ - id: 1, - affiliationId: 'affil-1', - givenName: 'Alice', - surName: 'Smith', - password: 'password', - role: UserRole.RESEARCHER, - languageId: defaultLanguageId, - acceptedTerms: true, - }), - new User({ - id: 2, - affiliationId: 'affil-1', - givenName: 'Bob', - surName: 'Jones', - password: 'password', - role: UserRole.RESEARCHER, - languageId: defaultLanguageId, - acceptedTerms: true, - }) - ], + items: [makeUser(1, UserRole.RESEARCHER), makeUser(2, UserRole.RESEARCHER)], totalCount: 2, hasNextPage: false, hasPreviousPage: false, - pageInfo: {} + pageInfo: {}, }; jest.spyOn(User, 'queryWithPagination').mockResolvedValue(mockPaginatedResults); }); - it('should return users for a given affiliationId and term', async () => { - const affiliationId = 'affil-1'; - const term = 'Alice'; - const options = { type: PaginationType.OFFSET, sortField: 'u.surName', sortDir: 'ASC' }; - const result = await User.findByAffiliationId('testRef', context, affiliationId, term, options); - expect(User.queryWithPagination).toHaveBeenCalled(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns users for a given affiliationId without role filter', async () => { + const result = await User.findByAffiliationId( + 'testRef', context, 'affil-1', 'Alice', + { type: PaginationType.OFFSET, sortField: 'u.surName', sortDir: 'ASC' }, + ); + + expect(User.queryWithPagination).toHaveBeenCalledTimes(1); expect(result.items.length).toBe(2); expect(result.items[0].givenName).toBe('Alice'); expect(result.items[1].givenName).toBe('Bob'); + + const [, , whereFilters, , values] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(whereFilters).toContain('u.affiliationId = ?'); + expect(whereFilters.every((f: string) => !f.includes('u.role = ?'))).toBe(true); + expect(values).toContain('affil-1'); + }); + + it('filters by role when role is provided', async () => { + (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ + items: [makeUser(3, UserRole.ADMIN)], + totalCount: 1, + hasNextPage: false, + hasPreviousPage: false, + pageInfo: {}, + }); + + const result = await User.findByAffiliationId( + 'testRef', context, 'affil-1', '', + { type: PaginationType.OFFSET }, + UserRole.ADMIN, + ); + + const [, , whereFilters, , values] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(whereFilters).toContain('u.role = ?'); + expect(values).toContain(UserRole.ADMIN); + expect(result.items.length).toBe(1); }); - it('should handle empty results', async () => { - (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ items: [], limit: 10, totalCount: 0, hasNextPage: false, hasPreviousPage: false }); - const options = { type: PaginationType.OFFSET }; - const result = await User.findByAffiliationId('testRef', context, 'affil-2', '', options); + it('does not add role filter when role is undefined', async () => { + await User.findByAffiliationId( + 'testRef', context, 'affil-1', '', + { type: PaginationType.OFFSET }, + ); + + const [, , whereFilters] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(whereFilters.every((f: string) => !f.includes('u.role = ?'))).toBe(true); + }); + + it('passes the search term through to the query values', async () => { + await User.findByAffiliationId( + 'testRef', context, 'affil-1', 'alice', + { type: PaginationType.OFFSET }, + ); + + const [, , whereFilters, , values] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(whereFilters.some((f: string) => f.includes('LOWER(u.givenName) LIKE ?'))).toBe(true); + expect(values.some((v: string) => v.includes('alice'))).toBe(true); + }); + + it('uses OFFSET pagination options when type is OFFSET', async () => { + await User.findByAffiliationId( + 'testRef', context, 'affil-1', '', + { type: PaginationType.OFFSET, sortField: 'u.surName', sortDir: 'ASC' }, + ); + + const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(opts.availableSortFields).toBeDefined(); + expect(opts.availableSortFields).toContain('u.surName'); + expect(opts.sortField).toBe('u.surName'); + expect(opts.sortDir).toBe('ASC'); + }); + + it('uses CURSOR pagination options when type is CURSOR', async () => { + await User.findByAffiliationId( + 'testRef', context, 'affil-1', '', + { type: PaginationType.CURSOR }, + ); + + const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(opts.cursorField).toBeDefined(); + expect(opts.availableSortFields).toBeUndefined(); + }); + + it('applies default sort field and direction when none are provided', async () => { + await User.findByAffiliationId( + 'testRef', context, 'affil-1', '', + { type: PaginationType.OFFSET }, + ); + + const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; + expect(opts.sortField).toBe('u.created'); + expect(opts.sortDir).toBe('DESC'); + }); + + it('handles empty results', async () => { + (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ + items: [], totalCount: 0, hasNextPage: false, hasPreviousPage: false, + }); + + const result = await User.findByAffiliationId( + 'testRef', context, 'affil-99', '', + { type: PaginationType.OFFSET }, + ); + expect(result.items).toEqual([]); expect(result.totalCount).toBe(0); }); @@ -1057,168 +1141,6 @@ describe('findByOrcid', () => { }); }); -describe('findByAffiliationIdAndUserRole', () => { - let context; - let mockPaginatedResults; - - const makeUser = (id: number, role: UserRole) => - new User({ - id, - affiliationId: 'affil-1', - givenName: id === 1 ? 'Alice' : 'Bob', - surName: id === 1 ? 'Smith' : 'Jones', - password: 'password', - role, - languageId: defaultLanguageId, - acceptedTerms: true, - }); - - beforeEach(async () => { - jest.resetAllMocks(); - context = await buildMockContextWithToken(logger); - - mockPaginatedResults = { - items: [makeUser(1, UserRole.RESEARCHER), makeUser(2, UserRole.RESEARCHER)], - totalCount: 2, - hasNextPage: false, - hasPreviousPage: false, - pageInfo: {}, - }; - - jest.spyOn(User, 'queryWithPagination').mockResolvedValue(mockPaginatedResults); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('calls queryWithPagination and returns users for a given affiliationId and role', async () => { - const result = await User.findByAffiliationIdAndUserRole( - 'testRef', - context, - 'affil-1', - '', - UserRole.RESEARCHER, - { type: PaginationType.OFFSET }, - ); - - expect(User.queryWithPagination).toHaveBeenCalledTimes(1); - expect(result.items.length).toBe(2); - expect(result.items[0].givenName).toBe('Alice'); - expect(result.items[1].givenName).toBe('Bob'); - expect(result.totalCount).toBe(2); - }); - - it('filters by ADMIN role', async () => { - const adminUser = makeUser(3, UserRole.ADMIN); - (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ - items: [adminUser], - totalCount: 1, - hasNextPage: false, - hasPreviousPage: false, - pageInfo: {}, - }); - - const result = await User.findByAffiliationIdAndUserRole( - 'testRef', - context, - 'affil-1', - '', - UserRole.ADMIN, - { type: PaginationType.OFFSET }, - ); - - const [, , whereFilters, , values] = (User.queryWithPagination as jest.Mock).mock.calls[0]; - expect(whereFilters).toContain('u.role = ?'); - expect(values).toContain(UserRole.ADMIN); - expect(result.items.length).toBe(1); - }); - - it('passes the search term through to the query values', async () => { - await User.findByAffiliationIdAndUserRole( - 'testRef', - context, - 'affil-1', - 'alice', - UserRole.RESEARCHER, - { type: PaginationType.OFFSET }, - ); - - const [, , whereFilters, , values] = (User.queryWithPagination as jest.Mock).mock.calls[0]; - expect(whereFilters.some((f: string) => f.includes('LOWER(u.givenName) LIKE ?'))).toBe(true); - expect(values.some((v: string) => v.includes('alice'))).toBe(true); - }); - - it('uses OFFSET pagination options when type is OFFSET', async () => { - await User.findByAffiliationIdAndUserRole( - 'testRef', - context, - 'affil-1', - '', - UserRole.RESEARCHER, - { type: PaginationType.OFFSET, sortField: 'u.surName', sortDir: 'ASC' }, - ); - - const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; - expect(opts.availableSortFields).toBeDefined(); - expect(opts.availableSortFields).toContain('u.surName'); - expect(opts.sortField).toBe('u.surName'); - expect(opts.sortDir).toBe('ASC'); - }); - - it('uses CURSOR pagination options when type is CURSOR', async () => { - await User.findByAffiliationIdAndUserRole( - 'testRef', - context, - 'affil-1', - '', - UserRole.RESEARCHER, - { type: PaginationType.CURSOR }, - ); - - const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; - expect(opts.cursorField).toBeDefined(); - expect(opts.availableSortFields).toBeUndefined(); - }); - - it('applies default sort field and direction when none are provided', async () => { - await User.findByAffiliationIdAndUserRole( - 'testRef', - context, - 'affil-1', - '', - UserRole.RESEARCHER, - { type: PaginationType.OFFSET }, - ); - - const [, , , , , opts] = (User.queryWithPagination as jest.Mock).mock.calls[0]; - expect(opts.sortField).toBe('u.created'); - expect(opts.sortDir).toBe('DESC'); - }); - - it('returns empty results when no users match', async () => { - (User.queryWithPagination as jest.Mock).mockResolvedValueOnce({ - items: [], - totalCount: 0, - hasNextPage: false, - hasPreviousPage: false, - pageInfo: {}, - }); - - const result = await User.findByAffiliationIdAndUserRole( - 'testRef', - context, - 'affil-99', - '', - UserRole.ADMIN, - { type: PaginationType.OFFSET }, - ); - - expect(result.items).toEqual([]); - expect(result.totalCount).toBe(0); - }); -}); - describe('search', () => { let context; let mockPaginatedResults; diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 483cbbaa..654d509a 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -46,7 +46,7 @@ export const resolvers: Resolvers = { return await User.search(reference, context, term, opts, role as unknown as UserRole,); } else if (isAdmin(context.token)) { - return await User.findByAffiliationIdAndUserRole(reference, context, context.token.affiliationId, term, role as unknown as UserRole, opts); + return await User.findByAffiliationId(reference, context, context.token.affiliationId, term, opts, role as unknown as UserRole,); } // Unauthorized! From 115641f8046ee9d406c0715db4860ad6892bf242 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Mon, 22 Jun 2026 08:07:03 -0700 Subject: [PATCH 06/80] Added affiliationId variable to 'users' resolver --- src/models/User.ts | 15 ++++++++++++++- src/resolvers/user.ts | 6 +++--- src/schemas/user.ts | 2 +- src/types.ts | 1 + 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/models/User.ts b/src/models/User.ts index 09cf9df4..f4c3fb77 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -283,6 +283,7 @@ export class User extends MySqlModel { term: string, options: PaginationOptions = User.getDefaultPaginationOptions(), role?: UserRole, + affiliationId?: string, ): Promise> { const whereFilters: string[] = []; const values: string[] = []; @@ -305,6 +306,18 @@ export class User extends MySqlModel { values.push(role); } + // Add role filter if provided + if (!isNullOrUndefined(role)) { + whereFilters.push('u.role = ?'); + values.push(role); + } + + // Add affiliation filter if provided + if (!isNullOrUndefined(affiliationId)) { + whereFilters.push('a.uri = ?'); + values.push(affiliationId); + } + // Determine the type of pagination being used let opts; if (options.type === PaginationType.OFFSET) { @@ -330,7 +343,7 @@ export class User extends MySqlModel { // Join users with user_emails const sqlStatement = ` - SELECT u.*, a.name FROM users u + SELECT u.*, a.name, a.uri FROM users u LEFT JOIN affiliations a ON u.affiliationId = a.uri LEFT JOIN userEmails ue ON u.id = ue.userId AND ue.isPrimary = 1 `; diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 654d509a..b59763c4 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -34,7 +34,7 @@ export const resolvers: Resolvers = { // Should only be callable by an Admin. Super returns all users, Admin gets only // the users associated with their affiliationId - users: async (_, { term, role, paginationOptions }, context): Promise => { + users: async (_, { term, role, affiliationId, paginationOptions }, context): Promise => { const reference = 'users resolver'; try { @@ -43,10 +43,10 @@ export const resolvers: Resolvers = { : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; if (isSuperAdmin(context.token)) { - return await User.search(reference, context, term, opts, role as unknown as UserRole,); + return await User.search(reference, context, term, opts, role as unknown as UserRole, affiliationId); } else if (isAdmin(context.token)) { - return await User.findByAffiliationId(reference, context, context.token.affiliationId, term, opts, role as unknown as UserRole,); + return await User.findByAffiliationId(reference, context, context.token.affiliationId, term, opts, role as unknown as UserRole); } // Unauthorized! diff --git a/src/schemas/user.ts b/src/schemas/user.ts index dad9355b..b409d7da 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -5,7 +5,7 @@ export const typeDefs = gql` "Returns the currently logged in user's information" me: User "Returns all of the users associated with the current admin's affiliation (Super admins get everything)" - users(term: String, role: UserRole, paginationOptions: PaginationOptions): UserSearchResults + users(term: String, role: UserRole, affiliationId: String, paginationOptions: PaginationOptions): UserSearchResults "Returns the specified user (Admin only)" user(userId: Int!): User } diff --git a/src/types.ts b/src/types.ts index f2a8d157..ddef3abd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3965,6 +3965,7 @@ export type QueryUserArgs = { export type QueryUsersArgs = { + affiliationId?: InputMaybe; paginationOptions?: InputMaybe; role?: InputMaybe; term?: InputMaybe; From 6314f695b815df44287cb2d36a832e5f0b6ab3fe Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Mon, 22 Jun 2026 08:07:12 -0700 Subject: [PATCH 07/80] Added affiliationId variable to 'users' resolver --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 881f5663..9bada5da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,7 +101,7 @@ - added data-migration to fix question JSON so that `"selected": 0` is now `"selected": false` (and `1` -> `true`). ### Updated -- Updated `users` resolver to include `role` [#240] +- Updated `users` resolver to include `role` and `affiliationId` [#240] - Added `findByAffiliationId` and `search` to pass in `role` as optional [#240] - Added `findByUserId` to `Plan` model to find all plans for a given user [#240] - Updated Trivy scripts to ignore the entire `docker/` directory From e8fee59fcb169d09ee977e49a32e852dd8b528f2 Mon Sep 17 00:00:00 2001 From: Brian Riley Date: Mon, 22 Jun 2026 09:53:28 -0700 Subject: [PATCH 08/80] added a research output question to the local default template --- CHANGELOG.md | 1 + ...3-add-research-output-to-default-tmplt.sql | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 data-migrations/local-only/2026-06-22-0923-add-research-output-to-default-tmplt.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c31dfe..1ff40757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v1.1.0 ### Added +- Added a default researc h output table question to the default template - Added data migration to add `displayAbbreviation` and `displayDomain` to the `affiliations` table - Added data migration to backfill those new DB fields - Added LocalStack port env variable to the docker compose file and `awsConfig` file diff --git a/data-migrations/local-only/2026-06-22-0923-add-research-output-to-default-tmplt.sql b/data-migrations/local-only/2026-06-22-0923-add-research-output-to-default-tmplt.sql new file mode 100644 index 00000000..b5293c4f --- /dev/null +++ b/data-migrations/local-only/2026-06-22-0923-add-research-output-to-default-tmplt.sql @@ -0,0 +1,42 @@ +SET @default_super_id := (SELECT id FROM userEmails WHERE email = 'super@example.com'); +SET @default_template_id := (SELECT id FROM templates WHERE name = 'Digital Curation Centre'); + +-- Ensure that the default template is set +UPDATE templates SET isDefault = 0 WHERE isDefault = 1; +UPDATE templates SET isDefault = 1 WHERE id = @default_template_id; + +-- Add the new Research Output section and question to the default template +INSERT INTO sections (templateId, name, displayOrder, createdById, created, modifiedById, modified) +VALUES (@default_template_id, 'Research Outputs', 8, @default_super_id, CURDATE(), @default_super_id, CURDATE()); +SET @section_id := LAST_INSERT_ID(); + +INSERT INTO sectionTags (sectionId, tagId, createdById, created, modifiedById, modified) +VALUES (@section_id, 10, @default_super_id, CURDATE(), @default_super_id, CURDATE()); +INSERT INTO sectionTags (sectionId, tagId, createdById, created, modifiedById, modified) +VALUES (@section_id, 11, @default_super_id, CURDATE(), @default_super_id, CURDATE()); +INSERT INTO sectionTags (sectionId, tagId, createdById, created, modifiedById, modified) +VALUES (@section_id, 13, @default_super_id, CURDATE(), @default_super_id, CURDATE()); + +INSERT INTO questions (templateId, sectionId, displayOrder, questionText, requirementText, createdById, created, modifiedById, modified, json) +VALUES (@default_template_id, @section_id, 1, 'Please list all research outputs that you intend to create as part of your project.', '

Include all datasets, software, audio visual files, etc.

', @default_super_id, CURDATE(), @default_super_id, CURDATE(), '{"meta": {"title": "Research Output Table", "schemaVersion": "1.0", "usageDescription": "A table for collecting structured research output data"}, "type": "researchOutputTable", "columns": [{"help": "Enter the title of this research output", "content": {"meta": {"schemaVersion": "1.0"}, "type": "text", "attributes": {"maxLength": 120}}, "enabled": true, "heading": "Title", "required": true}, {"help": "Enter a brief description of this research output", "content": {"meta": {"schemaVersion": "1.0"}, "type": "textArea", "attributes": {"cols": 20, "rows": 2, "label": "Description", "maxLength": 10000, "asRichText": true, "labelTranslationKey": "labels.description"}}, "enabled": true, "heading": "Description", "required": false}, {"help": "Select the type of this research output", "content": {"meta": {"schemaVersion": "1.0"}, "type": "selectBox", "options": [], "attributes": {"label": "Output Type", "multiple": false, "labelTranslationKey": "labels.outputType"}}, "enabled": true, "heading": "Output Type", "required": false}, {"help": "Mark all of the statements that are true about the dataset", "content": {"meta": {"schemaVersion": "1.0"}, "type": "checkBoxes", "options": [{"label": "May contain sensitive data?", "value": "sensitive", "selected": true}, {"label": "May contain personally identifiable information?", "value": "personal", "selected": true}], "attributes": {"labelTranslationKey": "labels.dataFlags"}}, "enabled": true, "heading": "Data Flags", "required": false}, {"help": "Select repository(ies) you would prefer users to deposit in", "content": {"meta": {"schemaVersion": "1.0"}, "type": "repositorySearch", "graphQL": {"query": "query Repositories($term: String, $keywords: [String!], $repositoryType: String, $paginationOptions: PaginationOptions){ repositories(term: $term, keywords: $keywords, repositoryType: $repositoryType, paginationOptions: $paginationOptions) { totalCount currentOffset limit hasNextPage hasPreviousPage availableSortFields items { id name uri description website keywords repositoryTypes } } }", "queryId": "useRepositoriesQuery", "variables": [{"name": "term", "type": "string", "label": "Search for a repository", "minLength": 3, "labelTranslationKey": "RepositorySearch.term"}, {"name": "keywords", "type": "string", "label": "Subject Areas", "minLength": 3, "labelTranslationKey": "RepositorySearch.keywords"}, {"name": "repositoryType", "type": "string", "label": "Repository type", "minLength": 3, "labelTranslationKey": "RepositorySearch.repositoryType"}, {"name": "paginationOptions", "type": "OFFSET", "label": "Pagination Options", "labelTranslationKey": "PaginationOptions.label"}], "answerField": "uri", "displayFields": [{"label": "Name", "propertyName": "name", "labelTranslationKey": "RepositorySearch.name"}, {"label": "Description", "propertyName": "description", "labelTranslationKey": "RepositorySearch.description"}, {"label": "Website", "propertyName": "website", "labelTranslationKey": "RepositorySearch.website"}, {"label": "Subject Areas", "propertyName": "keywords", "labelTranslationKey": "RepositorySearch.keywords"}], "responseField": "repositories.items"}, "attributes": {"help": "", "label": "Repositories", "labelTranslationKey": "labels.repositories"}}, "enabled": true, "heading": "Repositories", "required": false, "preferences": []}, {"help": "Select metadata standard(s) you would prefer users to use", "content": {"meta": {"schemaVersion": "1.0"}, "type": "metadataStandardSearch", "graphQL": {"query": "query MetadataStandards($term: String, $keywords: [String!], $paginationOptions: PaginationOptions){ metadataStandards(term: $term, keywords: $keywords, paginationOptions: $paginationOptions) { totalCount currentOffset limit hasNextPage hasPreviousPage availableSortFields items { id name uri description keywords } } }", "queryId": "useMetadataStandardsQuery", "variables": [{"name": "term", "type": "string", "label": "Search for a metadata standard", "minLength": 3, "labelTranslationKey": "MetadataStandardSearch.term"}, {"name": "keywords", "type": "string", "label": "Subject Areas", "minLength": 3, "labelTranslationKey": "MetadataStandardSearch.keywords"}, {"name": "paginationOptions", "type": "OFFSET", "label": "Pagination Options", "labelTranslationKey": "PaginationOptions.label"}], "answerField": "uri", "displayFields": [{"label": "Name", "propertyName": "name", "labelTranslationKey": "MetadataStandardSearch.name"}, {"label": "Description", "propertyName": "description", "labelTranslationKey": "MetadataStandardSearch.description"}, {"label": "Website", "propertyName": "website", "labelTranslationKey": "MetadataStandardSearch.website"}, {"label": "Subject Areas", "propertyName": "keywords", "labelTranslationKey": "MetadataStandardSearch.keywords"}], "responseField": "metadataStandards.items"}, "attributes": {"help": "", "label": "Metadata Standards", "labelTranslationKey": "labels.metadataStandards"}}, "enabled": true, "heading": "Metadata Standards", "required": false, "preferences": []}, {"help": "Select the license you will apply to the research output", "content": {"meta": {"schemaVersion": "1.0"}, "type": "licenseSearch", "graphQL": {"query": "query Licenses{ licenses { id name uri description } }", "queryId": "useLicensesQuery", "variables": [], "answerField": "uri", "displayFields": [{"label": "Name", "propertyName": "name", "labelTranslationKey": "LicenseSearch.name"}, {"label": "Description", "propertyName": "description", "labelTranslationKey": "LicenseSearch.description"}, {"label": "Recommended", "propertyName": "recommended", "labelTranslationKey": "LicenseSearch.recommended"}], "responseField": "licenses"}, "attributes": {"help": "", "label": "Licenses", "labelTranslationKey": "labels.licenses"}}, "enabled": true, "heading": "Licenses", "required": false, "preferences": []}, {"help": "Select the access level for this research output", "content": {"meta": {"schemaVersion": "1.0"}, "type": "radioButtons", "options": [], "attributes": {"label": "Initial Access Levels", "labelTranslationKey": "labels.initialAccessLevels"}}, "enabled": true, "heading": "Initial Access Levels", "required": false}], "attributes": {"help": "", "label": "", "canAddRows": true, "initialRows": 1, "canRemoveRows": true, "labelTranslationKey": ""}, "showCommentField": false}'); +SET @question_id := LAST_INSERT_ID(); + +-- Then generate/publish the new version +UPDATE versionedTemplates SET active = 0 WHERE templateId = @default_template_id AND active = 1; +INSERT INTO versionedTemplates (templateId, active, version, versionType, versionedById, comment, name, description, ownerId, visibility, bestPractice, isDefault, languageId, created, createdById, modified, modifiedById) + (SELECT id, 1, 'v3', 'PUBLISHED', createdById, 'Added a research output table question', name, description, ownerId, 'PUBLIC', bestPractice, isDefault, languageId, CURDATE(), createdById, CURDATE(), modifiedById + FROM templates WHERE id = @default_template_id); +SET @versioned_template_id := LAST_INSERT_ID(); + +INSERT INTO versionedSections (versionedTemplateId, sectionId, name, introduction, requirements, guidance, displayOrder, bestPractice, created, createdById, modified, modifiedById) + (SELECT @versioned_template_id, id, name, introduction, requirements, guidance, displayOrder, bestPractice, CURDATE(), createdById, CURDATE(), modifiedById + FROM sections WHERE id = @section_id); +SET @versioned_section_id := LAST_INSERT_ID(); + +INSERT INTO versionedSectionTags (versionedSectionId, tagId, created, createdById, modified, modifiedById) + (SELECT @versioned_section_id, tagId, CURDATE(), createdById, CURDATE(), modifiedById + FROM sectionTags WHERE sectionId = @section_id); + +INSERT INTO versionedQuestions (versionedTemplateId, versionedSectionId, questionId, questionText, json, requirementText, guidanceText, sampleText, required, displayOrder, created, createdById, modified, modifiedById) + (SELECT @versioned_template_id, @versioned_section_id, id, questionText, json, requirementText, guidanceText, sampleText, required, displayOrder, CURDATE(), createdById, CURDATE(), modifiedById + FROM questions WHERE id = @question_id); From 2708118c18147ca13324dc418109a5ecbd3fabd4 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Tue, 23 Jun 2026 08:19:59 -0700 Subject: [PATCH 09/80] Address security vulnerabilities in nodemailer and indici packages and added debugging to troublehsoot request feedback failure --- CHANGELOG.md | 1 + package-lock.json | 44 +++-------------- package.json | 4 +- src/resolvers/feedback.ts | 54 ++++++++++++--------- src/services/__tests__/emailService.spec.ts | 2 +- src/services/emailService.ts | 5 ++ 6 files changed, 48 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c31dfe..25fa067e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -235,6 +235,7 @@ - Fixed issue with templates not cloning with sections and questions by updating the `addTemplate` mutation to clone from non-versioned template, section and question [#1006] ### Chore +- Addressed security vulnerability in `nodemailer` and `undici` packages, and added debugging to troubleshoot request feedback failure [#285] - Updated `fast-xml-parser` to `v1.2.0` and `uuid` to `11.1.1` to address vulnerabilities. - Added `@types/nodemailer` [#189] - Added override for `lodash` to `4.18.1` to address high vulnerability issue diff --git a/package-lock.json b/package-lock.json index ed84283a..193512df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "jsonwebtoken": "^9.0.3", "keyv": "^5.6.0", "mysql2": "^3.20.0", - "nodemailer": "^8.0.7", + "nodemailer": "^9.0.1", "pino": "^10.3.1", "uuid": "^11.1.1", "uuid-random": "^1.3.2", @@ -5724,9 +5724,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5741,9 +5738,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5758,9 +5752,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5775,9 +5766,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5792,9 +5780,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5809,9 +5794,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5826,9 +5808,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5843,9 +5822,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5860,9 +5836,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5877,9 +5850,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12291,9 +12261,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", - "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -15075,9 +15045,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", - "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 09dac0e0..6eb97a04 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "jsonwebtoken": "^9.0.3", "keyv": "^5.6.0", "mysql2": "^3.20.0", - "nodemailer": "^8.0.7", + "nodemailer": "^9.0.1", "pino": "^10.3.1", "uuid": "^11.1.1", "uuid-random": "^1.3.2", @@ -110,4 +110,4 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.58.0" } -} +} \ No newline at end of file diff --git a/src/resolvers/feedback.ts b/src/resolvers/feedback.ts index d138e952..eacc69fe 100644 --- a/src/resolvers/feedback.ts +++ b/src/resolvers/feedback.ts @@ -153,61 +153,66 @@ export const resolvers: Resolvers = { try { if (isAuthorized(context.token)) { + context.logger.info({ planId, userId: context.token.id, affiliationId: context.token.affiliationId }, `${reference}: authorized, starting`); + const plan = await Plan.findById(reference, context, planId); if (!plan) { throw NotFoundError(`Plan with ID ${planId} not found`); } + context.logger.info({ planId, projectId: plan.projectId, versionedTemplateId: plan.versionedTemplateId }, `${reference}: plan found`); + const project = await Project.findById(reference, context, plan.projectId); if (!project) { throw NotFoundError(`Project with ID ${plan.projectId} not found`); } + context.logger.info({ projectId: project.id }, `${reference}: project found`); - // Get existing feedback for the given planId - const existingFeedback = await PlanFeedback.findByPlanId( - reference, - context, - planId, - ); - - // If there is already an active feedback round, then do not allow creation of a new one - const hasOpenFeedback = existingFeedback.some( - (fb) => fb.completed === null - ); + const existingFeedback = await PlanFeedback.findByPlanId(reference, context, planId); + context.logger.info({ existingFeedbackCount: existingFeedback.length }, `${reference}: existing feedback fetched`); + const hasOpenFeedback = existingFeedback.some((fb) => fb.completed === null); if (hasOpenFeedback) { throw ForbiddenError(`There is already feedback in progress for plan ${planId}`); } - //Feedback request can only be made by ADMINs and SUPERADMINs or a collaborator with PRIMARY access - if (await hasPermissionOnProject(context, project, ProjectCollaboratorAccessLevel.PRIMARY)) { - const feedbackComment = new PlanFeedback({ - planId, - messageToOrg: messageToOrg ?? '', - requestedById: context.token.id, - requested: getCurrentDate() - }); + const hasPrimaryPermission = await hasPermissionOnProject(context, project, ProjectCollaboratorAccessLevel.PRIMARY); + context.logger.info({ hasPrimaryPermission }, `${reference}: permission check`); + if (hasPrimaryPermission) { const affiliationId = context.token.affiliationId; if (!affiliationId) { throw NotFoundError(`Affiliation for user not found`); } + context.logger.info({ affiliationId }, `${reference}: looking up affiliation`); const affiliation = await Affiliation.findByURI(reference, context, affiliationId); + context.logger.info( + { affiliationUri: affiliation?.uri, feedbackEmailCount: affiliation?.feedbackEmails?.length ?? 0 }, + `${reference}: affiliation found` + ); if (affiliation.feedbackEmails.length === 0) { - context.logger.warn(prepareObjectForLogs({ affiliationId }), `Affiliation with ID ${affiliationId} has no feedback emails configured, so no notifications will be sent when requesting feedback`); + context.logger.warn({ affiliationId }, `${reference}: no feedback emails configured`); } const planURL = `/projects/${project.id}/dmp/${planId}`; const planOwnerName = [context.token.givenName, context.token.surname].filter(Boolean).join(' '); const planTitle = plan.title || 'Untitled Plan'; + context.logger.info({ planURL, planOwnerName, planTitle }, `${reference}: sending feedback request email`); - // Send emails to the feedback recipients await sendFeedbackRequestEmail(context, planOwnerName, planURL, planTitle, affiliation.feedbackEmails, messageToOrg ?? ''); + context.logger.info(`${reference}: feedback request email sent`); + + const feedbackComment = new PlanFeedback({ + planId, + messageToOrg: messageToOrg ?? '', + requestedById: context.token.id, + requested: getCurrentDate() + }); const createdFeedback = await feedbackComment.create(context); + context.logger.info({ createdFeedbackId: createdFeedback?.id ?? null }, `${reference}: feedback record created`); - // Notify all org admins of the feedback request if (createdFeedback?.id) { await AdminNotification.addNotificationForAffiliation( reference, @@ -216,11 +221,16 @@ export const resolvers: Resolvers = { 'FEEDBACK_REQUESTED', { planId }, ); + context.logger.info({ affiliationUri: affiliation.uri }, `${reference}: admin notification sent`); } return createdFeedback; } } + context.logger.warn( + { hasToken: !!context?.token, userId: context?.token?.id }, + `${reference}: reached auth fallthrough — user did not pass permission checks` + ); throw context?.token ? ForbiddenError() : AuthenticationError(); } catch (err) { if (err instanceof GraphQLError) throw err; diff --git a/src/services/__tests__/emailService.spec.ts b/src/services/__tests__/emailService.spec.ts index b0d4ab51..01868031 100644 --- a/src/services/__tests__/emailService.spec.ts +++ b/src/services/__tests__/emailService.spec.ts @@ -334,7 +334,7 @@ describe('sendEmail', () => { .replace('%{helpUrl}', `${domain}/help`); expect(sent).toBe(true); - expect(logger.info).toHaveBeenCalledTimes(emails.length); + expect(logger.info).toHaveBeenCalledTimes(emails.length + 2); // accounting for additional info logs for sending and finishing expect(mockSendEmail).toHaveBeenCalledTimes(emails.length); for (const email of emails) { const expectedHtml = baseHtml.replace('%{adminEmail}', email); diff --git a/src/services/emailService.ts b/src/services/emailService.ts index ad3369ab..2665ac4b 100644 --- a/src/services/emailService.ts +++ b/src/services/emailService.ts @@ -298,9 +298,13 @@ export const sendFeedbackRequestEmail = async ( .replace('%{helpDeskEmail}', emailConfig.helpDeskAddress) .replace('%{helpUrl}', `${domain}/help`); + context.logger.info(`Sending feedback request email to ${collaboratorEmails.length} collaborators for plan "${planTitle}" at URL ${domain}${planURL}`); + context.logger.debug(prepareObjectForLogs({ collaboratorEmails, planOwnerName, planURL, planTitle, feedbackRequestMessage }), `Feedback request email details`); + // Send each feedback email recipient their own email for (const email of collaboratorEmails) { const message = baseMessage.replace('%{adminEmail}', email); + context.logger.debug(prepareObjectForLogs({ email, message }), `Sending feedback request email to ${email}`); await sendEmail( context, 'FeedbackRequest', @@ -311,5 +315,6 @@ export const sendFeedbackRequestEmail = async ( message ); } + context.logger.info(`Finished sending feedback request emails for plan "${planTitle}"`); return true; } From 506b7633c5569309b4795ea45b15f0be41c7388e Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Tue, 23 Jun 2026 08:29:13 -0700 Subject: [PATCH 10/80] Removed duplicate code in search function --- src/models/User.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/models/User.ts b/src/models/User.ts index f4c3fb77..0990a7b5 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -306,12 +306,6 @@ export class User extends MySqlModel { values.push(role); } - // Add role filter if provided - if (!isNullOrUndefined(role)) { - whereFilters.push('u.role = ?'); - values.push(role); - } - // Add affiliation filter if provided if (!isNullOrUndefined(affiliationId)) { whereFilters.push('a.uri = ?'); From 0de55737f4672d3e20fda830188b5c6eb07f6df6 Mon Sep 17 00:00:00 2001 From: Juliet Shin Date: Wed, 24 Jun 2026 16:43:58 -0700 Subject: [PATCH 11/80] Initial updates for the new admin user profile page --- src/models/Plan.ts | 117 +++++++++++++++++++++++++++++++++++++++--- src/resolvers/plan.ts | 65 ++++++++++++++++------- src/resolvers/user.ts | 79 ++++++++++++++++++++++++++++ src/schemas/plan.ts | 30 +++++++++-- src/schemas/user.ts | 20 ++++++++ src/types.ts | 79 ++++++++++++++++++++++++++-- 6 files changed, 356 insertions(+), 34 deletions(-) diff --git a/src/models/Plan.ts b/src/models/Plan.ts index dc7b4c32..1fbb1959 100644 --- a/src/models/Plan.ts +++ b/src/models/Plan.ts @@ -13,6 +13,14 @@ import { PlanGuidance } from "./Guidance"; import { VersionedTemplate } from "./VersionedTemplate"; import { Project } from "./Project"; import { Tag } from "./Tag"; +import { + PaginatedQueryResults, + PaginationOptions, + PaginationOptionsForCursors, + PaginationOptionsForOffsets, + PaginationType +} from '../types/general'; +import { prepareObjectForLogs } from '../logger'; export const DEFAULT_TEMPORARY_DMP_ID_PREFIX = 'temp-dmpId-'; @@ -52,6 +60,7 @@ export class PlanSearchResult { public id: number; public createdBy: string; public created: string; + public createdById: number; public modifiedBy: string; public modified: string; public title: string; @@ -62,6 +71,7 @@ export class PlanSearchResult { public members: string; public templateTitle: string; public versionedTemplateId: number; + public templateOwnerAffiliationName: string; // The following fields will only be set when the plan is published! public dmpId: string; @@ -72,6 +82,7 @@ export class PlanSearchResult { this.id = options.id; this.createdBy = options.createdBy; this.created = options.created; + this.createdById = options.createdById; this.modifiedBy = options.modifiedBy; this.modified = options.modified; this.title = options.title; @@ -82,6 +93,7 @@ export class PlanSearchResult { this.members = options.members; this.templateTitle = options.title; this.versionedTemplateId = options.versionedTemplateId; + this.templateOwnerAffiliationName = options.templateOwnerAffiliationName; this.dmpId = options.dmpId; this.registeredBy = options.registeredBy; @@ -89,14 +101,14 @@ export class PlanSearchResult { } /** - * Find high-level details about the plans for a project. This information is - * meant to supply an overview of the plans. - * - * @param reference The caller's reference string for logging purposes' - * @param context The Apollo context object - * @param projectId The ID of the project to return plans for - * @returns An array of PlanSearchResult objects - */ + * Find high-level details about the plans for a project. This information is + * meant to supply an overview of the plans. + * + * @param reference The caller's reference string for logging purposes' + * @param context The Apollo context object + * @param projectId The ID of the project to return plans for + * @returns An array of PlanSearchResult objects + */ static async findByProjectId(reference: string, context: MyContext, projectId: number): Promise { const sql = 'SELECT p.id, ' + 'CONCAT(cu.givenName, CONCAT(\' \', cu.surName)) createdBy, p.created, ' + @@ -125,6 +137,94 @@ export class PlanSearchResult { const results = await Plan.query(context, sql, [projectId?.toString()], reference); return Array.isArray(results) ? results.map((entry) => new PlanSearchResult(entry)) : []; } + + /** + * Find high-level details about the plans for a project. This information is + * meant to supply an overview of the plans. + * + * @param reference The caller's reference string for logging purposes' + * @param context The Apollo context object + * @param projectId The ID of the project to return plans for + * @returns An array of PlanSearchResult objects + */ + static async findByProjectIdWithPagination( + reference: string, + context: MyContext, + projectId: number, + options: PaginationOptions = Plan.getDefaultPaginationOptions(), + term?: string, + ): Promise> { + const whereFilters = ['p.projectId = ?']; + const values = [projectId.toString()]; + + // Handle the incoming search term + const searchTerm = (term ?? '').toLowerCase().trim(); + if (searchTerm) { + whereFilters.push(`( + LOWER(p.title) LIKE ? OR + LOWER(vt.name) LIKE ? + )`); + values.push(`%${searchTerm}%`, `%${searchTerm}%`); + } + + const sqlStatement = ` + SELECT p.id, p.createdById, + CONCAT(cu.givenName, ' ', cu.surName) createdBy, p.created, + CONCAT(cm.givenName, ' ', cm.surName) modifiedBy, p.modified, + p.versionedTemplateId, p.title, p.status, p.visibility, p.dmpId, + CONCAT(cr.givenName, ' ', cr.surName) registeredBy, p.registered, p.featured, + GROUP_CONCAT(DISTINCT CONCAT(prc.givenName, ' ', prc.surName, ' (', r.label, ')')) members, + GROUP_CONCAT(DISTINCT fundings.name) funding + FROM plans p + LEFT JOIN users cu ON cu.id = p.createdById + LEFT JOIN users cm ON cm.id = p.modifiedById + LEFT JOIN users cr ON cr.id = p.registeredById + LEFT JOIN versionedTemplates vt ON vt.id = p.versionedTemplateId + LEFT JOIN planMembers plc ON plc.planId = p.id + LEFT JOIN projectMembers prc ON prc.id = plc.projectMemberId + LEFT JOIN planMemberRoles plcr ON plc.id = plcr.planMemberId + LEFT JOIN memberRoles r ON plcr.memberRoleId = r.id + LEFT JOIN planFundings ON planFundings.planId = p.id + LEFT JOIN projectFundings ON projectFundings.id = planFundings.projectFundingId + LEFT JOIN affiliations fundings ON projectFundings.affiliationId = fundings.uri + `; + + const groupBy = ` + GROUP BY p.id, p.createdById,cu.givenName, cu.surName, cm.givenName, cm.surName, + p.title, p.status, p.visibility, + p.dmpId, cr.givenName, cr.surName, p.registered, p.featured + `; + + let opts; + if (options.type === PaginationType.OFFSET) { + opts = { + ...options, + availableSortFields: ['p.title', 'p.status', 'p.created', 'p.modified', 'p.registered', 'p.visibility'], + } as PaginationOptionsForOffsets; + } else { + opts = { + ...options, + cursorField: 'CONCAT(p.title, p.id)', + } as PaginationOptionsForCursors; + } + + if (isNullOrUndefined(opts.sortField)) opts.sortField = 'p.created'; + if (isNullOrUndefined(opts.sortDir)) opts.sortDir = 'DESC'; + opts.countField = 'p.id'; + + const response: PaginatedQueryResults = await Plan.queryWithPagination( + context, + sqlStatement, + whereFilters, + groupBy, + values, + opts, + reference, + ); + + context.logger.debug(prepareObjectForLogs({ options, response }), reference); + return response; + } } export enum PlanSectionType { @@ -308,6 +408,7 @@ export class PlanSectionProgress { vs.id AS versionedSectionId, vs.displayOrder, vs.name AS title, + p.createdById, COUNT(DISTINCT vq.id) AS totalQuestions, COUNT(DISTINCT CASE WHEN a.id IS NOT NULL AND ${FILLED_ANSWER_CHECK} diff --git a/src/resolvers/plan.ts b/src/resolvers/plan.ts index fb2c7047..4394dd32 100644 --- a/src/resolvers/plan.ts +++ b/src/resolvers/plan.ts @@ -1,55 +1,58 @@ import { GraphQLError } from "graphql"; import { MyContext } from "../context"; import { Plan, PlanSearchResult, PlanSectionProgress, PlanProgress, PlanStatus, PlanVisibility } from "../models/Plan"; -import { prepareObjectForLogs } from "../logger"; -import { AuthenticationError, ForbiddenError, InternalServerError, NotFoundError } from "../utils/graphQLErrors"; import { Project } from "../models/Project"; import { User } from "../models/User"; -import { isAuthorized } from "../services/authService"; -import { - hasPermissionOnProject, - isProjectReadOnlyForCurrentUser -} from "../services/projectService"; import { PlanMember } from "../models/Member"; import { PlanFunding } from "../models/Funding"; import { PlanFeedback } from "../models/PlanFeedback"; -import { PlanFeedbackStatus, Resolvers } from "../types"; +import { Affiliation } from "../models/Affiliation"; import { VersionedTemplate } from "../models/VersionedTemplate"; import { Answer } from "../models/Answer"; import { ProjectCollaboratorAccessLevel } from "../models/Collaborator"; +import { AlternateIdentifier } from "../models/AlternateIdentifier"; import { isNullOrUndefined, normaliseDateTime } from "../utils/helpers"; +import { AuthenticationError, ForbiddenError, InternalServerError, NotFoundError } from "../utils/graphQLErrors"; +import { PaginationOptionsForCursors, PaginationOptionsForOffsets, PaginationType } from "../types/general"; +import { PaginatedPlanResults, PaginatedQueryResults, PlanFeedbackStatus, Resolvers } from "../types"; +import { prepareObjectForLogs } from "../logger"; + +// Services import { ensureDefaultPlanContact, saveMaDMPVersion } from "../services/planService"; -import { AlternateIdentifier } from "../models/AlternateIdentifier"; +import { + hasPermissionOnProject, + isProjectReadOnlyForCurrentUser +} from "../services/projectService"; +import { isAuthorized } from "../services/authService"; + export const resolvers: Resolvers = { Query: { - // return all of the projects that the current user owns or is a collaborator on - plans: async (_, { projectId }, context: MyContext): Promise => { - const reference = 'plans resolver'; + plans: async (_, { projectId, term, paginationOptions }, context: MyContext): Promise => { + const reference = 'plansWithPagination resolver'; try { if (isAuthorized(context.token)) { const project = await Project.findById(reference, context, projectId); - - if (!project) { - throw NotFoundError(`Project with ID ${projectId} not found`); - } + if (!project) throw NotFoundError(`Project with ID ${projectId} not found`); if (await hasPermissionOnProject(context, project, ProjectCollaboratorAccessLevel.COMMENT)) { - return await PlanSearchResult.findByProjectId(reference, context, projectId); + const opts = !isNullOrUndefined(paginationOptions) && paginationOptions.type === PaginationType.OFFSET + ? paginationOptions as PaginationOptionsForOffsets + : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; + + return await PlanSearchResult.findByProjectIdWithPagination(reference, context, projectId, opts, term); } } throw context?.token ? ForbiddenError() : AuthenticationError(); } catch (err) { if (err instanceof GraphQLError) throw err; - context.logger.error(prepareObjectForLogs(err), `Failure in ${reference}`); throw InternalServerError(); } }, - // Find the plan by its id plan: async (_, { planId }, context: MyContext): Promise => { const reference = 'plan resolver'; @@ -525,6 +528,30 @@ export const resolvers: Resolvers = { ); } return []; + }, + templateOwnerAffiliationName: async (parent: PlanSearchResult, _, context: MyContext): Promise => { + if (!parent?.versionedTemplateId) return null; + + const versionedTemplate = await VersionedTemplate.findById( + 'planSearchResult.templateOwnerAffiliationName resolver', + context, + parent.versionedTemplateId + ); + if (!versionedTemplate?.ownerId) return null; + + const affiliation = await Affiliation.findByURI( + 'planSearchResult.templateOwnerAffiliationName resolver', + context, + versionedTemplate.ownerId + ); + return affiliation?.displayName || null; + }, + user: async (parent: PlanSearchResult, _, context: MyContext): Promise => { + console.log('****parent', parent); + if (parent?.createdById) { + return await User.findById('planSearchResult.user resolver', context, parent.createdById); + } + return null; } } diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index b59763c4..08df76ed 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -13,6 +13,9 @@ import { prepareObjectForLogs } from "../logger"; import { GraphQLError } from "graphql"; import { PaginationOptionsForCursors, PaginationOptionsForOffsets, PaginationType } from "../types/general"; import { isNullOrUndefined, normaliseDateTime } from "../utils/helpers"; +import { + authenticatedResolver, +} from "../services/authService"; export const resolvers: Resolvers = { Query: { @@ -133,6 +136,82 @@ export const resolvers: Resolvers = { } }, + // Update the specifeied user's information (SuperAdmin only) + updateUserInfo: authenticatedResolver( + 'updateUserInfo resolver', + UserRole.SUPERADMIN, + async ( + _: Record, + { input: { userId, email, givenName, surName, affiliationId, otherAffiliationName, languageId } }: { + input: { + userId: number; + email: string; + givenName: string; + surName: string; + affiliationId: string; + otherAffiliationName?: string; + languageId: string; + } + }, + context: MyContext + ): Promise => { + console.log('****UpdateUserInfo', userId, email, givenName, surName, affiliationId, otherAffiliationName, languageId); + const reference = 'updateUserInfo resolver'; + try { + const user = await User.findById(reference, context, userId); + if (!user || !user.active || user.locked) { + throw ForbiddenError(); + } + + if (otherAffiliationName) { + const affiliation = await processOtherAffiliationName(context, otherAffiliationName); + if (affiliation.hasErrors()) { + const err = affiliation.errors?.general ?? 'Unable to save the affiliation at this time'; + user.addError('otherAffiliationName', err); + return user; + } + user.affiliationId = affiliation.uri; + } else { + user.affiliationId = affiliationId; + } + + // Update the email + const existingPrimaryEmail = await UserEmail.findPrimaryByUserId(reference, context, user.id); + if (existingPrimaryEmail) { + // Directly update the email field and mark as confirmed since a SuperAdmin is setting it + existingPrimaryEmail.email = email; + existingPrimaryEmail.isConfirmed = true; + await new UserEmail(existingPrimaryEmail).update(context); + } else { + // No primary exists yet — create one, marked as confirmed + const newEmail = new UserEmail({ + userId: user.id, + email, + isPrimary: true, + isConfirmed: true // SuperAdmin-set emails skip confirmation + }); + await newEmail.create(context); + } + + + // Update the user fields + user.givenName = givenName; + user.surName = surName; + user.languageId = languageId || defaultLanguageId; + const updated = await new User(user).update(context); + + if (!updated || updated.hasErrors()) { + user.addError('general', 'Unable to save the profile changes at this time'); + } + return user.hasErrors() ? user : await User.findById(reference, context, user.id); + + } catch (err) { + if (err instanceof GraphQLError) throw err; + context.logger.error(prepareObjectForLogs(err), `Failure in ${reference}`); + throw InternalServerError(); + } + }), + // Update the current user's email notifications updateUserNotifications: async (_, { input: { notify_on_comment_added, diff --git a/src/schemas/plan.ts b/src/schemas/plan.ts index 2b137391..f44e6891 100644 --- a/src/schemas/plan.ts +++ b/src/schemas/plan.ts @@ -2,8 +2,8 @@ import gql from "graphql-tag"; export const typeDefs = gql` extend type Query { - "Get all plans for the research project" - plans(projectId: Int!): [PlanSearchResult!] + "Get all plans for the research project with pagination support" + plans(projectId: Int!,term: String, paginationOptions: PaginationOptions): PaginatedPlanResults "Get a specific plan" plan(planId: Int!): Plan @@ -34,7 +34,7 @@ export const typeDefs = gql` removeAlternateIdentifierFromPlan(planId: Int!, alternateIdentifier: String!): AlternateIdentifier } - type PlanSearchResult { + type PlanSearchResult{ "The unique identifer for the Object" id: Int "The user who created the Object" @@ -68,8 +68,32 @@ export const typeDefs = gql` versionedSections: [PlanSectionProgress!] "The versioned template id the plan is based on" versionedTemplateId: Int + "The name of the affiliation that owns the template the plan is based on" + templateOwnerAffiliationName: String + "The user who created the plan" + user: User } + type PaginatedPlanResults implements PaginatedQueryResults { + "The plans that match the search criteria" + items: [PlanSearchResult] + "The total number of possible items" + totalCount: Int + "The number of items returned" + limit: Int + "The cursor to use for the next page of results (for infinite scroll/load more)" + nextCursor: String + "The current offset of the results (for standard offset pagination)" + currentOffset: Int + "Whether or not there is a next page" + hasNextPage: Boolean + "Whether or not there is a previous page" + hasPreviousPage: Boolean + "The sortFields that are available for this query (for standard offset pagination only!)" + availableSortFields: [String] +} + + "The progress the user has made within a section of the plan" type PlanSectionProgress { "Whether or not the section is a customization (i.e. added by the user and not part of the original template)" diff --git a/src/schemas/user.ts b/src/schemas/user.ts index b409d7da..17674948 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -13,6 +13,8 @@ export const typeDefs = gql` extend type Mutation { "Update the current user's information" updateUserProfile(input: UpdateUserProfileInput!): User + "Update the specified user's information (SuperAdmin only)" + updateUserInfo(input: UpdateUserInfoInput!): User "Update the current user's email notifications" updateUserNotifications(input: UpdateUserNotificationsInput!): User "Set the user's ORCID" @@ -199,6 +201,24 @@ export const typeDefs = gql` languageId: String } + input UpdateUserInfoInput { + "The user's id" + userId: Int! + "The user's email address" + email: String! + "The user's given name" + givenName: String! + "The user's surname" + surName: String! + "The id of the affiliation if the user selected one from the typeahead list" + affiliationId: String + "The name of the affiliation if the user did not select one from the typeahead list" + otherAffiliationName: String + "The user's preferred language" + languageId: String + } + + input UpdateUserNotificationsInput { "Whether or not email notifications are on for when a Plan has a new comment" notify_on_comment_added: Boolean! diff --git a/src/types.ts b/src/types.ts index ddef3abd..7211d6fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1737,6 +1737,8 @@ export type Mutation = { updateTemplate?: Maybe