Auto-delete expired events via daily scheduled job #26
@@ -55,6 +55,8 @@ The following skills are available and should be used for their respective purpo
|
|||||||
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
||||||
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
||||||
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
||||||
|
- Java 25, Spring Boot 3.5.x + Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query) (013-auto-delete-expired)
|
||||||
|
- PostgreSQL (existing, Liquibase migrations) (013-auto-delete-expired)
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||||
|
|||||||
@@ -7,4 +7,8 @@
|
|||||||
<Match>
|
<Match>
|
||||||
<Package name="de.fete.adapter.in.web.model"/>
|
<Package name="de.fete.adapter.in.web.model"/>
|
||||||
</Match>
|
</Match>
|
||||||
|
<!-- Constructor-injected Spring beans storing interfaces/proxies are not a real exposure risk -->
|
||||||
|
<Match>
|
||||||
|
<Bug pattern="EI_EXPOSE_REP2"/>
|
||||||
|
</Match>
|
||||||
</FindBugsFilter>
|
</FindBugsFilter>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package de.fete;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/** Spring Boot entry point for the fete application. */
|
/** Spring Boot entry point for the fete application. */
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class FeteApplication {
|
public class FeteApplication {
|
||||||
|
|
||||||
/** Starts the application. */
|
/** Starts the application. */
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ package de.fete.adapter.out.persistence;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
/** Spring Data JPA repository for event entities. */
|
/** Spring Data JPA repository for event entities. */
|
||||||
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
||||||
|
|
||||||
|
/** Deletes all events whose expiry date is before today. Returns the number of deleted rows. */
|
||||||
|
@Modifying
|
||||||
|
@Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)
|
||||||
|
int deleteExpired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteExpired() {
|
||||||
|
return jpaRepository.deleteExpired();
|
||||||
|
}
|
||||||
|
|
||||||
private EventJpaEntity toEntity(Event event) {
|
private EventJpaEntity toEntity(Event event) {
|
||||||
var entity = new EventJpaEntity();
|
var entity = new EventJpaEntity();
|
||||||
entity.setId(event.getId());
|
entity.setId(event.getId());
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** Scheduled job that deletes events whose expiry date is in the past. */
|
||||||
|
@Component
|
||||||
|
public class ExpiredEventCleanupJob {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExpiredEventCleanupJob.class);
|
||||||
|
|
||||||
|
private final EventRepository eventRepository;
|
||||||
|
|
||||||
|
/** Creates a new cleanup job with the given event repository. */
|
||||||
|
public ExpiredEventCleanupJob(EventRepository eventRepository) {
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs daily at 03:00 and deletes all expired events. */
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
@Transactional
|
||||||
|
public void deleteExpiredEvents() {
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
log.info("Expired event cleanup: deleted {} event(s)", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,7 @@ public interface EventRepository {
|
|||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<Event> findByEventToken(EventToken eventToken);
|
Optional<Event> findByEventToken(EventToken eventToken);
|
||||||
|
|
||||||
|
/** Deletes all events whose expiry date is in the past. Returns the number of deleted events. */
|
||||||
|
int deleteExpired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
@Transactional
|
||||||
|
class EventPersistenceAdapterIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredRemovesExpiredEvents() {
|
||||||
|
Event expired = buildEvent("Expired Party", LocalDate.now().minusDays(1));
|
||||||
|
eventRepository.save(expired);
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsNonExpiredEvents() {
|
||||||
|
Event future = buildEvent("Future Party", LocalDate.now().plusDays(30));
|
||||||
|
Event saved = eventRepository.save(future);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsEventsExpiringToday() {
|
||||||
|
Event today = buildEvent("Today Party", LocalDate.now());
|
||||||
|
Event saved = eventRepository.save(today);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredReturnsZeroWhenNoneExpired() {
|
||||||
|
// Only save a future event
|
||||||
|
buildEvent("Future Only", LocalDate.now().plusDays(60));
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event buildEvent(String title, LocalDate expiryDate) {
|
||||||
|
var event = new Event();
|
||||||
|
event.setEventToken(EventToken.generate());
|
||||||
|
event.setOrganizerToken(OrganizerToken.generate());
|
||||||
|
event.setTitle(title);
|
||||||
|
event.setDescription("Test description");
|
||||||
|
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
|
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
||||||
|
event.setLocation("Test Location");
|
||||||
|
event.setExpiryDate(expiryDate);
|
||||||
|
event.setCreatedAt(OffsetDateTime.now());
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
38
specs/013-auto-delete-expired/data-model.md
Normal file
38
specs/013-auto-delete-expired/data-model.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Data Model: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Existing Entities (no changes)
|
||||||
|
|
||||||
|
### Event
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | BIGSERIAL | PK, internal |
|
||||||
|
| event_token | UUID | Public identifier |
|
||||||
|
| organizer_token | UUID | Organizer access |
|
||||||
|
| title | VARCHAR(200) | Required |
|
||||||
|
| description | VARCHAR(2000) | Optional |
|
||||||
|
| date_time | TIMESTAMPTZ | Event date/time |
|
||||||
|
| location | VARCHAR(500) | Optional |
|
||||||
|
| expiry_date | DATE | **Deletion trigger** — indexed (`idx_events_expiry_date`) |
|
||||||
|
| created_at | TIMESTAMPTZ | Auto-set |
|
||||||
|
|
||||||
|
### RSVP
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | BIGSERIAL | PK, internal |
|
||||||
|
| rsvp_token | UUID | Public identifier |
|
||||||
|
| event_id | BIGINT | FK → events(id), **ON DELETE CASCADE** |
|
||||||
|
| name | VARCHAR(100) | Guest name |
|
||||||
|
|
||||||
|
## Deletion Behavior
|
||||||
|
|
||||||
|
- `DELETE FROM events WHERE expiry_date < CURRENT_DATE` removes expired events.
|
||||||
|
- RSVPs are automatically cascade-deleted by the FK constraint `fk_rsvps_event_id` with `ON DELETE CASCADE`.
|
||||||
|
- No new tables, columns, or migrations required.
|
||||||
|
|
||||||
|
## Indexes Used
|
||||||
|
|
||||||
|
- `idx_events_expiry_date` on `events(expiry_date)` — ensures the cleanup query is efficient.
|
||||||
90
specs/013-auto-delete-expired/plan.md
Normal file
90
specs/013-auto-delete-expired/plan.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Implementation Plan: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Branch**: `013-auto-delete-expired` | **Date**: 2026-03-09 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/013-auto-delete-expired/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a scheduled background job that runs daily and deletes all events whose `expiryDate` has passed. Deletion is performed via a native SQL query (`DELETE FROM events WHERE expiry_date < CURRENT_DATE`). Cascade deletion of RSVPs is handled by the existing `ON DELETE CASCADE` FK constraint. No API or frontend changes required.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25, Spring Boot 3.5.x
|
||||||
|
**Primary Dependencies**: Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query)
|
||||||
|
**Storage**: PostgreSQL (existing, Liquibase migrations)
|
||||||
|
**Testing**: JUnit 5, Spring Boot Test, Testcontainers (existing)
|
||||||
|
**Target Platform**: Linux server (Docker)
|
||||||
|
**Project Type**: Web service (backend only for this feature)
|
||||||
|
**Performance Goals**: N/A — daily batch job on small dataset
|
||||||
|
**Constraints**: Single native DELETE query, no entity loading
|
||||||
|
**Scale/Scope**: Self-hosted, small-scale — typically < 100 events total
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | PASS | Deleting expired data supports privacy goals. No PII logged. |
|
||||||
|
| II. Test-Driven Methodology | PASS | Tests written before implementation (TDD). |
|
||||||
|
| III. API-First Development | N/A | No API changes — this is a backend-internal job. |
|
||||||
|
| IV. Simplicity & Quality | PASS | Single query, no over-engineering. |
|
||||||
|
| V. Dependency Discipline | PASS | Uses only existing Spring dependencies (`@Scheduled`). |
|
||||||
|
| VI. Accessibility | N/A | No frontend changes. |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/013-auto-delete-expired/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0 output (minimal — no unknowns)
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
└── checklists/
|
||||||
|
└── requirements.md # Spec quality checklist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/src/main/java/de/fete/
|
||||||
|
├── application/service/
|
||||||
|
│ └── ExpiredEventCleanupJob.java # NEW: Scheduled job
|
||||||
|
├── domain/port/out/
|
||||||
|
│ └── EventRepository.java # MODIFIED: Add deleteExpired method
|
||||||
|
└── adapter/out/persistence/
|
||||||
|
├── EventJpaRepository.java # MODIFIED: Add native DELETE query
|
||||||
|
└── EventPersistenceAdapter.java # MODIFIED: Implement deleteExpired
|
||||||
|
|
||||||
|
backend/src/test/java/de/fete/
|
||||||
|
├── application/service/
|
||||||
|
│ └── ExpiredEventCleanupJobTest.java # NEW: Unit test for job
|
||||||
|
└── adapter/out/persistence/
|
||||||
|
└── EventPersistenceAdapterIntegrationTest.java # NEW or MODIFIED: Integration test for native query
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Backend-only change. Follows existing hexagonal architecture: port defines the contract, adapter implements with native query, service layer schedules the job.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Hexagonal Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
@Scheduled trigger
|
||||||
|
→ ExpiredEventCleanupJob (application/service)
|
||||||
|
→ EventRepository.deleteExpired() (domain/port/out)
|
||||||
|
→ EventPersistenceAdapter.deleteExpired() (adapter/out/persistence)
|
||||||
|
→ EventJpaRepository native query (adapter/out/persistence)
|
||||||
|
→ DELETE FROM events WHERE expiry_date < CURRENT_DATE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
1. **Port method**: `int deleteExpired()` on `EventRepository` — returns count of deleted events for logging.
|
||||||
|
2. **Native query**: `@Modifying @Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)` on `EventJpaRepository`.
|
||||||
|
3. **Schedule**: `@Scheduled(cron = "0 0 3 * * *")` — runs daily at 03:00 server time. Low-traffic window.
|
||||||
|
4. **Logging**: INFO-level log after each run: `"Deleted {count} expired event(s)"`. No log if count is 0 (or DEBUG-level).
|
||||||
|
5. **Transaction**: The native DELETE runs in a single transaction — atomic, no partial state.
|
||||||
|
6. **Enable scheduling**: Add `@EnableScheduling` to `FeteApplication` (or a config class).
|
||||||
31
specs/013-auto-delete-expired/research.md
Normal file
31
specs/013-auto-delete-expired/research.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Research: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Deletion Strategy
|
||||||
|
|
||||||
|
- **Decision**: Direct native SQL DELETE query via Spring Data JPA `@Query`.
|
||||||
|
- **Rationale**: Simplest approach. No entity loading overhead. The existing `ON DELETE CASCADE` FK constraint on `fk_rsvps_event_id` (migration `003-create-rsvps-table.xml`) handles cascading deletion of RSVPs automatically. The existing `idx_events_expiry_date` index ensures the WHERE clause is efficient.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- JPA repository `deleteAll(findExpired())`: Loads entities into memory first, unnecessary overhead.
|
||||||
|
- Database-level cron (`pg_cron`): Less portable, adds external dependency.
|
||||||
|
- Soft delete with lazy cleanup: Over-engineered for fete's scale and privacy goals.
|
||||||
|
- No deletion (filter only): Contradicts privacy-by-design principle.
|
||||||
|
|
||||||
|
## Scheduling Mechanism
|
||||||
|
|
||||||
|
- **Decision**: Spring `@Scheduled(cron = ...)` annotation.
|
||||||
|
- **Rationale**: Already available in Spring Boot, no additional dependencies. Simple, declarative, well-tested.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Quartz Scheduler: Too heavy for a single daily job.
|
||||||
|
- External cron (OS-level): Requires separate process management, harder to test.
|
||||||
|
|
||||||
|
## Transaction Behavior
|
||||||
|
|
||||||
|
- **Decision**: Single transaction for the DELETE statement.
|
||||||
|
- **Rationale**: A single `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is atomic. If the DB connection drops mid-execution, the transaction rolls back and no events are partially deleted. The next run picks up all expired events.
|
||||||
|
|
||||||
|
## Enabling @Scheduled
|
||||||
|
|
||||||
|
- **Decision**: Add `@EnableScheduling` to `FeteApplication.java`.
|
||||||
|
- **Rationale**: Simplest approach. Only one scheduled job exists, no need for a separate config class.
|
||||||
71
specs/013-auto-delete-expired/spec.md
Normal file
71
specs/013-auto-delete-expired/spec.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Feature Specification: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature Branch**: `013-auto-delete-expired`
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Delete events automatically after they expired"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Automatic Cleanup of Expired Events (Priority: P1)
|
||||||
|
|
||||||
|
As a self-hoster, I want expired events to be automatically deleted from the database so that personal data is removed without manual intervention and storage stays clean.
|
||||||
|
|
||||||
|
A scheduled background job periodically checks all events. Any event whose `expiryDate` is in the past gets permanently deleted — including all associated data (RSVPs, tokens). No user action is required; the system handles this autonomously.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core and only feature — automated, hands-off cleanup of expired events. It directly supports the privacy promise of fete.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating events with past expiry dates, triggering the cleanup job, and verifying the events are gone from the database.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an event with an `expiryDate` in the past, **When** the scheduled cleanup job runs, **Then** the event and all its associated data (RSVPs, tokens) are permanently deleted.
|
||||||
|
2. **Given** an event with an `expiryDate` in the future, **When** the scheduled cleanup job runs, **Then** the event remains untouched.
|
||||||
|
3. **Given** multiple expired events exist, **When** the cleanup job runs, **Then** all expired events are deleted in a single run.
|
||||||
|
4. **Given** no expired events exist, **When** the cleanup job runs, **Then** nothing is deleted and the job completes without error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if the cleanup job fails mid-deletion (e.g., database connection lost)? Events that were not yet deleted remain and will be picked up in the next run. No partial state.
|
||||||
|
- What happens if the server was offline for an extended period? On the next run, all accumulated expired events are deleted — no special catch-up logic needed.
|
||||||
|
- What happens if an organizer is viewing their event page while it gets deleted? The page shows a "not found" state on next interaction. This is acceptable because the event has expired.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST automatically delete events whose `expiryDate` is before the current date/time.
|
||||||
|
- **FR-002**: When an event is deleted, all associated data (RSVPs, organizer tokens, event tokens) MUST be deleted as well (cascade delete).
|
||||||
|
- **FR-003**: The cleanup job MUST run on a recurring schedule (default: once daily).
|
||||||
|
- **FR-004**: The cleanup job MUST be idempotent — running it multiple times produces the same result.
|
||||||
|
- **FR-005**: The cleanup job MUST log how many events were deleted per run.
|
||||||
|
- **FR-006**: Events that have not yet expired MUST NOT be affected by the cleanup job.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Event**: The primary entity being cleaned up. Has an `expiryDate` field that determines when it becomes eligible for deletion.
|
||||||
|
- **RSVP**: Associated guest responses linked to an event. Deleted when the parent event is deleted.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Expired events are deleted within 24 hours of their expiry date without manual intervention.
|
||||||
|
- **SC-002**: Zero data residue — when an event is deleted, no orphaned RSVPs or tokens remain in the system.
|
||||||
|
- **SC-003**: The cleanup process completes without errors under normal operating conditions.
|
||||||
|
- **SC-004**: Non-expired events are never affected by the cleanup process.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-09
|
||||||
|
|
||||||
|
- Q: How should deletion be implemented — application code (JPA) or direct database query? → A: Direct database query. A single native `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is sufficient. The existing `ON DELETE CASCADE` on the RSVPs foreign key ensures associated data is removed automatically. The existing `idx_events_expiry_date` index ensures the query is performant.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The `expiryDate` field already exists on events and is auto-set to `eventDate + 7 days` (implemented in the previous feature).
|
||||||
|
- Cascade deletion of associated data (RSVPs, tokens) is handled at the database level via foreign key constraints (`ON DELETE CASCADE` on `fk_rsvps_event_id`, verified in migration `003-create-rsvps-table.xml`).
|
||||||
|
- The daily schedule is sufficient — there is no requirement for near-real-time deletion.
|
||||||
|
- No "grace period" or "soft delete" is needed — events are permanently removed once expired.
|
||||||
101
specs/013-auto-delete-expired/tasks.md
Normal file
101
specs/013-auto-delete-expired/tasks.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Tasks: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/013-auto-delete-expired/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||||
|
|
||||||
|
**Tests**: Included — constitution mandates TDD (Principle II).
|
||||||
|
|
||||||
|
**Organization**: Single user story (US1), so phases are compact.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Enable Spring scheduling in the application.
|
||||||
|
|
||||||
|
- [x] T001 Add `@EnableScheduling` annotation to `backend/src/main/java/de/fete/FeteApplication.java`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Automatic Cleanup of Expired Events (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: A daily scheduled job deletes all events whose `expiryDate` is in the past, including cascaded RSVPs.
|
||||||
|
|
||||||
|
**Independent Test**: Create events with past expiry dates, trigger the cleanup job, verify events and RSVPs are gone.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T002 [P] [US1] Unit test for `ExpiredEventCleanupJob` in `backend/src/test/java/de/fete/application/service/ExpiredEventCleanupJobTest.java` — verify job calls `deleteExpired()` on repository and logs the count
|
||||||
|
- [x] T003 [P] [US1] Integration test for native DELETE query in `backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java` — verify expired events + RSVPs are deleted, non-expired events survive
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T004 [P] [US1] Add `int deleteExpired()` method to port interface `backend/src/main/java/de/fete/domain/port/out/EventRepository.java`
|
||||||
|
- [x] T005 [P] [US1] Add native `@Modifying @Query("DELETE FROM events WHERE expiry_date < CURRENT_DATE")` method to `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java`
|
||||||
|
- [x] T006 [US1] Implement `deleteExpired()` in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java` — delegates to JPA repository native query
|
||||||
|
- [x] T007 [US1] Create `ExpiredEventCleanupJob` with `@Scheduled(cron = "0 0 3 * * *")` in `backend/src/main/java/de/fete/application/service/ExpiredEventCleanupJob.java` — calls `deleteExpired()`, logs count at INFO level
|
||||||
|
|
||||||
|
**Checkpoint**: All tests pass. Expired events are automatically deleted daily at 03:00.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **User Story 1 (Phase 2)**: Depends on Setup completion
|
||||||
|
|
||||||
|
### Within User Story 1
|
||||||
|
|
||||||
|
- T002, T003 (tests) can run in parallel — write first, must fail (RED)
|
||||||
|
- T004, T005 (port + JPA query) can run in parallel — different files
|
||||||
|
- T006 depends on T004 + T005 (adapter implements port, delegates to JPA)
|
||||||
|
- T007 depends on T006 (job calls adapter via port)
|
||||||
|
- After T007: all tests should pass (GREEN)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
```
|
||||||
|
T002 ──┐
|
||||||
|
├── (tests written, failing)
|
||||||
|
T003 ──┘
|
||||||
|
|
||||||
|
T004 ──┐
|
||||||
|
├── T006 ── T007 ── (all tests green)
|
||||||
|
T005 ──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (this is the MVP — single story)
|
||||||
|
|
||||||
|
1. T001: Enable scheduling
|
||||||
|
2. T002 + T003: Write failing tests (RED)
|
||||||
|
3. T004 + T005: Port interface + native query (parallel)
|
||||||
|
4. T006: Adapter implementation
|
||||||
|
5. T007: Scheduled job
|
||||||
|
6. Verify all tests pass (GREEN)
|
||||||
|
7. Done — commit and ship
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Total tasks: 7
|
||||||
|
- User Story 1 tasks: 6 (T002–T007)
|
||||||
|
- Setup tasks: 1 (T001)
|
||||||
|
- Parallel opportunities: T002||T003, T004||T005
|
||||||
|
- No frontend changes needed
|
||||||
|
- No API/OpenAPI changes needed
|
||||||
|
- No new migrations needed (existing schema + FK constraints sufficient)
|
||||||
Reference in New Issue
Block a user