diff --git a/CHANGELOG.md b/CHANGELOG.md index a0554377..efefc84b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## v1.1.0 ### Added +- Added `guidanceText` and `sampleText` fields to `addQuestionCustomization` and added `json`, `questionText`, `requirementText`, `guidanceText`, `sampleText`, `useSampleTextAsDefault` and `required` to `addCustomQuestionInput` [#130] +- Added `questionCustomizationByVersionedQuestion` resolver [#130] +- Added `findByCustomizationAndVersionedQuestion` method to `QuestionCustomization` model [#130] - Added some opensearch variables for running it locally in docker-compose.yaml [#118] - Added opensearch to the docker compose file - Added OpenSearch integration for full-text search of re3data repositories @@ -96,6 +99,8 @@ - Removed `ioredis` package ### Fixed +- Had issue running `nuke-db.sh` and `process.sh`, so I turned off SSL by using `--ssl=off` instead +- Fixed `fetchTemplateData` query in `TemplateCustomization` model because `questionCustomizationHasSampleText` was incorrectly returning true [#130] - In `preparePaginationOptions` function, wrapped each cursorField with `COALESCE` to handle `NULL` values in SQL `CONCAT`, otherwise if any cursorField is NULL, it would just return a null value due to the way `CONCAT` works [#107] - Fixed breaking cloning of template. The `addTemplate` was updated to accept a `copyFromVersionedTemplateId` so that we copy from versioned template, section and questions, when it's not a template from the user's org. Otherwise we check for `copyFromTemplateId` to copy/clone from templates, sections and questions, and if neither are present, we continue to create a new record for `templates` table [#1006] - 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] diff --git a/data-migrations/nuke-db.sh b/data-migrations/nuke-db.sh index 042eb91d..60825129 100755 --- a/data-migrations/nuke-db.sh +++ b/data-migrations/nuke-db.sh @@ -44,9 +44,12 @@ drop_tables() { fi # If we are running locally, the MySQL cert will exist in /etc + # if [ -f "/etc/mysql-cert.pem" ]; then + # MIGRATION_ARGS="${MIGRATION_ARGS} --ssl-ca=/etc/mysql-cert.pem --ssl-verify-server-cert=OFF" + # fi if [ -f "/etc/mysql-cert.pem" ]; then - MIGRATION_ARGS="${MIGRATION_ARGS} --ssl-ca=/etc/mysql-cert.pem --ssl-verify-server-cert=OFF" - fi +MIGRATION_ARGS="${MIGRATION_ARGS} --ssl=off" +fi mariadb $MIGRATION_ARGS -N $1 <<< "$FKEY_OFF" diff --git a/data-migrations/process.sh b/data-migrations/process.sh index 08e666b2..903b5218 100755 --- a/data-migrations/process.sh +++ b/data-migrations/process.sh @@ -73,7 +73,7 @@ process_migration() { # If we are running locally, the MySQL cert will exist in /etc if [ -f "/etc/mysql-cert.pem" ]; then - MIGRATION_ARGS="${MIGRATION_ARGS} --ssl-ca=/etc/mysql-cert.pem --ssl-verify-server-cert=OFF" + MIGRATION_ARGS="${MIGRATION_ARGS} --ssl=off" fi EXISTS=$(mariadb ${MIGRATION_ARGS} -N ${1} <<< "SELECT * FROM dataMigrations WHERE migrationFile = '$2';") diff --git a/src/models/QuestionCustomization.ts b/src/models/QuestionCustomization.ts index e8fa1945..ad7a2f02 100644 --- a/src/models/QuestionCustomization.ts +++ b/src/models/QuestionCustomization.ts @@ -49,7 +49,7 @@ export class QuestionCustomization extends MySqlModel { this.addError('templateCustomizationId', 'Customization can\'t be blank'); } if (isNullOrUndefined(this.questionId)) { - this.addError('questionId','Question can\'t be blank'); + this.addError('questionId', 'Question can\'t be blank'); } return Object.keys(this.errors).length === 0; @@ -215,6 +215,33 @@ export class QuestionCustomization extends MySqlModel { return Array.isArray(results) && results.length > 0 ? new QuestionCustomization(results[0]) : undefined; } + + /** + * Find the customization by the customization and versioned question + * + * @param reference The reference to use for logging errors. + * @param context The Apollo context. + * @param templateCustomizatonId The id of the template customization. + * @param versionedQuestionId The versioned question id. + * @returns The Question customization. + */ + static async findByCustomizationAndVersionedQuestion( + reference: string, + context: MyContext, + templateCustomizatonId: number, + versionedQuestionId: number + ): Promise { + const results = await QuestionCustomization.query( + context, + `SELECT qc.* FROM ${QuestionCustomization.tableName} qc + INNER JOIN versionedQuestions vq ON qc.questionId = vq.questionId + WHERE qc.templateCustomizationId = ? AND vq.id = ?`, + [templateCustomizatonId.toString(), versionedQuestionId?.toString()], + reference + ); + return Array.isArray(results) && results.length > 0 ? new QuestionCustomization(results[0]) : undefined; + } + /** * Find all the question customizations for a specific template customization * diff --git a/src/models/TemplateCustomization.ts b/src/models/TemplateCustomization.ts index 79d4887f..c097ed55 100644 --- a/src/models/TemplateCustomization.ts +++ b/src/models/TemplateCustomization.ts @@ -426,7 +426,7 @@ export class TemplateCustomizationOverview { vq.displayOrder as versionedQuestionDisplayOrder, qc.id AS questionCustomizationId, qc.migrationStatus AS questionCustomizationMigrationStatus, (qc.guidanceText IS NOT NULL) AS questionCustomizationHasGuidanceText, - (qc.sampleText IS NOT NULL) AS questionCustomizationHasSampleText + (qc.sampleText IS NOT NULL AND qc.sampleText != '') AS questionCustomizationHasSampleText FROM templateCustomizations AS tc JOIN users AS u ON tc.modifiedById = u.id @@ -516,6 +516,7 @@ export class TemplateCustomizationOverview { [templateCustomizationId.toString()], reference ); + return Array.isArray(results) ? results : []; }; } @@ -605,7 +606,7 @@ export class TemplateCustomization extends MySqlModel { this.addError('affiliationId', 'Affiliation can\'t be blank'); } if (isNullOrUndefined(this.templateId)) { - this.addError('templateId','Template can\'t be blank'); + this.addError('templateId', 'Template can\'t be blank'); } if (isNullOrUndefined(this.currentVersionedTemplateId)) { this.addError( @@ -630,43 +631,43 @@ export class TemplateCustomization extends MySqlModel { this.addError('general', 'Customization has never been saved'); } else if (this.status === TemplateCustomizationStatus.PUBLISHED && !this.isDirty) { - // Can't publish if it is already published! - this.addError('general', 'Customization is already published!'); + // Can't publish if it is already published! + this.addError('general', 'Customization is already published!'); } else { - // Make sure the record is valid - if (await this.isValid()) { - - // Create a new published version of the customization - const newVersion = new VersionedTemplateCustomization( - { - affiliationId: this.affiliationId, - templateCustomizationId: this.id, - currentVersionedTemplateId: this.currentVersionedTemplateId, - active: true - } - ) + // Make sure the record is valid + if (await this.isValid()) { - const created: VersionedTemplateCustomization = await newVersion.create(context); + // Create a new published version of the customization + const newVersion = new VersionedTemplateCustomization( + { + affiliationId: this.affiliationId, + templateCustomizationId: this.id, + currentVersionedTemplateId: this.currentVersionedTemplateId, + active: true + } + ) - if (!isNullOrUndefined(created) && !created.hasErrors() && created.id) { - // Update the status of the customization to reflect the change - this.status = TemplateCustomizationStatus.PUBLISHED; - this.isDirty = false; - this.latestPublishedVersionId = created.id; - this.latestPublishedDate = created.created; - const published: TemplateCustomization = await this.update(context, true); // noTouch=true, the update method will not set isDirty to true + const created: VersionedTemplateCustomization = await newVersion.create(context); - if (!published) { - this.addError('general', 'Unable to publish'); - } - } else { - this.errors = created?.errors ?? this.errors; + if (!isNullOrUndefined(created) && !created.hasErrors() && created.id) { + // Update the status of the customization to reflect the change + this.status = TemplateCustomizationStatus.PUBLISHED; + this.isDirty = false; + this.latestPublishedVersionId = created.id; + this.latestPublishedDate = created.created; + const published: TemplateCustomization = await this.update(context, true); // noTouch=true, the update method will not set isDirty to true + + if (!published) { + this.addError('general', 'Unable to publish'); } + } else { + this.errors = created?.errors ?? this.errors; } } - return new TemplateCustomization(this); } + return new TemplateCustomization(this); + } /** * Unpublish the customization diff --git a/src/models/__tests__/QuestionCustomization.spec.ts b/src/models/__tests__/QuestionCustomization.spec.ts index 882d80d7..64224514 100644 --- a/src/models/__tests__/QuestionCustomization.spec.ts +++ b/src/models/__tests__/QuestionCustomization.spec.ts @@ -442,6 +442,70 @@ describe("QuestionCustomization", () => { }); }); + describe("findByCustomizationAndVersionedQuestion", () => { + it("should find QuestionCustomization by templateCustomizationId and versionedQuestionId", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ + { + id: 1, + templateCustomizationId: 100, + versionedQuestionId: 300, + }, + ]); + + const result = await QuestionCustomization.findByCustomizationAndVersionedQuestion( + "test.ref", + mockContext, + 100, + 300 + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + `SELECT qc.* FROM questionCustomizations qc + INNER JOIN versionedQuestions vq ON qc.questionId = vq.questionId + WHERE qc.templateCustomizationId = ? AND vq.id = ?`, + ["100", "300"], + "test.ref" + ); + expect(result).toBeInstanceOf(QuestionCustomization); + expect(result.templateCustomizationId).toBe(100); + }); + + it("should return undefined when not found", async () => { + jest.spyOn(MySqlModel, "query").mockResolvedValue([]); + + const result = await QuestionCustomization.findByCustomizationAndVersionedQuestion( + "test.ref", + mockContext, + 100, + 300 + ); + + expect(result).toBeUndefined(); + }); + + it("should handle null questionId", async () => { + const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([]); + + const result = await QuestionCustomization.findByCustomizationAndVersionedQuestion( + "test.ref", + mockContext, + 100, + null + ); + + expect(mockQuery).toHaveBeenCalledWith( + mockContext, + `SELECT qc.* FROM questionCustomizations qc + INNER JOIN versionedQuestions vq ON qc.questionId = vq.questionId + WHERE qc.templateCustomizationId = ? AND vq.id = ?`, + ["100", undefined], + "test.ref" + ); + expect(result).toBeUndefined(); + }); + }); + describe("findByCustomizationId", () => { it("should find QuestionCustomizations by templateCustomizationId", async () => { const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([ diff --git a/src/resolvers/__tests__/questionCustomization.spec.ts b/src/resolvers/__tests__/questionCustomization.spec.ts index a79d77a7..4c50e3de 100644 --- a/src/resolvers/__tests__/questionCustomization.spec.ts +++ b/src/resolvers/__tests__/questionCustomization.spec.ts @@ -40,7 +40,7 @@ let adminToken: JWTAccessToken; let query: string; // Proxy call to the Apollo server test server -async function executeQuery ( +async function executeQuery( query: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any variables: any, @@ -168,6 +168,89 @@ describe('questionCustomization resolver', () => { }); }); + describe('Query.questionCustomizationByVersionedQuestion', () => { + beforeEach(() => { + query = ` + query questionCustomizationByVersionedQuestion($templateCustomizationId: Int!, $versionedQuestionId: Int!) { + questionCustomizationByVersionedQuestion(templateCustomizationId: $templateCustomizationId, versionedQuestionId: $versionedQuestionId) { + id + templateCustomizationId + questionId + migrationStatus + guidanceText + sampleText + errors { + general + } + versionedQuestion { + id + questionText + } + } + } + `; + }); + + it('should return the section customization when found and user has permission', async () => { + const mockCustomization = { + id: 1, + templateCustomizationId: 10, + questionId: 5, + migrationStatus: 'OK', + guidanceText: 'Test guidance text', + sampleText: 'Test sample text' + }; + const mockParent = { id: 10, isDirty: false }; + + (QuestionCustomization.findByCustomizationAndVersionedQuestion as jest.Mock).mockResolvedValue(mockCustomization); + (getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent); + + const vars = { templateCustomizationId: 1, versionedQuestionId: 5 }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.id).toEqual(1); + expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.templateCustomizationId).toEqual(10); + expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.questionId).toEqual(5); + expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.migrationStatus).toEqual('OK'); + expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.guidanceText).toEqual('Test guidance text'); + expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.sampleText).toEqual('Test sample text'); + expect(QuestionCustomization.findByCustomizationAndVersionedQuestion).toHaveBeenCalledWith( + 'questionCustomizationByVersionedQuestion resolver', + expect.any(Object), + 1, + 5 + ); + }); + + it('should return NotFound error when section customization is not found', async () => { + (QuestionCustomization.findByCustomizationAndVersionedQuestion as jest.Mock).mockResolvedValue(null); + + const vars = { templateCustomizationId: 1, versionedQuestionId: 999 }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Not Found'); + }); + + it('should return NotFound error when parent template customization is not found', async () => { + const mockCustomization = { + id: 1, + templateCustomizationId: 10 + }; + + (QuestionCustomization.findByCustomizationAndVersionedQuestion as jest.Mock).mockResolvedValue(mockCustomization); + (getValidatedCustomization as jest.Mock).mockResolvedValue(null); + + const vars = { templateCustomizationId: 1, versionedQuestionId: 5 }; + const result = await executeQuery(query, vars, adminToken); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeDefined(); + expect(result.body.singleResult.errors[0].message).toEqual('Not Found'); + }); + }); + describe('Query.customQuestion', () => { beforeEach(() => { query = ` @@ -204,7 +287,7 @@ describe('questionCustomization resolver', () => { sectionId: 5, migrationStatus: 'OK' }; - const mockParent = {id: 10, isDirty: false}; + const mockParent = { id: 10, isDirty: false }; (CustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomQuestion); (getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent); @@ -322,8 +405,8 @@ describe('questionCustomization resolver', () => { templateCustomizationId: 10, versionedQuestionId: 5 }; - const mockSection = {id: 5}; - const mockParent = {id: 10, isDirty: false}; + const mockSection = { id: 5 }; + const mockParent = { id: 10, isDirty: false }; const mockCreated = { id: 1, questionId: 5, diff --git a/src/resolvers/questionCustomization.ts b/src/resolvers/questionCustomization.ts index a3491774..ad39f348 100644 --- a/src/resolvers/questionCustomization.ts +++ b/src/resolvers/questionCustomization.ts @@ -67,6 +67,28 @@ export const resolvers: Resolvers = { return customization; }), + /** + * ADMIN ONLY: Fetch the QuestionCustomization for a given versioned section + */ + questionCustomizationByVersionedQuestion: authenticatedResolver( + 'questionCustomizationByVersionedQuestion resolver', + UserRole.ADMIN, + async ( + _: Record, + { templateCustomizationId, versionedQuestionId }: { templateCustomizationId: number; versionedQuestionId: number }, + context: MyContext + ): Promise => { + const ref = 'questionCustomizationByVersionedQuestion resolver'; + + const parent = await getValidatedCustomization(ref, context, templateCustomizationId); + if (isNullOrUndefined(parent)) throw NotFoundError(); + + // Returns null if no customization exists yet — not a 404 + const customization = await QuestionCustomization.findByCustomizationAndVersionedQuestion(ref, context, templateCustomizationId, versionedQuestionId); + return customization ?? null; + } + ), + /** * ADMIN ONLY: Fetch the specified CustomQuestion * @@ -356,7 +378,7 @@ export const resolvers: Resolvers = { customization.sampleText = sampleText; customization.useSampleTextAsDefault = useSampleTextAsDefault; customization.required = required; - const updated: CustomQuestion = await customization.update(context); + const updated: CustomQuestion = await customization.update(context); // If it was successfully updated, update the parent's isDirty flag if (updated && !updated.hasErrors() && !parent.isDirty) { diff --git a/src/schemas/questionCustomization.ts b/src/schemas/questionCustomization.ts index 4e912c64..ffddd4c9 100644 --- a/src/schemas/questionCustomization.ts +++ b/src/schemas/questionCustomization.ts @@ -4,6 +4,8 @@ export const typeDefs = gql` extend type Query { "Get the custom guidance and sample text the affiliation has added to a funder question question (user must be an Admin)" questionCustomization(questionCustomizationId: Int!): QuestionCustomization + "Get the custom guidance and sample text the affiliation has added to a funder question question (user must be an Admin)" + questionCustomizationByVersionedQuestion(templateCustomizationId: Int!, versionedQuestionId: Int!): QuestionCustomization "Get the custom question the affiliation has added to a funder section or custom section (user must be an Admin)" customQuestion(customQuestionId: Int!): CustomQuestion } @@ -137,6 +139,10 @@ export const typeDefs = gql` templateCustomizationId: Int! "The identifier of the published funder question" versionedQuestionId: Int! + "The custom guidance for the question" + guidanceText: String + "The custom sample answer for the question" + sampleText: String } "Input parameters for updating custom guidance and sample text to a funder question" @@ -161,6 +167,20 @@ export const typeDefs = gql` pinnedQuestionType: CustomizableObjectOwnership "The identifier of the question this new custom question should appear after (null means it is the first question in the section)" pinnedQuestionId: Int + "The JSON representation of the question type" + json: String + "This will be used as a sort of title for the Question" + questionText: String + "Requirements associated with the Question" + requirementText: String + "Guidance to complete the question" + guidanceText: String + "Sample text to possibly provide a starting point or example to answer question" + sampleText: String + "Boolean indicating whether we should use content from sampleText as the default answer" + useSampleTextAsDefault: Boolean + "To indicate whether the question is required to be completed" + required: Boolean } "Input parameters for updating a custom section" input UpdateCustomQuestionInput { diff --git a/src/types.ts b/src/types.ts index 21e6bf98..5b45d3af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,16 +37,30 @@ export type Scalars = { /** Input parameters for adding a custom section to a funder template */ export type AddCustomQuestionInput = { + /** Guidance to complete the question */ + guidanceText?: InputMaybe; + /** The JSON representation of the question type */ + json?: InputMaybe; /** The identifier of the question this new custom question should appear after (null means it is the first question in the section) */ pinnedQuestionId?: InputMaybe; /** The type of the question this new custom question should appear after (null means it is the first question in the section) */ pinnedQuestionType?: InputMaybe; + /** This will be used as a sort of title for the Question */ + questionText?: InputMaybe; + /** To indicate whether the question is required to be completed */ + required?: InputMaybe; + /** Requirements associated with the Question */ + requirementText?: InputMaybe; + /** Sample text to possibly provide a starting point or example to answer question */ + sampleText?: InputMaybe; /** The identifier of the section this new custom question should appear within */ sectionId: Scalars['Int']['input']; /** The type of the section this new custom question should appear within */ sectionType: CustomizableObjectOwnership; /** The identifier of the parent template customization */ templateCustomizationId: Scalars['Int']['input']; + /** Boolean indicating whether we should use content from sampleText as the default answer */ + useSampleTextAsDefault?: InputMaybe; }; /** Input parameters for adding a custom section to a funder template */ @@ -154,6 +168,10 @@ export type AddQuestionConditionInput = { /** Input parameters for adding custom guidance and sample text to a funder question */ export type AddQuestionCustomizationInput = { + /** The custom guidance for the question */ + guidanceText?: InputMaybe; + /** The custom sample answer for the question */ + sampleText?: InputMaybe; /** The identifier of the parent template customization */ templateCustomizationId: Scalars['Int']['input']; /** The identifier of the published funder question */ @@ -3126,6 +3144,8 @@ export type Query = { questionConditions?: Maybe>>; /** Get the custom guidance and sample text the affiliation has added to a funder question question (user must be an Admin) */ questionCustomization?: Maybe; + /** Get the custom guidance and sample text the affiliation has added to a funder question question (user must be an Admin) */ + questionCustomizationByVersionedQuestion?: Maybe; /** Get the Questions that belong to the associated sectionId */ questions?: Maybe>>; /** return all distinct repository types from re3data with optional counts */ @@ -3472,6 +3492,12 @@ export type QueryQuestionCustomizationArgs = { }; +export type QueryQuestionCustomizationByVersionedQuestionArgs = { + templateCustomizationId: Scalars['Int']['input']; + versionedQuestionId: Scalars['Int']['input']; +}; + + export type QueryQuestionsArgs = { sectionId: Scalars['Int']['input']; }; @@ -7164,6 +7190,7 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; questionConditions?: Resolver>>, ParentType, ContextType, RequireFields>; questionCustomization?: Resolver, ParentType, ContextType, RequireFields>; + questionCustomizationByVersionedQuestion?: Resolver, ParentType, ContextType, RequireFields>; questions?: Resolver>>, ParentType, ContextType, RequireFields>; re3RepositoryTypesList?: Resolver>; re3SubjectList?: Resolver>;