diff --git a/backend/repositories/EventRepository.go b/backend/repositories/EventRepository.go index 21e4ad5..ddb91fd 100644 --- a/backend/repositories/EventRepository.go +++ b/backend/repositories/EventRepository.go @@ -19,6 +19,7 @@ import ( type EventRepository interface { Save(event *models.Event) error FindAll() ([]models.Event, error) + SoftDeleteEndedBefore(cutoffDate string) (int64, error) } type eventRepository struct { @@ -101,6 +102,18 @@ func (r *eventRepository) FindAll() ([]models.Event, error) { return events, result.Error } +// SoftDeleteEndedBefore marks events whose end_date is before cutoffDate +// (an exclusive "YYYY-MM-DD" boundary, the same lexicographic string +// comparison the frontend uses for its own date filtering) as deleted. +// .Delete() on a gorm.Model sets deleted_at rather than removing the row, +// consistent with how the frontend already treats soft-deleted events. +// Events with a blank end_date are left alone — there's nothing to +// compare, so leaving them is safer than guessing they're stale. +func (r *eventRepository) SoftDeleteEndedBefore(cutoffDate string) (int64, error) { + result := r.db.Where("end_date <> '' AND end_date < ?", cutoffDate).Delete(&models.Event{}) + return result.RowsAffected, result.Error +} + func (r *eventRepository) saveMediaFile(filename string, url string, mediaType string, extension string) error { mediaFilePath := fmt.Sprintf("./public/storage/%s/%s%s", strings.ToLower(mediaType), filename, extension) diff --git a/backend/services/EventService.go b/backend/services/EventService.go index 67887be..07ec252 100644 --- a/backend/services/EventService.go +++ b/backend/services/EventService.go @@ -2,11 +2,18 @@ package services import ( "log" + "time" "whimsoc/vindn-backend/models" "whimsoc/vindn-backend/repositories" "whimsoc/vindn-backend/utils" ) +// Events older than this are pure housekeeping — the frontend's display +// queries already hide anything past its own end_date immediately, so this +// buffer is just how long a soft-deleted event's detail page (e.g. a +// bookmarked or shared link) keeps resolving before it's cleaned up too. +const cleanupAfterDays = 90 + func FetchEvents() ([]models.Event, error) { return utils.FetchDataFromAPI() } @@ -36,3 +43,19 @@ func (s *EventService) ImportEvents() error { return nil } + +// CleanupOldEvents soft-deletes events that ended more than +// cleanupAfterDays ago, so the events table doesn't grow forever — the ODH +// import only ever upserts and never removes rows on its own. +func (s *EventService) CleanupOldEvents() error { + cutoff := time.Now().AddDate(0, 0, -cleanupAfterDays).Format("2006-01-02") + + affected, err := s.repo.SoftDeleteEndedBefore(cutoff) + if err != nil { + return err + } + + log.Printf("Cleanup: soft-deleted %d event(s) that ended before %s", affected, cutoff) + + return nil +} diff --git a/backend/tasks/Cronjob.go b/backend/tasks/Cronjob.go index 6d3ca74..8f31f30 100644 --- a/backend/tasks/Cronjob.go +++ b/backend/tasks/Cronjob.go @@ -18,5 +18,13 @@ func StartCronJobs(eventService *services.EventService) { } }) + // Einmal täglich um 03:00 Uhr, außerhalb der Import-Zeiten + c.AddFunc("0 3 * * *", func() { + log.Println("Starte geplante Bereinigung alter Events...") + if err := eventService.CleanupOldEvents(); err != nil { + log.Printf("Fehler bei der Bereinigung: %v", err) + } + }) + c.Start() } diff --git a/frontend/src/Repository/EventRepository.php b/frontend/src/Repository/EventRepository.php index b9aa460..3d7a747 100644 --- a/frontend/src/Repository/EventRepository.php +++ b/frontend/src/Repository/EventRepository.php @@ -27,7 +27,7 @@ class EventRepository extends ServiceEntityRepository /** @return Event[] */ public function findRecent(int $limit): array { - return $this->orderByStartDate($this->notDeleted()) + return $this->orderByStartDate($this->notEnded($this->notDeleted())) ->setMaxResults($limit) ->getQuery() ->getResult(); @@ -36,7 +36,7 @@ class EventRepository extends ServiceEntityRepository /** @return Event[] */ public function findWithCoordinates(int $limit = 500): array { - return $this->notDeleted() + return $this->notEnded($this->notDeleted()) ->andWhere('e.latitude IS NOT NULL AND e.longitude IS NOT NULL') ->andWhere('e.latitude <> 0 OR e.longitude <> 0') ->setMaxResults($limit) @@ -56,6 +56,12 @@ class EventRepository extends ServiceEntityRepository */ public function distancesWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array { + // end_date comparison mirrors notEnded()/filterByWhen(): day-level + // lexicographic comparison against the raw ISO-ish string column, + // in Europe/Berlin — this is raw SQL (DBAL, not the QueryBuilder), + // so it can't just call that helper directly. + $today = (new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin')))->format('Y-m-d'); + $sql = 'SELECT id, distance_km FROM ( SELECT e.id, (6371 * acos(least(1, greatest(-1, @@ -66,6 +72,7 @@ class EventRepository extends ServiceEntityRepository WHERE e.deleted_at IS NULL AND e.latitude IS NOT NULL AND e.longitude IS NOT NULL AND (e.latitude <> 0 OR e.longitude <> 0) + AND (e.end_date >= :today OR e.end_date = \'\') ) sub WHERE distance_km <= :radius ORDER BY distance_km ASC @@ -73,8 +80,8 @@ class EventRepository extends ServiceEntityRepository $rows = $this->getEntityManager()->getConnection()->executeQuery( $sql, - ['lat' => $lat, 'lon' => $lon, 'radius' => $radiusKm, 'limit' => $limit], - ['lat' => ParameterType::STRING, 'lon' => ParameterType::STRING, 'radius' => ParameterType::STRING, 'limit' => ParameterType::INTEGER] + ['lat' => $lat, 'lon' => $lon, 'radius' => $radiusKm, 'limit' => $limit, 'today' => $today], + ['lat' => ParameterType::STRING, 'lon' => ParameterType::STRING, 'radius' => ParameterType::STRING, 'limit' => ParameterType::INTEGER, 'today' => ParameterType::STRING] )->fetchAllAssociative(); $distances = []; @@ -188,7 +195,7 @@ class EventRepository extends ServiceEntityRepository private function baseSearchQb(?string $q, ?string $category, string $when): QueryBuilder { - $qb = $this->notDeleted(); + $qb = $this->notEnded($this->notDeleted()); if ($q) { $qb->andWhere('LOWER(e.title) LIKE :q OR LOWER(e.city) LIKE :q OR LOWER(e.street) LIKE :q') @@ -280,6 +287,26 @@ class EventRepository extends ServiceEntityRepository ->andWhere('e.deletedAt IS NULL'); } + /** + * Hides events that have already ended — compared by calendar day + * (Europe/Berlin), the same precision filterByWhen() uses for + * "today"/"week", not exact time (end_date is a raw ISO-ish string, + * not a real date/time column). Blank end_date is treated as "still + * current" rather than excluded, matching orderByStartDate()'s + * defensive handling of blank date strings. + * + * Deliberately NOT applied to findOneNotDeleted() — a bookmarked or + * shared link to a single past event should still resolve, only the + * listing/discovery queries should hide it. + */ + private function notEnded(QueryBuilder $qb): QueryBuilder + { + $today = new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin')); + + return $qb->andWhere("e.endDate >= :notEndedBoundary OR e.endDate = ''") + ->setParameter('notEndedBoundary', $today->format('Y-m-d')); + } + /** * startDate is a raw ISO-ish string column (may be empty when the * source had no time interval); push undated events to the end