Move deleted projects to the trash for 30 days
Java · Spring Boot · beginner · modification
Deleting a project now moves it to the trash instead of dropping the row, so a member who deletes the wrong project has 30 days to bring it back. `DELETE` sets `deletedAt`; the list and get endpoints filter trashed projects out; the new `POST .../restore` endpoint clears `deletedAt` while the project is still inside its 30 days; and `TrashPurgeJob` deletes whatever has been in the trash for 30 days or more every night at 03:00 UTC. Trashing and restoring are each a single conditional `UPDATE`, so a double-clicked delete gets one 204 and one 404. Time comes from the injected `Clock`, like `createdAt` already does.
Project management app on Spring Boot 3.3 (Java 21), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16; Hibernate maps `Instant` to `timestamptz`. Existing and unchanged: the `Clock` bean (`Clock.systemUTC()`), `@EnableScheduling` on a config class (the app already runs other `@Scheduled` jobs, on a single instance), `ProjectResponse` (`id`, `name`, `createdAt`), `CreateProjectRequest`, the `workspaceAccess.isMember` check that the class-level `@PreAuthorize` applies to every endpoint of the controller (any member may delete and restore), and `ProjectNotFoundException`, annotated `@ResponseStatus(NOT_FOUND)`. `Project` has no JPA associations; tasks and comments reference `projects.id` with `ON DELETE CASCADE` in the schema, which the old `DELETE` already relied on. Project names need not be unique. Nothing outside `ProjectService` uses `ProjectRepository`, and nothing outside `ProjectController` calls `ProjectService.delete`, so the methods this PR renames have no other callers. The task and comment endpoints (`/projects/{id}/tasks`, `/tasks/{id}/comments`) are out of scope for this PR. The nullable `deleted_at timestamptz` column is added by a migration that ships separately and is already applied; a workspace has at most a few thousand projects.
Requirements
- `DELETE /api/workspaces/{workspaceId}/projects/{id}` (existing) no longer deletes the row: it moves the project to the trash by setting `deletedAt` to the current time and answers 204. A project that is not in that workspace, or is already in the trash, answers 404.
- A project in the trash is hidden: the existing list endpoint leaves it out and the existing get endpoint answers 404 for it, exactly as if it did not exist.
- New `POST /api/workspaces/{workspaceId}/projects/{id}/restore` takes a project out of the trash (clears `deletedAt`) and answers 200 with the project, in the same shape as the get endpoint. It works while less than 30 days (30 × 24 hours) have passed since `deletedAt`: at exactly 30 days, or later, the project can no longer be restored, even if the purge below has not removed it yet. A project that is not in the trash, not in that workspace, or past those 30 days answers 404.
- A job runs every night at 03:00 UTC and permanently deletes every project that has been in the trash for 30 days or more (`deletedAt` at or before the current time minus 30 days). A failed run is simply caught up by the next night's run.
- Read the current time from the application's existing `Clock` bean, never from `Instant.now()`, so tests can pin it.
Files touched
- src/main/java/com/acme/projects/project/Project.java
- src/main/java/com/acme/projects/project/ProjectRepository.java
- src/main/java/com/acme/projects/project/ProjectService.java
- src/main/java/com/acme/projects/project/ProjectController.java
- src/main/java/com/acme/projects/project/TrashPurgeJob.java
--- src/main/java/com/acme/projects/project/Project.java
private Instant createdAt;
+ private Instant deletedAt;
+
protected Project() {}
public String getName() { return name; }
public Instant getCreatedAt() { return createdAt; }
+ public Instant getDeletedAt() { return deletedAt; }
}
--- src/main/java/com/acme/projects/project/ProjectRepository.java
package com.acme.projects.project;
+import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface ProjectRepository extends JpaRepository<Project, Long> {
- List<Project> findByWorkspaceIdOrderByNameAsc(Long workspaceId);
+ List<Project> findByWorkspaceIdAndDeletedAtIsNullOrderByNameAsc(Long workspaceId);
- Optional<Project> findByIdAndWorkspaceId(Long id, Long workspaceId);
+ Optional<Project> findByIdAndWorkspaceIdAndDeletedAtIsNull(Long id, Long workspaceId);