Skip to content

Commit e2e72bb

Browse files
authored
Merge pull request #48 from iFixit/feat--events-api
Feature: Add New Public Events API Endpoints
2 parents 794f24e + 41e1219 commit e2e72bb

19 files changed

Lines changed: 889 additions & 3 deletions

.env.base

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ FEATURE__WIKI_INTEGRATION=false
2828
FEATURE__DISCOURSE_INTEGRATION=false
2929
FEATURE__AUTO_APPROVE_GROUPS=false
3030
FEATURE__AUTO_APPROVE_EVENTS=false
31+
FEATURE__PUBLIC_EVENTS_API=false
3132

3233
# =============================================================================
3334
# LOGGING CONFIGURATION
@@ -183,4 +184,4 @@ SEEDING_TRUNCATE_SKILLS=true
183184
L5_SWAGGER_GENERATE_ALWAYS=true
184185
REPAIRDIRECTORY_URL=http://map.restarters.test
185186
META_TWITTER_SITE=
186-
META_TWITTER_IMAGE_ALT=
187+
META_TWITTER_IMAGE_ALT=

.env.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ FEATURE__WIKI_INTEGRATION="$FEATURE__WIKI_INTEGRATION"
2828
FEATURE__DISCOURSE_INTEGRATION="$FEATURE__DISCOURSE_INTEGRATION"
2929
FEATURE__AUTO_APPROVE_GROUPS="$FEATURE__AUTO_APPROVE_GROUPS"
3030
FEATURE__AUTO_APPROVE_EVENTS="$FEATURE__AUTO_APPROVE_EVENTS"
31+
FEATURE__PUBLIC_EVENTS_API="$FEATURE__PUBLIC_EVENTS_API"
3132

3233
# =============================================================================
3334
# LOGGING CONFIGURATION
@@ -183,4 +184,4 @@ SEEDING_TRUNCATE_SKILLS="$SEEDING_TRUNCATE_SKILLS"
183184
L5_SWAGGER_GENERATE_ALWAYS="$L5_SWAGGER_GENERATE_ALWAYS"
184185
REPAIRDIRECTORY_URL="$REPAIRDIRECTORY_URL"
185186
META_TWITTER_SITE="$META_TWITTER_SITE"
186-
META_TWITTER_IMAGE_ALT="$META_TWITTER_IMAGE_ALT"
187+
META_TWITTER_IMAGE_ALT="$META_TWITTER_IMAGE_ALT"
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\ApiClient;
6+
use Carbon\Carbon;
7+
use Illuminate\Console\Command;
8+
use Illuminate\Support\Str;
9+
10+
class ApiClientsCreate extends Command
11+
{
12+
/**
13+
* The name and signature of the console command.
14+
*
15+
* @var string
16+
*/
17+
protected $signature = 'api-clients:create
18+
{--name= : Display name for the integration client}
19+
{--scopes=events:read : Comma-separated scopes}
20+
{--origins= : Comma-separated allowed origins}
21+
{--networks= : Comma-separated allowed network IDs}
22+
{--rate=120 : Requests per minute}
23+
{--expires-at= : Expiration datetime}';
24+
25+
/**
26+
* The console command description.
27+
*
28+
* @var string
29+
*/
30+
protected $description = 'Create a read-only integration API client and print its secret once';
31+
32+
/**
33+
* Execute the console command.
34+
*/
35+
public function handle(): int
36+
{
37+
$name = trim((string) $this->option('name'));
38+
39+
if ($name === '') {
40+
$this->error('The --name option is required.');
41+
return 1;
42+
}
43+
44+
$rate = (int) $this->option('rate');
45+
if ($rate < 1) {
46+
$this->error('The --rate option must be greater than zero.');
47+
return 1;
48+
}
49+
50+
$scopes = $this->parseCsvOption((string) $this->option('scopes'));
51+
$origins = $this->parseCsvOption((string) $this->option('origins'));
52+
$networks = $this->parseCsvOption((string) $this->option('networks'));
53+
$networkIds = array_values(array_filter(array_map('intval', $networks), fn (int $id) => $id > 0));
54+
55+
$expiresAt = null;
56+
if ($this->option('expires-at')) {
57+
$expiresAt = Carbon::parse((string) $this->option('expires-at'));
58+
}
59+
60+
$plainToken = Str::random(64);
61+
62+
$client = ApiClient::create([
63+
'name' => $name,
64+
'token_hash' => hash('sha256', $plainToken),
65+
'scopes' => $scopes ?: ['events:read'],
66+
'allowed_origins' => $origins ?: null,
67+
'allowed_network_ids' => $networkIds ?: null,
68+
'rate_limit_per_minute' => $rate,
69+
'active' => true,
70+
'expires_at' => $expiresAt,
71+
]);
72+
73+
$this->info('API client created.');
74+
$this->line("ID: {$client->id}");
75+
$this->line("Name: {$client->name}");
76+
$this->line("Token: {$plainToken}");
77+
$this->warn('Store this token now. It will not be shown again.');
78+
79+
return 0;
80+
}
81+
82+
private function parseCsvOption(string $value): array
83+
{
84+
if (trim($value) === '') {
85+
return [];
86+
}
87+
88+
return array_values(array_filter(array_map('trim', explode(',', $value))));
89+
}
90+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\ApiClient;
6+
use Illuminate\Console\Command;
7+
8+
class ApiClientsRevoke extends Command
9+
{
10+
/**
11+
* The name and signature of the console command.
12+
*
13+
* @var string
14+
*/
15+
protected $signature = 'api-clients:revoke {id : API client ID}';
16+
17+
/**
18+
* The console command description.
19+
*
20+
* @var string
21+
*/
22+
protected $description = 'Revoke an integration API client';
23+
24+
/**
25+
* Execute the console command.
26+
*/
27+
public function handle(): int
28+
{
29+
$client = ApiClient::find($this->argument('id'));
30+
31+
if (!$client) {
32+
$this->error('API client not found.');
33+
return 1;
34+
}
35+
36+
$client->active = false;
37+
$client->save();
38+
39+
$this->info("Revoked API client {$client->id} ({$client->name}).");
40+
41+
return 0;
42+
}
43+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\ApiClient;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Support\Str;
8+
9+
class ApiClientsRotate extends Command
10+
{
11+
/**
12+
* The name and signature of the console command.
13+
*
14+
* @var string
15+
*/
16+
protected $signature = 'api-clients:rotate {id : API client ID}';
17+
18+
/**
19+
* The console command description.
20+
*
21+
* @var string
22+
*/
23+
protected $description = 'Rotate an integration API client token';
24+
25+
/**
26+
* Execute the console command.
27+
*/
28+
public function handle(): int
29+
{
30+
$client = ApiClient::find($this->argument('id'));
31+
32+
if (!$client) {
33+
$this->error('API client not found.');
34+
return 1;
35+
}
36+
37+
$plainToken = Str::random(64);
38+
39+
$client->token_hash = hash('sha256', $plainToken);
40+
$client->active = true;
41+
$client->save();
42+
43+
$this->info("Rotated API client {$client->id} ({$client->name}).");
44+
$this->line("Token: {$plainToken}");
45+
$this->warn('Store this token now. It will not be shown again.');
46+
47+
return 0;
48+
}
49+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\API;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Http\Resources\Party as PartyResource;
7+
use App\Models\Group;
8+
use App\Models\Party;
9+
use Carbon\Carbon;
10+
use Illuminate\Database\Eloquent\Builder;
11+
use Illuminate\Http\JsonResponse;
12+
use Illuminate\Http\Request;
13+
14+
class PublicEventController extends Controller
15+
{
16+
public function listEvents(Request $request): JsonResponse
17+
{
18+
return $this->listWithFilters($request);
19+
}
20+
21+
public function showEvent(Request $request, int $id): JsonResponse
22+
{
23+
$query = $this->buildBaseEventQuery();
24+
$this->applyClientRestrictions($query, $request);
25+
26+
$event = $query
27+
->where('events.idevents', $id)
28+
->firstOrFail();
29+
30+
return response()->json([
31+
'data' => $this->toPublicEventArray($event),
32+
]);
33+
}
34+
35+
public function listGroupEvents(Request $request, int $id): JsonResponse
36+
{
37+
Group::findOrFail($id);
38+
39+
return $this->listWithFilters($request, function (Builder $query) use ($id) {
40+
$query->where('events.group', $id);
41+
});
42+
}
43+
44+
private function listWithFilters(Request $request, ?callable $filter = null): JsonResponse
45+
{
46+
$validated = $request->validate([
47+
'start' => ['nullable', 'date'],
48+
'end' => ['nullable', 'date'],
49+
'updated_start' => ['nullable', 'date'],
50+
'updated_end' => ['nullable', 'date'],
51+
'page' => ['nullable', 'integer', 'min:1'],
52+
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
53+
]);
54+
55+
$query = $this->buildBaseEventQuery();
56+
$this->applyClientRestrictions($query, $request);
57+
58+
if ($filter) {
59+
$filter($query);
60+
}
61+
62+
$this->applyDateFilters($query, $validated);
63+
64+
$maxUpdatedAt = (clone $query)->max('events.updated_at');
65+
66+
$perPage = (int) ($validated['per_page'] ?? 50);
67+
$paginator = $query->paginate($perPage);
68+
69+
return response()->json([
70+
'data' => $paginator->getCollection()->map(function (Party $event) {
71+
return $this->toPublicEventArray($event);
72+
})->values(),
73+
'meta' => [
74+
'page' => $paginator->currentPage(),
75+
'per_page' => $paginator->perPage(),
76+
'total' => $paginator->total(),
77+
'last_page' => $paginator->lastPage(),
78+
],
79+
'sync' => [
80+
'generated_at' => Carbon::now()->toIso8601String(),
81+
'max_updated_at' => $maxUpdatedAt ? Carbon::parse($maxUpdatedAt)->toIso8601String() : null,
82+
],
83+
]);
84+
}
85+
86+
private function toPublicEventArray(Party $event): array
87+
{
88+
$data = PartyResource::make($event)->resolve();
89+
90+
// Keep payload lightweight for third-party display use-cases.
91+
unset($data['stats'], $data['network_data']);
92+
93+
if (isset($data['group']) && is_array($data['group'])) {
94+
unset($data['group']['networks']);
95+
}
96+
97+
return $data;
98+
}
99+
100+
private function buildBaseEventQuery(): Builder
101+
{
102+
return Party::query()
103+
->join('groups', 'groups.idgroups', '=', 'events.group')
104+
->whereNull('events.deleted_at')
105+
->whereNull('groups.deleted_at')
106+
->where('events.approved', true)
107+
->where('groups.approved', true)
108+
->distinct()
109+
->select('events.*')
110+
->orderBy('events.event_start_utc', 'asc');
111+
}
112+
113+
private function applyClientRestrictions(Builder $query, Request $request): void
114+
{
115+
$client = $request->attributes->get('apiClient');
116+
$allowedNetworkIds = $client?->allowed_network_ids ?: [];
117+
118+
if (!empty($allowedNetworkIds)) {
119+
$query->join('group_network as permitted_network', 'permitted_network.group_id', '=', 'groups.idgroups')
120+
->whereIn('permitted_network.network_id', $allowedNetworkIds);
121+
}
122+
}
123+
124+
private function applyDateFilters(Builder $query, array $validated): void
125+
{
126+
if (!empty($validated['start'])) {
127+
$start = Carbon::parse($validated['start'])->setTimezone('UTC')->toIso8601String();
128+
$query->where('events.event_start_utc', '>=', $start);
129+
} else {
130+
$query->where('events.event_end_utc', '>=', Carbon::now()->setTimezone('UTC')->toIso8601String());
131+
}
132+
133+
if (!empty($validated['end'])) {
134+
$end = Carbon::parse($validated['end'])->setTimezone('UTC')->toIso8601String();
135+
$query->where('events.event_end_utc', '<=', $end);
136+
}
137+
138+
if (!empty($validated['updated_start'])) {
139+
$updatedStart = Carbon::parse($validated['updated_start'])->setTimezone('UTC')->toDateTimeString();
140+
$query->where('events.updated_at', '>=', $updatedStart);
141+
}
142+
143+
if (!empty($validated['updated_end'])) {
144+
$updatedEnd = Carbon::parse($validated['updated_end'])->setTimezone('UTC')->toDateTimeString();
145+
$query->where('events.updated_at', '<=', $updatedEnd);
146+
}
147+
}
148+
149+
}

0 commit comments

Comments
 (0)