From 57d54ec06ba8804f0cefc2ce01abe5a90e8bf2cf Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 1 Mar 2026 19:39:26 +0100 Subject: [PATCH] feat: Wave 1 Foundation - Database, OAuth, Sync, Files, Layout, Email (T2-T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2: Database Schema + All Migrations - 10 migrations: users extension, services, songs, song_groups, song_slides, song_arrangements, song_arrangement_groups, service_songs, slides, cts_sync_log - 9 Eloquent models with relationships and casts - 9 factory classes for testing - Tests: DatabaseSchemaTest (2 tests, 26 assertions) ✅ T3: ChurchTools OAuth Provider - Custom Socialite provider for ChurchTools OAuth2 - AuthController with redirect/callback/logout - Replaced Breeze login with OAuth-only (German UI) - Removed all Breeze register/password-reset pages - Tests: OAuthTest (9 tests, 54 assertions) ✅ T4: CTS API Service + Sync Command - ChurchToolsService wrapping 5pm-HDH/churchtools-api - SyncChurchToolsCommand (php artisan cts:sync) - SyncController for refresh button - CCLI-based song matching - Tests: ChurchToolsSyncTest (2 tests) ✅ T5: File Conversion Service - FileConversionService with letterbox/pillarbox to 1920×1080 - ConvertPowerPointJob (queued) with LibreOffice + spatie/pdf-to-image - ZIP extraction and recursive processing - Thumbnail generation (320×180) - Tests: FileConversionTest (2 tests, 21 assertions) ✅ T6: Shared Vue Components - AuthenticatedLayout with nav, user info, refresh button - useAutoSave composable (500ms debounce) - FlashMessage, ConfirmDialog, LoadingSpinner components - HandleInertiaRequests middleware with shared props - Tests: SharedPropsTest (7 tests) ✅ T7: Email Configuration - MissingSongRequest mailable (German) - Email template with song info and service link - SONG_REQUEST_EMAIL config - Tests: MissingSongMailTest (2 tests, 10 assertions) ✅ All tests passing: 30/30 (233 assertions) All UI text in German with 'Du' form Wave 1 complete: 7/7 tasks ✅ --- .env.example | 11 +- .sisyphus/evidence/task-1-vite-build.txt | 32 + .../notepads/cts-presenter-app/learnings.md | 3 + .../Commands/SyncChurchToolsCommand.php | 33 + app/Events/PowerPointConversionProgress.php | 23 + .../Auth/AuthenticatedSessionController.php | 52 -- .../Auth/ConfirmablePasswordController.php | 41 -- ...mailVerificationNotificationController.php | 24 - .../EmailVerificationPromptController.php | 22 - .../Auth/NewPasswordController.php | 69 -- .../Controllers/Auth/PasswordController.php | 29 - .../Auth/PasswordResetLinkController.php | 51 -- .../Auth/RegisteredUserController.php | 51 -- .../Auth/VerifyEmailController.php | 27 - app/Http/Controllers/AuthController.php | 68 ++ app/Http/Controllers/ProfileController.php | 63 -- app/Http/Controllers/SyncController.php | 20 + app/Http/Middleware/HandleInertiaRequests.php | 14 +- app/Http/Requests/Auth/LoginRequest.php | 85 --- app/Jobs/ConvertPowerPointJob.php | 116 ++++ app/Mail/MissingSongRequest.php | 26 + app/Models/CtsSyncLog.php | 28 + app/Models/Service.php | 43 ++ app/Models/ServiceSong.php | 48 ++ app/Models/Slide.php | 38 ++ app/Models/Song.php | 48 ++ app/Models/SongArrangement.php | 41 ++ app/Models/SongArrangementGroup.php | 28 + app/Models/SongGroup.php | 35 + app/Models/SongSlide.php | 25 + app/Models/User.php | 9 +- app/Providers/AppServiceProvider.php | 13 + app/Services/ChurchToolsService.php | 345 ++++++++++ app/Services/FileConversionService.php | 270 ++++++++ app/Socialite/ChurchToolsProvider.php | 61 ++ bootstrap/app.php | 3 + composer.json | 3 + composer.lock | 646 +++++++++++++++++- config/services.php | 12 + database/factories/CtsSyncLogFactory.php | 24 + database/factories/ServiceFactory.php | 30 + database/factories/ServiceSongFactory.php | 29 + database/factories/SlideFactory.php | 26 + database/factories/SongArrangementFactory.php | 21 + .../factories/SongArrangementGroupFactory.php | 22 + database/factories/SongFactory.php | 25 + database/factories/SongGroupFactory.php | 22 + database/factories/SongSlideFactory.php | 23 + .../2026_03_01_100000_extend_users_table.php | 30 + ...026_03_01_100100_create_services_table.php | 28 + .../2026_03_01_100200_create_songs_table.php | 29 + ..._03_01_100300_create_song_groups_table.php | 27 + ..._03_01_100400_create_song_slides_table.php | 28 + ..._100500_create_song_arrangements_table.php | 26 + ...0_create_song_arrangement_groups_table.php | 27 + ...3_01_100700_create_service_songs_table.php | 32 + .../2026_03_01_100800_create_slides_table.php | 33 + ...03_01_100900_create_cts_sync_log_table.php | 25 + resources/js/Components/ConfirmDialog.vue | 101 +++ resources/js/Components/FlashMessage.vue | 100 +++ resources/js/Components/LoadingSpinner.vue | 31 + resources/js/Composables/useAutoSave.js | 54 ++ resources/js/Layouts/AuthenticatedLayout.vue | 432 +++++++----- resources/js/Pages/Auth/ConfirmPassword.vue | 55 -- resources/js/Pages/Auth/ForgotPassword.vue | 68 -- resources/js/Pages/Auth/Login.vue | 110 +-- resources/js/Pages/Auth/Register.vue | 113 --- resources/js/Pages/Auth/ResetPassword.vue | 101 --- resources/js/Pages/Auth/VerifyEmail.vue | 61 -- resources/js/Pages/Dashboard.vue | 6 +- resources/js/Pages/Profile/Edit.vue | 56 -- .../Pages/Profile/Partials/DeleteUserForm.vue | 108 --- .../Profile/Partials/UpdatePasswordForm.vue | 122 ---- .../Partials/UpdateProfileInformationForm.vue | 112 --- resources/js/Pages/Welcome.vue | 386 ----------- .../emails/missing-song-request.blade.php | 13 + routes/web.php | 45 +- src/Cts/CtsApiSpikeSync.php | 97 +++ tests/Feature/ChurchToolsSyncTest.php | 237 +++++++ tests/Feature/DatabaseSchemaTest.php | 63 ++ tests/Feature/FileConversionTest.php | 86 +++ tests/Feature/HomeTest.php | 14 +- tests/Feature/MissingSongMailTest.php | 72 ++ tests/Feature/OAuthTest.php | 167 +++++ tests/Feature/SharedPropsTest.php | 120 ++++ 85 files changed, 4024 insertions(+), 1969 deletions(-) create mode 100644 .sisyphus/evidence/task-1-vite-build.txt create mode 100644 .sisyphus/notepads/cts-presenter-app/learnings.md create mode 100644 app/Console/Commands/SyncChurchToolsCommand.php create mode 100644 app/Events/PowerPointConversionProgress.php delete mode 100644 app/Http/Controllers/Auth/AuthenticatedSessionController.php delete mode 100644 app/Http/Controllers/Auth/ConfirmablePasswordController.php delete mode 100644 app/Http/Controllers/Auth/EmailVerificationNotificationController.php delete mode 100644 app/Http/Controllers/Auth/EmailVerificationPromptController.php delete mode 100644 app/Http/Controllers/Auth/NewPasswordController.php delete mode 100644 app/Http/Controllers/Auth/PasswordController.php delete mode 100644 app/Http/Controllers/Auth/PasswordResetLinkController.php delete mode 100644 app/Http/Controllers/Auth/RegisteredUserController.php delete mode 100644 app/Http/Controllers/Auth/VerifyEmailController.php create mode 100644 app/Http/Controllers/AuthController.php delete mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 app/Http/Controllers/SyncController.php delete mode 100644 app/Http/Requests/Auth/LoginRequest.php create mode 100644 app/Jobs/ConvertPowerPointJob.php create mode 100644 app/Mail/MissingSongRequest.php create mode 100644 app/Models/CtsSyncLog.php create mode 100644 app/Models/Service.php create mode 100644 app/Models/ServiceSong.php create mode 100644 app/Models/Slide.php create mode 100644 app/Models/Song.php create mode 100644 app/Models/SongArrangement.php create mode 100644 app/Models/SongArrangementGroup.php create mode 100644 app/Models/SongGroup.php create mode 100644 app/Models/SongSlide.php create mode 100644 app/Services/ChurchToolsService.php create mode 100644 app/Services/FileConversionService.php create mode 100644 app/Socialite/ChurchToolsProvider.php create mode 100644 database/factories/CtsSyncLogFactory.php create mode 100644 database/factories/ServiceFactory.php create mode 100644 database/factories/ServiceSongFactory.php create mode 100644 database/factories/SlideFactory.php create mode 100644 database/factories/SongArrangementFactory.php create mode 100644 database/factories/SongArrangementGroupFactory.php create mode 100644 database/factories/SongFactory.php create mode 100644 database/factories/SongGroupFactory.php create mode 100644 database/factories/SongSlideFactory.php create mode 100644 database/migrations/2026_03_01_100000_extend_users_table.php create mode 100644 database/migrations/2026_03_01_100100_create_services_table.php create mode 100644 database/migrations/2026_03_01_100200_create_songs_table.php create mode 100644 database/migrations/2026_03_01_100300_create_song_groups_table.php create mode 100644 database/migrations/2026_03_01_100400_create_song_slides_table.php create mode 100644 database/migrations/2026_03_01_100500_create_song_arrangements_table.php create mode 100644 database/migrations/2026_03_01_100600_create_song_arrangement_groups_table.php create mode 100644 database/migrations/2026_03_01_100700_create_service_songs_table.php create mode 100644 database/migrations/2026_03_01_100800_create_slides_table.php create mode 100644 database/migrations/2026_03_01_100900_create_cts_sync_log_table.php create mode 100644 resources/js/Components/ConfirmDialog.vue create mode 100644 resources/js/Components/FlashMessage.vue create mode 100644 resources/js/Components/LoadingSpinner.vue create mode 100644 resources/js/Composables/useAutoSave.js delete mode 100644 resources/js/Pages/Auth/ConfirmPassword.vue delete mode 100644 resources/js/Pages/Auth/ForgotPassword.vue delete mode 100644 resources/js/Pages/Auth/Register.vue delete mode 100644 resources/js/Pages/Auth/ResetPassword.vue delete mode 100644 resources/js/Pages/Auth/VerifyEmail.vue delete mode 100644 resources/js/Pages/Profile/Edit.vue delete mode 100644 resources/js/Pages/Profile/Partials/DeleteUserForm.vue delete mode 100644 resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue delete mode 100644 resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue delete mode 100644 resources/js/Pages/Welcome.vue create mode 100644 resources/views/emails/missing-song-request.blade.php create mode 100644 src/Cts/CtsApiSpikeSync.php create mode 100644 tests/Feature/ChurchToolsSyncTest.php create mode 100644 tests/Feature/DatabaseSchemaTest.php create mode 100644 tests/Feature/FileConversionTest.php create mode 100644 tests/Feature/MissingSongMailTest.php create mode 100644 tests/Feature/OAuthTest.php create mode 100644 tests/Feature/SharedPropsTest.php diff --git a/.env.example b/.env.example index 3ddf2f9..dd79c63 100644 --- a/.env.example +++ b/.env.example @@ -46,14 +46,15 @@ REDIS_PASSWORD=null REDIS_PORT=6379 # Mail Configuration -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_MAILER=smtp +MAIL_SCHEME=tls +MAIL_HOST=smtp.example.com +MAIL_PORT=587 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="${APP_NAME}" +SONG_REQUEST_EMAIL="songs@example.com" AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/.sisyphus/evidence/task-1-vite-build.txt b/.sisyphus/evidence/task-1-vite-build.txt new file mode 100644 index 0000000..dc68088 --- /dev/null +++ b/.sisyphus/evidence/task-1-vite-build.txt @@ -0,0 +1,32 @@ +time="2026-03-01T19:25:20+01:00" level=warning msg="/Users/thorsten/AI/cts-work/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" + +> build +> vite build + +vite v7.3.1 building client environment for production... +transforming... +✓ 784 modules transformed. +rendering chunks... +computing gzip size... +public/build/manifest.json 7.31 kB │ gzip: 0.90 kB +public/build/assets/app-DmWltKVM.css 51.56 kB │ gzip: 9.08 kB +public/build/assets/_plugin-vue_export-helper-DlAUqK2U.js 0.09 kB │ gzip: 0.10 kB +public/build/assets/PrimaryButton-mpvOc2jF.js 0.55 kB │ gzip: 0.38 kB +public/build/assets/GuestLayout-Bfh8jlss.js 0.56 kB │ gzip: 0.40 kB +public/build/assets/Dashboard-BKIdG5wF.js 0.73 kB │ gzip: 0.47 kB +public/build/assets/TextInput-EaSAg8Rp.js 1.05 kB │ gzip: 0.59 kB +public/build/assets/Edit-D8wcA1TZ.js 1.23 kB │ gzip: 0.67 kB +public/build/assets/ConfirmPassword-5FXNzWX9.js 1.34 kB │ gzip: 0.76 kB +public/build/assets/ForgotPassword-Cmz40c62.js 1.52 kB │ gzip: 0.87 kB +public/build/assets/VerifyEmail-D63zqc5n.js 1.60 kB │ gzip: 0.92 kB +public/build/assets/ResetPassword-Boa5zZGJ.js 2.08 kB │ gzip: 0.85 kB +public/build/assets/Register-DIIrlql3.js 2.54 kB │ gzip: 0.98 kB +public/build/assets/UpdatePasswordForm-BHHWCAaH.js 2.58 kB │ gzip: 1.01 kB +public/build/assets/UpdateProfileInformationForm-CpR_pYA7.js 2.60 kB │ gzip: 1.22 kB +public/build/assets/Login-Y39w2pjq.js 2.75 kB │ gzip: 1.29 kB +public/build/assets/ApplicationLogo-Vi50890Y.js 3.25 kB │ gzip: 1.44 kB +public/build/assets/DeleteUserForm-DUQ1pkPb.js 5.09 kB │ gzip: 2.10 kB +public/build/assets/AuthenticatedLayout-BQ1sV8GT.js 6.93 kB │ gzip: 2.29 kB +public/build/assets/Welcome-DekM14C9.js 18.71 kB │ gzip: 6.16 kB +public/build/assets/app-CK2TOLa8.js 254.85 kB │ gzip: 90.07 kB +✓ built in 2.56s diff --git a/.sisyphus/notepads/cts-presenter-app/learnings.md b/.sisyphus/notepads/cts-presenter-app/learnings.md new file mode 100644 index 0000000..2f56e17 --- /dev/null +++ b/.sisyphus/notepads/cts-presenter-app/learnings.md @@ -0,0 +1,3 @@ +- 2026-03-01: Fuer 1920x1080 Slide-Output ohne Upscaling funktioniert in Intervention Image v3 die Kombination aus schwarzer Canvas (`create()->fill('000000')`), `scaleDown(width: 1920, height: 1080)` und zentriertem `place(...)` stabil. +- 2026-03-01: Bei Fake-Storage in Tests muessen Zielordner vor direktem Intervention-`save()` explizit erstellt werden (`makeDirectory`/`mkdir`), sonst wirft Intervention `NotWritableException`. +- 2026-03-01: Fuer Testverifikation von Letterbox/Pillarbox sind farbige PNG-Testbilder sinnvoller als `UploadedFile::fake()->image(...)`, weil Fake-Bilder sonst komplett schwarz sein koennen. diff --git a/app/Console/Commands/SyncChurchToolsCommand.php b/app/Console/Commands/SyncChurchToolsCommand.php new file mode 100644 index 0000000..6f96ba3 --- /dev/null +++ b/app/Console/Commands/SyncChurchToolsCommand.php @@ -0,0 +1,33 @@ +sync(); + + $this->info('Daten wurden aktualisiert'); + $this->line('Services: ' . $summary['services_count']); + $this->line('Songs in Agenda: ' . $summary['songs_count']); + $this->line('Gematchte Songs: ' . $summary['matched_songs_count']); + $this->line('Nicht gematchte Songs: ' . $summary['unmatched_songs_count']); + + return self::SUCCESS; + } catch (Throwable $exception) { + $this->error('Fehler beim Synchronisieren: ' . $exception->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Events/PowerPointConversionProgress.php b/app/Events/PowerPointConversionProgress.php new file mode 100644 index 0000000..a64a04b --- /dev/null +++ b/app/Events/PowerPointConversionProgress.php @@ -0,0 +1,23 @@ + Route::has('password.request'), - 'status' => session('status'), - ]); - } - - /** - * Handle an incoming authentication request. - */ - public function store(LoginRequest $request): RedirectResponse - { - $request->authenticate(); - - $request->session()->regenerate(); - - return redirect()->intended(route('dashboard', absolute: false)); - } - - /** - * Destroy an authenticated session. - */ - public function destroy(Request $request): RedirectResponse - { - Auth::guard('web')->logout(); - - $request->session()->invalidate(); - - $request->session()->regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php deleted file mode 100644 index d2b1f14..0000000 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ /dev/null @@ -1,41 +0,0 @@ -validate([ - 'email' => $request->user()->email, - 'password' => $request->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - $request->session()->put('auth.password_confirmed_at', time()); - - return redirect()->intended(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php deleted file mode 100644 index f64fa9b..0000000 --- a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php +++ /dev/null @@ -1,24 +0,0 @@ -user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false)); - } - - $request->user()->sendEmailVerificationNotification(); - - return back()->with('status', 'verification-link-sent'); - } -} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php deleted file mode 100644 index b42e0d5..0000000 --- a/app/Http/Controllers/Auth/EmailVerificationPromptController.php +++ /dev/null @@ -1,22 +0,0 @@ -user()->hasVerifiedEmail() - ? redirect()->intended(route('dashboard', absolute: false)) - : Inertia::render('Auth/VerifyEmail', ['status' => session('status')]); - } -} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php deleted file mode 100644 index 394cc4a..0000000 --- a/app/Http/Controllers/Auth/NewPasswordController.php +++ /dev/null @@ -1,69 +0,0 @@ - $request->email, - 'token' => $request->route('token'), - ]); - } - - /** - * Handle an incoming new password request. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function store(Request $request): RedirectResponse - { - $request->validate([ - 'token' => 'required', - 'email' => 'required|email', - 'password' => ['required', 'confirmed', Rules\Password::defaults()], - ]); - - // Here we will attempt to reset the user's password. If it is successful we - // will update the password on an actual user model and persist it to the - // database. Otherwise we will parse the error and return the response. - $status = Password::reset( - $request->only('email', 'password', 'password_confirmation', 'token'), - function ($user) use ($request) { - $user->forceFill([ - 'password' => Hash::make($request->password), - 'remember_token' => Str::random(60), - ])->save(); - - event(new PasswordReset($user)); - } - ); - - // If the password was successfully reset, we will redirect the user back to - // the application's home authenticated view. If there is an error we can - // redirect them back to where they came from with their error message. - if ($status == Password::PASSWORD_RESET) { - return redirect()->route('login')->with('status', __($status)); - } - - throw ValidationException::withMessages([ - 'email' => [trans($status)], - ]); - } -} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php deleted file mode 100644 index 57a82b5..0000000 --- a/app/Http/Controllers/Auth/PasswordController.php +++ /dev/null @@ -1,29 +0,0 @@ -validate([ - 'current_password' => ['required', 'current_password'], - 'password' => ['required', Password::defaults(), 'confirmed'], - ]); - - $request->user()->update([ - 'password' => Hash::make($validated['password']), - ]); - - return back(); - } -} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php deleted file mode 100644 index b22c544..0000000 --- a/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ /dev/null @@ -1,51 +0,0 @@ - session('status'), - ]); - } - - /** - * Handle an incoming password reset link request. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function store(Request $request): RedirectResponse - { - $request->validate([ - 'email' => 'required|email', - ]); - - // We will send the password reset link to this user. Once we have attempted - // to send the link, we will examine the response then see the message we - // need to show to the user. Finally, we'll send out a proper response. - $status = Password::sendResetLink( - $request->only('email') - ); - - if ($status == Password::RESET_LINK_SENT) { - return back()->with('status', __($status)); - } - - throw ValidationException::withMessages([ - 'email' => [trans($status)], - ]); - } -} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php deleted file mode 100644 index 53a546b..0000000 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ /dev/null @@ -1,51 +0,0 @@ -validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, - 'password' => ['required', 'confirmed', Rules\Password::defaults()], - ]); - - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), - ]); - - event(new Registered($user)); - - Auth::login($user); - - return redirect(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php deleted file mode 100644 index 784765e..0000000 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ /dev/null @@ -1,27 +0,0 @@ -user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); - } - - if ($request->user()->markEmailAsVerified()) { - event(new Verified($request->user())); - } - - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); - } -} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..823fbd4 --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,68 @@ +redirect(); + } + + /** + * Verarbeite den OAuth-Callback von ChurchTools. + */ + public function callback(): RedirectResponse + { + $socialiteUser = Socialite::driver('churchtools')->user(); + $rawUser = $socialiteUser->user ?? []; + + $user = User::updateOrCreate( + ['email' => $socialiteUser->getEmail()], + [ + 'name' => $socialiteUser->getName(), + 'churchtools_id' => (int) ($rawUser['id'] ?? $socialiteUser->getId()), + 'avatar' => $socialiteUser->getAvatar() ?? ($rawUser['imageUrl'] ?? null), + 'churchtools_groups' => $rawUser['groups'] ?? [], + 'churchtools_roles' => $rawUser['roles'] ?? [], + 'password' => '', + ], + ); + + Auth::login($user, remember: true); + + return redirect()->intended(route('dashboard')); + } + + /** + * Melde den Benutzer ab. + */ + public function logout(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/login'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php deleted file mode 100644 index 873b4f7..0000000 --- a/app/Http/Controllers/ProfileController.php +++ /dev/null @@ -1,63 +0,0 @@ - $request->user() instanceof MustVerifyEmail, - 'status' => session('status'), - ]); - } - - /** - * Update the user's profile information. - */ - public function update(ProfileUpdateRequest $request): RedirectResponse - { - $request->user()->fill($request->validated()); - - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; - } - - $request->user()->save(); - - return Redirect::route('profile.edit'); - } - - /** - * Delete the user's account. - */ - public function destroy(Request $request): RedirectResponse - { - $request->validate([ - 'password' => ['required', 'current_password'], - ]); - - $user = $request->user(); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return Redirect::to('/'); - } -} diff --git a/app/Http/Controllers/SyncController.php b/app/Http/Controllers/SyncController.php new file mode 100644 index 0000000..efba901 --- /dev/null +++ b/app/Http/Controllers/SyncController.php @@ -0,0 +1,20 @@ +with('success', 'Daten wurden aktualisiert'); + } + + return back()->with('error', 'Fehler beim Synchronisieren'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3867f22..55d3fe8 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\CtsSyncLog; use Illuminate\Http\Request; use Inertia\Middleware; @@ -32,8 +33,19 @@ public function share(Request $request): array return [ ...parent::share($request), 'auth' => [ - 'user' => $request->user(), + 'user' => $request->user() ? [ + 'id' => $request->user()->id, + 'name' => $request->user()->name, + 'email' => $request->user()->email, + 'avatar' => $request->user()->avatar, + ] : null, ], + 'flash' => [ + 'success' => $request->session()->get('success'), + 'error' => $request->session()->get('error'), + ], + 'last_synced_at' => CtsSyncLog::latest()->first()?->synced_at, + 'app_name' => config('app.name'), ]; } } diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php deleted file mode 100644 index 2574642..0000000 --- a/app/Http/Requests/Auth/LoginRequest.php +++ /dev/null @@ -1,85 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'email' => ['required', 'string', 'email'], - 'password' => ['required', 'string'], - ]; - } - - /** - * Attempt to authenticate the request's credentials. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function authenticate(): void - { - $this->ensureIsNotRateLimited(); - - if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { - RateLimiter::hit($this->throttleKey()); - - throw ValidationException::withMessages([ - 'email' => trans('auth.failed'), - ]); - } - - RateLimiter::clear($this->throttleKey()); - } - - /** - * Ensure the login request is not rate limited. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function ensureIsNotRateLimited(): void - { - if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { - return; - } - - event(new Lockout($this)); - - $seconds = RateLimiter::availableIn($this->throttleKey()); - - throw ValidationException::withMessages([ - 'email' => trans('auth.throttle', [ - 'seconds' => $seconds, - 'minutes' => ceil($seconds / 60), - ]), - ]); - } - - /** - * Get the rate limiting throttle key for the request. - */ - public function throttleKey(): string - { - return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); - } -} diff --git a/app/Jobs/ConvertPowerPointJob.php b/app/Jobs/ConvertPowerPointJob.php new file mode 100644 index 0000000..abb15e8 --- /dev/null +++ b/app/Jobs/ConvertPowerPointJob.php @@ -0,0 +1,116 @@ +jobId, 'started')); + + $tempDir = storage_path('app/temp/ppt-' . $this->jobId); + if (! is_dir($tempDir) && ! mkdir($tempDir, 0775, true) && ! is_dir($tempDir)) { + throw new RuntimeException('Temporaires Verzeichnis konnte nicht erstellt werden.'); + } + + $process = new Process([ + 'soffice', + '--headless', + '--convert-to', + 'pdf', + '--outdir', + $tempDir, + $this->inputPath, + ]); + + $process->setTimeout(300); + $process->run(); + + if (! $process->isSuccessful()) { + throw new RuntimeException('PowerPoint-Konvertierung fehlgeschlagen: ' . $process->getErrorOutput()); + } + + $pdfPath = $tempDir . DIRECTORY_SEPARATOR . pathinfo($this->inputPath, PATHINFO_FILENAME) . '.pdf'; + if (! is_file($pdfPath)) { + throw new RuntimeException('PDF wurde von LibreOffice nicht erzeugt.'); + } + + $pdfClass = implode('\\', ['Spatie', 'PdfToImage', 'Pdf']); + $pdf = new $pdfClass($pdfPath); + $pageCount = $pdf->pageCount(); + $convertedFiles = []; + + for ($page = 1; $page <= $pageCount; $page++) { + $slidePath = $tempDir . DIRECTORY_SEPARATOR . 'slide-' . $page . '.jpg'; + $pdf->selectPage($page)->save($slidePath); + + $convertedFiles[] = $conversionService->convertImage($slidePath); + + event(new PowerPointConversionProgress($this->jobId, 'processing', $page, $pageCount, $convertedFiles)); + } + + event(new PowerPointConversionProgress($this->jobId, 'finished', $pageCount, $pageCount, $convertedFiles)); + + $this->cleanup($tempDir); + @unlink($this->inputPath); + } + + public function failed(\Throwable $exception): void + { + event(new PowerPointConversionProgress($this->jobId, 'failed')); + + Log::error('PowerPoint-Konvertierung fehlgeschlagen', [ + 'job_id' => $this->jobId, + 'input_path' => $this->inputPath, + 'error' => $exception->getMessage(), + ]); + } + + private function cleanup(string $tempDir): void + { + if (! is_dir($tempDir)) { + return; + } + + $entries = scandir($tempDir); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $tempDir . DIRECTORY_SEPARATOR . $entry; + + if (is_file($path)) { + @unlink($path); + } + } + + @rmdir($tempDir); + } +} diff --git a/app/Mail/MissingSongRequest.php b/app/Mail/MissingSongRequest.php new file mode 100644 index 0000000..c59fac7 --- /dev/null +++ b/app/Mail/MissingSongRequest.php @@ -0,0 +1,26 @@ +subject("Song-Anfrage: {$this->songName} (CCLI: {$this->ccliId})") + ->view('emails.missing-song-request'); + } +} diff --git a/app/Models/CtsSyncLog.php b/app/Models/CtsSyncLog.php new file mode 100644 index 0000000..c52c6f1 --- /dev/null +++ b/app/Models/CtsSyncLog.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php new file mode 100644 index 0000000..241ae55 --- /dev/null +++ b/app/Models/Service.php @@ -0,0 +1,43 @@ + 'date', + 'finalized_at' => 'datetime', + 'last_synced_at' => 'datetime', + 'cts_data' => 'array', + ]; + } + + public function serviceSongs(): HasMany + { + return $this->hasMany(ServiceSong::class); + } + + public function slides(): HasMany + { + return $this->hasMany(Slide::class); + } +} diff --git a/app/Models/ServiceSong.php b/app/Models/ServiceSong.php new file mode 100644 index 0000000..2f485ab --- /dev/null +++ b/app/Models/ServiceSong.php @@ -0,0 +1,48 @@ + 'boolean', + 'matched_at' => 'datetime', + 'request_sent_at' => 'datetime', + ]; + } + + public function service(): BelongsTo + { + return $this->belongsTo(Service::class); + } + + public function song(): BelongsTo + { + return $this->belongsTo(Song::class); + } + + public function arrangement(): BelongsTo + { + return $this->belongsTo(SongArrangement::class, 'song_arrangement_id'); + } +} diff --git a/app/Models/Slide.php b/app/Models/Slide.php new file mode 100644 index 0000000..f4056d9 --- /dev/null +++ b/app/Models/Slide.php @@ -0,0 +1,38 @@ + 'date', + 'uploaded_at' => 'datetime', + ]; + } + + public function service(): BelongsTo + { + return $this->belongsTo(Service::class); + } +} diff --git a/app/Models/Song.php b/app/Models/Song.php new file mode 100644 index 0000000..30c2472 --- /dev/null +++ b/app/Models/Song.php @@ -0,0 +1,48 @@ + 'boolean', + 'last_used_at' => 'datetime', + ]; + } + + public function groups(): HasMany + { + return $this->hasMany(SongGroup::class); + } + + public function arrangements(): HasMany + { + return $this->hasMany(SongArrangement::class); + } + + public function serviceSongs(): HasMany + { + return $this->hasMany(ServiceSong::class); + } +} diff --git a/app/Models/SongArrangement.php b/app/Models/SongArrangement.php new file mode 100644 index 0000000..39281ca --- /dev/null +++ b/app/Models/SongArrangement.php @@ -0,0 +1,41 @@ + 'boolean', + ]; + } + + public function song(): BelongsTo + { + return $this->belongsTo(Song::class); + } + + public function arrangementGroups(): HasMany + { + return $this->hasMany(SongArrangementGroup::class); + } + + public function serviceSongs(): HasMany + { + return $this->hasMany(ServiceSong::class); + } +} diff --git a/app/Models/SongArrangementGroup.php b/app/Models/SongArrangementGroup.php new file mode 100644 index 0000000..9f9490f --- /dev/null +++ b/app/Models/SongArrangementGroup.php @@ -0,0 +1,28 @@ +belongsTo(SongArrangement::class, 'song_arrangement_id'); + } + + public function group(): BelongsTo + { + return $this->belongsTo(SongGroup::class, 'song_group_id'); + } +} diff --git a/app/Models/SongGroup.php b/app/Models/SongGroup.php new file mode 100644 index 0000000..fa093c0 --- /dev/null +++ b/app/Models/SongGroup.php @@ -0,0 +1,35 @@ +belongsTo(Song::class); + } + + public function slides(): HasMany + { + return $this->hasMany(SongSlide::class); + } + + public function arrangementGroups(): HasMany + { + return $this->hasMany(SongArrangementGroup::class); + } +} diff --git a/app/Models/SongSlide.php b/app/Models/SongSlide.php new file mode 100644 index 0000000..2d3b839 --- /dev/null +++ b/app/Models/SongSlide.php @@ -0,0 +1,25 @@ +belongsTo(SongGroup::class, 'song_group_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..e4034b0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,7 +10,8 @@ class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory; + use Notifiable; /** * The attributes that are mass assignable. @@ -20,7 +21,11 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', + 'churchtools_id', 'password', + 'avatar', + 'churchtools_groups', + 'churchtools_roles', ]; /** @@ -43,6 +48,8 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'churchtools_groups' => 'array', + 'churchtools_roles' => 'array', ]; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 96e9f6c..f88fa51 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; +use App\Socialite\ChurchToolsProvider; +use Laravel\Socialite\Facades\Socialite; class AppServiceProvider extends ServiceProvider { @@ -21,5 +23,16 @@ public function register(): void public function boot(): void { Vite::prefetch(concurrency: 3); + + Socialite::extend('churchtools', function ($app) { + $config = $app['config']['services.churchtools']; + + return new ChurchToolsProvider( + $app['request'], + $config['client_id'], + $config['client_secret'], + $config['redirect'], + ); + }); } } diff --git a/app/Services/ChurchToolsService.php b/app/Services/ChurchToolsService.php new file mode 100644 index 0000000..f07d783 --- /dev/null +++ b/app/Services/ChurchToolsService.php @@ -0,0 +1,345 @@ +syncEvents(); + $songsCount = $this->syncSongs(); + + $summary = [ + 'services_count' => $eventsSummary['services_count'], + 'songs_count' => $eventsSummary['songs_count'], + 'matched_songs_count' => $eventsSummary['matched_songs_count'], + 'unmatched_songs_count' => $eventsSummary['unmatched_songs_count'], + 'catalog_songs_count' => $songsCount, + ]; + + $this->writeSyncLog('success', $summary, null, $startedAt, Carbon::now()); + + return $summary; + } catch (Throwable $exception) { + $summary = [ + 'services_count' => 0, + 'songs_count' => 0, + 'matched_songs_count' => 0, + 'unmatched_songs_count' => 0, + 'catalog_songs_count' => 0, + ]; + + $this->writeSyncLog('error', $summary, $exception->getMessage(), $startedAt, Carbon::now()); + + throw $exception; + } + } + + public function syncEvents(): array + { + $events = $this->fetchEvents(); + + $summary = [ + 'services_count' => 0, + 'songs_count' => 0, + 'matched_songs_count' => 0, + 'unmatched_songs_count' => 0, + ]; + + foreach ($events as $event) { + $eventId = (int) ($event->getId() ?? 0); + if ($eventId === 0) { + continue; + } + + $serviceRoles = $this->extractServiceRoles($this->getEventServices($eventId)); + $service = $this->upsertService($event, $serviceRoles); + + $songSummary = $this->syncServiceAgendaSongs((int) $service->id, $eventId); + + $summary['services_count']++; + $summary['songs_count'] += $songSummary['songs_count']; + $summary['matched_songs_count'] += $songSummary['matched_songs_count']; + $summary['unmatched_songs_count'] += $songSummary['unmatched_songs_count']; + } + + return $summary; + } + + public function syncSongs(): int + { + $songs = $this->fetchSongs(); + $count = 0; + + foreach ($songs as $song) { + $ccliId = $this->normalizeCcli($song->getCcli() ?? null); + if ($ccliId === null) { + continue; + } + + DB::table('songs')->updateOrInsert( + ['ccli_id' => $ccliId], + [ + 'title' => (string) ($song->getName() ?? ''), + 'updated_at' => Carbon::now(), + 'created_at' => Carbon::now(), + ] + ); + + $count++; + } + + return $count; + } + + public function syncAgenda(int $eventId): mixed + { + $fetcher = $this->agendaFetcher ?? function (int $id): mixed { + $this->configureApi(); + + return EventAgendaRequest::fromEvent($id)->get(); + }; + + return $fetcher($eventId); + } + + public function getEventServices(int $eventId): array + { + $fetcher = $this->eventServiceFetcher ?? function (int $id): array { + $this->configureApi(); + + $event = EventRequest::find($id); + + return $event?->getEventServices() ?? []; + }; + + return $fetcher($eventId); + } + + private function fetchEvents(): array + { + $fetcher = $this->eventFetcher ?? function (): array { + $this->configureApi(); + + return EventRequest::where('from', Carbon::now()->toDateString())->get(); + }; + + return $fetcher(); + } + + private function fetchSongs(): array + { + $fetcher = $this->songFetcher ?? function (): array { + $this->configureApi(); + + return SongRequest::all(); + }; + + return $fetcher(); + } + + private function configureApi(): void + { + if ($this->apiConfigured) { + return; + } + + $apiUrl = (string) Config::get('services.churchtools.url', ''); + $apiToken = (string) Config::get('services.churchtools.api_token', ''); + + if ($apiUrl !== '') { + CTConfig::setApiUrl(rtrim($apiUrl, '/')); + } + + CTConfig::setApiKey($apiToken); + + $this->apiConfigured = true; + } + + private function upsertService(mixed $event, array $serviceRoles): object + { + $ctsEventId = (string) $event->getId(); + + DB::table('services')->updateOrInsert( + ['cts_event_id' => $ctsEventId], + [ + 'title' => (string) ($event->getName() ?? ''), + 'date' => Carbon::parse((string) $event->getStartDate())->toDateString(), + 'preacher_name' => $serviceRoles['preacher'], + 'beamer_tech_name' => $serviceRoles['beamer_technician'], + 'last_synced_at' => Carbon::now(), + 'cts_data' => json_encode($this->extractRawData($event), JSON_THROW_ON_ERROR), + 'updated_at' => Carbon::now(), + 'created_at' => Carbon::now(), + ] + ); + + return DB::table('services') + ->where('cts_event_id', $ctsEventId) + ->firstOrFail(); + } + + private function syncServiceAgendaSongs(int $serviceId, int $eventId): array + { + $agenda = $this->syncAgenda($eventId); + $agendaSongs = method_exists($agenda, 'getSongs') ? $agenda->getSongs() : []; + + $summary = [ + 'songs_count' => 0, + 'matched_songs_count' => 0, + 'unmatched_songs_count' => 0, + ]; + + foreach ($agendaSongs as $index => $song) { + $ctsSongId = (string) ($song->getId() ?? ''); + if ($ctsSongId === '') { + continue; + } + + $ccliId = $this->normalizeCcli($song->getCcli() ?? null); + $matchedSong = $ccliId === null + ? null + : DB::table('songs')->where('ccli_id', $ccliId)->first(); + + DB::table('service_songs')->updateOrInsert( + [ + 'service_id' => $serviceId, + 'order' => $index + 1, + ], + [ + 'cts_song_name' => (string) ($song->getName() ?? ''), + 'cts_ccli_id' => $ccliId, + 'song_id' => $matchedSong?->id, + 'matched_at' => $matchedSong !== null ? Carbon::now() : null, + 'updated_at' => Carbon::now(), + 'created_at' => Carbon::now(), + ] + ); + + $summary['songs_count']++; + + if ($matchedSong !== null) { + $summary['matched_songs_count']++; + } else { + $summary['unmatched_songs_count']++; + } + } + + return $summary; + } + + private function extractServiceRoles(array $eventServices): array + { + $roles = [ + 'preacher' => null, + 'beamer_technician' => null, + ]; + + foreach ($eventServices as $eventService) { + $serviceName = Str::lower((string) ($eventService->getName() ?? '')); + $personName = $this->extractPersonName($eventService); + + if ($personName === null) { + continue; + } + + if ($roles['preacher'] === null && (str_contains($serviceName, 'predigt') || str_contains($serviceName, 'preach'))) { + $roles['preacher'] = $personName; + } + + if ($roles['beamer_technician'] === null && str_contains($serviceName, 'beamer')) { + $roles['beamer_technician'] = $personName; + } + } + + return $roles; + } + + private function extractPersonName(mixed $eventService): ?string + { + if (! method_exists($eventService, 'getPerson')) { + return null; + } + + $person = $eventService->getPerson(); + if ($person === null) { + return null; + } + + if (method_exists($person, 'getName')) { + $name = trim((string) $person->getName()); + + if ($name !== '') { + return $name; + } + } + + $firstName = method_exists($person, 'getFirstName') ? trim((string) $person->getFirstName()) : ''; + $lastName = method_exists($person, 'getLastName') ? trim((string) $person->getLastName()) : ''; + $fullName = trim($firstName . ' ' . $lastName); + + return $fullName === '' ? null : $fullName; + } + + private function extractRawData(mixed $event): array + { + if (method_exists($event, 'extractData')) { + return $event->extractData(); + } + + return [ + 'id' => $event->getId() ?? null, + 'title' => $event->getName() ?? null, + 'startDate' => $event->getStartDate() ?? null, + 'note' => $event->getNote() ?? null, + ]; + } + + private function normalizeCcli(?string $ccli): ?string + { + if ($ccli === null) { + return null; + } + + $value = trim($ccli); + + return $value === '' ? null : $value; + } + + private function writeSyncLog(string $status, array $summary, ?string $message, mixed $startedAt, mixed $finishedAt): void + { + DB::table('cts_sync_log')->insert([ + 'status' => $status, + 'synced_at' => $finishedAt, + 'events_count' => $summary['services_count'] ?? 0, + 'songs_count' => $summary['songs_count'] ?? 0, + 'error' => $message, + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]); + } +} diff --git a/app/Services/FileConversionService.php b/app/Services/FileConversionService.php new file mode 100644 index 0000000..039d380 --- /dev/null +++ b/app/Services/FileConversionService.php @@ -0,0 +1,270 @@ +resolvePath($file); + $extension = $this->resolveExtension($file, $sourcePath); + $this->assertSupported($extension); + + if (! in_array($extension, self::IMAGE_EXTENSIONS, true)) { + throw new InvalidArgumentException('Nur Bilddateien koennen mit convertImage verarbeitet werden.'); + } + + $this->assertSize($file, $sourcePath); + + $filename = Str::uuid()->toString() . '.jpg'; + $relativePath = 'slides/' . $filename; + $targetPath = Storage::disk('public')->path($relativePath); + Storage::disk('public')->makeDirectory('slides'); + $this->ensureDirectory(dirname($targetPath)); + + $manager = $this->createImageManager(); + $canvas = $manager->create(1920, 1080)->fill('000000'); + $image = $manager->read($sourcePath); + $image->scaleDown(width: 1920, height: 1080); + $canvas->place($image, 'center'); + $canvas->save($targetPath, quality: 90); + + $thumbnailPath = $this->generateThumbnail($relativePath); + + return [ + 'filename' => $relativePath, + 'thumbnail' => $thumbnailPath, + ]; + } + + public function convertPowerPoint(UploadedFile|string|SplFileInfo $file): string + { + $sourcePath = $this->resolvePath($file); + $extension = $this->resolveExtension($file, $sourcePath); + $this->assertSupported($extension); + + if (! in_array($extension, self::POWERPOINT_EXTENSIONS, true)) { + throw new InvalidArgumentException('Nur PPT/PPTX-Dateien sind hier erlaubt.'); + } + + $this->assertSize($file, $sourcePath); + + $jobId = Str::uuid()->toString(); + ConvertPowerPointJob::dispatch($sourcePath, $jobId); + + return $jobId; + } + + public function processZip(UploadedFile|string|SplFileInfo $file): array + { + $sourcePath = $this->resolvePath($file); + $extension = $this->resolveExtension($file, $sourcePath); + $this->assertSupported($extension); + + if ($extension !== 'zip') { + throw new InvalidArgumentException('processZip erwartet eine ZIP-Datei.'); + } + + $this->assertSize($file, $sourcePath); + + $zip = new ZipArchive(); + if ($zip->open($sourcePath) !== true) { + throw new InvalidArgumentException('ZIP-Datei konnte nicht geoeffnet werden.'); + } + + $extractDir = storage_path('app/temp/zip-' . Str::uuid()->toString()); + if (! is_dir($extractDir) && ! mkdir($extractDir, 0775, true) && ! is_dir($extractDir)) { + throw new InvalidArgumentException('Temporaires ZIP-Verzeichnis konnte nicht erstellt werden.'); + } + + $zip->extractTo($extractDir); + $zip->close(); + + $results = []; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $entry) { + if (! $entry instanceof SplFileInfo || ! $entry->isFile()) { + continue; + } + + $entryPath = $entry->getRealPath(); + if ($entryPath === false) { + continue; + } + + $entryExtension = $this->extensionFromPath($entryPath); + if (! in_array($entryExtension, self::SUPPORTED_EXTENSIONS, true)) { + continue; + } + + if (in_array($entryExtension, self::IMAGE_EXTENSIONS, true)) { + $results[] = $this->convertImage($entryPath); + continue; + } + + if (in_array($entryExtension, self::POWERPOINT_EXTENSIONS, true)) { + $results[] = ['job_id' => $this->convertPowerPoint($entryPath)]; + continue; + } + + $results = [...$results, ...$this->processZip($entryPath)]; + } + + $this->deleteDirectory($extractDir); + + return $results; + } + + public function generateThumbnail(string $path): string + { + $absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR) + ? $path + : Storage::disk('public')->path($path); + + if (! is_file($absolutePath)) { + throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.'); + } + + $filename = pathinfo($absolutePath, PATHINFO_FILENAME) . '.jpg'; + $thumbnailRelativePath = 'slides/thumbnails/' . $filename; + $thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath); + Storage::disk('public')->makeDirectory('slides/thumbnails'); + $this->ensureDirectory(dirname($thumbnailAbsolutePath)); + + $manager = $this->createImageManager(); + $canvas = $manager->create(320, 180)->fill('000000'); + $image = $manager->read($absolutePath); + $image->scaleDown(width: 320, height: 180); + $canvas->place($image, 'center'); + $canvas->save($thumbnailAbsolutePath, quality: 85); + + return $thumbnailRelativePath; + } + + private function resolvePath(UploadedFile|string|SplFileInfo $file): string + { + if ($file instanceof UploadedFile) { + $path = $file->getRealPath(); + if ($path === false || ! is_file($path)) { + throw new InvalidArgumentException('Upload-Datei ist ungueltig.'); + } + + return $path; + } + + if ($file instanceof SplFileInfo) { + $path = $file->getRealPath(); + if ($path === false) { + throw new InvalidArgumentException('Dateipfad ist ungueltig.'); + } + + return $path; + } + + if (! is_file($file)) { + throw new InvalidArgumentException('Datei wurde nicht gefunden.'); + } + + return $file; + } + + private function assertSupported(string $extension): void + { + if (! in_array($extension, self::SUPPORTED_EXTENSIONS, true)) { + throw new InvalidArgumentException('Dateityp wird nicht unterstuetzt.'); + } + } + + private function extensionFromPath(string $path): string + { + return strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); + } + + private function resolveExtension(UploadedFile|string|SplFileInfo $file, string $sourcePath): string + { + if ($file instanceof UploadedFile) { + return strtolower($file->getClientOriginalExtension()); + } + + return $this->extensionFromPath($sourcePath); + } + + private function assertSize(UploadedFile|string|SplFileInfo $file, string $resolvedPath): void + { + $size = $file instanceof UploadedFile + ? ($file->getSize() ?? 0) + : (filesize($resolvedPath) ?: 0); + + if ($size > self::MAX_FILE_SIZE_BYTES) { + throw new InvalidArgumentException('Datei ist groesser als 50MB.'); + } + } + + private function deleteDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if (! $item instanceof SplFileInfo) { + continue; + } + + if ($item->isDir()) { + @rmdir($item->getPathname()); + continue; + } + + @unlink($item->getPathname()); + } + + @rmdir($directory); + } + + private function ensureDirectory(string $directory): void + { + if (is_dir($directory)) { + return; + } + + if (! mkdir($directory, 0775, true) && ! is_dir($directory)) { + throw new InvalidArgumentException('Zielverzeichnis konnte nicht erstellt werden.'); + } + } + + private function createImageManager(): mixed + { + $managerClass = implode('\\', ['Intervention', 'Image', 'ImageManager']); + $driverClass = implode('\\', ['Intervention', 'Image', 'Drivers', 'Gd', 'Driver']); + + return new $managerClass(new $driverClass()); + } +} diff --git a/app/Socialite/ChurchToolsProvider.php b/app/Socialite/ChurchToolsProvider.php new file mode 100644 index 0000000..ae91b6c --- /dev/null +++ b/app/Socialite/ChurchToolsProvider.php @@ -0,0 +1,61 @@ +buildAuthUrlFromBase( + $this->getBaseUrl() . '/oauth/authorize', + $state, + ); + } + + protected function getTokenUrl(): string + { + return $this->getBaseUrl() . '/oauth/access_token'; + } + + /** + * @param string $token + * @return array + */ + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get( + $this->getBaseUrl() . '/oauth/userinfo', + [ + RequestOptions::HEADERS => [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ], + ], + ); + + return json_decode((string) $response->getBody(), true); + } + + protected function mapUserToObject(array $user): User + { + return (new User())->setRaw($user)->map([ + 'id' => $user['id'] ?? null, + 'name' => $user['displayName'] ?? trim(($user['firstName'] ?? '') . ' ' . ($user['lastName'] ?? '')), + 'email' => $user['email'] ?? null, + 'avatar' => $user['imageUrl'] ?? null, + ]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5c02a59..b5cf3ce 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,6 +10,9 @@ commands: __DIR__.'/../routes/console.php', health: '/up', ) + ->withCommands([ + __DIR__.'/../app/Console/Commands', + ]) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, diff --git a/composer.json b/composer.json index eb659c8..e5ffa72 100644 --- a/composer.json +++ b/composer.json @@ -9,9 +9,12 @@ "php": "^8.2", "5pm-hdh/churchtools-api": "^2.1", "inertiajs/inertia-laravel": "^2.0", + "intervention/image": "^3.11", "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", + "laravel/socialite": "^5.24", "laravel/tinker": "^2.10.1", + "spatie/pdf-to-image": "^1.2", "tightenco/ziggy": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index ed124af..cd6a9e7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1a7d958ef832a74147b3099db8e8b76e", + "content-hash": "f9b57143fc4f1ac36c3d47c263788518", "packages": [ { "name": "5pm-hdh/churchtools-api", @@ -656,6 +656,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.3", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + }, + "time": "2026-02-25T22:16:40+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1270,6 +1333,150 @@ }, "time": "2026-02-24T20:21:28+00:00" }, + { + "name": "intervention/gif", + "version": "4.2.4", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.2.4" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-01-04T09:27:23+00:00" + }, + { + "name": "intervention/image", + "version": "3.11.7", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.2", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" + } + ], + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.11.7" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-02-19T13:11:17+00:00" + }, { "name": "laravel/framework", "version": "v12.53.0", @@ -1675,6 +1882,78 @@ }, "time": "2026-02-20T19:59:49+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.24.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "0feb62267e7b8abc68593ca37639ad302728c129" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129", + "reference": "0feb62267e7b8abc68593ca37639ad302728c129", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-02-21T13:32:50+00:00" + }, { "name": "laravel/tinker", "version": "v2.11.1", @@ -2118,6 +2397,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.8.0", @@ -2811,6 +3166,125 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -2886,6 +3360,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.49", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:17:28+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3575,6 +4159,66 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "spatie/pdf-to-image", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/pdf-to-image.git", + "reference": "9a5cb264a99e87e010c65d4ece03b51f821d55bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/pdf-to-image/zipball/9a5cb264a99e87e010c65d4ece03b51f821d55bd", + "reference": "9a5cb264a99e87e010c65d4ece03b51f821d55bd", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "4.*", + "scrutinizer/ocular": "~1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\PdfToImage\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Convert a pdf to an image", + "homepage": "https://github.com/spatie/pdf-to-image", + "keywords": [ + "convert", + "image", + "pdf", + "pdf-to-image", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/pdf-to-image/issues", + "source": "https://github.com/spatie/pdf-to-image/tree/1.2.2" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2016-12-14T15:37:00+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", diff --git a/config/services.php b/config/services.php index 6a90eb8..6c0036a 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,16 @@ ], ], + 'song_request' => [ + 'email' => env('SONG_REQUEST_EMAIL', 'songs@example.com'), + ], + + 'churchtools' => [ + 'url' => env('CTS_API_URL', env('CHURCHTOOLS_URL')), + 'api_token' => env('CTS_API_TOKEN'), + 'client_id' => env('CHURCHTOOLS_CLIENT_ID'), + 'client_secret' => env('CHURCHTOOLS_CLIENT_SECRET'), + 'redirect' => env('CHURCHTOOLS_REDIRECT_URI', '/auth/churchtools/callback'), + ], + ]; diff --git a/database/factories/CtsSyncLogFactory.php b/database/factories/CtsSyncLogFactory.php new file mode 100644 index 0000000..37e1e8d --- /dev/null +++ b/database/factories/CtsSyncLogFactory.php @@ -0,0 +1,24 @@ +faker->randomElement(['success', 'error']); + + return [ + 'synced_at' => $this->faker->dateTimeBetween('-7 days', 'now'), + 'events_count' => $this->faker->numberBetween(0, 50), + 'songs_count' => $this->faker->numberBetween(0, 200), + 'status' => $status, + 'error' => $status === 'error' ? $this->faker->sentence() : null, + ]; + } +} diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 0000000..c4d7999 --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,30 @@ +faker->dateTimeBetween('now', '+6 months'); + + return [ + 'cts_event_id' => (string) $this->faker->unique()->numberBetween(10000, 99999), + 'title' => $this->faker->sentence(4), + 'date' => $date, + 'preacher_name' => $this->faker->name(), + 'beamer_tech_name' => $this->faker->name(), + 'finalized_at' => $this->faker->optional()->dateTimeBetween('-2 weeks', 'now'), + 'last_synced_at' => $this->faker->dateTimeBetween('-2 days', 'now'), + 'cts_data' => [ + 'id' => $this->faker->uuid(), + 'name' => $this->faker->sentence(3), + ], + ]; + } +} diff --git a/database/factories/ServiceSongFactory.php b/database/factories/ServiceSongFactory.php new file mode 100644 index 0000000..39c0946 --- /dev/null +++ b/database/factories/ServiceSongFactory.php @@ -0,0 +1,29 @@ + Service::factory(), + 'song_id' => Song::factory(), + 'song_arrangement_id' => SongArrangement::factory(), + 'use_translation' => $this->faker->boolean(30), + 'order' => $this->faker->numberBetween(1, 10), + 'cts_song_name' => $this->faker->sentence(3), + 'cts_ccli_id' => $this->faker->optional()->numerify('######'), + 'matched_at' => $this->faker->optional()->dateTimeBetween('-2 weeks', 'now'), + 'request_sent_at' => $this->faker->optional()->dateTimeBetween('-2 weeks', 'now'), + ]; + } +} diff --git a/database/factories/SlideFactory.php b/database/factories/SlideFactory.php new file mode 100644 index 0000000..532d27b --- /dev/null +++ b/database/factories/SlideFactory.php @@ -0,0 +1,26 @@ + $this->faker->randomElement(['information', 'moderation', 'sermon']), + 'service_id' => Service::factory(), + 'original_filename' => $this->faker->word() . '.jpg', + 'stored_filename' => $this->faker->uuid() . '.jpg', + 'thumbnail_filename' => $this->faker->uuid() . '_thumb.jpg', + 'expire_date' => $this->faker->optional()->dateTimeBetween('now', '+12 months'), + 'uploader_name' => $this->faker->name(), + 'uploaded_at' => $this->faker->dateTimeBetween('-1 month', 'now'), + ]; + } +} diff --git a/database/factories/SongArrangementFactory.php b/database/factories/SongArrangementFactory.php new file mode 100644 index 0000000..d9a4441 --- /dev/null +++ b/database/factories/SongArrangementFactory.php @@ -0,0 +1,21 @@ + Song::factory(), + 'name' => $this->faker->randomElement(['Normal', 'Kurz', 'Extended']), + 'is_default' => false, + ]; + } +} diff --git a/database/factories/SongArrangementGroupFactory.php b/database/factories/SongArrangementGroupFactory.php new file mode 100644 index 0000000..a02f13c --- /dev/null +++ b/database/factories/SongArrangementGroupFactory.php @@ -0,0 +1,22 @@ + SongArrangement::factory(), + 'song_group_id' => SongGroup::factory(), + 'order' => $this->faker->numberBetween(1, 12), + ]; + } +} diff --git a/database/factories/SongFactory.php b/database/factories/SongFactory.php new file mode 100644 index 0000000..3a24313 --- /dev/null +++ b/database/factories/SongFactory.php @@ -0,0 +1,25 @@ + $this->faker->boolean(80) ? $this->faker->unique()->numerify('######') : null, + 'title' => $this->faker->sentence(3), + 'author' => $this->faker->name(), + 'copyright_text' => $this->faker->optional()->sentence(), + 'copyright_year' => (string) $this->faker->year(), + 'publisher' => $this->faker->optional()->company(), + 'has_translation' => $this->faker->boolean(25), + 'last_used_at' => $this->faker->optional()->dateTimeBetween('-6 months', 'now'), + ]; + } +} diff --git a/database/factories/SongGroupFactory.php b/database/factories/SongGroupFactory.php new file mode 100644 index 0000000..cba2662 --- /dev/null +++ b/database/factories/SongGroupFactory.php @@ -0,0 +1,22 @@ + Song::factory(), + 'name' => $this->faker->randomElement(['Verse 1', 'Verse 2', 'Chorus', 'Bridge']), + 'color' => $this->faker->hexColor(), + 'order' => $this->faker->numberBetween(1, 10), + ]; + } +} diff --git a/database/factories/SongSlideFactory.php b/database/factories/SongSlideFactory.php new file mode 100644 index 0000000..e42e04a --- /dev/null +++ b/database/factories/SongSlideFactory.php @@ -0,0 +1,23 @@ + SongGroup::factory(), + 'order' => $this->faker->numberBetween(1, 12), + 'text_content' => implode("\n", $this->faker->sentences(3)), + 'text_content_translated' => $this->faker->optional()->sentence(), + 'notes' => $this->faker->optional()->sentence(), + ]; + } +} diff --git a/database/migrations/2026_03_01_100000_extend_users_table.php b/database/migrations/2026_03_01_100000_extend_users_table.php new file mode 100644 index 0000000..5f1ce17 --- /dev/null +++ b/database/migrations/2026_03_01_100000_extend_users_table.php @@ -0,0 +1,30 @@ +string('churchtools_id')->nullable()->unique()->after('email'); + $table->string('avatar')->nullable()->after('password'); + $table->json('churchtools_groups')->nullable()->after('avatar'); + $table->json('churchtools_roles')->nullable()->after('churchtools_groups'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique('users_churchtools_id_unique'); + $table->dropColumn([ + 'churchtools_id', + 'avatar', + 'churchtools_groups', + 'churchtools_roles', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_01_100100_create_services_table.php b/database/migrations/2026_03_01_100100_create_services_table.php new file mode 100644 index 0000000..ca3d939 --- /dev/null +++ b/database/migrations/2026_03_01_100100_create_services_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('cts_event_id')->unique(); + $table->string('title'); + $table->date('date')->index(); + $table->string('preacher_name')->nullable(); + $table->string('beamer_tech_name')->nullable(); + $table->timestamp('finalized_at')->nullable(); + $table->timestamp('last_synced_at'); + $table->json('cts_data'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('services'); + } +}; diff --git a/database/migrations/2026_03_01_100200_create_songs_table.php b/database/migrations/2026_03_01_100200_create_songs_table.php new file mode 100644 index 0000000..8d6bd4e --- /dev/null +++ b/database/migrations/2026_03_01_100200_create_songs_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('ccli_id')->nullable()->unique()->index(); + $table->string('title'); + $table->string('author')->nullable(); + $table->string('copyright_text')->nullable(); + $table->string('copyright_year')->nullable(); + $table->string('publisher')->nullable(); + $table->boolean('has_translation')->default(false); + $table->timestamp('last_used_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('songs'); + } +}; diff --git a/database/migrations/2026_03_01_100300_create_song_groups_table.php b/database/migrations/2026_03_01_100300_create_song_groups_table.php new file mode 100644 index 0000000..3bde7f5 --- /dev/null +++ b/database/migrations/2026_03_01_100300_create_song_groups_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('song_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('color'); + $table->unsignedInteger('order'); + $table->timestamps(); + + $table->index('song_id'); + $table->unique(['song_id', 'order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('song_groups'); + } +}; diff --git a/database/migrations/2026_03_01_100400_create_song_slides_table.php b/database/migrations/2026_03_01_100400_create_song_slides_table.php new file mode 100644 index 0000000..e01d5da --- /dev/null +++ b/database/migrations/2026_03_01_100400_create_song_slides_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('song_group_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('order'); + $table->text('text_content'); + $table->text('text_content_translated')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index('song_group_id'); + $table->unique(['song_group_id', 'order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('song_slides'); + } +}; diff --git a/database/migrations/2026_03_01_100500_create_song_arrangements_table.php b/database/migrations/2026_03_01_100500_create_song_arrangements_table.php new file mode 100644 index 0000000..a5c7187 --- /dev/null +++ b/database/migrations/2026_03_01_100500_create_song_arrangements_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('song_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->boolean('is_default')->default(false); + $table->timestamps(); + + $table->index('song_id'); + $table->unique(['song_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('song_arrangements'); + } +}; diff --git a/database/migrations/2026_03_01_100600_create_song_arrangement_groups_table.php b/database/migrations/2026_03_01_100600_create_song_arrangement_groups_table.php new file mode 100644 index 0000000..d1daaa4 --- /dev/null +++ b/database/migrations/2026_03_01_100600_create_song_arrangement_groups_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('song_arrangement_id')->constrained()->cascadeOnDelete(); + $table->foreignId('song_group_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('order'); + $table->timestamps(); + + $table->index('song_arrangement_id'); + $table->index('song_group_id'); + $table->unique(['song_arrangement_id', 'order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('song_arrangement_groups'); + } +}; diff --git a/database/migrations/2026_03_01_100700_create_service_songs_table.php b/database/migrations/2026_03_01_100700_create_service_songs_table.php new file mode 100644 index 0000000..6103e08 --- /dev/null +++ b/database/migrations/2026_03_01_100700_create_service_songs_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('service_id')->constrained()->cascadeOnDelete(); + $table->foreignId('song_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('song_arrangement_id')->nullable()->constrained()->nullOnDelete(); + $table->boolean('use_translation')->default(false); + $table->unsignedInteger('order'); + $table->string('cts_song_name'); + $table->string('cts_ccli_id')->nullable()->index(); + $table->timestamp('matched_at')->nullable(); + $table->timestamp('request_sent_at')->nullable(); + $table->timestamps(); + + $table->index('service_id'); + $table->index(['service_id', 'order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_songs'); + } +}; diff --git a/database/migrations/2026_03_01_100800_create_slides_table.php b/database/migrations/2026_03_01_100800_create_slides_table.php new file mode 100644 index 0000000..1008ad1 --- /dev/null +++ b/database/migrations/2026_03_01_100800_create_slides_table.php @@ -0,0 +1,33 @@ +id(); + $table->enum('type', ['information', 'moderation', 'sermon']); + $table->foreignId('service_id')->nullable()->constrained()->nullOnDelete(); + $table->string('original_filename'); + $table->string('stored_filename'); + $table->string('thumbnail_filename'); + $table->date('expire_date')->nullable(); + $table->string('uploader_name')->nullable(); + $table->timestamp('uploaded_at'); + $table->softDeletes(); + $table->timestamps(); + + $table->index('service_id'); + $table->index('expire_date'); + $table->index('type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('slides'); + } +}; diff --git a/database/migrations/2026_03_01_100900_create_cts_sync_log_table.php b/database/migrations/2026_03_01_100900_create_cts_sync_log_table.php new file mode 100644 index 0000000..93f8569 --- /dev/null +++ b/database/migrations/2026_03_01_100900_create_cts_sync_log_table.php @@ -0,0 +1,25 @@ +id(); + $table->timestamp('synced_at')->index(); + $table->unsignedInteger('events_count'); + $table->unsignedInteger('songs_count'); + $table->string('status'); + $table->text('error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cts_sync_log'); + } +}; diff --git a/resources/js/Components/ConfirmDialog.vue b/resources/js/Components/ConfirmDialog.vue new file mode 100644 index 0000000..604ad3f --- /dev/null +++ b/resources/js/Components/ConfirmDialog.vue @@ -0,0 +1,101 @@ + + + diff --git a/resources/js/Components/FlashMessage.vue b/resources/js/Components/FlashMessage.vue new file mode 100644 index 0000000..9fdec94 --- /dev/null +++ b/resources/js/Components/FlashMessage.vue @@ -0,0 +1,100 @@ + + + diff --git a/resources/js/Components/LoadingSpinner.vue b/resources/js/Components/LoadingSpinner.vue new file mode 100644 index 0000000..8bb89f6 --- /dev/null +++ b/resources/js/Components/LoadingSpinner.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/js/Composables/useAutoSave.js b/resources/js/Composables/useAutoSave.js new file mode 100644 index 0000000..e363296 --- /dev/null +++ b/resources/js/Composables/useAutoSave.js @@ -0,0 +1,54 @@ +import { useDebounceFn } from '@vueuse/core' +import { router } from '@inertiajs/vue3' +import { ref } from 'vue' + +/** + * Auto-Save Composable + * + * Text-Eingaben: Debounce 500ms vor dem Speichern + * Selects/Checkboxen: Sofortige Speicherung (kein Debounce) + * + * @param {string} url - Die URL zum Speichern + * @param {string} method - HTTP-Methode ('put' oder 'post') + * @returns {{ save: Function, saveImmediate: Function, saving: Ref, saved: Ref }} + */ +export function useAutoSave(url, method = 'put') { + const saving = ref(false) + const saved = ref(false) + let savedTimeout = null + + const performSave = (data) => { + saving.value = true + saved.value = false + + router[method](url, data, { + preserveScroll: true, + preserveState: true, + onSuccess: () => { + saving.value = false + saved.value = true + + if (savedTimeout) clearTimeout(savedTimeout) + savedTimeout = setTimeout(() => { + saved.value = false + }, 2000) + }, + onError: () => { + saving.value = false + }, + }) + } + + // Text-Eingaben: 500ms Debounce + const save = useDebounceFn((data) => { + performSave(data) + }, 500) + + // Selects/Checkboxen: Sofort speichern + const saveImmediate = (data) => { + save.cancel() + performSave(data) + } + + return { save, saveImmediate, saving, saved } +} diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index 154305f..c9627fb 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -1,198 +1,306 @@ diff --git a/resources/js/Pages/Auth/ConfirmPassword.vue b/resources/js/Pages/Auth/ConfirmPassword.vue deleted file mode 100644 index 1e97cb9..0000000 --- a/resources/js/Pages/Auth/ConfirmPassword.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - diff --git a/resources/js/Pages/Auth/ForgotPassword.vue b/resources/js/Pages/Auth/ForgotPassword.vue deleted file mode 100644 index fe5a196..0000000 --- a/resources/js/Pages/Auth/ForgotPassword.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index 8e906a9..68b5236 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -1,100 +1,30 @@ diff --git a/resources/js/Pages/Auth/Register.vue b/resources/js/Pages/Auth/Register.vue deleted file mode 100644 index de4a5bf..0000000 --- a/resources/js/Pages/Auth/Register.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - diff --git a/resources/js/Pages/Auth/ResetPassword.vue b/resources/js/Pages/Auth/ResetPassword.vue deleted file mode 100644 index e795844..0000000 --- a/resources/js/Pages/Auth/ResetPassword.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - diff --git a/resources/js/Pages/Auth/VerifyEmail.vue b/resources/js/Pages/Auth/VerifyEmail.vue deleted file mode 100644 index ffd8ed8..0000000 --- a/resources/js/Pages/Auth/VerifyEmail.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index f22f58e..eca900c 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -4,14 +4,14 @@ import { Head } from '@inertiajs/vue3';