Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.5 KiB
PHP
231 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\PublicApi\V1;
|
|
|
|
use App\Http\Resources\PublicApi\PublicIssueResource;
|
|
use App\Http\Resources\PublicApi\PublicTechnologyResource;
|
|
use App\Models\Call;
|
|
use App\Models\Client;
|
|
use App\Models\Member;
|
|
use App\Models\Page;
|
|
use App\Models\Project;
|
|
use App\Models\Redirect;
|
|
use App\Models\Repository;
|
|
use App\Models\RepositoryIssue;
|
|
use App\Models\Service;
|
|
use App\Models\Technology;
|
|
use App\Support\ContentVersion;
|
|
use App\Support\SiteUrls;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
|
|
/**
|
|
* Site-wide endpoints that do not belong to a single content type.
|
|
*/
|
|
class SiteController extends PublicController
|
|
{
|
|
/**
|
|
* GET /api/public/v1/site
|
|
*
|
|
* What the frontend needs once, on any page: which languages exist, and
|
|
* the headline numbers a collective's home page tends to show.
|
|
*/
|
|
public function index(): JsonResponse
|
|
{
|
|
return response()->json([
|
|
'data' => [
|
|
'locales' => collect(config('locales.supported'))
|
|
->map(fn (array $meta, string $code) => [
|
|
'code' => $code,
|
|
'name' => $meta['native'] ?? $code,
|
|
'flag' => $meta['flag'] ?? null,
|
|
'is_default' => $code === config('locales.default'),
|
|
])
|
|
->values(),
|
|
|
|
'default_locale' => config('locales.default'),
|
|
|
|
/*
|
|
* A number that moves whenever published content does. Keep it
|
|
* as part of your cache key and everything downstream becomes
|
|
* stale the moment the site really changed, instead of when a
|
|
* timer says so.
|
|
*/
|
|
'content_version' => ContentVersion::current(),
|
|
|
|
// So the frontend hides its forms when the inbox is closed,
|
|
// rather than rendering one that would be refused on submit.
|
|
'submissions_enabled' => (bool) config('site.submissions.enabled', true),
|
|
|
|
'stats' => [
|
|
'projects' => Project::query()->published()->count(),
|
|
'active_projects' => Project::query()->published()->where('status', 'active')->count(),
|
|
'members' => Member::query()->public()->current()->count(),
|
|
'open_calls' => Call::query()->open()->count(),
|
|
'services' => Service::query()->published()->count(),
|
|
'clients' => Client::query()->public()->whereHas('projects', fn ($q) => $q->published())->count(),
|
|
'good_first_issues' => RepositoryIssue::query()
|
|
->where('is_good_first_issue', true)
|
|
->whereNull('assignee')
|
|
->whereHas('repository', fn ($q) => $q->publiclyVisible())
|
|
->count(),
|
|
'stars' => (int) Repository::query()->publiclyVisible()->sum('stars'),
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/public/v1/slugs
|
|
*
|
|
* Every published URL, grouped by type and locale. This is what the
|
|
* frontend builds its sitemap and static paths from — one request instead
|
|
* of paging through every collection.
|
|
*/
|
|
public function slugs(): JsonResponse
|
|
{
|
|
$entries = [];
|
|
|
|
foreach (Page::query()->published()->get(['id', 'slug', 'updated_at']) as $page) {
|
|
$entries['pages'][] = $this->entry($page, 'page');
|
|
}
|
|
|
|
foreach (Project::query()->published()->get(['id', 'slug', 'updated_at']) as $project) {
|
|
$entries['projects'][] = $this->entry($project, 'project');
|
|
}
|
|
|
|
foreach (Call::query()->open()->get(['id', 'slug', 'updated_at']) as $call) {
|
|
$entries['calls'][] = $this->entry($call, 'call');
|
|
}
|
|
|
|
foreach (Service::query()->published()->get(['id', 'slug', 'updated_at']) as $service) {
|
|
$entries['services'][] = $this->entry($service, 'service');
|
|
}
|
|
|
|
foreach (Member::query()->public()->current()->get(['id', 'slug', 'updated_at']) as $member) {
|
|
$entries['members'][] = [
|
|
'id' => $member->id,
|
|
'updated_at' => $member->updated_at?->toIso8601String(),
|
|
'alternates' => collect(array_keys(config('locales.supported')))
|
|
->mapWithKeys(fn (string $l) => [$l => SiteUrls::path('member', $l, $member->slug)])
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
return response()->json(['data' => $entries]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/public/v1/redirects
|
|
*
|
|
* Every URL that used to work and where it goes now, so the frontend can
|
|
* answer a 301 instead of a 404. Most of these were written by the panel
|
|
* itself the moment somebody renamed a slug — the only moment at which the
|
|
* old address was still known.
|
|
*
|
|
* Returned whole rather than looked up one at a time: there are never many,
|
|
* and an SSR frontend would otherwise ask this API on every 404 it serves.
|
|
*/
|
|
public function redirects(): JsonResponse
|
|
{
|
|
$redirects = Redirect::query()
|
|
->enabled()
|
|
->orderBy('from')
|
|
->get(['from', 'to', 'status', 'locale']);
|
|
|
|
return response()->json([
|
|
'data' => $redirects->map(fn (Redirect $redirect) => [
|
|
'from' => $redirect->from,
|
|
'to' => $redirect->to,
|
|
'status' => $redirect->status,
|
|
|
|
// Null means it applies whatever language the visitor is
|
|
// browsing in.
|
|
'locale' => $redirect->locale,
|
|
])->all(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/public/v1/radar
|
|
*
|
|
* The tech radar: what the collective adopts, is trialling, is assessing
|
|
* and is holding off on. Highly shareable and cheap to maintain.
|
|
*/
|
|
public function radar(): AnonymousResourceCollection
|
|
{
|
|
$technologies = Technology::query()
|
|
->onRadar()
|
|
->with('logoMedia')
|
|
->withCount(['projects', 'members'])
|
|
->orderBy('category')
|
|
->orderBy('position')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
return PublicTechnologyResource::collection($technologies);
|
|
}
|
|
|
|
/**
|
|
* GET /api/public/v1/technologies
|
|
*/
|
|
public function technologies(Request $request): AnonymousResourceCollection
|
|
{
|
|
$technologies = Technology::query()
|
|
->with('logoMedia')
|
|
->withCount(['projects', 'members'])
|
|
->when($request->filled('category'), fn ($q) => $q
|
|
->whereIn('category', (array) $request->input('category')))
|
|
// By default only technologies actually in use, so the list is not
|
|
// padded with tags nobody applied.
|
|
->when($request->boolean('in_use', true), fn ($q) => $q->has('projects'))
|
|
->orderBy('position')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
return PublicTechnologyResource::collection($technologies);
|
|
}
|
|
|
|
/**
|
|
* GET /api/public/v1/good-first-issues
|
|
*
|
|
* The single most effective thing on a collective's site: it turns a
|
|
* visitor who was only browsing into a contributor in one click.
|
|
*/
|
|
public function goodFirstIssues(Request $request): AnonymousResourceCollection
|
|
{
|
|
$issues = RepositoryIssue::query()
|
|
->with(['repository.project'])
|
|
->where(fn ($q) => $q
|
|
->where('is_good_first_issue', true)
|
|
->orWhere('is_help_wanted', $request->boolean('include_help_wanted', true)))
|
|
// An issue somebody already took is not an invitation.
|
|
->available()
|
|
->whereHas('repository', fn ($q) => $q->publiclyVisible()->where('is_archived', false))
|
|
->when($request->filled('language'), fn ($q) => $q
|
|
->whereHas('repository', fn ($r) => $r
|
|
->where('primary_language', $request->string('language'))))
|
|
->orderByDesc('issue_created_at')
|
|
->limit(min($request->integer('limit', 20), 100))
|
|
->get();
|
|
|
|
return PublicIssueResource::collection($issues);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function entry(Model $record, string $type): array
|
|
{
|
|
return [
|
|
'id' => $record->id,
|
|
'updated_at' => $record->updated_at?->toIso8601String(),
|
|
'alternates' => SiteUrls::alternates($record, $type),
|
|
];
|
|
}
|
|
}
|