Skip to content

Commit bb50f3e

Browse files
authored
Merge pull request #3669 from codeeu/fix/recurring-events-source-ref
Fix recurring events blocked by unique source_ref
2 parents ef74a27 + 5634abd commit bb50f3e

2 files changed

Lines changed: 148 additions & 9 deletions

File tree

‎app/Console/Commands/GenerateRecurringEvents.php‎

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
* and whose next occurrence does not exceed end_date.
1616
*
1717
* Clones event data and related pivot tables (themes, tags, audiences).
18-
* Stores reference in `source_ref` as "parent:{event_id}" for traceability.
18+
* Stores reference in `source_ref` as "parent:{event_id}:{Y-m-d}" so each
19+
* occurrence is unique — `events.source_ref` has a unique index, and the old
20+
* bare "parent:{event_id}" form could only ever store one child per parent.
1921
*
2022
* Options:
2123
* --dry-run : simulate generation without DB writes.
@@ -48,6 +50,7 @@ public function handle(): int
4850

4951
if ($events->isEmpty()) {
5052
$this->info('No eligible recurring events found.');
53+
5154
return self::SUCCESS;
5255
}
5356

@@ -64,30 +67,32 @@ public function handle(): int
6467
// Skip if next start exceeds event's end_date
6568
if ($nextStart->gt($event->end_date)) {
6669
$bar->advance();
70+
6771
continue;
6872
}
6973

70-
// Avoid duplicates: check for same parent + same next date
71-
$exists = Event::query()
72-
->where('source_ref', 'parent:' . $event->id)
73-
->whereDate('start_date', $nextStart->toDateString())
74-
->exists();
74+
if (! $dryRun) {
75+
$this->rekeyLegacyChild($event);
76+
}
7577

76-
if ($exists) {
78+
$sourceRef = $this->occurrenceSourceRef($event->id, $nextStart);
79+
80+
if ($this->occurrenceExists($event->id, $nextStart, $sourceRef)) {
7781
$bar->advance();
82+
7883
continue;
7984
}
8085

8186
// Clone event data
8287
$newEventData = $event->replicate([
83-
'id', 'created_at', 'updated_at'
88+
'id', 'created_at', 'updated_at',
8489
])->toArray();
8590

8691
$durationSeconds = Carbon::parse($event->end_date)->diffInSeconds(Carbon::parse($event->start_date));
8792

8893
$newEventData['start_date'] = $nextStart;
8994
$newEventData['end_date'] = $nextStart->copy()->addSeconds($durationSeconds);
90-
$newEventData['source_ref'] = 'parent:' . $event->id;
95+
$newEventData['source_ref'] = $sourceRef;
9196
$newEventData['status'] = $event->status;
9297

9398
if ($dryRun) {
@@ -145,4 +150,42 @@ private function getNextDate(Carbon $startDate, string $recurrence): Carbon
145150

146151
return $next;
147152
}
153+
154+
private function occurrenceSourceRef(int $parentId, Carbon $start): string
155+
{
156+
return sprintf('parent:%d:%s', $parentId, $start->toDateString());
157+
}
158+
159+
private function occurrenceExists(int $parentId, Carbon $start, string $sourceRef): bool
160+
{
161+
return Event::query()
162+
->where(function ($query) use ($parentId, $sourceRef) {
163+
$query->where('source_ref', $sourceRef)
164+
->orWhere('source_ref', 'parent:'.$parentId)
165+
->orWhere('source_ref', 'like', 'parent:'.$parentId.':%');
166+
})
167+
->whereDate('start_date', $start->toDateString())
168+
->exists();
169+
}
170+
171+
/**
172+
* Older runs wrote a single "parent:{id}" child. That value is unique, so it
173+
* blocks every later occurrence. Rewrite it to the dated form once.
174+
*/
175+
private function rekeyLegacyChild(Event $event): void
176+
{
177+
$legacy = Event::query()
178+
->where('source_ref', 'parent:'.$event->id)
179+
->first();
180+
181+
if ($legacy === null) {
182+
return;
183+
}
184+
185+
$legacy->source_ref = $this->occurrenceSourceRef(
186+
$event->id,
187+
Carbon::parse($legacy->start_date)
188+
);
189+
$legacy->save();
190+
}
148191
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Event;
6+
use Carbon\Carbon;
7+
use Illuminate\Foundation\Testing\RefreshDatabase;
8+
use Illuminate\Support\Facades\Artisan;
9+
use PHPUnit\Framework\Attributes\Test;
10+
use Tests\TestCase;
11+
12+
class GenerateRecurringEventsTest extends TestCase
13+
{
14+
use RefreshDatabase;
15+
16+
private function makeWeeklyParent(): Event
17+
{
18+
return Event::factory()->create([
19+
'status' => 'APPROVED',
20+
'title' => 'Weekly Coding Club',
21+
'recurring_event' => 'weekly',
22+
'start_date' => Carbon::now()->subWeeks(4)->startOfDay()->setTime(9, 0),
23+
'end_date' => Carbon::now()->addMonths(2)->endOfDay(),
24+
'source_ref' => null,
25+
]);
26+
}
27+
28+
#[Test]
29+
public function it_writes_a_dated_source_ref_so_later_occurrences_can_coexist(): void
30+
{
31+
$parent = $this->makeWeeklyParent();
32+
33+
Artisan::call('events:generate-recurring');
34+
35+
$child = Event::query()
36+
->where('source_ref', 'like', 'parent:'.$parent->id.':%')
37+
->first();
38+
39+
$this->assertNotNull($child);
40+
$this->assertMatchesRegularExpression(
41+
'/^parent:'.$parent->id.':\d{4}-\d{2}-\d{2}$/',
42+
$child->source_ref
43+
);
44+
}
45+
46+
#[Test]
47+
public function it_rekeys_a_legacy_parent_ref_and_creates_the_next_occurrence(): void
48+
{
49+
$parent = $this->makeWeeklyParent();
50+
51+
// The unique index on source_ref meant the first implementation could only
52+
// ever store one child, keyed as the bare "parent:{id}".
53+
$legacyStart = Carbon::now()->subWeeks(3)->startOfDay()->setTime(9, 0);
54+
$legacy = Event::factory()->create([
55+
'status' => 'APPROVED',
56+
'title' => 'Weekly Coding Club (legacy child)',
57+
'recurring_event' => 'weekly',
58+
'start_date' => $legacyStart,
59+
'end_date' => $legacyStart->copy()->addHours(2),
60+
'source_ref' => 'parent:'.$parent->id,
61+
]);
62+
63+
Artisan::call('events:generate-recurring');
64+
65+
$legacy->refresh();
66+
$this->assertSame(
67+
'parent:'.$parent->id.':'.$legacyStart->toDateString(),
68+
$legacy->source_ref,
69+
'The legacy row must be rewritten so it no longer blocks the unique index.'
70+
);
71+
72+
$children = Event::query()
73+
->where('source_ref', 'like', 'parent:'.$parent->id.':%')
74+
->orderBy('start_date')
75+
->get();
76+
77+
$this->assertGreaterThanOrEqual(2, $children->count());
78+
$this->assertTrue(
79+
$children->contains(fn (Event $event) => $event->id === $legacy->id)
80+
);
81+
}
82+
83+
#[Test]
84+
public function running_twice_does_not_duplicate_the_same_occurrence(): void
85+
{
86+
$parent = $this->makeWeeklyParent();
87+
88+
Artisan::call('events:generate-recurring');
89+
Artisan::call('events:generate-recurring');
90+
91+
$this->assertSame(
92+
1,
93+
Event::query()->where('source_ref', 'like', 'parent:'.$parent->id.':%')->count()
94+
);
95+
}
96+
}

0 commit comments

Comments
 (0)