Compare commits

...

6 Commits

Author SHA1 Message Date
kolaente 1dcd27e566 feat(frontend): add soft-delete UI with Bin page and undo support
- Add deletedAt field to IProject interface and ProjectModel
- Add restore() and getDeletedProjects() to ProjectService
- Add restoreProject() and fetchDeletedProjects() to project store
- Create ProjectsBin.vue page showing deleted projects with restore
- Add Bin link to sidebar navigation
- Update delete modal text to softer language (move to bin)
- Add undo toast action after deleting a project
- Add route for /projects/bin
2026-03-30 23:21:07 +02:00
kolaente 86cabee5c6 test: add test fixtures and tests for project soft-delete
- Add soft-deleted project fixtures (IDs 41, 42, 43)
- Update existing delete tests to verify soft-delete behavior
- Add tests for restore, list deleted, and permanent delete
- Verify soft-deleted projects are excluded from ReadAll and permissions
2026-03-30 23:21:07 +02:00
kolaente 56f42a293c fix: exclude soft-deleted projects from all raw SQL queries
Add deleted_at IS NULL filters to:
- getUserProjectsStatement (project listing base query)
- getAllProjectsForUser recursive CTE
- GetAllParentProjects recursive CTE
- setArchiveStateForProjectDescendants CTE
- checkPermissionsForProjects permission resolver CTE
- Task overdue reminders JOIN
- Subscription CTEs (project and task)
- Task search parent_project sub-table filters
- ListUsersFromProject query
- RepairOrphanedProjects (exclude soft-deleted from orphan detection)
2026-03-30 23:21:07 +02:00
kolaente 6c2e2cda4f feat: add REST endpoints for project restore and deleted listing
- POST /projects/:project/restore - restores a soft-deleted project
- GET /projects/deleted - lists all soft-deleted projects for the user
2026-03-30 23:21:07 +02:00
kolaente 9aabd37b5d 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
2026-03-30 23:21:07 +02:00
kolaente 49a3cde3ac feat: add database migration for project soft-delete
Add deleted_at nullable timestamp column to projects table to support
soft-delete functionality.
2026-03-30 23:21:07 +02:00
25 changed files with 898 additions and 52 deletions

View File

@ -71,6 +71,16 @@
{{ $t('team.title') }}
</RouterLink>
</li>
<li>
<RouterLink
:to="{ name: 'projects.bin'}"
>
<span class="menu-item-icon icon">
<Icon icon="trash-alt" />
</span>
{{ $t('project.bin.title') }}
</RouterLink>
</li>
</menu>
</nav>

View File

@ -322,12 +322,22 @@
"delete": {
"title": "Delete \"{project}\"",
"header": "Delete this project",
"text1": "Are you sure you want to delete this project and all of its contents?",
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
"text1": "Are you sure you want to move this project to the bin?",
"text2": "It will be permanently deleted after 30 days.",
"success": "The project was moved to the bin.",
"tasksToDelete": "This will affect approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will affect approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete.",
"undoSuccess": "The project was restored.",
"undo": "Undo"
},
"bin": {
"title": "Bin",
"empty": "No deleted projects.",
"daysRemaining": "{days} days remaining",
"deletedOn": "Deleted on {date}",
"restore": "Restore",
"restoreSuccess": "The project was restored successfully."
},
"duplicate": {
"title": "Duplicate this project",

View File

@ -21,7 +21,8 @@ export interface IProject extends IAbstract {
backgroundBlurHash: string
parentProjectId: number
views: IProjectView[]
deletedAt: Date | null
created: Date
updated: Date
}

View File

@ -26,7 +26,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
backgroundBlurHash = ''
parentProjectId = 0
views: IProjectView[] = []
deletedAt: Date | null = null
created: Date = null
updated: Date = null
@ -51,6 +52,10 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
this.views = this.views.map(v => new ProjectViewModel(v))
if (this.deletedAt) {
this.deletedAt = new Date(this.deletedAt)
}
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}

View File

@ -212,6 +212,11 @@ const router = createRouter({
name: 'projects.index',
component: () => import('@/views/project/ListProjects.vue'),
},
{
path: '/projects/bin',
name: 'projects.bin',
component: () => import('@/views/project/ProjectsBin.vue'),
},
{
path: '/projects/new',
name: 'project.create',

View File

@ -52,6 +52,28 @@ export default class ProjectService extends AbstractService<IProject> {
return window.URL.createObjectURL(new Blob([response.data]))
}
async restore(projectId: IProject['id']): Promise<IProject> {
const cancel = this.setLoading()
try {
const response = await this.http.post(`/projects/${projectId}/restore`)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
async getDeletedProjects(): Promise<IProject[]> {
const cancel = this.setLoading()
try {
const response = await this.http.get('/projects/deleted')
return response.data.map((p: IProject) => this.modelFactory(p))
} finally {
cancel()
}
}
async removeBackground(project: IProject) {
const cancel = this.setLoading()

View File

@ -257,6 +257,30 @@ export const useProjectStore = defineStore('project', () => {
}
}
async function restoreProject(projectId: IProject['id']) {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
const restoredProject = await projectService.restore(projectId)
setProject(restoredProject)
return restoredProject
} finally {
cancel()
}
}
async function fetchDeletedProjects(): Promise<IProject[]> {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
return await projectService.getDeletedProjects()
} finally {
cancel()
}
}
async function loadAllProjects() {
const cancel = setModuleLoading(setIsLoading)
@ -353,6 +377,8 @@ export const useProjectStore = defineStore('project', () => {
createProject,
updateProject,
deleteProject,
restoreProject,
fetchDeletedProjects,
getAncestors,
setProjectView,
removeProjectView,

View File

@ -0,0 +1,113 @@
<template>
<div
class="content loader-container"
:class="{'is-loading': isLoading}"
>
<h1>{{ $t('project.bin.title') }}</h1>
<p v-if="deletedProjects.length === 0 && !isLoading">
{{ $t('project.bin.empty') }}
</p>
<div
v-for="project in deletedProjects"
:key="project.id"
class="deleted-project"
>
<div class="deleted-project-info">
<span class="deleted-project-title">{{ project.title }}</span>
<span class="deleted-project-meta">
{{ $t('project.bin.deletedOn', {date: formatDateShort(project.deletedAt)}) }}
&mdash;
{{ $t('project.bin.daysRemaining', {days: daysRemaining(project.deletedAt)}) }}
</span>
</div>
<XButton
variant="secondary"
:loading="restoring === project.id"
@click="restoreProject(project)"
>
{{ $t('project.bin.restore') }}
</XButton>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, onMounted} from 'vue'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
import {formatDateShort} from '@/helpers/time/formatDate'
import type {IProject} from '@/modelTypes/IProject'
const SOFT_DELETE_RETENTION_DAYS = 30
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
useTitle(() => t('project.bin.title'))
const deletedProjects = ref<IProject[]>([])
const isLoading = ref(false)
const restoring = ref<number | null>(null)
onMounted(async () => {
isLoading.value = true
try {
deletedProjects.value = await projectStore.fetchDeletedProjects()
} finally {
isLoading.value = false
}
})
function daysRemaining(deletedAt: Date | null): number {
if (!deletedAt) return 0
const deleted = new Date(deletedAt)
const purgeDate = new Date(deleted.getTime() + SOFT_DELETE_RETENTION_DAYS * 24 * 60 * 60 * 1000)
const remaining = Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
return Math.max(0, remaining)
}
async function restoreProject(project: IProject) {
restoring.value = project.id
try {
await projectStore.restoreProject(project.id)
deletedProjects.value = deletedProjects.value.filter(p => p.id !== project.id)
success({message: t('project.bin.restoreSuccess')})
} finally {
restoring.value = null
}
}
</script>
<style lang="scss" scoped>
.deleted-project {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-block-end: 1px solid var(--grey-200);
&:last-child {
border-block-end: none;
}
}
.deleted-project-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.deleted-project-title {
font-weight: bold;
}
.deleted-project-meta {
font-size: 0.875rem;
color: var(--grey-500);
}
</style>

View File

@ -25,7 +25,7 @@
/>
<p>
{{ $t('misc.cannotBeUndone') }}
{{ $t('project.delete.text2') }}
</p>
</template>
</Modal>
@ -88,8 +88,17 @@ async function deleteProject() {
return
}
await projectStore.deleteProject(project.value)
success({message: t('project.delete.success')})
const deletedProject = project.value
await projectStore.deleteProject(deletedProject)
success({message: t('project.delete.success')}, [
{
title: t('project.delete.undo'),
callback: async () => {
await projectStore.restoreProject(deletedProject.id)
success({message: t('project.delete.undoSuccess')})
},
},
])
router.push({name: 'home'})
}
</script>

View File

@ -361,3 +361,39 @@
position: 40
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
# Soft-deleted project owned by user 1
-
id: 41
title: Test41 soft-deleted
description: A soft-deleted project
identifier: test41
owner_id: 1
parent_project_id: 0
position: 41
deleted_at: 2026-03-20 10:00:00
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
# Soft-deleted parent project owned by user 1 (parent of 43)
-
id: 42
title: Test42 soft-deleted parent
description: A soft-deleted parent project
identifier: test42
owner_id: 1
parent_project_id: 0
position: 42
deleted_at: 2026-03-20 10:00:00
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
# Soft-deleted child project owned by user 1 (child of 42)
-
id: 43
title: Test43 soft-deleted child
description: A soft-deleted child project
identifier: test43
owner_id: 1
parent_project_id: 42
position: 43
deleted_at: 2026-03-20 10:00:00
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

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

View File

@ -0,0 +1,45 @@
// 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 migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type projects20260326 struct {
DeletedAt *time.Time `xorm:"deleted null"`
}
func (projects20260326) TableName() string {
return "projects"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260326155123",
Description: "Add deleted_at column to projects for soft-delete support",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync(projects20260326{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

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

@ -261,7 +261,7 @@ WITH RECURSIVE
0 AS level,
id AS original_project_id
FROM projects
WHERE id IN (`+utils.JoinInt64Slice(projectIDs, ", ")+`)
WHERE id IN (`+utils.JoinInt64Slice(projectIDs, ", ")+`) AND deleted_at IS NULL
UNION ALL
@ -271,7 +271,8 @@ WITH RECURSIVE
ph.level + 1,
ph.original_project_id
FROM projects p
INNER JOIN project_hierarchy ph ON p.id = ph.parent_project_id),
INNER JOIN project_hierarchy ph ON p.id = ph.parent_project_id
WHERE p.deleted_at IS NULL),
-- Calculate max team permission for each project/user combination
max_team_permissions AS (

View File

@ -36,9 +36,11 @@ func RepairOrphanedProjects(s *xorm.Session, dryRun bool) (*RepairOrphanedProjec
result := &RepairOrphanedProjectsResult{}
var orphans []*Project
// Use raw SQL that includes soft-deleted parents to avoid false positives:
// a child of a soft-deleted parent is NOT orphaned.
err := s.SQL(`SELECT p.* FROM projects p
LEFT JOIN projects parent ON p.parent_project_id = parent.id
WHERE p.parent_project_id > 0 AND parent.id IS NULL`).
WHERE p.parent_project_id > 0 AND parent.id IS NULL AND p.deleted_at IS NULL`).
Find(&orphans)
if err != nil {
return nil, err

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

@ -347,6 +347,31 @@ func TestProject_CreateOrUpdate(t *testing.T) {
})
}
func assertSoftDeleted(t *testing.T, projectID int64) {
t.Helper()
s := db.NewSession()
defer s.Close()
// Use Unscoped to bypass soft-delete filter
p := &Project{}
exists, err := s.Unscoped().Where("id = ?", projectID).Get(p)
require.NoError(t, err)
require.True(t, exists, "Project %d should still exist in db after soft-delete", projectID)
assert.NotNil(t, p.DeletedAt, "Project %d should have deleted_at set", projectID)
}
func assertNotSoftDeleted(t *testing.T, projectID int64) {
t.Helper()
s := db.NewSession()
defer s.Close()
p := &Project{}
exists, err := s.Unscoped().Where("id = ?", projectID).Get(p)
require.NoError(t, err)
require.True(t, exists, "Project %d should exist in db", projectID)
assert.Nil(t, p.DeletedAt, "Project %d should not have deleted_at set", projectID)
}
func TestProject_Delete(t *testing.T) {
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -359,12 +384,12 @@ func TestProject_Delete(t *testing.T) {
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertMissing(t, "projects", map[string]interface{}{
// With soft-delete, project row still exists but has deleted_at set
assertSoftDeleted(t, 1)
// Tasks should still exist (not permanently deleted)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
})
db.AssertMissing(t, "tasks", map[string]interface{}{
"id": 1,
})
}, false)
})
t.Run("with background", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -378,12 +403,11 @@ func TestProject_Delete(t *testing.T) {
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertMissing(t, "projects", map[string]interface{}{
"id": 35,
})
db.AssertMissing(t, "files", map[string]interface{}{
// Project is soft-deleted, background file still exists
assertSoftDeleted(t, 35)
db.AssertExists(t, "files", map[string]interface{}{
"id": 1,
})
}, false)
})
t.Run("default project of the same user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -407,7 +431,7 @@ func TestProject_Delete(t *testing.T) {
require.Error(t, err)
assert.True(t, IsErrCannotDeleteDefaultProject(err))
})
t.Run("deletes archived parent and its child atomically", func(t *testing.T) {
t.Run("soft-deletes archived parent and its child", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
@ -420,10 +444,10 @@ func TestProject_Delete(t *testing.T) {
err = s.Commit()
require.NoError(t, err)
db.AssertMissing(t, "projects", map[string]interface{}{"id": 22})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 21})
assertSoftDeleted(t, 22)
assertSoftDeleted(t, 21)
})
t.Run("deletes deeply nested child projects recursively", func(t *testing.T) {
t.Run("soft-deletes deeply nested child projects recursively", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
@ -435,10 +459,151 @@ func TestProject_Delete(t *testing.T) {
err = s.Commit()
require.NoError(t, err)
db.AssertMissing(t, "projects", map[string]interface{}{"id": 27})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 12})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 25})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 26})
assertSoftDeleted(t, 27)
assertSoftDeleted(t, 12)
assertSoftDeleted(t, 25)
assertSoftDeleted(t, 26)
})
t.Run("soft-deleted projects are excluded from ReadAll", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 41 is soft-deleted and owned by user 1
p := &Project{}
projects, _, _, err := p.ReadAll(s, &user.User{ID: 1}, "", 1, 50)
require.NoError(t, err)
projectList := projects.([]*Project)
for _, proj := range projectList {
assert.NotEqual(t, int64(41), proj.ID, "Soft-deleted project 41 should not appear in ReadAll")
assert.NotEqual(t, int64(42), proj.ID, "Soft-deleted project 42 should not appear in ReadAll")
assert.NotEqual(t, int64(43), proj.ID, "Soft-deleted project 43 should not appear in ReadAll")
}
})
t.Run("soft-deleted projects return no permission", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 41 is soft-deleted and owned by user 1
// XORM auto-filters soft-deleted projects, so CanRead will error with "Project does not exist"
p := &Project{ID: 41}
canRead, _, err := p.CanRead(s, &user.User{ID: 1})
if err != nil {
assert.True(t, IsErrProjectDoesNotExist(err), "Expected project not found error for soft-deleted project")
} else {
assert.False(t, canRead, "Should not be able to read soft-deleted project")
}
})
}
func TestProject_Restore(t *testing.T) {
t.Run("restore soft-deleted project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 41 is soft-deleted and owned by user 1
project, err := RestoreProject(s, 41, &user.User{ID: 1})
require.NoError(t, err)
require.NotNil(t, project)
assert.Nil(t, project.DeletedAt)
err = s.Commit()
require.NoError(t, err)
assertNotSoftDeleted(t, 41)
})
t.Run("restore soft-deleted parent restores children", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 42 is soft-deleted parent, 43 is soft-deleted child
_, err := RestoreProject(s, 42, &user.User{ID: 1})
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assertNotSoftDeleted(t, 42)
assertNotSoftDeleted(t, 43)
})
t.Run("restore non-existent project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := RestoreProject(s, 999, &user.User{ID: 1})
require.Error(t, err)
assert.True(t, IsErrProjectDoesNotExist(err))
})
t.Run("restore project without admin access", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 41 is owned by user 1, user 2 has no access
_, err := RestoreProject(s, 41, &user.User{ID: 2})
require.Error(t, err)
})
}
func TestProject_GetDeletedProjects(t *testing.T) {
t.Run("returns deleted projects for user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// User 1 owns soft-deleted projects 41, 42, 43
projects, err := GetDeletedProjects(s, &user.User{ID: 1})
require.NoError(t, err)
assert.Len(t, projects, 3)
ids := make(map[int64]bool)
for _, p := range projects {
ids[p.ID] = true
}
assert.True(t, ids[41])
assert.True(t, ids[42])
assert.True(t, ids[43])
})
t.Run("returns empty for user with no deleted projects", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// User 2 owns no soft-deleted projects
projects, err := GetDeletedProjects(s, &user.User{ID: 2})
require.NoError(t, err)
assert.Empty(t, projects)
})
}
func TestProject_PermanentDelete(t *testing.T) {
t.Run("permanently deletes project and all related entities", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// First soft-delete the project
project := Project{ID: 1}
err := project.Delete(s, &user.User{ID: 1})
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
// Now permanently delete it
s2 := db.NewSession()
defer s2.Close()
p := &Project{ID: 1}
err = p.PermanentDelete(s2, &user.User{ID: 1})
require.NoError(t, err)
err = s2.Commit()
require.NoError(t, err)
db.AssertMissing(t, "projects", map[string]interface{}{"id": 1})
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 1})
})
}

View File

@ -252,7 +252,7 @@ WITH RECURSIVE project_hierarchy AS (
0 AS level,
id AS original_project_id
FROM projects
WHERE id IN (`+entityIDString+`)
WHERE id IN (`+entityIDString+`) AND deleted_at IS NULL
UNION ALL
@ -264,6 +264,7 @@ WITH RECURSIVE project_hierarchy AS (
ph.original_project_id
FROM projects p
INNER JOIN project_hierarchy ph ON p.id = ph.parent_project_id
WHERE p.deleted_at IS NULL
),
subscription_hierarchy AS (
@ -318,7 +319,7 @@ WITH RECURSIVE project_hierarchy AS (
t.id AS task_id
FROM tasks t
JOIN projects p ON t.project_id = p.id
WHERE t.id IN (`+entityIDString+`)
WHERE t.id IN (`+entityIDString+`) AND p.deleted_at IS NULL
UNION ALL
@ -330,6 +331,7 @@ WITH RECURSIVE project_hierarchy AS (
ph.task_id
FROM projects p
INNER JOIN project_hierarchy ph ON p.id = ph.parent_project_id
WHERE p.deleted_at IS NULL
),
subscription_hierarchy AS (

View File

@ -38,7 +38,7 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time, cond builder.Cond) (u
var tasks []*Task
err = s.
Where("due_date is not null AND due_date < ? AND projects.is_archived = false", nextMinute.Add(time.Hour*14).Format(dbTimeFormat)).
Where("due_date is not null AND due_date < ? AND projects.is_archived = false AND projects.deleted_at IS NULL", nextMinute.Add(time.Hour*14).Format(dbTimeFormat)).
Join("LEFT", "projects", "projects.id = tasks.project_id").
And("done = false").
Find(&tasks)

View File

@ -64,13 +64,13 @@ var subTableFilters = SubTableFilters{
},
"parent_project": {
Table: "projects",
BaseFilter: "tasks.project_id = id",
BaseFilter: "tasks.project_id = id AND deleted_at IS NULL",
FilterableField: "parent_project_id",
AllowNullCheck: false,
},
"parent_project_id": {
Table: "projects",
BaseFilter: "tasks.project_id = id",
BaseFilter: "tasks.project_id = id AND deleted_at IS NULL",
FilterableField: "parent_project_id",
AllowNullCheck: false,
},

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) {

View File

@ -66,6 +66,7 @@ func ListUsersFromProject(s *xorm.Session, l *Project, currentUser *user.User, s
builder.Or(builder.Eq{"tl.permission": PermissionAdmin}),
),
builder.Eq{"l.id": currentProject.ID},
builder.IsNull{"l.deleted_at"},
).
Find(&currentUserIDs)
if err != nil {

View File

@ -0,0 +1,105 @@
// 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 v1
import (
"net/http"
"strconv"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
auth2 "code.vikunja.io/api/pkg/modules/auth"
"github.com/labstack/echo/v5"
)
// RestoreProject restores a soft-deleted project.
// @Summary Restore a deleted project
// @Description Restores a project that was previously deleted (soft-deleted). Also restores all descendant projects that were soft-deleted at the same time.
// @tags project
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Project ID"
// @Success 200 {object} models.Project "The restored project."
// @Failure 400 {object} web.HTTPError "Invalid project ID."
// @Failure 403 {object} web.HTTPError "The user does not have admin access to the project."
// @Failure 404 {object} web.HTTPError "The project does not exist or is not deleted."
// @Failure 500 {object} models.Message "Internal server error."
// @Router /projects/{id}/restore [post]
func RestoreProject(c *echo.Context) error {
projectID, err := strconv.ParseInt(c.Param("project"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"message": "Invalid project ID",
})
}
auth, err := auth2.GetAuthFromClaims(c)
if err != nil {
return err
}
s := db.NewSession()
defer s.Close()
project, err := models.RestoreProject(s, projectID, auth)
if err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
return c.JSON(http.StatusOK, project)
}
// ListDeletedProjects returns all soft-deleted projects the user has admin access to.
// @Summary Get all deleted projects
// @Description Returns all soft-deleted projects that the current user has admin access to, along with their deletion date and days remaining before permanent purge.
// @tags project
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {array} models.Project "All deleted projects."
// @Failure 500 {object} models.Message "Internal server error."
// @Router /projects/deleted [get]
func ListDeletedProjects(c *echo.Context) error {
auth, err := auth2.GetAuthFromClaims(c)
if err != nil {
return err
}
s := db.NewSession()
defer s.Close()
projects, err := models.GetDeletedProjects(s, auth)
if err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
return c.JSON(http.StatusOK, projects)
}

View File

@ -478,10 +478,12 @@ func registerAPIRoutes(a *echo.Group) {
},
}
a.GET("/projects", projectHandler.ReadAllWeb)
a.GET("/projects/deleted", apiv1.ListDeletedProjects)
a.GET("/projects/:project", projectHandler.ReadOneWeb)
a.POST("/projects/:project", projectHandler.UpdateWeb)
a.DELETE("/projects/:project", projectHandler.DeleteWeb)
a.PUT("/projects", projectHandler.CreateWeb)
a.POST("/projects/:project/restore", apiv1.RestoreProject)
a.GET("/projects/:project/projectusers", apiv1.ListUsersForProject)
if config.ServiceEnableLinkSharing.GetBool() {

View File

@ -48,6 +48,9 @@ func TestProject(t *testing.T) {
assert.NotContains(t, rec.Body.String(), `Test5`)
assert.NotContains(t, rec.Body.String(), `Test21`) // Archived through parent project
assert.NotContains(t, rec.Body.String(), `Test22`) // Archived directly
assert.NotContains(t, rec.Body.String(), `Test41`) // Soft-deleted
assert.NotContains(t, rec.Body.String(), `Test42`) // Soft-deleted parent
assert.NotContains(t, rec.Body.String(), `Test43`) // Soft-deleted child
})
t.Run("Search", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"s": []string{"Test1"}}, nil)