diff --git a/app/graphql/__generated__/schema.graphql b/app/graphql/__generated__/schema.graphql new file mode 100644 index 0000000..4b83029 --- /dev/null +++ b/app/graphql/__generated__/schema.graphql @@ -0,0 +1,605 @@ +type Axis { + id: ID! + name: String! + subjects: [Subject!]! +} + +type Category { + id: ID! + name: String! + subjects: [Subject!]! +} + +""" +Autogenerated input type of CreateQuestion +""" +input CreateQuestionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + question: QuestionCreateInput! +} + +""" +Autogenerated return type of CreateQuestion +""" +type CreateQuestionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + question: Question +} + +""" +Autogenerated input type of CreateReviewMessage +""" +input CreateReviewMessageInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + message: ReviewMessageInput! +} + +""" +Autogenerated return type of CreateReviewMessage +""" +type CreateReviewMessagePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + reviewMessage: ReviewMessage +} + +input DateRangeInput { + endAt: ISO8601Date! + startAt: ISO8601Date! +} + +""" +Autogenerated input type of DestroyQuestion +""" +input DestroyQuestionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + questionId: ID! +} + +""" +Autogenerated return type of DestroyQuestion +""" +type DestroyQuestionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + deletedQuestionId: ID + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! +} + +""" +Autogenerated input type of FinishQuestion +""" +input FinishQuestionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + questionId: ID! +} + +""" +Autogenerated return type of FinishQuestion +""" +type FinishQuestionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + question: Question +} + +""" +An ISO 8601-encoded date +""" +scalar ISO8601Date + +""" +An ISO 8601-encoded datetime +""" +scalar ISO8601DateTime + +""" +Represents untyped JSON +""" +scalar JSON + +type Mutation { + createQuestion( + """ + Parameters for CreateQuestion + """ + input: CreateQuestionInput! + ): CreateQuestionPayload + createReviewMessage( + """ + Parameters for CreateReviewMessage + """ + input: CreateReviewMessageInput! + ): CreateReviewMessagePayload + destroyQuestion( + """ + Parameters for DestroyQuestion + """ + input: DestroyQuestionInput! + ): DestroyQuestionPayload + finishQuestion( + """ + Parameters for FinishQuestion + """ + input: FinishQuestionInput! + ): FinishQuestionPayload + updateQuestion( + """ + Parameters for UpdateQuestion + """ + input: UpdateQuestionInput! + ): UpdateQuestionPayload +} + +""" +An object with an ID. +""" +interface Node { + """ + ID of the object. + """ + id: ID! +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String + + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String +} + +type Query { + currentUser: User + + """ + Fetches an object given its ID. + """ + node( + """ + ID of the object. + """ + id: ID! + ): Node + + """ + Fetches a list of objects given a list of IDs. + """ + nodes( + """ + IDs of the objects. + """ + ids: [ID!]! + ): [Node]! + questions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + where: QuestionWhereInput + ): QuestionConnection! + reviewers( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): UserConnection! + subjects( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): SubjectConnection! +} + +type Question implements Node { + alternatives: JSON! + authorship: String + authorshipYear: String + bloomTaxonomy: QuestionBloomTaxonomy + body: String + checkType: QuestionCheckType + createdAt: ISO8601DateTime! + difficulty: QuestionDifficulty + explanation: String + id: ID! + instruction: String + intention: String + references: String + status: QuestionStatus! + subjectId: Int + support: String + updatedAt: ISO8601DateTime! + userId: Int! +} + +input QuestionAlternativeInput { + correct: Boolean + text: String +} + +enum QuestionBloomTaxonomy { + ANALYZE + APPLY + CREATE + EVALUATE + REMEMBER + UNDERSTAND +} + +enum QuestionCheckType { + ASSERTION_AND_REASON + ASSOCIATION + CONSTANT_ALTERNATIVES + GAP + INCOMPLETE_AFFIRMATION + INTERPRETATION + MULTIPLE_ANSWER + NEGATIVE_FOCUS + ORDERING_OR_RANKING + UNIQUE_ANSWER +} + +""" +The connection type for Question. +""" +type QuestionConnection { + """ + A list of edges. + """ + edges: [QuestionEdge] + + """ + A list of nodes. + """ + nodes: [Question] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +input QuestionCreateInput { + alternatives: [QuestionAlternativeInput!]! + authorship: String! + authorshipYear: String! + bloomTaxonomy: QuestionBloomTaxonomy + body: String! + checkType: QuestionCheckType + difficulty: QuestionDifficulty + explanation: String! + instruction: String! + intention: String + references: String! + reviewerUserId: ID + status: QuestionStatus! + subjectId: ID + support: String! +} + +enum QuestionDifficulty { + EASY + HARD + MEDIUM +} + +""" +An edge in a connection. +""" +type QuestionEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Question +} + +enum QuestionStatus { + APPROVED + DRAFT + REGISTERED + WAITING_REVIEW + WITH_REQUESTED_CHANGES +} + +input QuestionUpdateInput { + alternatives: [QuestionAlternativeInput!]! + authorship: String! + authorshipYear: String! + bloomTaxonomy: QuestionBloomTaxonomy + body: String! + checkType: QuestionCheckType + difficulty: QuestionDifficulty + explanation: String! + id: ID! + instruction: String! + intention: String + references: String! + reviewerUserId: ID + status: QuestionStatus! + subjectId: ID + support: String! +} + +input QuestionWhereInput { + authorshipYear: [String!] + bloomTaxonomy: [QuestionBloomTaxonomy!] + checkType: [QuestionCheckType!] + createDate: DateRangeInput + difficulty: [QuestionDifficulty!] + status: [QuestionStatus!] + subjectId: ID + unifesoAuthorship: Boolean + userId: ID +} + +type ReviewMessage { + createdAt: ISO8601DateTime! + feedbackType: ReviewMessageFeedbackType! + id: ID! + question: Question! + text: String! + updatedAt: ISO8601DateTime! + user: User! +} + +enum ReviewMessageFeedbackType { + ANSWER + APPROVE + REQUEST_CHANGES +} + +input ReviewMessageInput { + feedbackType: ReviewMessageFeedbackType! + questionId: ID! + text: String! +} + +type Subject { + axis: Axis! + category: Category! + id: ID! + name: String! + questions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + where: QuestionWhereInput + ): QuestionConnection! +} + +""" +The connection type for Subject. +""" +type SubjectConnection { + """ + A list of edges. + """ + edges: [SubjectEdge] + + """ + A list of nodes. + """ + nodes: [Subject] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type SubjectEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Subject +} + +""" +Autogenerated input type of UpdateQuestion +""" +input UpdateQuestionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + question: QuestionUpdateInput! +} + +""" +Autogenerated return type of UpdateQuestion +""" +type UpdateQuestionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + question: Question +} + +type User { + email: String! + id: ID! + name: String! + roles: [UserRole!]! +} + +""" +The connection type for User. +""" +type UserConnection { + """ + A list of edges. + """ + edges: [UserEdge] + + """ + A list of nodes. + """ + nodes: [User] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type UserEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: User +} + +enum UserRole { + ADMIN + CENTER_DIRECTOR + COORDINATOR + NDE + PRO_RECTOR + TEACHER +} diff --git a/app/graphql/enums/review_message_feedback_type_enum.rb b/app/graphql/enums/review_message_feedback_type_enum.rb new file mode 100644 index 0000000..da4ed68 --- /dev/null +++ b/app/graphql/enums/review_message_feedback_type_enum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Enums + class ReviewMessageFeedbackTypeEnum < Types::BaseEnum + graphql_name "ReviewMessageFeedbackType" + + values_from_enumerize(ReviewMessage.feedback_type) + end +end diff --git a/app/graphql/inputs/question_alternative_input.rb b/app/graphql/inputs/question_alternative_input.rb new file mode 100644 index 0000000..bf955aa --- /dev/null +++ b/app/graphql/inputs/question_alternative_input.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Inputs + class QuestionAlternativeInput < Types::BaseInputObject + argument :correct, Boolean, required: false + argument :text, String, required: false + end +end diff --git a/app/graphql/inputs/question_create_input.rb b/app/graphql/inputs/question_create_input.rb new file mode 100644 index 0000000..31e7038 --- /dev/null +++ b/app/graphql/inputs/question_create_input.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Inputs + class QuestionCreateInput < Types::BaseInputObject + argument :instruction, String, required: true + argument :support, String, required: true + argument :body, String, required: true + argument :alternatives, [QuestionAlternativeInput], required: true + argument :explanation, String, required: true + argument :references, String, required: true + argument :authorship_year, String, required: true + argument :authorship, String, required: true + argument :intention, String, required: false + argument :status, Enums::QuestionStatusEnum, required: true + argument :check_type, Enums::QuestionCheckTypeEnum, required: false + argument :difficulty, Enums::QuestionDifficultyEnum, required: false + argument :bloom_taxonomy, Enums::QuestionBloomTaxonomyEnum, required: false + argument :subject_id, ID, required: false + argument :reviewer_user_id, ID, required: false + end +end diff --git a/app/graphql/inputs/question_update_input.rb b/app/graphql/inputs/question_update_input.rb new file mode 100644 index 0000000..5c43fb1 --- /dev/null +++ b/app/graphql/inputs/question_update_input.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Inputs + class QuestionUpdateInput < QuestionCreateInput + argument :id, ID, required: true + end +end diff --git a/app/graphql/inputs/review_message_input.rb b/app/graphql/inputs/review_message_input.rb new file mode 100644 index 0000000..4fd533a --- /dev/null +++ b/app/graphql/inputs/review_message_input.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Inputs + class ReviewMessageInput < Types::BaseInputObject + argument :feedback_type, Enums::ReviewMessageFeedbackTypeEnum, required: true + argument :text, String, required: true + argument :question_id, ID, required: true + end +end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index 0749ec0..f0234bc 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -4,5 +4,13 @@ module Mutations field_class Types::BaseField input_object_class Types::BaseInputObject object_class Types::BaseObject + + field :errors, [String], + null: false, + description: "Errors encountered during execution of the mutation." + + def current_user + context[:current_user] + end end end diff --git a/app/graphql/mutations/create_question.rb b/app/graphql/mutations/create_question.rb new file mode 100644 index 0000000..bb4ce7c --- /dev/null +++ b/app/graphql/mutations/create_question.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + class CreateQuestion < BaseMutation + field :question, Types::QuestionType, null: true + + argument :question, Inputs::QuestionCreateInput, required: true + + def resolve(question:) + question = question.to_h + reviewer_user_id = question.delete(:reviewer_user_id) + + record = Question.new(question) + record.user_id = context[:current_user].id + + policy = QuestionPolicy.new(context[:current_user], record) + + raise Pundit::NotAuthorizedError unless policy.create? + + ActiveRecord::Base.transaction do + record.save! + + if reviewer_user_id.present? && question[:status] != "draft" + record.review_requests.create!(user_id: reviewer_user_id) + end + + { question: record, errors: [] } + rescue ActiveRecord::RecordInvalid + { question: nil, errors: record.errors.full_messages } + end + rescue Pundit::NotAuthorizedError => e + { question: nil, errors: [e.message] } + end + end +end diff --git a/app/graphql/mutations/create_review_message.rb b/app/graphql/mutations/create_review_message.rb new file mode 100644 index 0000000..eee5797 --- /dev/null +++ b/app/graphql/mutations/create_review_message.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Mutations + class CreateReviewMessage < BaseMutation + field :review_message, Types::ReviewMessageType, null: true + + argument :message, Inputs::ReviewMessageInput, required: true + + def resolve(message:) + message = message.to_h + + ActiveRecord::Base.transaction do + record = ReviewMessage.create!({ + **message, + user_id: current_user.id, + }) + + update_question_status(record.question, message[:feedback_type]) + update_review_requests(record.question, message[:feedback_type]) + + { review_message: record, errors: [] } + rescue ActiveRecord::RecordInvalid => e + { review_message: nil, errors: e.record.errors.full_messages } + end + rescue Pundit::NotAuthorizedError => e + { review_message: nil, errors: [e.message] } + end + + private + + def update_question_status(question, feedback_type) + new_question_status = case feedback_type + when "request_changes" + "with_requested_changes" + when "approve" + "approved" + when "answer" + "waiting_review" + end + + question.update!(status: new_question_status) + end + + def update_review_requests(question, feedback_type) + return question.review_requests.update_all(answered: false) if feedback_type == "answer" + + question + .review_requests + .where(user_id: current_user.id) + .update_all(answered: question.user_id != current_user.id) + end + end +end diff --git a/app/graphql/mutations/destroy_question.rb b/app/graphql/mutations/destroy_question.rb new file mode 100644 index 0000000..ddf2b29 --- /dev/null +++ b/app/graphql/mutations/destroy_question.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + class DestroyQuestion < BaseMutation + field :deleted_question_id, ID, null: true + + argument :question_id, ID, required: true + + def resolve(question_id:) + question = Question.find_by(id: question_id) + reviewer = question.reviewer + + raise Pundit::NotAuthorizedError unless QuestionPolicy.new(context[:current_user], question).destroy? + + return { errors: question.errors.full_messages } unless question.destroy! + + ReviewerMailer.with(question_id: question_id, recipient: reviewer) + .question_deleted_notification + .deliver if reviewer + + { deleted_question_id: question_id, errors: [] } + rescue Pundit::NotAuthorizedError => e + { errors: [e.message] } + end + end +end diff --git a/app/graphql/mutations/finish_question.rb b/app/graphql/mutations/finish_question.rb new file mode 100644 index 0000000..8bd040d --- /dev/null +++ b/app/graphql/mutations/finish_question.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + class FinishQuestion < BaseMutation + field :question, Types::QuestionType, null: true + + argument :question_id, ID, required: true + + def resolve(input) + user = context[:current_user] + + question = ::Question.find(input[:question_id]) + + raise Pundit::NotAuthorizedError unless QuestionPolicy.new(user, question).finish? + + if question.update(status: :registered) + { question: question, errors: [] } + else + { question: nil, errors: question.errors.full_messages } + end + + rescue Pundit::NotAuthorizedError => e + { question: nil, errors: [e.message] } + end + end +end diff --git a/app/graphql/mutations/update_question.rb b/app/graphql/mutations/update_question.rb new file mode 100644 index 0000000..4585e54 --- /dev/null +++ b/app/graphql/mutations/update_question.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + class UpdateQuestion < BaseMutation + field :question, Types::QuestionType, null: true + + argument :question, Inputs::QuestionUpdateInput, required: true + + def resolve(question:) + question = question.to_h + reviewer_user_id = question.delete(:reviewer_user_id) + + record = Question.find(question[:id]) + + raise Pundit::NotAuthorizedError unless QuestionPolicy.new(context[:current_user], record).update? + + ActiveRecord::Base.transaction do + record.update!(question) + + if reviewer_user_id.present? && question[:status] != "draft" + review_request = record.review_requests.find_or_create_by!( + user_id: reviewer_user_id + ) + + review_request.update!(answered: false) + end + + { question: record, errors: [] } + rescue ActiveRecord::RecordInvalid + { question: nil, errors: question.errors.full_messages } + end + + rescue Pundit::NotAuthorizedError => e + { question: nil, errors: [e.message] } + end + end +end diff --git a/app/graphql/progress_test_schema.rb b/app/graphql/progress_test_schema.rb index e4caa11..dc77946 100644 --- a/app/graphql/progress_test_schema.rb +++ b/app/graphql/progress_test_schema.rb @@ -1,4 +1,6 @@ class ProgressTestSchema < GraphQL::Schema + DEFINITION_DUMP_PATH = "app/graphql/__generated__/schema.graphql" + mutation(Types::MutationType) query(Types::QueryType) diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 28516a5..5fdbb1d 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -1,10 +1,9 @@ module Types class MutationType < Types::BaseObject - # TODO: remove me - field :test_field, String, null: false, - description: "An example field added by the generator" - def test_field - "Hello World" - end + field :create_question, mutation: Mutations::CreateQuestion + field :update_question, mutation: Mutations::UpdateQuestion + field :destroy_question, mutation: Mutations::DestroyQuestion + field :finish_question, mutation: Mutations::FinishQuestion + field :create_review_message, mutation: Mutations::CreateReviewMessage end end diff --git a/app/graphql/types/review_message_type.rb b/app/graphql/types/review_message_type.rb new file mode 100644 index 0000000..0c322b6 --- /dev/null +++ b/app/graphql/types/review_message_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module Types + class ReviewMessageType < Types::BaseObject + graphql_name "ReviewMessage" + + field :id, ID, null: false + field :user, UserType, null: false + field :question, QuestionType, null: false + field :text, String, null: false + field :feedback_type, Enums::ReviewMessageFeedbackTypeEnum, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end +end diff --git a/app/graphql/types/review_request_type.rb b/app/graphql/types/review_request_type.rb new file mode 100644 index 0000000..89b3aad --- /dev/null +++ b/app/graphql/types/review_request_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + class ReviewRequestType < Types::BaseObject + graphql_name "ReviewRequest" + + field :id, ID, null: false + field :answered, Boolean, null: false + + field :question, Types::QuestionType, null: false + def question + dataloader.with(Sources::ActiveRecord, Question).load(object.question_id) + end + + field :user, Types::UserType, null: false + def user + dataloader.with(Sources::ActiveRecord, User).load(object.user_id) + end + end +end diff --git a/lib/tasks/graphql.rake b/lib/tasks/graphql.rake new file mode 100644 index 0000000..dcc7d54 --- /dev/null +++ b/lib/tasks/graphql.rake @@ -0,0 +1,11 @@ +namespace :graphql do + desc "Dump graphql schema to app/graphql/__generated__/schema.graphql" + task dump: :environment do + File.write( + Rails.root.join(ProgressTestSchema::DEFINITION_DUMP_PATH), + ProgressTestSchema.to_definition + ) + + puts("#{ProgressTestSchema::DEFINITION_DUMP_PATH} updated") + end +end diff --git a/spec/graphql/progress_test_schema_spec.rb b/spec/graphql/progress_test_schema_spec.rb new file mode 100644 index 0000000..9ddf96c --- /dev/null +++ b/spec/graphql/progress_test_schema_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ProgressTestSchema do + context "schema dump" do + it "is updated" do + File.open(described_class::DEFINITION_DUMP_PATH, "r") do |f| + expect(f.read).to(eq(described_class.to_definition)) + end + end + end +end