diff --git a/README.md b/README.md index 6df96fe..a61c1e9 100644 --- a/README.md +++ b/README.md @@ -1317,6 +1317,9 @@ POST /api/v1/{tipo}/{id}/return devolver con motivo {note} POST /api/v1/{tipo}/{id}/publish aprobar: ya, o programado con {at} POST /api/v1/{tipo}/{id}/unpublish POST /api/v1/{tipo}/{id}/preview enlaces de vista previa por idioma +GET /api/v1/health la pantalla de salud del sitio +GET /api/v1/translations/pending lo que falta traducir (missing) o se quedó + atrás (stale); filtros: locale, type, published ``` Lo mismo que los botones del panel, con las mismas reglas: pedir revisión es diff --git a/app/Http/Controllers/Api/V1/HealthController.php b/app/Http/Controllers/Api/V1/HealthController.php new file mode 100644 index 0000000..8f831c8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/HealthController.php @@ -0,0 +1,129 @@ +> + */ + protected const TRANSLATED = [Page::class, Project::class, Service::class, Call::class]; + + /** + * Every check the caller may see, problems first. A check behind a + * permission they lack is left out, as on the panel. + */ + public function index(Request $request, HealthReport $report): JsonResponse + { + // Who may open the panel's screen; each check guards itself further. + $this->authorize('panel.access'); + + $findings = $report->runFor($request->user()); + + return response()->json([ + 'data' => $findings->map(fn (Finding $finding) => [ + 'key' => $finding->key, + 'severity' => $finding->severity->value, + 'count' => $finding->count, + 'title' => $finding->title(), + 'description' => $finding->description(), + 'samples' => $finding->samples, + 'panel_url' => $finding->url, + ])->values(), + 'meta' => [ + 'problems' => $findings->filter(fn (Finding $f) => $f->severity->isProblem())->count(), + 'checked_at' => now()->toIso8601String(), + ], + ]); + } + + /** + * Records with a language still to write (`missing`) or left behind by the + * one it was translated from (`stale`, with since when). + * + * Filters: locale (only what that language lacks), type, published (only + * what is on the site — open, for calls). + */ + public function translations(Request $request): JsonResponse + { + $locales = array_keys((array) config('locales.supported')); + + $request->validate([ + 'locale' => ['sometimes', Rule::in($locales)], + 'type' => ['sometimes', Rule::in(['pages', 'projects', 'services', 'calls'])], + 'published' => ['sometimes', 'boolean'], + ]); + + $wanted = $request->input('locale'); + $items = collect(); + + foreach (self::TRANSLATED as $model) { + $type = AdminContent::nameOf(new $model); + + if ($request->filled('type') && $request->input('type') !== $type) { + continue; + } + + if ($request->user()->cannot('viewAny', $model)) { + continue; + } + + $model::query() + ->when($request->boolean('published'), fn ($q) => $model === Call::class ? $q->open() : $q->published()) + ->with('revisions') + ->get() + ->each(function (Model $record) use ($locales, $wanted, $type, $items): void { + $missing = array_values(array_filter( + $locales, + fn (string $locale) => blank($record->getTranslation('title', $locale, false)), + )); + + $stale = collect(TranslationFreshness::for($record)) + ->map(fn ($since) => $since->toIso8601String()) + ->all(); + + if ($wanted !== null) { + $missing = array_values(array_intersect($missing, [$wanted])); + $stale = array_intersect_key($stale, [$wanted => true]); + } + + if ($missing === [] && $stale === []) { + return; + } + + $items->push([ + 'type' => $type, + 'id' => $record->getKey(), + 'title' => ContentTypes::title($record), + 'missing' => $missing, + 'stale' => (object) $stale, + ]); + }); + } + + return response()->json(['data' => $items->values()]); + } +} diff --git a/public/openapi.yaml b/public/openapi.yaml index 778a16c..d9fe6a1 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -134,6 +134,8 @@ tags: description: Who changed what and when, and undoing a change - name: Reviews description: Review, publication and preview links + - name: Health + description: What is wrong with the site, and what is left to translate - name: Users description: Local user administration - name: Media @@ -3527,6 +3529,66 @@ paths: data: { $ref: "#/components/schemas/PreviewLinks" } "403": { $ref: "#/components/responses/Forbidden" } + /v1/health: + get: + tags: [Health] + summary: Site health + description: | + The panel's health screen: every check the caller may see, problems + first — broken images, missing slugs, stale translations, unanswered + submissions… A check behind a permission the caller lacks is left out. + responses: + "200": + description: The findings + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/HealthFinding" } + meta: + type: object + properties: + problems: { type: integer } + checked_at: { type: string, format: date-time } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/translations/pending: + get: + tags: [Health] + summary: What is left to translate + description: | + Pages, projects, services and calls with a language still to write + (`missing`) or left behind by the one it was translated from (`stale`, + with since when). Unpaginated. + parameters: + - name: locale + in: query + description: "Only what this language lacks." + schema: { type: string } + - name: type + in: query + description: "`pages`, `projects`, `services` or `calls`." + schema: { type: string } + - name: published + in: query + description: "Only what is on the site \u2014 open, for calls." + schema: { type: boolean } + responses: + "200": + description: The records + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PendingTranslation" } + "422": { $ref: "#/components/responses/ValidationError" } + /v1/media: get: tags: [Media] @@ -5277,6 +5339,26 @@ components: links: { type: object, additionalProperties: { type: string, format: uri }, description: Language → link. } expires_at: { type: string, format: date-time } + HealthFinding: + type: object + properties: + key: { type: string, example: missing-translations } + severity: { type: string, enum: [ok, warning, error] } + count: { type: integer } + title: { type: string } + description: { type: string } + samples: { type: array, items: { type: string }, description: Up to three examples. } + panel_url: { type: string, nullable: true, description: Where to fix it in the panel. } + + PendingTranslation: + type: object + properties: + type: { type: string, enum: [pages, projects, services, calls] } + id: { type: integer } + title: { type: string } + missing: { type: array, items: { type: string }, description: Languages with nothing written. } + stale: { type: object, additionalProperties: { type: string, format: date-time }, description: Language → since when it has been behind. } + MediaFor: type: string enum: [pages, technologies, projects, members, clients, testimonials] diff --git a/routes/api.php b/routes/api.php index e600e9e..7800d53 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationControl use App\Http\Controllers\Api\V1\CallController; use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\ClientController; +use App\Http\Controllers\Api\V1\HealthController; use App\Http\Controllers\Api\V1\MaintenanceController; use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MediaController; @@ -243,6 +244,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('reviews', [ReviewController::class, 'queue'])->name('reviews.queue'); + // The panel's health screen, and what is left to translate. + Route::get('health', [HealthController::class, 'index'])->name('health'); + Route::get('translations/pending', [HealthController::class, 'translations'])->name('translations.pending'); + foreach (array_keys(AdminContent::REVIEWABLE) as $resource) { $parameter = AdminContent::parameter($resource); diff --git a/tests/Feature/Api/HealthApiTest.php b/tests/Feature/Api/HealthApiTest.php new file mode 100644 index 0000000..22962f3 --- /dev/null +++ b/tests/Feature/Api/HealthApiTest.php @@ -0,0 +1,97 @@ +seed(RolePermissionSeeder::class); + } + + protected function actingWithRole(string $role): User + { + $user = User::factory()->create(); + $user->syncRoles([$role]); + + $this->withHeaders($this->apiHeadersFor($user)); + + return $user; + } + + #[Test] + public function the_health_report_lists_problems_first(): void + { + $this->actingWithRole('editor'); + + // Published with no Catalan title: one missing translation. + Page::factory()->published()->create([ + 'title' => ['es' => 'Hola', 'en' => 'Hello'], + 'slug' => ['es' => 'hola', 'en' => 'hello'], + ]); + + $response = $this->getJson('/api/v1/health')->assertOk(); + + $finding = collect($response->json('data'))->firstWhere('key', 'missing-translations'); + + $this->assertSame('warning', $finding['severity']); + $this->assertSame(1, $finding['count']); + $this->assertNotSame('ok', $response->json('data.0.severity')); + $this->assertGreaterThanOrEqual(1, $response->json('meta.problems')); + } + + #[Test] + public function a_check_behind_a_permission_is_left_out(): void + { + $this->actingWithRole('viewer'); + + $keys = collect($this->getJson('/api/v1/health')->assertOk()->json('data'))->pluck('key'); + + // Strangers' submissions are not a viewer's to see, not even counted. + $this->assertNotContains('unanswered-submissions', $keys); + } + + #[Test] + public function pending_translations_say_which_languages(): void + { + $this->actingWithRole('editor'); + + $page = Page::factory()->draft()->create([ + 'title' => ['es' => 'Solo en castellano'], + 'slug' => ['es' => 'solo'], + ]); + Page::factory()->draft()->create([ + 'title' => ['es' => 'Completa', 'en' => 'Complete', 'ca' => 'Completa'], + 'slug' => ['es' => 'completa', 'en' => 'complete', 'ca' => 'completa'], + ]); + + $this->getJson('/api/v1/translations/pending?type=pages') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $page->id) + ->assertJsonPath('data.0.missing', ['en', 'ca']); + + $this->getJson('/api/v1/translations/pending?locale=ca') + ->assertJsonPath('data.0.missing', ['ca']); + + // Drafts only, so nothing is on the site yet. + $this->getJson('/api/v1/translations/pending?published=1')->assertJsonCount(0, 'data'); + } +}