add mutations to questions and reviews

This commit is contained in:
João Geonizeli
2022-07-21 15:10:49 -03:00
parent d38acaab98
commit 3ce6c421b1
18 changed files with 909 additions and 6 deletions

605
app/graphql/__generated__/schema.graphql generated Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
module Inputs
class QuestionUpdateInput < QuestionCreateInput
argument :id, ID, required: true
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,6 @@
class ProgressTestSchema < GraphQL::Schema
DEFINITION_DUMP_PATH = "app/graphql/__generated__/schema.graphql"
mutation(Types::MutationType)
query(Types::QueryType)

View File

@@ -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

View File

@@ -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

View File

@@ -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

11
lib/tasks/graphql.rake Normal file
View File

@@ -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

View File

@@ -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