{"record":{"id":"0100a2e2027991e1","repo":"instructure/canvas-lms","slug":"enrollment-is-not-in-invited-state","errorCode":null,"errorMessage":"Enrollment is not in invited state","messagePattern":"Enrollment is not in invited state","errorType":"exception","errorClass":"GraphQL::ExecutionError","httpStatus":null,"severity":"error","filePath":"app/graphql/mutations/accept_enrollment_invitation.rb","lineNumber":39,"sourceCode":"module Mutations\n  class AcceptEnrollmentInvitation < BaseMutation\n    argument :enrollment_uuid, String, required: true\n\n    field :enrollment, Types::EnrollmentType, null: true\n    field :success, Boolean, null: false\n\n    def resolve(input:, **)\n      user = context[:current_user]\n      raise GraphQL::ExecutionError, I18n.t(\"Must be logged in\") unless user\n\n      enrollment = Enrollment.where(uuid: input[:enrollment_uuid]).first\n      raise GraphQL::ExecutionError, I18n.t(\"Enrollment invitation not found\") unless enrollment\n\n      # Verify the enrollment belongs to the current user\n      raise GraphQL::ExecutionError, I18n.t(\"Unauthorized\") unless enrollment.user == user\n\n      # Verify the enrollment is in invited state\n      raise GraphQL::ExecutionError, I18n.t(\"Enrollment is not in invited state\") unless enrollment.invited?\n\n      begin\n        if enrollment.accept!\n          {\n            enrollment:,\n            success: true\n          }\n        else\n          {\n            enrollment: nil,\n            success: false,\n            errors: [{ message: I18n.t(\"Failed to accept enrollment invitation\") }]\n          }\n        end\n      rescue => e\n        {\n          enrollment: nil,\n          success: false,","sourceCodeStart":21,"sourceCodeEnd":57,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/graphql/mutations/accept_enrollment_invitation.rb#L21-L57","documentation":"The mutation only accepts invitations in the 'invited' workflow_state; otherwise it raises 'Enrollment is not in invited state'. Accepting is a state transition, so enrollments already accepted, declined, rejected, completed, or deleted cannot pass through accept! again.","triggerScenarios":"Double-clicking accept or retrying the mutation after a first successful accept; calling it on a previously declined invitation; course concluded/invitation expired so the enrollment moved out of 'invited'; batch scripts re-running on already-processed enrollments.","commonSituations":"Users replaying invitation links from email after already accepting; automated sync jobs lacking idempotency; race conditions where two tabs accept simultaneously and the second sees a non-invited state.","solutions":["Check the enrollment's current workflow_state before calling (or fetch the enrollment via GraphQL/API) and treat already-accepted as success.","Make the caller idempotent: persist locally that the invitation was accepted and skip repeat calls.","If the invitation was declined/expired, re-invite the user to generate a new enrollment.","Investigate enrollment state transitions (concluded course, deleted enrollment) if the state changed unexpectedly.","On the server side you could accept or return success for already-accepted enrollments instead of erroring, for idempotency."],"exampleFix":"// before\nif (!enrollment.invited?) raise GraphQL::ExecutionError, \"Enrollment is not in invited state\"\n// after\nif (enrollment.accepted?) return { enrollment:, success: true } # idempotent\nraise GraphQL::ExecutionError, \"Enrollment is not in invited state\" unless enrollment.invited?","handlingStrategy":"fallback","validationCode":"// make the call idempotent: check state first, skip if already accepted\nif (locallyAccepted.has(enrollmentUuid)) return { success: true }","typeGuard":null,"tryCatchPattern":"try {\n  return await client.request(ACCEPT_INVITATION, { enrollmentUuid })\n} catch (e) {\n  if (/not in invited state/i.test(e.message))\n    return { success: true, alreadyHandled: true } // treat as done\n  throw e\n}","preventionTips":["Disable the accept button after first success","Don't retry non-idempotent mutations on timeout without checking state","Re-invite instead of re-accepting declined/expired invitations","Persist accepted uuids locally for sync jobs"],"tags":["graphql","enrollment","state-machine","idempotency"],"backgroundTag":"invalid-state-transition","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}