added a help system, replaced the words "tenant" and "Pwa" with better alternatives. corrected and implemented cron jobs. prepared going live on a coolify-powered system.

This commit is contained in:
Codex Agent
2025-11-10 16:23:09 +01:00
parent ba9e64dfcb
commit 447a90a742
123 changed files with 6398 additions and 153 deletions

View File

@@ -1251,6 +1251,8 @@ class EventPublicController extends BaseController
'photos.emotion_id',
'photos.task_id',
'photos.guest_name',
'photos.created_at',
'photos.ingest_source',
'tasks.title as task_title',
])
->where('photos.event_id', $eventId)
@@ -1258,7 +1260,9 @@ class EventPublicController extends BaseController
->limit(60);
// MyPhotos filter
if ($filter === 'myphotos' && $deviceId !== 'anon') {
if ($filter === 'photobooth') {
$query->where('photos.ingest_source', Photo::SOURCE_PHOTOBOOTH);
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
$query->where('guest_name', $deviceId);
}
@@ -1276,6 +1280,8 @@ class EventPublicController extends BaseController
$r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe');
}
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
return $r;
});
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
@@ -1495,6 +1501,7 @@ class EventPublicController extends BaseController
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
'likes_count' => 0,
'ingest_source' => Photo::SOURCE_GUEST_PWA,
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId),

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Api;
use App\Support\Help\HelpRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken;
use RuntimeException;
class HelpController extends Controller
{
public function __construct(private readonly HelpRepository $repository) {}
public function index(Request $request): JsonResponse
{
[$audience, $locale] = $this->resolveContext($request);
$articles = $this->getArticles($audience, $locale)
->map(fn ($article) => Arr::only($article, config('help.list_fields')))
->values();
return response()->json([
'data' => $articles,
]);
}
public function show(Request $request, string $slug): JsonResponse
{
[$audience, $locale] = $this->resolveContext($request);
$article = $this->getArticle($audience, $locale, $slug);
abort_if(! $article, 404, 'Help article not found.');
return response()->json([
'data' => $article,
]);
}
/**
* @return array{string, string}
*/
private function resolveContext(Request $request): array
{
$this->attemptTokenAuthentication($request);
$audience = Str::of($request->string('audience', 'guest'))->lower()->value();
$locale = Str::of($request->string('locale', config('help.default_locale')))->lower()->value();
if ($audience === 'admin' && ! $request->user()) {
abort(401, 'Authentication required for admin help content.');
}
if (! in_array($audience, config('help.audiences', []), true)) {
abort(400, 'Invalid audience supplied.');
}
return [$audience, $locale];
}
private function attemptTokenAuthentication(Request $request): void
{
if ($request->user()) {
return;
}
$bearer = $request->bearerToken();
if (! $bearer) {
return;
}
$token = PersonalAccessToken::findToken($bearer);
if (! $token) {
return;
}
$user = $token->tokenable;
if (! $user) {
return;
}
if (method_exists($user, 'withAccessToken')) {
$user->withAccessToken($token);
}
Auth::setUser($user);
$request->setUserResolver(fn () => $user);
}
private function getArticles(string $audience, string $locale)
{
try {
return $this->repository->list($audience, $locale);
} catch (RuntimeException $e) {
$fallback = config('help.fallback_locale');
if ($locale === $fallback) {
throw $e;
}
return $this->repository->list($audience, $fallback);
}
}
private function getArticle(string $audience, string $locale, string $slug): ?array
{
try {
$article = $this->repository->find($audience, $locale, $slug);
} catch (RuntimeException $e) {
$fallback = config('help.fallback_locale');
if ($locale !== $fallback) {
return $this->repository->find($audience, $fallback, $slug);
}
throw $e;
}
return $article;
}
}

View File

@@ -153,14 +153,12 @@ class PhotoController extends Controller
$thumbnailPath = $thumbnailRelative;
}
// Create photo record
$photo = Photo::create([
$photoAttributes = [
'event_id' => $event->id,
'filename' => $filename,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'path' => $path,
'file_path' => $path,
'thumbnail_path' => $thumbnailPath,
'width' => null, // Filled below
'height' => null,
@@ -168,7 +166,17 @@ class PhotoController extends Controller
'uploader_id' => null,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
'ingest_source' => Photo::SOURCE_TENANT_ADMIN,
];
if (Photo::supportsFilenameColumn()) {
$photoAttributes['filename'] = $filename;
}
if (Photo::hasColumn('path')) {
$photoAttributes['path'] = $path;
}
$photo = Photo::create($photoAttributes);
// Record primary asset metadata
$checksum = hash_file('sha256', $file->getRealPath());
@@ -663,19 +671,27 @@ class PhotoController extends Controller
$thumbnailPath = $thumbnailRelative;
}
// Create photo record
$photo = Photo::create([
$photoAttributes = [
'event_id' => $event->id,
'filename' => $filename,
'original_name' => $request->original_name,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'path' => $path,
'file_path' => $path,
'thumbnail_path' => $thumbnailPath,
'status' => 'pending',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
'ingest_source' => Photo::SOURCE_TENANT_ADMIN,
];
if (Photo::supportsFilenameColumn()) {
$photoAttributes['filename'] = $filename;
}
if (Photo::hasColumn('path')) {
$photoAttributes['path'] = $path;
}
$photo = Photo::create($photoAttributes);
[$width, $height] = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]);

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Resources\Tenant\PhotoboothStatusResource;
use App\Models\Event;
use App\Models\PhotoboothSetting;
use App\Services\Photobooth\PhotoboothProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PhotoboothController extends Controller
{
public function __construct(private readonly PhotoboothProvisioner $provisioner) {}
public function show(Request $request, Event $event): PhotoboothStatusResource
{
$this->assertEventBelongsToTenant($request, $event);
return $this->resource($event);
}
public function enable(Request $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('tenant');
$updated = $this->provisioner->enable($event);
return response()->json([
'message' => __('Photobooth-Zugang aktiviert.'),
'data' => $this->resource($updated),
]);
}
public function rotate(Request $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('tenant');
$updated = $this->provisioner->rotate($event);
return response()->json([
'message' => __('Zugangsdaten neu generiert.'),
'data' => $this->resource($updated),
]);
}
public function disable(Request $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('tenant');
$updated = $this->provisioner->disable($event);
return response()->json([
'message' => __('Photobooth-Zugang deaktiviert.'),
'data' => $this->resource($updated),
]);
}
protected function resource(Event $event): PhotoboothStatusResource
{
return PhotoboothStatusResource::make([
'event' => $event->fresh(),
'settings' => PhotoboothSetting::current(),
]);
}
protected function assertEventBelongsToTenant(Request $request, Event $event): void
{
$tenantId = (int) $request->attributes->get('tenant_id');
if ($tenantId !== (int) $event->tenant_id) {
abort(403, 'Event gehört nicht zu diesem Tenant.');
}
}
}

View File

@@ -34,6 +34,7 @@ class PhotoResource extends JsonResource
'is_liked' => false,
'uploaded_at' => $this->created_at->toISOString(),
'uploader_name' => $this->guest_name ?? null,
'ingest_source' => $this->ingest_source,
'event' => [
'id' => $this->event->id,
'name' => $this->event->name,
@@ -57,4 +58,4 @@ class PhotoResource extends JsonResource
{
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Resources\Tenant;
use App\Models\Event;
use App\Models\PhotoboothSetting;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PhotoboothStatusResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$payload = $this->resolvePayload();
/** @var Event $event */
$event = $payload['event'];
/** @var PhotoboothSetting $settings */
$settings = $payload['settings'];
$password = $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password;
return [
'enabled' => (bool) $event->photobooth_enabled,
'status' => $event->photobooth_status,
'username' => $event->photobooth_username,
'password' => $password,
'path' => $event->photobooth_path,
'ftp_url' => $this->buildFtpUrl($event, $settings, $password),
'expires_at' => optional($event->photobooth_expires_at)->toIso8601String(),
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
'ftp' => [
'host' => config('photobooth.ftp.host'),
'port' => $settings->ftp_port,
'require_ftps' => (bool) $settings->require_ftps,
],
];
}
/**
* @return array{event: Event, settings: PhotoboothSetting}
*/
protected function resolvePayload(): array
{
$resource = $this->resource;
if ($resource instanceof Event) {
return [
'event' => $resource,
'settings' => PhotoboothSetting::current(),
];
}
return [
'event' => $resource['event'] ?? $resource,
'settings' => $resource['settings'] ?? PhotoboothSetting::current(),
];
}
protected function buildFtpUrl(Event $event, PhotoboothSetting $settings, ?string $password): ?string
{
$host = config('photobooth.ftp.host');
$username = $event->photobooth_username;
if (! $host || ! $username || ! $password) {
return null;
}
$scheme = $settings->require_ftps ? 'ftps' : 'ftp';
$port = $settings->ftp_port ?: config('photobooth.ftp.port', 21);
return sprintf('%s://%s:%s@%s:%d', $scheme, $username, $password, $host, $port);
}
}