validate([ 'project_id' => ['required', 'integer'], ]); $user = $this->kanbanGitMcp->user($request); $project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id')); $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string', new VisualTicketDescription], 'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)], 'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)], 'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)], 'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'story_points' => ['nullable', 'integer', 'min:0'], 'is_blocked' => ['sometimes', 'boolean'], 'blocked_comment' => ['nullable', 'string'], 'acceptance_criteria' => ['nullable', 'array', 'list'], 'acceptance_criteria.*' => ['array:text,checked,indent'], 'acceptance_criteria.*.text' => ['required', 'string'], 'acceptance_criteria.*.checked' => ['sometimes', 'boolean'], 'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL], 'field_values' => ['sometimes', 'array', 'list'], 'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'], 'field_values.*.ticket_field_definition_id' => [ 'required', 'integer', 'distinct', Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id), ], 'field_values.*.value_number' => ['nullable', 'numeric'], 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], ]); $this->validateTicketConstraints($project, $user, $validated); $ticket = $this->createProjectTicket->handle($project, $validated, $user); return Response::structured([ 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), 'meta' => [ 'message' => "{$ticket->ticket_key} was created.", ], ]); } /** * @param array $validated */ private function validateTicketConstraints(Project $project, User $user, array $validated): void { $errors = []; $parentTicketId = Arr::get($validated, 'parent_ticket_id'); if ($parentTicketId !== null) { $parentTicket = Ticket::query() ->where('project_id', $project->id) ->with('ticketType') ->find($parentTicketId); if (! $parentTicket?->ticketType?->is_parent) { $errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.'; } } if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) { $errors['blocked_comment'] = 'Blocked tickets require a blocker comment.'; } if ( Arr::exists($validated, 'author_user_id') && (int) Arr::get($validated, 'author_user_id') !== $user->id && ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project) ) { $errors['author_user_id'] = 'Only project admins can change the author.'; } $fieldValues = Arr::get($validated, 'field_values', []); if ($fieldValues !== []) { $fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id'); foreach ($fieldValues as $index => $fieldValue) { $fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id')); $incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id'; if (Arr::get($fieldValue, $incompatibleValue) !== null) { $errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.'; } } } if ($errors !== []) { throw ValidationException::withMessages($errors); } } /** * @return array */ public function schema(JsonSchema $schema): array { return [ 'project_id' => $schema->integer()->description('Project ID.')->required(), 'title' => $schema->string()->max(255)->description('Ticket title.')->required(), 'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()), 'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(), 'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(), 'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(), 'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'), 'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'), 'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'), 'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'), 'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'), 'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'), 'acceptance_criteria' => $schema->array()->nullable() ->items($schema->object([ 'text' => $schema->string()->description('Checklist item text.')->required(), 'checked' => $schema->boolean()->default(false), 'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0), ])->withoutAdditionalProperties()) ->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()), 'field_values' => $schema->array() ->items($schema->object([ 'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(), 'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'), 'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'), ])->withoutAdditionalProperties()) ->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'), ]; } }