Files
gitea/routers/api/v1/user/project.go
T
Oleks 3fd0aa751d feat(project): publish board events from service+model choke points
Wrap the model-layer column/project/issue mutation funcs in service-layer
helpers (CreateColumn, EditColumn, DeleteColumn, ReorderColumns,
DeleteProject, AssignOrRemoveProjects) that publish the matching SSE
event after the underlying call succeeds.

Routers (web + REST) are migrated to call these service helpers so the
publish side-effects fire uniformly across repo, user, and org scopes.

DeleteColumn snapshots the column's issues before deletion and emits
one CardMoved per affected issue (alongside the ColumnDeleted event)
so the receiving tab can patch the DOM without a full reload.

Move-issue publishing fires after the txn commits so we never emit
events for moves that get rolled back.
2026-05-15 21:53:35 +03:00

971 lines
25 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"errors"
"net/http"
"slices"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/optional"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
project_service "code.gitea.io/gitea/services/projects"
)
// reqUserProjectWriter returns false and writes a 403 if the doer is neither
// the context user nor a site admin. Call at the top of every write handler.
func reqUserProjectWriter(ctx *context.APIContext) bool {
if ctx.Doer.ID != ctx.ContextUser.ID && !ctx.Doer.IsAdmin {
ctx.APIError(http.StatusForbidden, "only the owner or a site admin may modify user projects")
return false
}
return true
}
func getUserProjectByID(ctx *context.APIContext) *project_model.Project {
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil
}
return project
}
func getUserProjectColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) {
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id"))
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil
}
project, err := project_model.GetProjectByIDAndOwner(ctx, column.ProjectID, ctx.ContextUser.ID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil
}
if project.ID != ctx.PathParamInt64("id") {
ctx.APIErrorNotFound()
return nil, nil
}
return project, column
}
func rejectIfUserProjectClosed(ctx *context.APIContext, project *project_model.Project) bool {
if project.IsClosed {
ctx.APIError(http.StatusForbidden, "project is closed")
return true
}
return false
}
func validateUserColumnColor(ctx *context.APIContext, color string) bool {
if color == "" {
return true
}
if !project_model.ColumnColorPattern.MatchString(color) {
ctx.APIError(http.StatusUnprocessableEntity, "color must be a 6-digit hex string like #FF0000")
return false
}
return true
}
// ListUserProjects lists all projects owned by a user
func ListUserProjects(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects user userListProjects
// ---
// summary: List projects owned by a user
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the projects
// type: string
// required: true
// - name: state
// in: query
// description: State of the project (open, closed, all)
// type: string
// enum: [open, closed, all]
// default: open
// - name: page
// in: query
// description: page number of results
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectList"
// "404":
// "$ref": "#/responses/notFound"
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state"))
listOptions := utils.GetListOptions(ctx)
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: listOptions,
OwnerID: ctx.ContextUser.ID,
IsClosed: isClosed,
Type: project_model.TypeIndividual,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer))
}
// GetUserProject gets a single user-scope project
func GetUserProject(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects/{id} user userGetProject
// ---
// summary: Get a user-scope project
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/Project"
// "404":
// "$ref": "#/responses/notFound"
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
}
// CreateUserProject creates a new user-scope project
func CreateUserProject(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects user userCreateProject
// ---
// summary: Create a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectOption"
// responses:
// "201":
// "$ref": "#/responses/Project"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
form := web.GetForm(ctx).(*api.CreateProjectOption)
templateType, err := convert.ProjectTemplateTypeFromString(form.TemplateType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
cardType, err := convert.ProjectCardTypeFromString(form.CardType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
p := &project_model.Project{
OwnerID: ctx.ContextUser.ID,
Title: form.Title,
Description: form.Description,
CreatorID: ctx.Doer.ID,
TemplateType: templateType,
CardType: cardType,
Type: project_model.TypeIndividual,
}
if err := project_model.NewProject(ctx, p); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer))
}
// EditUserProject updates a user-scope project
func EditUserProject(ctx *context.APIContext) {
// swagger:operation PATCH /users/{username}/projects/{id} user userEditProject
// ---
// summary: Edit a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectOption"
// responses:
// "200":
// "$ref": "#/responses/Project"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*api.EditProjectOption)
opts := project_service.UpdateProjectOptions{
Title: optional.FromPtr(form.Title),
Description: optional.FromPtr(form.Description),
}
if form.CardType != nil {
cardType, err := convert.ProjectCardTypeFromString(*form.CardType)
if err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return
}
opts.CardType = optional.Some(cardType)
}
if form.State != nil {
switch *form.State {
case api.StateOpen:
opts.IsClosed = optional.Some(false)
case api.StateClosed:
opts.IsClosed = optional.Some(true)
default:
ctx.APIError(http.StatusUnprocessableEntity, "state must be 'open' or 'closed'")
return
}
}
if err := project_service.UpdateProject(ctx, project, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
}
// DeleteUserProject deletes a user-scope project
func DeleteUserProject(ctx *context.APIContext) {
// swagger:operation DELETE /users/{username}/projects/{id} user userDeleteProject
// ---
// summary: Delete a user-scope project
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListUserProjectColumns lists all columns in a user-scope project
func ListUserProjectColumns(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects/{id}/columns user userListProjectColumns
// ---
// summary: List columns in a user-scope project
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectColumnList"
// "404":
// "$ref": "#/responses/notFound"
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
total, err := project_model.CountProjectColumns(ctx, project.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
listOptions := utils.GetListOptions(ctx)
columns, err := project_model.GetProjectColumns(ctx, project.ID, listOptions)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
}
// CreateUserProjectColumn creates a new column in a user-scope project
func CreateUserProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects/{id}/columns user userCreateProjectColumn
// ---
// summary: Create a new column in a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectColumnOption"
// responses:
// "201":
// "$ref": "#/responses/ProjectColumn"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.CreateProjectColumnOption)
if !validateUserColumnColor(ctx, form.Color) {
return
}
column := &project_model.Column{
Title: form.Title,
Color: form.Color,
ProjectID: project.ID,
CreatorID: ctx.Doer.ID,
}
if err := project_service.CreateColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer))
}
// EditUserProjectColumn updates a column in a user-scope project
func EditUserProjectColumn(ctx *context.APIContext) {
// swagger:operation PATCH /users/{username}/projects/{id}/columns/{column_id} user userEditProjectColumn
// ---
// summary: Edit a column in a user-scope project
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectColumnOption"
// responses:
// "200":
// "$ref": "#/responses/ProjectColumn"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.EditProjectColumnOption)
if form.Color != nil && !validateUserColumnColor(ctx, *form.Color) {
return
}
if form.Title != nil {
column.Title = *form.Title
}
if form.Color != nil {
column.Color = *form.Color
}
if form.Sorting != nil {
if *form.Sorting < -128 || *form.Sorting > 127 {
ctx.APIError(http.StatusBadRequest, "sorting value out of range, must be between -128 and 127")
return
}
column.Sorting = int8(*form.Sorting)
}
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer))
}
// DeleteUserProjectColumn deletes a column in a user-scope project
func DeleteUserProjectColumn(ctx *context.APIContext) {
// swagger:operation DELETE /users/{username}/projects/{id}/columns/{column_id} user userDeleteProjectColumn
// ---
// summary: Delete a column in a user-scope project
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
if !reqUserProjectWriter(ctx) {
return
}
project, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListUserProjectColumnIssues lists all issues in a user-scope project column
func ListUserProjectColumnIssues(ctx *context.APIContext) {
// swagger:operation GET /users/{username}/projects/{id}/columns/{column_id}/issues user userListProjectColumnIssues
// ---
// summary: List issues in a user-scope project column
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/IssueList"
// "404":
// "$ref": "#/responses/notFound"
_, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
listOptions := utils.GetListOptions(ctx)
// project_issue join already constrains to issues attached to this column;
// no Owner/Doer filter — same approach as the org-scope handler.
issuesOpts := &issues_model.IssuesOptions{
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
SortType: issues_model.SortTypeProjectColumnSorting,
}
count, err := issues_model.CountIssues(ctx, issuesOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
issues, err := issues_model.Issues(ctx, issuesOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
}
// AddIssueToUserProjectColumn adds an issue to a user-scope project column
func AddIssueToUserProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id} user userAddIssueToProjectColumn
// ---
// summary: Add an issue to a user-scope project column
// description: Gitea projects only contain issues — note cards and pull requests cannot be added.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// responses:
// "201":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
assignIssueToUserProjectColumn(ctx, true)
}
// RemoveIssueFromUserProjectColumn removes an issue from a user-scope project column.
// This fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
func RemoveIssueFromUserProjectColumn(ctx *context.APIContext) {
// swagger:operation DELETE /users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id} user userRemoveIssueFromProjectColumn
// ---
// summary: Remove an issue from a user-scope project column
// description: Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: column_id
// in: path
// description: id of the column
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
assignIssueToUserProjectColumn(ctx, false)
}
func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) {
if !reqUserProjectWriter(ctx) {
return
}
project, column := getUserProjectColumn(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
// User-scope projects can contain issues from any repo; no repo-ID constraint.
issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
if err := issue.LoadProjects(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
currentProjectIDs := make([]int64, 0, len(issue.Projects))
for _, p := range issue.Projects {
currentProjectIDs = append(currentProjectIDs, p.ID)
}
if !add {
exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !exists {
ctx.APIErrorNotFound()
return
}
newProjectIDs := make([]int64, 0, len(currentProjectIDs))
for _, id := range currentProjectIDs {
if id != column.ProjectID {
newProjectIDs = append(newProjectIDs, id)
}
}
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
} else {
alreadyInProject := slices.Contains(currentProjectIDs, column.ProjectID)
if !alreadyInProject {
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
copy(newProjectIDs, currentProjectIDs)
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
}
if err := project_model.MoveIssueToColumn(ctx, issue.ID, column.ProjectID, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
}
if add {
ctx.Status(http.StatusCreated)
} else {
ctx.Status(http.StatusNoContent)
}
}
// MoveUserProjectIssue moves an issue between columns of a user-scope project
func MoveUserProjectIssue(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/projects/{id}/issues/{issue_id}/move user userMoveProjectIssue
// ---
// summary: Move an issue between columns of a user-scope project
// description: Atomically moves an existing project issue into a different column, optionally setting its sorting position.
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: owner of the project
// type: string
// required: true
// - name: id
// in: path
// description: id of the project
// type: integer
// format: int64
// required: true
// - name: issue_id
// in: path
// description: id of the issue
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/MoveProjectIssueOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !reqUserProjectWriter(ctx) {
return
}
project := getUserProjectByID(ctx)
if ctx.Written() {
return
}
if rejectIfUserProjectClosed(ctx, project) {
return
}
form := web.GetForm(ctx).(*api.MoveProjectIssueOption)
column, err := project_model.GetColumn(ctx, form.ColumnID)
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.APIError(http.StatusUnprocessableEntity, "target column does not exist")
} else {
ctx.APIErrorInternal(err)
}
return
}
if column.ProjectID != project.ID {
ctx.APIError(http.StatusUnprocessableEntity, "target column does not belong to this project")
return
}
issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
var sorting int64
if form.Sorting != nil {
sorting = *form.Sorting
} else {
next, err := project_model.GetColumnIssueNextSorting(ctx, project.ID, column.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
sorting = next
}
if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{sorting: issue.ID}); err != nil {
if errors.Is(err, project_service.ErrIssueNotInProject) {
ctx.APIErrorNotFound()
return
}
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}