Add photobooth uploader download email
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-13 09:59:39 +01:00
parent b1250c6246
commit 24a1319cc2
12 changed files with 309 additions and 1 deletions

View File

@@ -3,12 +3,16 @@
namespace App\Http\Controllers\Api\Tenant; namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
use App\Http\Resources\Tenant\PhotoboothStatusResource; use App\Http\Resources\Tenant\PhotoboothStatusResource;
use App\Mail\PhotoboothUploaderDownload;
use App\Models\Event; use App\Models\Event;
use App\Models\PhotoboothSetting; use App\Models\PhotoboothSetting;
use App\Services\Photobooth\PhotoboothProvisioner; use App\Services\Photobooth\PhotoboothProvisioner;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class PhotoboothController extends Controller class PhotoboothController extends Controller
{ {
@@ -69,6 +73,39 @@ class PhotoboothController extends Controller
]); ]);
} }
public function sendUploaderDownloadEmail(PhotoboothSendUploaderDownloadRequest $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$user = $request->user();
if (! $user || ! $user->email) {
throw ValidationException::withMessages([
'email' => __('No email address is configured for this account.'),
]);
}
$locale = $user->preferred_locale ?: app()->getLocale();
$eventName = $this->resolveEventName($event, $locale);
$recipientName = $user->fullName ?? $user->name ?? $user->email;
Mail::to($user->email)
->locale($locale)
->queue(new PhotoboothUploaderDownload(
recipientName: $recipientName,
eventName: $eventName,
links: [
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
],
));
return response()->json([
'message' => __('Download links sent via email.'),
]);
}
protected function resource(Event $event): PhotoboothStatusResource protected function resource(Event $event): PhotoboothStatusResource
{ {
return PhotoboothStatusResource::make([ return PhotoboothStatusResource::make([
@@ -92,4 +129,30 @@ class PhotoboothController extends Controller
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp'; return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
} }
protected function resolveEventName(Event $event, ?string $locale = null): string
{
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
$locale = $locale ?: app()->getLocale();
$localized = $name[$locale] ?? null;
if (is_string($localized) && trim($localized) !== '') {
return $localized;
}
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: __('emails.photobooth_uploader.event_fallback');
}
} }

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Photobooth;
use Illuminate\Foundation\Http\FormRequest;
class PhotoboothSendUploaderDownloadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PhotoboothUploaderDownload extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* @param array{windows:string, macos:string, linux:string} $links
*/
public function __construct(
public string $recipientName,
public string $eventName,
public array $links,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __('emails.photobooth_uploader.subject', [
'event' => $this->eventName,
]),
);
}
public function content(): Content
{
return new Content(
view: 'emails.photobooth-uploader-download',
with: [
'recipientName' => $this->recipientName,
'eventName' => $this->eventName,
'links' => $this->links,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -65,6 +65,25 @@ return [
'benefit4' => 'Unterstuetzung, wenn du sie brauchst', 'benefit4' => 'Unterstuetzung, wenn du sie brauchst',
'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.', 'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.',
], ],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App fuer :event',
'preheader' => 'Download-Links fuer die Fotospiel Photobooth Uploader App.',
'hero_title' => 'Hallo :name,',
'hero_subtitle' => 'Deine Uploader App fuer :event ist bereit.',
'body' => 'Hier findest du die Download-Links fuer die Fotospiel Photobooth Uploader App. Installiere die passende Version auf dem Photobooth-PC, bevor dein Event startet.',
'downloads_title' => 'Download-Links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download fuer Windows',
'cta_macos' => 'Download fuer macOS',
'cta_linux' => 'Download fuer Linux',
'credentials_hint' => 'Die Zugangsdaten bleiben im Admin-Dashboard. Erstelle einen Verbindungscode, sobald du die App koppeln moechtest.',
'footer' => 'Fragen? Antworte einfach auf diese E-Mail.',
'event_fallback' => 'dein Event',
],
'package_limits' => [ 'package_limits' => [
'package_fallback' => 'Paket', 'package_fallback' => 'Paket',
'team_fallback' => 'dein Team', 'team_fallback' => 'dein Team',

View File

@@ -65,6 +65,25 @@ return [
'benefit4' => 'Friendly support whenever you need help', 'benefit4' => 'Friendly support whenever you need help',
'footer' => 'Need help? Reply to this email.', 'footer' => 'Need help? Reply to this email.',
], ],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App for :event',
'preheader' => 'Download links for the Fotospiel Photobooth Uploader.',
'hero_title' => 'Hi :name,',
'hero_subtitle' => 'Your uploader app for :event is ready.',
'body' => 'Here are the download links for the Fotospiel Photobooth Uploader. Install the right version on the photobooth PC before your event starts.',
'downloads_title' => 'Download links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download for Windows',
'cta_macos' => 'Download for macOS',
'cta_linux' => 'Download for Linux',
'credentials_hint' => 'Connection credentials stay in the admin dashboard. Generate a connect code when you are ready to pair the app.',
'footer' => 'Questions? Reply to this email and we will help.',
'event_fallback' => 'your event',
],
'package_limits' => [ 'package_limits' => [
'package_fallback' => 'package', 'package_fallback' => 'package',
'team_fallback' => 'your team', 'team_fallback' => 'your team',

View File

@@ -2068,6 +2068,13 @@ export async function createEventPhotoboothConnectCode(
}; };
} }
export async function sendEventPhotoboothUploaderEmail(slug: string): Promise<void> {
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/uploader-email`, {
method: 'POST',
});
await jsonOrThrow<{ message?: string }>(response, 'Failed to send photobooth uploader email');
}
export async function submitTenantFeedback(payload: { export async function submitTenantFeedback(payload: {
category: string; category: string;
sentiment?: 'positive' | 'neutral' | 'negative'; sentiment?: 'positive' | 'neutral' | 'negative';

View File

@@ -1211,6 +1211,9 @@
"uploaderDownload": { "uploaderDownload": {
"title": "Fotospiel Uploader App", "title": "Fotospiel Uploader App",
"description": "Die Fotospiel Uploader App wird benötigt, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.", "description": "Die Fotospiel Uploader App wird benötigt, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.",
"emailAction": "Download-Links per E-Mail senden",
"emailSuccess": "Download-Links wurden per E-Mail gesendet.",
"emailFailed": "E-Mail konnte nicht gesendet werden.",
"actionWindows": "Uploader herunterladen (Windows)", "actionWindows": "Uploader herunterladen (Windows)",
"actionMac": "Uploader herunterladen (macOS)", "actionMac": "Uploader herunterladen (macOS)",
"actionLinux": "Uploader herunterladen (Linux)" "actionLinux": "Uploader herunterladen (Linux)"

View File

@@ -924,6 +924,9 @@
"uploaderDownload": { "uploaderDownload": {
"title": "Fotospiel Uploader App", "title": "Fotospiel Uploader App",
"description": "The Fotospiel Uploader App is required so uploads stay stable, credentials remain protected, and no files are lost.", "description": "The Fotospiel Uploader App is required so uploads stay stable, credentials remain protected, and no files are lost.",
"emailAction": "Send download links by email",
"emailSuccess": "Download links were sent by email.",
"emailFailed": "Email could not be sent.",
"actionWindows": "Download uploader (Windows)", "actionWindows": "Download uploader (Windows)",
"actionMac": "Download uploader (macOS)", "actionMac": "Download uploader (macOS)",
"actionLinux": "Download uploader (Linux)" "actionLinux": "Download uploader (Linux)"

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } from 'lucide-react'; import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3, Mail } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
@@ -15,6 +15,7 @@ import {
disableEventPhotobooth, disableEventPhotobooth,
rotateEventPhotobooth, rotateEventPhotobooth,
createEventPhotoboothConnectCode, createEventPhotoboothConnectCode,
sendEventPhotoboothUploaderEmail,
PhotoboothStatus, PhotoboothStatus,
TenantEvent, TenantEvent,
} from '../api'; } from '../api';
@@ -41,6 +42,7 @@ export default function MobileEventPhotoboothPage() {
const [connectCode, setConnectCode] = React.useState<string | null>(null); const [connectCode, setConnectCode] = React.useState<string | null>(null);
const [connectExpiresAt, setConnectExpiresAt] = React.useState<string | null>(null); const [connectExpiresAt, setConnectExpiresAt] = React.useState<string | null>(null);
const [connectLoading, setConnectLoading] = React.useState(false); const [connectLoading, setConnectLoading] = React.useState(false);
const [sendingEmail, setSendingEmail] = React.useState(false);
const [showCredentials, setShowCredentials] = React.useState(false); const [showCredentials, setShowCredentials] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
@@ -138,6 +140,23 @@ export default function MobileEventPhotoboothPage() {
} }
}; };
const handleSendDownloadEmail = async () => {
if (!slug) return;
setSendingEmail(true);
try {
await sendEventPhotoboothUploaderEmail(slug);
toast.success(t('photobooth.uploaderDownload.emailSuccess', 'Download-Links wurden per E-Mail gesendet.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(
getApiErrorMessage(err, t('photobooth.uploaderDownload.emailFailed', 'E-Mail konnte nicht gesendet werden.'))
);
}
} finally {
setSendingEmail(false);
}
};
const spark = status?.sparkbooth ?? null; const spark = status?.sparkbooth ?? null;
const metrics = spark?.metrics ?? null; const metrics = spark?.metrics ?? null;
const expiresAt = spark?.expires_at ?? status?.expires_at; const expiresAt = spark?.expires_at ?? status?.expires_at;
@@ -261,6 +280,17 @@ export default function MobileEventPhotoboothPage() {
'Die Fotospiel Uploader App ist verpflichtend, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.' 'Die Fotospiel Uploader App ist verpflichtend, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.'
)} )}
</Text> </Text>
<CTAButton
label={
sendingEmail
? t('common.processing', '...')
: t('photobooth.uploaderDownload.emailAction', 'Download-Links per E-Mail senden')
}
tone="ghost"
onPress={handleSendDownloadEmail}
iconLeft={<Mail size={14} color={text} />}
disabled={sendingEmail}
/>
<CTAButton <CTAButton
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')} label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')}
onPress={() => { onPress={() => {

View File

@@ -0,0 +1,66 @@
@extends('emails.partials.layout')
@section('title', __('emails.photobooth_uploader.subject', ['event' => $eventName]))
@section('preheader', __('emails.photobooth_uploader.preheader', ['event' => $eventName]))
@section('hero_title', __('emails.photobooth_uploader.hero_title', ['name' => $recipientName]))
@section('hero_subtitle', __('emails.photobooth_uploader.hero_subtitle', ['event' => $eventName]))
@section('content')
<p style="margin:0 0 16px; font-size:15px; color:#1f2937;">
{{ __('emails.photobooth_uploader.body', ['event' => $eventName]) }}
</p>
<p style="margin:0 0 12px; font-size:14px; color:#6b7280;">
{{ __('emails.photobooth_uploader.downloads_title') }}
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:12px;">
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.windows') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['windows'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['windows'] }}
</a>
</td>
</tr>
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.macos') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['macos'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['macos'] }}
</a>
</td>
</tr>
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.linux') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['linux'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['linux'] }}
</a>
</td>
</tr>
</table>
<p style="margin:0; font-size:14px; color:#6b7280;">
{{ __('emails.photobooth_uploader.credentials_hint') }}
</p>
@endsection
@section('cta')
<a href="{{ $links['windows'] }}" style="display:inline-block; background-color:#111827; color:#ffffff; text-decoration:none; padding:12px 20px; border-radius:999px; font-weight:600; font-size:14px; margin-right:8px;">
{{ __('emails.photobooth_uploader.cta_windows') }}
</a>
<a href="{{ $links['macos'] }}" style="display:inline-block; background-color:#f3f4f6; color:#111827; text-decoration:none; padding:12px 18px; border-radius:999px; font-weight:600; font-size:14px; margin-right:8px;">
{{ __('emails.photobooth_uploader.cta_macos') }}
</a>
<a href="{{ $links['linux'] }}" style="display:inline-block; background-color:#f3f4f6; color:#111827; text-decoration:none; padding:12px 18px; border-radius:999px; font-weight:600; font-size:14px;">
{{ __('emails.photobooth_uploader.cta_linux') }}
</a>
@endsection
@section('footer')
{!! __('emails.photobooth_uploader.footer') !!}
@endsection

View File

@@ -270,6 +270,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable'); Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store']) Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
->name('tenant.events.photobooth.connect-codes.store'); ->name('tenant.events.photobooth.connect-codes.store');
Route::post('/uploader-email', [PhotoboothController::class, 'sendUploaderDownloadEmail'])
->name('tenant.events.photobooth.uploader-email');
}); });
Route::get('members', [EventMemberController::class, 'index']) Route::get('members', [EventMemberController::class, 'index'])

View File

@@ -0,0 +1,28 @@
<?php
namespace Tests\Feature\Photobooth;
use App\Mail\PhotoboothUploaderDownload;
use App\Models\Event;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\Test;
use Tests\Feature\Tenant\TenantTestCase;
class PhotoboothUploaderDownloadEmailTest extends TenantTestCase
{
#[Test]
public function it_sends_the_photobooth_uploader_download_email(): void
{
Mail::fake();
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'photobooth-email',
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/uploader-email");
$response->assertOk();
Mail::assertQueued(PhotoboothUploaderDownload::class);
}
}