Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ SESSION_DOMAIN=web.codebar.test

BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
# Not "sync": the network profile link is sent from a queued job, and running it inside
# the request makes the deliberately constant response observable by its timing.
# Needs `php artisan queue:work` (see README).
QUEUE_CONNECTION=database

MAIL_MAILER=log
MAIL_HOST=localhost
Expand Down Expand Up @@ -62,3 +65,6 @@ LARAVEL_DEFAULT_FATHOM_SITE_ID=WECFOADW
LITELLM_URL=https://llm.codebar.net
LITELLM_MASTER_KEY=
GITHUB_TOKEN=

# Where a failing health check reports to. Unset means no notification is sent.
HEALTH_NOTIFICATION_EMAIL=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ auth.json
# Lighthouse audit reports (regenerate via tests/lighthouse/run.sh)
/tests/lighthouse/reports/
--cache-directory

# Generated Lighthouse runs — keep run.sh and pages.json, not their output.
tests/lighthouse/reports/
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ The site ships in German (`de_CH`) and English (`en_CH`) under distinct, fully t

- **Structured data** — a single `@graph` JSON-LD payload (`App\Seo\SchemaGraph` / `App\Seo\SchemaNodes`) built from `config/company.php`, the one source of truth for the company's name, addresses, phone and `sameAs` profiles. Every page ships `Organization`, `WebSite`, `WebPage` and `BreadcrumbList` nodes; content pages add `Service`, `Person`, `BlogPosting`, etc.
- **Sitemap** — `App\Sitemap\SitemapBuilder` + `App\Http\Controllers\Sitemap\SitemapController` build `/sitemap.xml` from the same models the site renders, cached 24h via `Cache::remember`.
- **Response cache** — `spatie/laravel-responsecache` caches full HTTP responses for up to 7 days. `App\Observers\SitemapCacheObserver` drops the sitemap's own data cache on every relevant model save, and `responsecache:clear` runs hourly via the scheduler (`routes/console.php`) as a backstop for the full-page cache layer — these are two independent caches and both need clearing after a bulk content change (`php artisan responsecache:clear` after a fresh `db:seed`, for instance).
- **Response cache** — `spatie/laravel-responsecache` caches full HTTP responses for up to 7 days. Invalidation is event-driven, not scheduled: `App\Observers\SitemapCacheObserver` drops the sitemap's data cache and the content observers drop both the listing caches and the rendered pages on every relevant model save. Clearing goes through `App\Support\ResponseCacheFlusher`, which collapses the flush to once per import — the response cache has no per-URL invalidation, so an import that saves 50 rows would otherwise clear the whole site 50 times.
- **Social images** — article heroes that are local SVG placeholders (no real photography yet) are not usable as `og:image` — social crawlers don't render SVG. `App\Support\NewsImage::ogImage()` falls back to a same-named `.png` rendered from the SVG when one exists, or the site's default share image otherwise.
- **Tests** — `tests/Feature/Seo/` asserts on the actual rendered JSON-LD and meta tags (not just "does it look right"), and `tests/lighthouse/` audits real Lighthouse scores against the built (`npm run build`) output, not the Vite dev server.

Expand Down Expand Up @@ -100,6 +100,17 @@ valet open

> Set `valetTls: 'your-domain.test'` below `refresh: true` in `vite.config.js` if you use `valet secure` / `herd secure`.

### Background work

`QUEUE_CONNECTION=database`, so a worker has to be running for anything queued to happen — the network profile link, the LLM usage sync. Deliberately not `sync`: running the mail job inside the request makes the intentionally constant response of `network.request.store` observable by its timing.

```bash
php artisan queue:work # network profile mails, LLM usage sync
php artisan schedule:work # hourly LLM sync, health checks
```

Without the scheduler, `health:check` never runs and `/health` reports stale results. A dispatched job with no worker is a silent black hole — `JobsCheck` exists to catch exactly that, and reports through `/health`.

## Testing & code quality

```bash
Expand All @@ -115,16 +126,19 @@ The site runs on [Laravel Cloud](https://cloud.laravel.com), behind Cloudflare.

- `CSP_ENABLED=true` — enables Content-Security-Policy enforcement via Spatie CSP middleware
- `FPH_ENABLED=true` — enables Permissions-Policy headers
- `HEALTH_NOTIFICATION_EMAIL` — where a failing health check reports to; unset means no notification is sent

HSTS, COOP, CORP, `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options` are applied automatically by the `SecurityHeaders` middleware on every web response.

HSTS, COOP, `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options` are applied automatically by the `SecurityHeaders` middleware on every web response.
`/health` returns the Spatie Health JSON result set. It is rate limited and never response-cached; it needs `schedule:work` running to hold current results.

**Lighthouse note:** deprecated-API warnings for `/cdn-cgi/challenge-platform/scripts/jsd/main.js` come from Cloudflare's bot protection, injected at the edge — not from application code. Run Lighthouse in incognito without extensions for an accurate score, and prefer `tests/lighthouse/` (which audits the built output) over ad-hoc runs against the dev server.

## Key packages

- **Content & storage** — `spatie/laravel-translatable`, `codebar-ag/laravel-flysystem-cloudinary` (editorial images), `league/flysystem-aws-s3-v3` (DigitalOcean Spaces for other assets), `symfony/yaml`
- **SEO** — `spatie/laravel-sitemap`, `spatie/laravel-responsecache`
- **Security & health** — `spatie/laravel-csp`, `spatie/laravel-honeypot`, `spatie/laravel-permission`, `spatie/laravel-health`, `spatie/security-advisories-health-check`, `mazedlx/laravel-feature-policy`
- **Security & health** — `spatie/laravel-csp`, `spatie/laravel-honeypot` (network request form), `spatie/laravel-permission`, `spatie/laravel-health`, `spatie/security-advisories-health-check`, `mazedlx/laravel-feature-policy`
- **Ops** — `laravel/nightwatch` (observability), `symfony/postmark-mailer`
- **Analytics** — [Fathom](https://usefathom.com) (privacy-friendly, no cookie banner)

Expand Down
98 changes: 68 additions & 30 deletions app/Actions/LlmUsageStatsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use Closure;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
Expand Down Expand Up @@ -44,16 +43,18 @@ public function hasOtherModels(): bool
}

/**
* @return Collection<int, numeric-string>
* The calendar years usage has been recorded in, oldest first.
*
* @return Collection<int, string>
*/
public function years(): Collection
{
return $this->remember('years', function () {
return AiModelDailyUsage::query()
->orderBy('date')
->get(['date'])
->map(fn (AiModelDailyUsage $row): string => $row->date->format('Y'))
->unique()
->selectRaw('DISTINCT EXTRACT(YEAR FROM date)::int AS year')
->orderBy('year')
->pluck('year')
->map(fn (mixed $year): string => $this->toYear($year))
->values();
});
}
Expand All @@ -78,31 +79,33 @@ private function toDate(mixed $value): ?Carbon
}

/**
* @return Collection<int, array{label: string, prompt_tokens: int<0, max>, completion_tokens: int<0, max>, total_tokens: int<0, max>, requests: int<0, max>}>
* @return Collection<int, array{label: string, prompt_tokens: int, completion_tokens: int, total_tokens: int, requests: int}>
*/
public function monthlyBreakdown(?string $year, ?string $month, ?string $model): Collection
{
$suffix = 'breakdown_'.($year ?? 'all').'_'.($month ?? 'all').'_'.($model ?? 'all');

return $this->remember($suffix, function () use ($year, $month, $model) {
return AiModelDailyUsage::query()
->when($model === self::OTHER_MODEL, fn (Builder $query) => $query->whereNull('ai_model_id'))
->when($model && $model !== self::OTHER_MODEL, fn (Builder $query) => $query->where('model', $model))
// Grouped in the database rather than in PHP: this table grows by one row
// per model per day forever, and the page only ever shows the totals.
return $this->filtered($model)
->when($year, fn (Builder $query) => $query->whereYear('date', $year))
->when($month, fn (Builder $query) => $query->whereMonth('date', $month))
->orderBy('date')
->selectRaw("to_char(date, 'YYYY-MM') AS label")
->selectRaw('COALESCE(SUM(prompt_tokens), 0)::bigint AS prompt_tokens')
->selectRaw('COALESCE(SUM(completion_tokens), 0)::bigint AS completion_tokens')
->selectRaw('COALESCE(SUM(total_tokens), 0)::bigint AS total_tokens')
->selectRaw('COALESCE(SUM(requests), 0)::bigint AS requests')
->groupByRaw("to_char(date, 'YYYY-MM')")
->orderByRaw("to_char(date, 'YYYY-MM')")
->get()
->groupBy(fn (AiModelDailyUsage $row) => $row->date->format('Y-m'))
->map(
/** @param EloquentCollection<int, AiModelDailyUsage> $rows */
fn (EloquentCollection $rows, string $label) => [
'label' => $label,
'prompt_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->prompt_tokens),
'completion_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->completion_tokens),
'total_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->total_tokens),
'requests' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->requests),
]
)
->map(fn (AiModelDailyUsage $row): array => [
'label' => $this->toString($row->getAttribute('label')),
'prompt_tokens' => $this->toInt($row->getAttribute('prompt_tokens')),
'completion_tokens' => $this->toInt($row->getAttribute('completion_tokens')),
'total_tokens' => $this->toInt($row->getAttribute('total_tokens')),
'requests' => $this->toInt($row->getAttribute('requests')),
])
->values();
});
}
Expand Down Expand Up @@ -137,21 +140,56 @@ public function totalSummary(?string $model = null): array
private function summary(string $suffix, ?CarbonImmutable $from, ?string $model = null): array
{
return $this->remember($suffix.'_'.($model ?? 'all'), function () use ($from, $model) {
$rows = AiModelDailyUsage::query()
$row = $this->filtered($model)
->when($from, fn (Builder $query) => $query->where('date', '>=', $from))
->when($model === self::OTHER_MODEL, fn (Builder $query) => $query->whereNull('ai_model_id'))
->when($model && $model !== self::OTHER_MODEL, fn (Builder $query) => $query->where('model', $model))
->get();
->selectRaw('COALESCE(SUM(prompt_tokens), 0)::bigint AS prompt_tokens')
->selectRaw('COALESCE(SUM(completion_tokens), 0)::bigint AS completion_tokens')
->selectRaw('COALESCE(SUM(total_tokens), 0)::bigint AS total_tokens')
->selectRaw('COALESCE(SUM(requests), 0)::bigint AS requests')
->first();

return [
'prompt_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->prompt_tokens),
'completion_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->completion_tokens),
'total_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->total_tokens),
'requests' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->requests),
'prompt_tokens' => $this->toInt($row?->getAttribute('prompt_tokens')),
'completion_tokens' => $this->toInt($row?->getAttribute('completion_tokens')),
'total_tokens' => $this->toInt($row?->getAttribute('total_tokens')),
'requests' => $this->toInt($row?->getAttribute('requests')),
];
});
}

/**
* Aggregate columns come back untyped — PostgreSQL hands SUM() over a bigint back as
* a string, and a grouped row carries no cast from the model.
*/
private function toInt(mixed $value): int
{
return is_numeric($value) ? (int) $value : 0;
}

private function toString(mixed $value): string
{
return is_string($value) ? $value : '';
}

/** The filter compares against the query string, so a year travels as text. */
private function toYear(mixed $value): string
{
return (string) $this->toInt($value);
}

/**
* The model filter every aggregate shares: a named model, everything that is not a
* known model ("other"), or no filter at all.
*
* @return Builder<AiModelDailyUsage>
*/
private function filtered(?string $model): Builder
{
return AiModelDailyUsage::query()
->when($model === self::OTHER_MODEL, fn (Builder $query) => $query->whereNull('ai_model_id'))
->when($model !== null && $model !== self::OTHER_MODEL, fn (Builder $query) => $query->where('model', $model));
}

/**
* @template TValue
*
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/PageAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public function news(News $news, bool $withReferences = false, ?string $locale =
title: $this->translatedString($news->getTranslation('title', $locale)),
description: $this->translatedString($news->getTranslation('teaser', $locale)),
image: $news->hero_image,
lastModificationDate: Carbon::parse($news->updated_at ?? now()),
lastModificationDate: Carbon::parse($news->revised_at ?? $news->published_at ?? $news->updated_at ?? now()),
routeParameters: LocalizedRouteParameters::for(['locale' => $locale, 'news' => $news], $locale),
referencePages: $withReferences ? $this->alternateLocalePages($news, $locale, fn (News $n, string $l) => $this->news($n, false, $l)) : null,
publishedAt: $news->published_at !== null ? Carbon::parse($news->published_at) : null,
Expand Down
57 changes: 41 additions & 16 deletions app/Actions/ViewDataAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Enums\ContactSectionEnum;
use App\Models\AiModel;
use App\Models\Contact;
use App\Models\Network;
use App\Models\News;
use App\Models\OpenSource;
use App\Models\Product;
Expand Down Expand Up @@ -125,7 +126,13 @@ public function openSource(string $locale): Collection
});
}

public function contacts(string $locale): \stdClass
/**
* The published team, grouped by the section each person appears in. Every section
* is present, empty ones included, so a caller never has to guard the lookup.
*
* @return Collection<string, Collection<int, ContactDTO>>
*/
public function contacts(string $locale): Collection
{
$key = CacheKeyEnum::CONTACTS_PUBLISHED->forLocale($locale);

Expand All @@ -138,21 +145,39 @@ public function contacts(string $locale): \stdClass
->orderBy('name')
->get();

return (object) collect([
ContactSectionEnum::EMPLOYEES,
ContactSectionEnum::COLLABORATIONS,
ContactSectionEnum::BOARD_MEMBERS,
])->mapWithKeys(function (string $section) use ($publishedContacts, $locale): array {
$contacts = $publishedContacts
->filter(function (Contact $contact) use ($section): bool {
$sections = $contact->sections ?? [];

return array_key_exists($section, $sections);
})
->map(fn (Contact $contact) => ContactDTO::fromModel($contact, $section, $locale));

return [$section => $contacts->values()];
})->all();
return collect(ContactSectionEnum::cases())
->mapWithKeys(fn (ContactSectionEnum $section): array => [
$section->value => $publishedContacts
->filter(fn (Contact $contact): bool => array_key_exists($section->value, $contact->sections ?? []))
->map(fn (Contact $contact): ContactDTO => ContactDTO::fromModel($contact, $section, $locale))
->values(),
]);
});
}

/**
* @return Collection<int, ContactDTO>
*/
public function contactsInSection(string $locale, ContactSectionEnum $section): Collection
{
/** @var Collection<int, ContactDTO> $contacts */
$contacts = $this->contacts($locale)->get($section->value, new Collection);

return $contacts;
}

/**
* @return Collection<int, Network>
*/
public function networks(): Collection
{
return Cache::rememberForever(CacheKeyEnum::NETWORKS_PUBLISHED->value, function () {
return Network::query()
->published()
->active()
->with('publishedUsers')
->orderBy('sort')
->get();
});
}
}
33 changes: 0 additions & 33 deletions app/Checks/FilesystemsDefaultCheck.php

This file was deleted.

8 changes: 8 additions & 0 deletions app/Console/Commands/ImportCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

namespace App\Console\Commands;

use App\Support\ResponseCacheFlusher;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;

Expand All @@ -24,6 +27,11 @@ abstract class ImportCommand extends Command
/** Directory under database/ this importer reads when --path is not given. */
abstract protected function defaultPath(): string;

protected function execute(InputInterface $input, OutputInterface $output): int
{
return ResponseCacheFlusher::batch(fn (): int => parent::execute($input, $output));
}

protected function basePath(): string
{
$override = $this->option('path');
Expand Down
Loading