feat: implement project soft-delete with restore and purge

- Add DeletedAt field to Project model with XORM soft-delete tag
- Replace hard delete with soft-delete in Project.Delete()
- Recursively soft-delete all descendant projects via CTE
- Add PermanentDelete() for actual cascade deletion (used by purge job
  and user deletion)
- Add RestoreProject() to restore soft-deleted projects and descendants
- Add GetDeletedProjects() to list soft-deleted projects for a user
- Add background purge cron job (hourly) for projects past 30-day
  retention
- Update user deletion to use PermanentDelete instead of soft-delete
This commit is contained in:
kolaente 2026-03-26 16:14:22 +01:00
parent 49a3cde3ac
commit 9aabd37b5d
4 changed files with 298 additions and 15 deletions

View File

@ -125,6 +125,7 @@ func FullInit() {
models.RegisterAddTaskToFilterViewCron()
user.RegisterTokenCleanupCron()
models.RegisterSessionCleanupCron()
models.RegisterSoftDeletedProjectPurgeCron()
user.RegisterDeletionNotificationCron()
openid.CleanupSavedOpenIDProviders()
openid.RegisterEmptyOpenIDTeamCleanupCron()

View File

@ -80,6 +80,9 @@ type Project struct {
Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"`
MaxPermission Permission `xorm:"-" json:"max_permission"`
// A timestamp when this project was deleted. If null, the project has not been deleted.
DeletedAt *time.Time `xorm:"deleted null" json:"deleted_at"`
// A timestamp when this project was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this project was last updated. You cannot change this value.
@ -463,6 +466,7 @@ func getUserProjectsStatement(userID int64, search string) *builder.Builder {
builder.Eq{"ul.user_id": userID},
builder.Eq{"l.owner_id": userID},
),
builder.IsNull{"l.deleted_at"},
}
ids := []int64{}
@ -555,7 +559,8 @@ func getAllProjectsForUser(s *xorm.Session, userID int64, opts *projectOptions)
baseQuery := querySQLString + `
UNION ALL
SELECT p.id, p.title, p.description, p.identifier, p.hex_color, p.owner_id, p.parent_project_id, (ap.is_archived OR p.is_archived) AS is_archived, p.background_file_id, p.background_blur_hash, p.position, p.created, p.updated FROM projects p
INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
INNER JOIN all_projects ap ON p.parent_project_id = ap.id
WHERE p.deleted_at IS NULL`
columnStr := strings.Join([]string{
"all_projects.id",
@ -679,13 +684,14 @@ func GetAllParentProjects(s *xorm.Session, projectID int64) (allProjects map[int
FROM
projects p
WHERE
p.id = ?
p.id = ? AND p.deleted_at IS NULL
UNION ALL
SELECT
p.*
FROM
projects p
INNER JOIN all_projects pc ON p.ID = pc.parent_project_id
WHERE p.deleted_at IS NULL
)
SELECT DISTINCT * FROM all_projects`, projectID).Find(&allProjects)
return
@ -1208,6 +1214,64 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
return &ErrCannotDeleteDefaultProject{ProjectID: p.ID}
}
// XORM's Delete() auto-sets deleted_at = NOW() because of the `deleted` tag
_, err = s.ID(p.ID).Delete(&Project{})
if err != nil {
return
}
events.DispatchOnCommit(s, &ProjectDeletedEvent{
Project: p,
Doer: a,
})
// Recursively soft-delete all descendant projects
err = softDeleteProjectDescendants(s, p.ID, a)
return
}
// softDeleteProjectDescendants uses a recursive CTE to find and soft-delete all descendant projects.
func softDeleteProjectDescendants(s *xorm.Session, parentProjectID int64, _ web.Auth) error {
var descendantIDs []int64
err := s.SQL(
`
WITH RECURSIVE descendant_ids (id) AS (
SELECT id
FROM projects
WHERE parent_project_id = ? AND deleted_at IS NULL
UNION ALL
SELECT p.id
FROM projects p
INNER JOIN descendant_ids di ON p.parent_project_id = di.id
WHERE p.deleted_at IS NULL
)
SELECT id FROM descendant_ids`,
parentProjectID,
).Find(&descendantIDs)
if err != nil {
return fmt.Errorf("failed to find descendant projects for parent ID %d: %w", parentProjectID, err)
}
if len(descendantIDs) == 0 {
return nil
}
now := time.Now()
_, err = s.Unscoped().
In("id", descendantIDs).
Cols("deleted_at").
Update(&Project{DeletedAt: &now})
if err != nil {
return fmt.Errorf("failed to soft-delete descendant projects for parent ID %d: %w", parentProjectID, err)
}
return nil
}
// PermanentDelete permanently deletes a project and all related entities.
// This is called by the purge job for projects past the retention period.
func (p *Project) PermanentDelete(s *xorm.Session, a web.Auth) (err error) {
// Delete all tasks on that project
// Using the loop to make sure all related entities to all tasks are properly deleted as well.
tasks, _, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{})
@ -1222,7 +1286,15 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
}
}
fullProject, err := GetProjectSimpleByID(s, p.ID)
// Use Unscoped to find the soft-deleted project
fullProject := &Project{}
exists, err := s.Unscoped().Where("id = ?", p.ID).Get(fullProject)
if err != nil {
return
}
if !exists {
return ErrProjectDoesNotExist{ID: p.ID}
}
if err != nil {
return
}
@ -1233,6 +1305,10 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
}
// If we're deleting a default project, remove it as default
isDefaultProject, err := p.isDefaultProject(s)
if err != nil {
return err
}
if isDefaultProject {
_, err = s.Where("default_project_id = ?", p.ID).
Cols("default_project_id").
@ -1282,25 +1358,21 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
return
}
// Delete the project
_, err = s.ID(p.ID).Delete(&Project{})
// Permanently delete the project row (bypass soft-delete)
_, err = s.Unscoped().ID(p.ID).Delete(&Project{})
if err != nil {
return
}
events.DispatchOnCommit(s, &ProjectDeletedEvent{
Project: fullProject,
Doer: a,
})
// Recursively permanently delete child projects
childProjects := []*Project{}
err = s.Where("parent_project_id = ?", fullProject.ID).Find(&childProjects)
err = s.Unscoped().Where("parent_project_id = ?", fullProject.ID).Find(&childProjects)
if err != nil {
return
}
for _, child := range childProjects {
err = child.Delete(s, a)
err = child.PermanentDelete(s, a)
if err != nil {
return
}
@ -1309,6 +1381,131 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
return
}
// RestoreProject restores a soft-deleted project and all its descendants that were soft-deleted.
func RestoreProject(s *xorm.Session, projectID int64, a web.Auth) (project *Project, err error) {
project = &Project{}
exists, err := s.Unscoped().
Where("id = ? AND deleted_at IS NOT NULL", projectID).
Get(project)
if err != nil {
return nil, err
}
if !exists {
return nil, ErrProjectDoesNotExist{ID: projectID}
}
// Check admin permission (using Unscoped so the permission check can find the deleted project)
isAdmin, err := checkProjectAdminUnscoped(s, projectID, a)
if err != nil {
return nil, err
}
if !isAdmin {
return nil, ErrGenericForbidden{}
}
// Restore the project itself
_, err = s.Unscoped().
Where("id = ?", projectID).
Cols("deleted_at").
Update(&Project{})
if err != nil {
return nil, err
}
// Restore all descendant projects that are soft-deleted
var descendantIDs []int64
err = s.SQL(
`
WITH RECURSIVE descendant_ids (id) AS (
SELECT id
FROM projects
WHERE parent_project_id = ?
UNION ALL
SELECT p.id
FROM projects p
INNER JOIN descendant_ids di ON p.parent_project_id = di.id
)
SELECT id FROM descendant_ids`,
projectID,
).Find(&descendantIDs)
if err != nil {
return nil, fmt.Errorf("failed to find descendant projects for restore: %w", err)
}
if len(descendantIDs) > 0 {
_, err = s.Unscoped().
In("id", descendantIDs).
Where("deleted_at IS NOT NULL").
Cols("deleted_at").
Update(&Project{})
if err != nil {
return nil, fmt.Errorf("failed to restore descendant projects: %w", err)
}
}
project.DeletedAt = nil
return project, nil
}
// checkProjectAdminUnscoped checks if a user has admin permission on a project,
// including soft-deleted projects.
func checkProjectAdminUnscoped(s *xorm.Session, projectID int64, a web.Auth) (bool, error) {
// Check if the user is the owner
project := &Project{}
exists, err := s.Unscoped().
Where("id = ?", projectID).
Get(project)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
if project.OwnerID == a.GetID() {
return true, nil
}
// Check direct user shares with admin permission
var count int64
count, err = s.Where("project_id = ? AND user_id = ? AND `right` = ?", projectID, a.GetID(), PermissionAdmin).
Count(&ProjectUser{})
if err != nil {
return false, err
}
if count > 0 {
return true, nil
}
// Check team shares with admin permission
count, err = s.SQL(
`SELECT COUNT(*) FROM team_projects tp
INNER JOIN team_members tm ON tp.team_id = tm.team_id
WHERE tp.project_id = ? AND tm.user_id = ? AND tp.`+"`right`"+` = ?`,
projectID, a.GetID(), PermissionAdmin,
).Count()
if err != nil {
return false, err
}
return count > 0, nil
}
// SoftDeleteRetentionDays is the number of days a soft-deleted project is retained before permanent purge.
const SoftDeleteRetentionDays = 30
// GetDeletedProjects returns all soft-deleted projects the user has admin access to.
func GetDeletedProjects(s *xorm.Session, a web.Auth) (projects []*Project, err error) {
projects = []*Project{}
err = s.Unscoped().
Where("deleted_at IS NOT NULL AND owner_id = ?", a.GetID()).
Find(&projects)
if err != nil {
return nil, err
}
return projects, nil
}
// DeleteBackgroundFileIfExists deletes the list's background file from the db and the filesystem,
// if one exists
func (p *Project) DeleteBackgroundFileIfExists(s *xorm.Session) (err error) {
@ -1347,11 +1544,12 @@ func setArchiveStateForProjectDescendants(s *xorm.Session, parentProjectID int64
WITH RECURSIVE descendant_ids (id) AS (
SELECT id
FROM projects
WHERE parent_project_id = ?
WHERE parent_project_id = ? AND deleted_at IS NULL
UNION ALL
SELECT p.id
FROM projects p
INNER JOIN descendant_ids di ON p.parent_project_id = di.id
WHERE p.deleted_at IS NULL
)
SELECT id FROM descendant_ids`,
parentProjectID,

View File

@ -0,0 +1,83 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"time"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
)
// RegisterSoftDeletedProjectPurgeCron registers the cron job that permanently
// deletes projects whose soft-delete retention period has expired.
func RegisterSoftDeletedProjectPurgeCron() {
err := cron.Schedule("0 * * * *", purgeSoftDeletedProjects)
if err != nil {
log.Errorf("Could not register soft-deleted project purge cron: %s", err.Error())
}
}
func purgeSoftDeletedProjects() {
cutoff := time.Now().Add(-SoftDeleteRetentionDays * 24 * time.Hour)
s := db.NewSession()
defer s.Close()
var projects []*Project
err := s.Unscoped().
Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
Find(&projects)
if err != nil {
log.Errorf("Could not get soft-deleted projects for purge: %s", err)
return
}
if len(projects) == 0 {
return
}
log.Debugf("Found %d soft-deleted projects past retention period for purge", len(projects))
for _, p := range projects {
func() {
ps := db.NewSession()
defer ps.Close()
// Use a system user auth for the permanent delete
doer := &user.User{ID: p.OwnerID}
err = p.PermanentDelete(ps, doer)
if err != nil {
_ = ps.Rollback()
log.Errorf("Could not permanently delete project %d: %s", p.ID, err)
return
}
err = ps.Commit()
if err != nil {
_ = ps.Rollback()
log.Errorf("Could not commit permanent deletion of project %d: %s", p.ID, err)
return
}
log.Debugf("Permanently deleted project %d", p.ID)
}()
}
}

View File

@ -137,10 +137,11 @@ func DeleteUser(s *xorm.Session, u *user.User) (err error) {
for _, p := range projectsToDelete {
if p.ParentProjectID != 0 {
// Child projects are deleted by p.Delete
// Child projects are deleted by p.PermanentDelete
continue
}
err = p.Delete(s, u)
// Use PermanentDelete for user deletion since the user account is being removed entirely
err = p.PermanentDelete(s, u)
// If the user is the owner of the default project it will be deleted, if they are not the owner
// we can ignore the error as the project was shared in that case.
if err != nil && !IsErrCannotDeleteDefaultProject(err) {