Skip to content
Open
Show file tree
Hide file tree
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
13 changes: 13 additions & 0 deletions app-modules/api/src/Http/Controllers/EventApiV1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use HackGreenville\Api\Http\Requests\EventApiV1Request;
use HackGreenville\Api\Resources\Events\V1\EventCollection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;

class EventApiV1Controller extends Controller
{
Expand Down Expand Up @@ -47,6 +48,18 @@ public function __invoke(EventApiV1Request $request)
->when($request->filled('max_rsvp'), function (Builder $query) use ($request) {
$query->where('rsvp_count', '<=', $request->integer('max_rsvp'));
})
->when($request->filled('venue_slug'), function (Builder $query) use ($request) {
// Str::slug() also strips LIKE metacharacters (% and _), so extra wildcard escaping is unnecessary.
$normalized = Str::slug($request->input('venue_slug'));

if ($normalized === '') {
return $query->whereRaw('0 = 1');
}

$query->whereHas('venue', function (Builder $query) use ($normalized) {
$query->whereLike('slug', '%' . $normalized . '%');
Comment thread
allella marked this conversation as resolved.
});
})
->when($request->filled('venue_city'), function (Builder $query) use ($request) {
$query->whereHas('venue', function (Builder $query) use ($request) {
$query->where('city', 'like', '%' . $request->input('venue_city') . '%');
Expand Down
5 changes: 5 additions & 0 deletions app-modules/api/src/Http/Requests/EventApiV1Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public function rules()
'min:0',
'gte:min_rsvp'
],
'venue_slug' => ['nullable', 'string', 'max:255'],
'venue_city' => ['nullable', 'string', 'max:255'],
'venue_state' => ['nullable', 'string', 'size:2'],
'is_paid' => [
Expand Down Expand Up @@ -92,6 +93,10 @@ public function queryParameters()
'max_rsvp' => [
'example' => null,
],
'venue_slug' => [
'example' => 'openworks',
'description' => 'Filter events by venue slug. Partial match against the stored slug shown on each venue in the response (e.g. "openworks" or "synergy-mill"). Slugs are set in the admin dashboard and generated from the venue name when missing.',
],
'venue_city' => ['example' => null,],
'venue_state' => ['example' => null,],
'is_paid' => [
Expand Down
1 change: 1 addition & 0 deletions app-modules/api/src/Resources/Events/V1/VenueResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public function toArray(Request $request): array
{
return [
'name' => $this->resource->name,
'slug' => $this->resource->slug,
'address' => $this->resource->address,
'city' => $this->resource->city,
'state' => [
Expand Down
140 changes: 140 additions & 0 deletions app-modules/api/tests/Feature/EventApiV1Test.php
Comment thread
irby marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,146 @@ public function test_can_filter_events_by_is_paid_null()
$response->assertJsonPath('data.0.is_paid', null);
}

public function test_events_include_venue_slug()
{
$org = Org::factory()->create();
$venue = Venue::factory()->create([
'name' => 'Open Works',
'slug' => 'openworks',
]);

Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => $venue->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

$response = $this->getJson('/api/v1/events');

$response->assertStatus(200);
$response->assertJsonPath('data.0.venue.name', 'Open Works');
$response->assertJsonPath('data.0.venue.slug', 'openworks');
}

public function test_can_filter_events_by_venue_slug()
{
$org = Org::factory()->create();

$matchingEvent = Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => Venue::factory()->create([
'name' => 'SynergyMill',
'slug' => 'synergy-mill',
])->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => Venue::factory()->create([
'name' => 'The Commons',
'slug' => 'the-commons',
])->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

$response = $this->getJson('/api/v1/events?venue_slug=synergy');

$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $matchingEvent->event_uuid);
$response->assertJsonPath('data.0.venue.name', 'SynergyMill');
$response->assertJsonPath('data.0.venue.slug', 'synergy-mill');
}

public function test_venue_slug_filter_matches_custom_admin_slug()
{
$org = Org::factory()->create();

$openWorksEvent = Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => Venue::factory()->create([
'name' => 'Open Works',
'slug' => 'openworks',
])->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => Venue::factory()->create([
'name' => 'The Commons',
'slug' => 'the-commons',
])->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

$response = $this->getJson('/api/v1/events?venue_slug=openworks');

$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $openWorksEvent->event_uuid);
$response->assertJsonPath('data.0.venue.name', 'Open Works');
$response->assertJsonPath('data.0.venue.slug', 'openworks');
}

public function test_venue_slug_filter_normalizes_spaced_names()
{
$org = Org::factory()->create();

$openWorksEvent = Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => Venue::factory()->create([
'name' => 'Open Works',
'slug' => 'open-works',
])->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => Venue::factory()->create([
'name' => 'The Commons',
'slug' => 'the-commons',
])->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

$response = $this->getJson('/api/v1/events?venue_slug=Open%20Works');

$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $openWorksEvent->event_uuid);
$response->assertJsonPath('data.0.venue.slug', 'open-works');
}

public function test_unslugable_venue_slug_filter_matches_nothing()
{
$org = Org::factory()->create();

Event::factory()->create([
'organization_id' => $org->id,
'venue_id' => Venue::factory()->create([
'name' => 'Open Works',
'slug' => 'openworks',
])->id,
'active_at' => now(),
'expire_at' => now()->addDays(1),
]);

$response = $this->getJson('/api/v1/events?venue_slug=---');

$response->assertStatus(200);
$response->assertJsonCount(0, 'data');
}

public function test_deleted_org_events_do_not_show()
{
$active_org = Org::factory()->create();
Expand Down
22 changes: 21 additions & 1 deletion app-modules/event-importer/src/Data/VenueData.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,30 @@ public function __construct(
) {
}

/**
* Reuse a venue with this name, or with the slug derived from this name.
*
* Address and coordinates are stored only when creating a venue. Matching
* on the full address created a new row whenever lat/lng or street
* formatting drifted. A custom admin slug still matches through the name.
*/
public function resolveVenue(): Venue
{
return Venue::updateOrCreate([
$slug = Venue::normalizeSlug(null, $this->name);

$existing = Venue::query()->where('name', $this->name)->orderBy('id')->first();

if ( ! $existing && $slug !== null) {
$existing = Venue::query()->where('slug', $slug)->orderBy('id')->first();
}

if ($existing) {
return $existing;
}

return Venue::create([
'name' => $this->name,
'slug' => $slug,
'address' => $this->address,
'zipcode' => $this->zip,
'city' => $this->city,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public function test_active_eventbrite_event_is_imported_correctly(): void
->firstOrFail();

$this->assertEquals('SC', $venue->state);
$this->assertEquals('fluor-management-center', $venue->slug);
}

public function test_cancelled_eventbrite_event_is_imported_correctly(): void
Expand Down
146 changes: 146 additions & 0 deletions app-modules/event-importer/tests/Feature/VenueDataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

namespace HackGreenville\EventImporter\Tests\Feature;

use App\Models\Venue;
use HackGreenville\EventImporter\Data\VenueData;
use Tests\DatabaseTestCase;

class VenueDataTest extends DatabaseTestCase
{
public function test_same_name_with_different_coordinates_reuses_the_venue(): void
{
$original = Venue::factory()->create([
'name' => 'Fireforge Crafted Beer',
'slug' => 'fireforge-crafted-beer',
'address' => '1 Main St',
'lat' => 34.1,
'lng' => -82.1,
]);

$resolved = $this->venueData(
'Fireforge Crafted Beer',
'1 Main Street',
34.10001,
-82.10001,
)->resolveVenue();

$this->assertTrue($original->is($resolved));
$this->assertSame('1 Main St', $resolved->fresh()->address);
$this->assertSame('fireforge-crafted-beer', $resolved->fresh()->slug);
$this->assertSame(1, Venue::query()->count());
}

public function test_custom_slug_is_reused_when_the_name_matches(): void
{
$original = Venue::factory()->create([
'name' => 'Open Works',
'slug' => 'openworks',
'address' => '101 N Main St',
]);

$resolved = $this->venueData('Open Works', '101 North Main Street')->resolveVenue();

$this->assertTrue($original->is($resolved));
$this->assertSame('openworks', $resolved->fresh()->slug);
$this->assertSame('101 N Main St', $resolved->fresh()->address);
$this->assertSame(1, Venue::query()->count());
}

public function test_slug_match_reuses_a_venue_whose_name_was_edited(): void
{
$original = Venue::factory()->create([
'name' => 'Open Works Downtown',
'slug' => 'open-works',
'address' => '101 N Main St',
]);

$resolved = $this->venueData('Open Works', 'Elsewhere')->resolveVenue();

$this->assertTrue($original->is($resolved));
$this->assertSame('Open Works Downtown', $resolved->fresh()->name);
$this->assertSame('101 N Main St', $resolved->fresh()->address);
}

public function test_name_match_wins_over_an_older_slug_match(): void
{
Venue::factory()->create([
'name' => 'Other Place',
'slug' => 'open-works',
]);
$custom = Venue::factory()->create([
'name' => 'Open Works',
'slug' => 'openworks',
]);

$resolved = $this->venueData('Open Works')->resolveVenue();

$this->assertTrue($custom->is($resolved));
$this->assertSame(2, Venue::query()->count());
}

public function test_new_name_creates_one_venue_with_a_slug(): void
{
$resolved = $this->venueData('Zen Greenville', '5 Court St')->resolveVenue();

$this->assertSame('Zen Greenville', $resolved->name);
$this->assertSame('zen-greenville', $resolved->slug);
$this->assertSame('5 Court St', $resolved->address);
$this->assertSame('Greenville', $resolved->city);
$this->assertSame('SC', $resolved->state);
$this->assertSame(1, Venue::query()->count());

$again = $this->venueData('Zen Greenville', '500 Court Street', 1.0, 2.0)->resolveVenue();

$this->assertTrue($resolved->is($again));
$this->assertSame('5 Court St', $again->fresh()->address);
$this->assertSame(1, Venue::query()->count());
}

public function test_unslugable_names_do_not_share_a_blank_slug(): void
{
$first = $this->venueData('!!!')->resolveVenue();
$second = $this->venueData('???')->resolveVenue();
$repeat = $this->venueData('!!!', 'Different address')->resolveVenue();

$this->assertNull($first->slug);
$this->assertNull($second->slug);
$this->assertFalse($first->is($second));
$this->assertTrue($first->is($repeat));
$this->assertSame(2, Venue::query()->count());
}

public function test_soft_deleted_venue_is_not_reused(): void
{
$deleted = Venue::factory()->create([
'name' => 'Fireforge Crafted Beer',
'slug' => 'fireforge-crafted-beer',
]);
$deleted->delete();

$resolved = $this->venueData('Fireforge Crafted Beer')->resolveVenue();

$this->assertFalse($deleted->is($resolved));
$this->assertSame(1, Venue::query()->count());
$this->assertSame(2, Venue::withTrashed()->count());
}

private function venueData(
string $name,
string $address = '100 Main St',
float $lat = 34.8,
float $lon = -82.4,
): VenueData {
return VenueData::from([
'id' => 'venue-1',
'name' => $name,
'address' => $address,
'city' => 'Greenville',
'state' => 'SC',
'zip' => '29601',
'country' => 'US',
'lat' => $lat,
'lon' => $lon,
]);
}
}
Loading
Loading