Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 72 additions & 41 deletions app/Console/Commands/CleanDuplicateEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
use Illuminate\Support\Facades\DB;

/**
* Detect and optionally delete duplicate events (same title, start_date, end_date)
* that have consecutive IDs and were created within <2 seconds.
* Detect and optionally delete duplicate event *chains* in 2025
* (same title, start_date, end_date) created within <2s of each other.
*
* Related pivot tables:
* - event_tag
Expand All @@ -27,74 +27,109 @@ class CleanDuplicateEvents extends Command
protected $signature = 'events:clean-duplicates {--delete : Permanently delete detected duplicates and related records from DB}';

/** @var string */
protected $description = 'Detect duplicate events (same title, start_date, end_date, consecutive IDs, created <2s apart) and optionally delete them with related pivot data.';
protected $description = 'Detect duplicate event groups in 2025 (same title/start/end, created <2s apart) and optionally delete them with related pivot data.';

public function handle(): int
{
$shouldDelete = $this->option('delete');

$this->info(sprintf(
'Scanning for possible duplicate events... (mode: %s)',
'Scanning for possible duplicate events in 2025... (mode: %s)',
$shouldDelete ? 'DELETE' : 'DRY-RUN'
));

// --- Detect duplicate event pairs ---
// --- Detect clusters of events with same title/start/end within 2 seconds ---
$duplicates = DB::select("
SELECT
e1.id AS id1,
e2.id AS id2,
e1.title,
e1.start_date,
e1.end_date,
TIMESTAMPDIFF(SECOND, e1.created_at, e2.created_at) AS diff_seconds
FROM events e1
JOIN events e2 ON e2.id = e1.id + 1
WHERE e1.title = e2.title
AND e1.start_date = e2.start_date
AND e1.end_date = e2.end_date
AND ABS(TIMESTAMPDIFF(SECOND, e1.created_at, e2.created_at)) < 2
ORDER BY e1.id
e.id,
e.title,
e.start_date,
e.end_date,
e.created_at,
LAG(e.created_at) OVER (PARTITION BY e.title, e.start_date, e.end_date ORDER BY e.created_at) AS prev_created
FROM events e
WHERE YEAR(e.created_at) = 2025
AND e.title <> ''
AND e.start_date IS NOT NULL
AND e.end_date IS NOT NULL
ORDER BY e.title, e.start_date, e.created_at
");

if (empty($duplicates)) {
$this->info('No duplicate events found.');
$toDelete = [];
$groups = [];
$prevTitle = $prevStart = $prevEnd = null;
$group = [];

foreach ($duplicates as $row) {
$diff = null;
if ($row->prev_created) {
$diff = abs(strtotime($row->created_at) - strtotime($row->prev_created));
}

if (
$row->title === $prevTitle &&
$row->start_date === $prevStart &&
$row->end_date === $prevEnd &&
$diff !== null && $diff < 2
) {
$group[] = $row->id;
} else {
if (count($group) > 1) {
$groups[] = $group;
}
$group = [$row->id];
}

$prevTitle = $row->title;
$prevStart = $row->start_date;
$prevEnd = $row->end_date;
}

if (count($group) > 1) {
$groups[] = $group;
}

if (empty($groups)) {
$this->info('No duplicate event groups found in 2025.');
return self::SUCCESS;
}

$this->info(sprintf('Found %d duplicate pair(s):', count($duplicates)));
$this->info(sprintf('Found %d duplicate group(s):', count($groups)));

$rows = [];
foreach ($duplicates as $row) {
foreach ($groups as $g) {
$firstEvent = collect($duplicates)->firstWhere('id', $g[0]);

$rows[] = [
$row->id1,
$row->id2,
$row->title,
$row->start_date,
$row->end_date,
$row->diff_seconds . 's',
implode(', ', $g),
count($g) . ' items',
$firstEvent?->title ?? '—',
$firstEvent?->start_date ?? '—',
$firstEvent?->end_date ?? '—',
];

$toDelete = array_merge($toDelete, array_slice($g, 1));
}

$this->table(['Event ID A', 'Event ID B', 'Title', 'Start', 'End', 'At'], $rows);
$this->table(
['Event IDs (Group)', 'Count', 'Title', 'Start date', 'End date'],
$rows
);

if (!$shouldDelete) {
$this->newLine();
$this->info('💡 Use --delete to remove duplicates permanently after review.');
$this->info('Use --delete to remove duplicates permanently after review.');
return self::SUCCESS;
}

$confirm = $this->confirm(
sprintf('Are you sure you want to delete %d duplicate entries (including pivot records)?', count($duplicates))
sprintf('Delete %d duplicate event(s) across %d groups (including pivot records)?', count($toDelete), count($groups))
);
if (!$confirm) {
$this->warn('Aborted. No records deleted.');
return self::SUCCESS;
}

// --- Proceed deletion safely ---
$toDelete = array_column($duplicates, 'id2');
$deleted = 0;

$bar = $this->output->createProgressBar(count($toDelete));
$bar->start();

Expand All @@ -104,24 +139,20 @@ public function handle(): int
DB::table('event_tag')->where('event_id', $eventId)->delete();
DB::table('event_theme')->where('event_id', $eventId)->delete();
DB::table('audience_event')->where('event_id', $eventId)->delete();

DB::table('events')->where('id', $eventId)->delete();

$deleted++;
$bar->advance();
}

DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
$bar->finish();
$this->error("\nError occurred: " . $e->getMessage());
$this->error("\nError: " . $e->getMessage());
return self::FAILURE;
}

$bar->finish();
$this->newLine(2);
$this->info(sprintf('Successfully deleted %d duplicate event(s) and related pivot records.', $deleted));
$this->info(sprintf('Deleted %d duplicate event(s) and related pivot records.', count($toDelete)));

return self::SUCCESS;
}
Expand Down
Loading