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.
This commit is contained in:
Oleks
2026-05-15 21:53:35 +03:00
parent 3c831efc0c
commit 3fd0aa751d
10 changed files with 302 additions and 35 deletions
+6 -6
View File
@@ -344,7 +344,7 @@ func DeleteOrgProject(ctx *context.APIContext) {
return
}
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -463,7 +463,7 @@ func CreateOrgProjectColumn(ctx *context.APIContext) {
CreatorID: ctx.Doer.ID,
}
if err := project_model.NewColumn(ctx, column); err != nil {
if err := project_service.CreateColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -540,7 +540,7 @@ func EditOrgProjectColumn(ctx *context.APIContext) {
column.Sorting = int8(*form.Sorting)
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -587,7 +587,7 @@ func DeleteOrgProjectColumn(ctx *context.APIContext) {
return
}
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -801,7 +801,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) {
newProjectIDs = append(newProjectIDs, id)
}
}
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -811,7 +811,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) {
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
copy(newProjectIDs, currentProjectIDs)
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
+2 -1
View File
@@ -31,6 +31,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
issue_service "code.gitea.io/gitea/services/issue"
project_service "code.gitea.io/gitea/services/projects"
)
// buildSearchIssuesRepoIDs builds the list of repository IDs for issue search based on query parameters.
@@ -915,7 +916,7 @@ func EditIssue(ctx *context.APIContext) {
// Update projects if provided
if canWrite && form.Projects != nil {
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, *form.Projects); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, *form.Projects); err != nil {
if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusBadRequest, err)
} else {
+6 -6
View File
@@ -371,7 +371,7 @@ func DeleteProject(ctx *context.APIContext) {
return
}
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -500,7 +500,7 @@ func CreateProjectColumn(ctx *context.APIContext) {
CreatorID: ctx.Doer.ID,
}
if err := project_model.NewColumn(ctx, column); err != nil {
if err := project_service.CreateColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -582,7 +582,7 @@ func EditProjectColumn(ctx *context.APIContext) {
column.Sorting = int8(*form.Sorting)
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -634,7 +634,7 @@ func DeleteProjectColumn(ctx *context.APIContext) {
return
}
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -866,7 +866,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) {
newProjectIDs = append(newProjectIDs, id)
}
}
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -878,7 +878,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) {
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
copy(newProjectIDs, currentProjectIDs)
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
+6 -6
View File
@@ -366,7 +366,7 @@ func DeleteUserProject(ctx *context.APIContext) {
return
}
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -489,7 +489,7 @@ func CreateUserProjectColumn(ctx *context.APIContext) {
CreatorID: ctx.Doer.ID,
}
if err := project_model.NewColumn(ctx, column); err != nil {
if err := project_service.CreateColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -570,7 +570,7 @@ func EditUserProjectColumn(ctx *context.APIContext) {
column.Sorting = int8(*form.Sorting)
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -621,7 +621,7 @@ func DeleteUserProjectColumn(ctx *context.APIContext) {
return
}
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -837,7 +837,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) {
newProjectIDs = append(newProjectIDs, id)
}
}
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
@@ -847,7 +847,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) {
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
copy(newProjectIDs, currentProjectIDs)
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
ctx.APIErrorInternal(err)
return
}
+5 -5
View File
@@ -214,7 +214,7 @@ func DeleteProject(ctx *context.Context) {
return
}
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
if err := project_service.DeleteProject(ctx, p.ID); err != nil {
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
@@ -283,7 +283,7 @@ func EditProjectPost(ctx *context.Context) {
p.Title = form.Title
p.Description = form.Content
p.CardType = form.CardType
if err = project_model.UpdateProject(ctx, p); err != nil {
if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil {
ctx.ServerError("UpdateProjects", err)
return
}
@@ -496,7 +496,7 @@ func DeleteProjectColumn(ctx *context.Context) {
return
}
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil {
if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
@@ -514,7 +514,7 @@ func AddColumnToProjectPost(ctx *context.Context) {
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
if err := project_service.CreateColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
@@ -567,7 +567,7 @@ func EditProjectColumn(ctx *context.Context) {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
+6 -6
View File
@@ -191,7 +191,7 @@ func DeleteProject(ctx *context.Context) {
return
}
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
if err := project_service.DeleteProject(ctx, p.ID); err != nil {
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
@@ -264,7 +264,7 @@ func EditProjectPost(ctx *context.Context) {
p.Title = form.Title
p.Description = form.Content
p.CardType = form.CardType
if err = project_model.UpdateProject(ctx, p); err != nil {
if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil {
ctx.ServerError("UpdateProjects", err)
return
}
@@ -459,7 +459,7 @@ func UpdateIssueProject(ctx *context.Context) {
projectIDs = filteredIDs
var failedIssues []int64
for _, issue := range issues {
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil {
if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, projectIDs); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
failedIssues = append(failedIssues, issue.ID)
continue
@@ -569,7 +569,7 @@ func DeleteProjectColumn(ctx *context.Context) {
return
}
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil {
if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
@@ -597,7 +597,7 @@ func AddColumnToProjectPost(ctx *context.Context) {
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
if err := project_service.CreateColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
@@ -672,7 +672,7 @@ func EditProjectColumn(ctx *context.Context) {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
if err := project_service.EditColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/services/context"
project_service "code.gitea.io/gitea/services/projects"
)
// MoveColumns moves or keeps columns in a project and sorts them inside that project
@@ -39,7 +40,7 @@ func MoveColumns(ctx *context.Context) {
sortedColumnIDs[column.Sorting] = column.ColumnID
}
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
if err = project_service.ReorderColumns(ctx, project, sortedColumnIDs); err != nil {
ctx.ServerError("MoveColumnsOnProject", err)
return
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/services/project_events"
)
// CreateColumn inserts a new column into a project and publishes a
// ColumnCreated event. Routers should call this instead of
// project_model.NewColumn so the SSE side-effect fires uniformly across
// repo, user, and org scopes.
func CreateColumn(ctx context.Context, column *project_model.Column) error {
if err := project_model.NewColumn(ctx, column); err != nil {
return err
}
project_events.PublishColumnCreated(ctx, project_events.ColumnCreated{
ProjectID: column.ProjectID,
ColumnID: column.ID,
Title: column.Title,
Color: column.Color,
Sorting: int64(column.Sorting),
IsDefault: column.Default,
})
return nil
}
// EditColumn updates a column and publishes a ColumnUpdated event.
func EditColumn(ctx context.Context, column *project_model.Column) error {
if err := project_model.UpdateColumn(ctx, column); err != nil {
return err
}
project_events.PublishColumnUpdated(ctx, project_events.ColumnUpdated{
ProjectID: column.ProjectID,
ColumnID: column.ID,
Title: column.Title,
Color: column.Color,
Sorting: int64(column.Sorting),
})
return nil
}
// DeleteColumn removes a column from a project and publishes the
// matching ColumnDeleted event. The model layer also moves the
// column's issues to the project's default column; we publish those
// individual moves so receiving tabs can patch the DOM without a full
// reload. We snapshot affected issues *before* the delete so we have
// their ids; the destination column id is resolved after.
func DeleteColumn(ctx context.Context, columnID int64) error {
// Snapshot the column + its issues so we know what to publish
// after the delete commits. Errors here are non-fatal: we still
// run the delete, and just skip per-issue events.
col, snapErr := project_model.GetColumn(ctx, columnID)
var (
projectID int64
movedIssues []int64
)
if snapErr == nil {
projectID = col.ProjectID
issues, err := col.GetIssues(ctx)
if err == nil {
movedIssues = make([]int64, 0, len(issues))
for _, pi := range issues {
movedIssues = append(movedIssues, pi.IssueID)
}
}
}
if err := project_model.DeleteColumnByID(ctx, columnID); err != nil {
return err
}
if snapErr != nil || projectID == 0 {
return nil
}
project_events.PublishColumnDeleted(ctx, project_events.ColumnDeleted{
ProjectID: projectID,
ColumnID: columnID,
})
// Resolve the new (default) column to attach to the per-issue
// CardMoved events. Failures here are tolerated; the frontend
// already knows the column is gone and will simply render its
// next refresh as authoritative.
project, err := project_model.GetProjectByID(ctx, projectID)
if err != nil {
return nil
}
defaultCol, err := project.MustDefaultColumn(ctx)
if err != nil {
return nil
}
for _, issueID := range movedIssues {
project_events.PublishCardMoved(ctx, project_events.CardMoved{
ProjectID: projectID,
IssueID: issueID,
FromColumnID: columnID,
ToColumnID: defaultCol.ID,
})
}
return nil
}
// ReorderColumns persists a new sort order for project columns and
// publishes a ColumnReordered batch event.
func ReorderColumns(ctx context.Context, project *project_model.Project, sortedColumnIDs map[int64]int64) error {
if err := project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
return err
}
cols := make([]project_events.ColumnSort, 0, len(sortedColumnIDs))
for sorting, columnID := range sortedColumnIDs {
cols = append(cols, project_events.ColumnSort{
ColumnID: columnID,
Sorting: sorting,
})
}
project_events.PublishColumnReordered(ctx, project_events.ColumnReordered{
ProjectID: project.ID,
Columns: cols,
})
return nil
}
// DeleteProject deletes a project and publishes a ProjectDeleted event.
func DeleteProject(ctx context.Context, projectID int64) error {
if err := project_model.DeleteProjectByID(ctx, projectID); err != nil {
return err
}
project_events.PublishProjectDeleted(ctx, project_events.ProjectDeleted{
ProjectID: projectID,
})
return nil
}
// publishProjectUpdated emits a ProjectUpdated event for the current
// state of the given project. It is exported via UpdateProject in
// project.go after the txn commits.
func publishProjectUpdated(ctx context.Context, project *project_model.Project) {
project_events.PublishProjectUpdated(ctx, project_events.ProjectUpdated{
ProjectID: project.ID,
Title: project.Title,
Description: project.Description,
CardType: convert.ProjectCardTypeToString(project.CardType),
IsClosed: project.IsClosed,
})
}
+110 -1
View File
@@ -15,6 +15,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/services/project_events"
)
// ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move
@@ -23,7 +24,14 @@ var ErrIssueNotInProject = errors.New("all issues have to be added to a project
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
// movedEvents accumulates one CardMoved per issue we touch so they
// can be published after the transaction commits successfully.
// We capture the from-column inside the txn (cheap extra query)
// and emit *all* moves, including same-column reorders, so the
// frontend can update sorting without re-fetching the whole column.
var movedEvents []project_events.CardMoved
err := db.WithTx(ctx, func(ctx context.Context) error {
movedEvents = movedEvents[:0]
issueIDs := make([]int64, 0, len(sortedIssueIDs))
for _, issueID := range sortedIssueIDs {
issueIDs = append(issueIDs, issueID)
@@ -89,9 +97,110 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
if err != nil {
return err
}
movedEvents = append(movedEvents, project_events.CardMoved{
ProjectID: column.ProjectID,
IssueID: issueID,
FromColumnID: projectColumnID,
ToColumnID: column.ID,
Sorting: sorting,
})
}
return nil
})
if err != nil {
return err
}
for _, ev := range movedEvents {
project_events.PublishCardMoved(ctx, ev)
}
return nil
}
// AssignOrRemoveProjects updates the projects associated with an issue
// (delegating to issues_model.IssueAssignOrRemoveProject) and publishes
// SSE events for each link/unlink so other tabs viewing the relevant
// project boards can update without a reload.
//
// Routers should prefer this helper over calling the model function
// directly so the publish side-effects fire at every call site.
func AssignOrRemoveProjects(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, newProjectIDs []int64) error {
// Snapshot the current project ids before the update so we can
// compute the link/unlink diff. If this read fails we just skip
// publishing — the user-visible operation still succeeds.
oldProjectIDs, snapErr := issueProjectIDs(ctx, issue.ID)
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, newProjectIDs); err != nil {
return err
}
if snapErr != nil {
return nil
}
added, removed := diffInt64Slices(oldProjectIDs, newProjectIDs)
for _, pid := range removed {
project_events.PublishCardUnlinked(ctx, project_events.CardUnlinked{
ProjectID: pid,
IssueID: issue.ID,
})
}
// For additions we want to surface the destination column so the
// receiving tab can refetch only that column's contents. The model
// function places newly added issues in each project's default
// column; re-derive that here.
for _, pid := range added {
project, err := project_model.GetProjectByID(ctx, pid)
if err != nil {
continue
}
col, err := project.MustDefaultColumn(ctx)
if err != nil {
continue
}
project_events.PublishCardLinked(ctx, project_events.CardLinked{
ProjectID: pid,
IssueID: issue.ID,
ColumnID: col.ID,
})
}
return nil
}
// issueProjectIDs reads the set of project ids currently linked to issue.
// Mirrors models/issues/(*Issue).projectIDs but lives at the service layer
// so we can keep the model surface untouched.
func issueProjectIDs(ctx context.Context, issueID int64) ([]int64, error) {
var ids []int64
err := db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ?", issueID).
Cols("project_id").
Find(&ids)
return ids, err
}
// diffInt64Slices returns the elements present in `b` but missing in `a`
// (added) and the elements present in `a` but missing in `b` (removed).
// Both inputs are treated as sets.
func diffInt64Slices(a, b []int64) (added, removed []int64) {
inA := make(map[int64]struct{}, len(a))
for _, v := range a {
inA[v] = struct{}{}
}
inB := make(map[int64]struct{}, len(b))
for _, v := range b {
inB[v] = struct{}{}
}
for _, v := range b {
if _, ok := inA[v]; !ok {
added = append(added, v)
}
}
for _, v := range a {
if _, ok := inB[v]; !ok {
removed = append(removed, v)
}
}
return added, removed
}
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) {
+8 -3
View File
@@ -19,9 +19,10 @@ type UpdateProjectOptions struct {
IsClosed optional.Option[bool]
}
// UpdateProject applies the provided options to the project atomically.
// UpdateProject applies the provided options to the project atomically
// and emits a ProjectUpdated SSE event when the txn commits.
func UpdateProject(ctx context.Context, project *project_model.Project, opts UpdateProjectOptions) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if opts.Title.Has() {
project.Title = opts.Title.Value()
}
@@ -40,5 +41,9 @@ func UpdateProject(ctx context.Context, project *project_model.Project, opts Upd
}
}
return nil
})
}); err != nil {
return err
}
publishProjectUpdated(ctx, project)
return nil
}