From 84eec0039e3ee8d02c761ce8d7fe895a0465a466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Wed, 2 Sep 2026 08:21:27 +0200 Subject: [PATCH] Initial commit --- .env.example | 83 + .env.production.example | 164 + .gitignore | 35 + CHANGELOG.md | 94 + CLAUDE.md | 2622 ++++++++ DEPLOYMENT_CHECKLIST.md | 236 + README.md | 928 +++ Webinar-platform.code-workspace | 8 + backend/.dockerignore | 4 + backend/Dockerfile | 25 + backend/pom.xml | 194 + .../softspm/webinar/WebinarApplication.java | 12 + .../webinar/academy/AcademyController.java | 197 + .../softspm/webinar/academy/AcademyDtos.java | 228 + .../webinar/academy/AcademyMailService.java | 182 + .../webinar/academy/AcademyService.java | 430 ++ .../webinar/academy/AuthenticatedStudent.java | 32 + .../webinar/academy/LearningController.java | 73 + .../softspm/webinar/academy/LearningDtos.java | 126 + .../webinar/academy/LearningService.java | 431 ++ .../academy/LessonMediaController.java | 135 + .../webinar/academy/PageSectionBootstrap.java | 139 + .../academy/PageSectionController.java | 189 + .../academy/PublicAcademyController.java | 92 + .../webinar/academy/PublicAcademyDtos.java | 232 + .../webinar/academy/PublicAcademyService.java | 375 ++ .../academy/StudentAdminController.java | 184 + .../webinar/academy/StudentAdminDtos.java | 177 + .../webinar/academy/StudentAdminService.java | 542 ++ .../academy/StudentAuthenticationFilter.java | 80 + .../webinar/academy/StudentCsvWriter.java | 112 + .../academy/StudentParticipationService.java | 182 + .../attendance/AttendanceScheduler.java | 38 + .../webinar/attendance/AttendanceService.java | 277 + .../softspm/webinar/auth/AdminBootstrap.java | 97 + .../softspm/webinar/auth/AuthController.java | 77 + .../com/softspm/webinar/auth/AuthService.java | 156 + .../webinar/auth/AuthenticatedUser.java | 25 + .../webinar/auth/ChangePasswordRequest.java | 14 + .../webinar/auth/JwtAuthenticationFilter.java | 69 + .../softspm/webinar/auth/JwtProperties.java | 59 + .../com/softspm/webinar/auth/JwtService.java | 135 + .../webinar/auth/LoginAttemptService.java | 108 + .../softspm/webinar/auth/LoginRequest.java | 12 + .../softspm/webinar/auth/LoginResponse.java | 11 + .../webinar/auth/ResetPasswordResponse.java | 10 + .../webinar/auth/UnifiedLoginDtos.java | 56 + .../webinar/auth/UnifiedLoginService.java | 154 + .../webinar/auth/UserAdminController.java | 68 + .../com/softspm/webinar/auth/UserRequest.java | 31 + .../com/softspm/webinar/auth/UserService.java | 155 + .../com/softspm/webinar/auth/UserView.java | 17 + .../webinar/banner/BannerAdminController.java | 69 + .../webinar/banner/BannerEventWriter.java | 95 + .../softspm/webinar/banner/BannerRequest.java | 48 + .../softspm/webinar/banner/BannerService.java | 376 ++ .../softspm/webinar/banner/BannerView.java | 77 + .../webinar/banner/PublicBannerView.java | 49 + .../banner/WebinarBannerController.java | 48 + .../webinar/calendar/IcsGenerator.java | 137 + .../CertificateAdminController.java | 209 + .../certificate/CertificateCsvWriter.java | 79 + .../certificate/CertificateOverviewDtos.java | 52 + .../CertificateOverviewService.java | 356 ++ .../certificate/CertificatePdfRenderer.java | 255 + .../certificate/CertificateService.java | 556 ++ .../webinar/certificate/CertificateView.java | 89 + .../certificate/CourseCertificateMailer.java | 137 + .../certificate/CourseCertificateSender.java | 200 + .../certificate/CourseCertificateService.java | 405 ++ .../PublicCertificateController.java | 168 + .../softspm/webinar/config/AsyncConfig.java | 32 + .../config/CertificateTemplateConfig.java | 78 + .../webinar/config/MailTemplateConfig.java | 59 + .../config/RestAccessDeniedHandler.java | 36 + .../config/RestAuthenticationEntryPoint.java | 36 + .../webinar/config/SchedulingConfig.java | 29 + .../webinar/config/SecurityConfig.java | 211 + .../contact/ContactAdminController.java | 200 + .../webinar/contact/ContactCsvWriter.java | 73 + .../webinar/contact/ContactImportService.java | 388 ++ .../webinar/contact/ContactRequest.java | 47 + .../webinar/contact/ContactService.java | 491 ++ .../softspm/webinar/contact/ContactView.java | 59 + .../contact/InvitationAdminController.java | 60 + .../webinar/contact/InvitationRequest.java | 23 + .../webinar/contact/InvitationSender.java | 249 + .../webinar/contact/InvitationService.java | 234 + .../webinar/contact/InvitationView.java | 40 + .../contact/PublicUnsubscribeController.java | 44 + .../webinar/content/HtmlSanitizer.java | 160 + .../webinar/content/ImageTypeDetector.java | 86 + .../webinar/content/MediaController.java | 119 + .../softspm/webinar/content/MediaService.java | 185 + .../softspm/webinar/content/MediaView.java | 35 + .../webinar/content/PlainTextSanitizer.java | 62 + .../webinar/content/VideoTypeDetector.java | 89 + .../webinar/course/CourseAdminController.java | 262 + .../course/CourseCompletionService.java | 124 + .../webinar/course/CourseCopyService.java | 146 + .../softspm/webinar/course/CourseDtos.java | 260 + .../course/CourseEnrollmentService.java | 154 + .../softspm/webinar/course/CourseService.java | 529 ++ .../softspm/webinar/domain/AppSetting.java | 43 + .../webinar/domain/AttendanceSession.java | 90 + .../webinar/domain/AttendanceSource.java | 9 + .../com/softspm/webinar/domain/Banner.java | 129 + .../softspm/webinar/domain/BannerEvent.java | 63 + .../webinar/domain/BannerEventType.java | 22 + .../webinar/domain/BannerPlacement.java | 25 + .../softspm/webinar/domain/Certificate.java | 110 + .../webinar/domain/CertificateCounter.java | 35 + .../com/softspm/webinar/domain/ChatMode.java | 14 + .../com/softspm/webinar/domain/Consent.java | 73 + .../softspm/webinar/domain/ConsentType.java | 16 + .../com/softspm/webinar/domain/Contact.java | 157 + .../softspm/webinar/domain/ContactGroup.java | 39 + .../webinar/domain/ContactGroupMember.java | 70 + .../softspm/webinar/domain/ContactSource.java | 11 + .../com/softspm/webinar/domain/Course.java | 189 + .../softspm/webinar/domain/CourseLevel.java | 8 + .../softspm/webinar/domain/CourseModule.java | 66 + .../softspm/webinar/domain/CourseStatus.java | 23 + .../softspm/webinar/domain/Enrollment.java | 109 + .../webinar/domain/EnrollmentStatus.java | 21 + .../softspm/webinar/domain/Interaction.java | 146 + .../webinar/domain/InteractionStatus.java | 20 + .../webinar/domain/InteractionType.java | 17 + .../webinar/domain/InteractionVote.java | 45 + .../softspm/webinar/domain/Invitation.java | 66 + .../webinar/domain/InvitationStatus.java | 8 + .../com/softspm/webinar/domain/Lesson.java | 125 + .../webinar/domain/LessonProgress.java | 93 + .../webinar/domain/LessonProgressStatus.java | 13 + .../softspm/webinar/domain/LessonType.java | 31 + .../com/softspm/webinar/domain/Material.java | 72 + .../com/softspm/webinar/domain/MediaFile.java | 63 + .../softspm/webinar/domain/Notification.java | 99 + .../webinar/domain/NotificationChannel.java | 7 + .../webinar/domain/NotificationStatus.java | 8 + .../softspm/webinar/domain/OfferTopic.java | 65 + .../com/softspm/webinar/domain/Order.java | 119 + .../softspm/webinar/domain/OrderStatus.java | 29 + .../softspm/webinar/domain/PageSection.java | 107 + .../webinar/domain/PageSectionType.java | 20 + .../webinar/domain/PaymentNotification.java | 79 + .../java/com/softspm/webinar/domain/Quiz.java | 123 + .../softspm/webinar/domain/QuizAnswer.java | 45 + .../softspm/webinar/domain/QuizAttempt.java | 145 + .../webinar/domain/QuizAttemptAnswer.java | 80 + .../softspm/webinar/domain/QuizQuestion.java | 79 + .../com/softspm/webinar/domain/Recording.java | 106 + .../webinar/domain/RecordingStatus.java | 23 + .../softspm/webinar/domain/Registration.java | 116 + .../webinar/domain/RegistrationRoomRole.java | 31 + .../webinar/domain/RegistrationStatus.java | 8 + .../softspm/webinar/domain/ReminderRule.java | 65 + .../softspm/webinar/domain/SpeakRequest.java | 65 + .../webinar/domain/SpeakRequestStatus.java | 36 + .../java/com/softspm/webinar/domain/Task.java | 64 + .../java/com/softspm/webinar/domain/User.java | 62 + .../com/softspm/webinar/domain/UserRole.java | 7 + .../softspm/webinar/domain/Visibility.java | 17 + .../com/softspm/webinar/domain/Webinar.java | 191 + .../webinar/domain/WebinarSpeaker.java | 58 + .../softspm/webinar/domain/WebinarStatus.java | 9 + .../webinar/health/HealthController.java | 33 + .../webinar/health/HealthResponse.java | 10 + .../InteractionAdminController.java | 192 + .../interaction/InteractionBroadcaster.java | 178 + .../interaction/InteractionPrincipal.java | 27 + .../interaction/InteractionService.java | 420 ++ .../InteractionStompController.java | 134 + .../webinar/interaction/InteractionView.java | 48 + .../interaction/ModeratorInteractionView.java | 64 + .../webinar/interaction/OfferService.java | 359 ++ .../PublicInteractionController.java | 114 + .../PublicTaskOfferController.java | 264 + .../webinar/interaction/SalesNotifier.java | 178 + .../SpeakRequestAdminController.java | 83 + .../interaction/SpeakRequestService.java | 262 + .../webinar/interaction/SpeakRequestView.java | 54 + .../StompAuthChannelInterceptor.java | 273 + .../interaction/TaskOfferAdminController.java | 245 + .../webinar/interaction/TaskService.java | 329 + .../softspm/webinar/interaction/TaskView.java | 40 + .../webinar/interaction/WebSocketConfig.java | 66 + .../com/softspm/webinar/mail/MailService.java | 289 + .../webinar/mail/NotificationService.java | 176 + .../softspm/webinar/mail/SafeDelivery.java | 261 + .../webinar/media/FileAccessGuard.java | 94 + .../webinar/media/MaterialService.java | 239 + .../softspm/webinar/media/MaterialView.java | 39 + .../webinar/media/MediaAdminController.java | 106 + .../webinar/media/PublicFileController.java | 100 + .../softspm/webinar/media/RangeResponder.java | 206 + .../webinar/media/RecordingService.java | 415 ++ .../softspm/webinar/media/RecordingView.java | 46 + .../webinar/media/VideoWebhookController.java | 268 + .../webinar/media/ZoomWebhookVerifier.java | 133 + .../webinar/payment/AutopayConfig.java | 107 + .../webinar/payment/AutopayGatewayClient.java | 196 + .../softspm/webinar/payment/AutopayHash.java | 106 + .../webinar/payment/AutopayItnController.java | 91 + .../webinar/payment/AutopayItnParser.java | 127 + .../webinar/payment/AutopayItnService.java | 331 + .../webinar/payment/OrderAdminController.java | 139 + .../softspm/webinar/payment/OrderDtos.java | 156 + .../payment/OrderFulfilmentService.java | 121 + .../webinar/payment/OrderMailService.java | 126 + .../softspm/webinar/payment/OrderService.java | 317 + .../payment/OrderStudentController.java | 65 + .../webinar/quiz/QuizAdminController.java | 147 + .../webinar/quiz/QuizAttemptService.java | 573 ++ .../com/softspm/webinar/quiz/QuizDtos.java | 258 + .../softspm/webinar/quiz/QuizScheduler.java | 45 + .../com/softspm/webinar/quiz/QuizService.java | 606 ++ .../webinar/quiz/QuizStudentController.java | 108 + .../registration/MyRegistrationView.java | 54 + .../registration/RegistrationCsvWriter.java | 123 + .../registration/RegistrationRequest.java | 46 + .../registration/RegistrationResult.java | 16 + .../registration/RegistrationService.java | 420 ++ .../registration/RegistrationView.java | 30 + .../reminder/ReminderAdminController.java | 151 + .../webinar/reminder/ReminderRuleRequest.java | 17 + .../webinar/reminder/ReminderRuleService.java | 124 + .../webinar/reminder/ReminderRuleView.java | 25 + .../webinar/reminder/ReminderScheduler.java | 240 + .../webinar/reminder/ReminderSender.java | 136 + .../webinar/reminder/ReminderTextBuilder.java | 133 + .../webinar/reminder/ScheduledSendView.java | 22 + .../repository/AppSettingRepository.java | 7 + .../AttendanceSessionRepository.java | 148 + .../repository/BannerEventRepository.java | 18 + .../webinar/repository/BannerRepository.java | 103 + .../CertificateCounterRepository.java | 24 + .../repository/CertificateRepository.java | 226 + .../webinar/repository/ConsentRepository.java | 37 + .../ContactGroupMemberRepository.java | 35 + .../repository/ContactGroupRepository.java | 17 + .../webinar/repository/ContactRepository.java | 195 + .../repository/CourseModuleRepository.java | 15 + .../webinar/repository/CourseRepository.java | 66 + .../repository/EnrollmentRepository.java | 113 + .../repository/InteractionRepository.java | 201 + .../repository/InteractionVoteRepository.java | 16 + .../repository/InvitationRepository.java | 45 + .../repository/LessonProgressRepository.java | 36 + .../webinar/repository/LessonRepository.java | 112 + .../repository/MaterialRepository.java | 16 + .../repository/MediaFileRepository.java | 11 + .../repository/NotificationRepository.java | 125 + .../repository/OfferTopicRepository.java | 13 + .../webinar/repository/OrderRepository.java | 64 + .../repository/PageSectionRepository.java | 30 + .../PaymentNotificationRepository.java | 20 + .../repository/QuizAnswerRepository.java | 29 + .../QuizAttemptAnswerRepository.java | 34 + .../repository/QuizAttemptRepository.java | 82 + .../repository/QuizQuestionRepository.java | 46 + .../webinar/repository/QuizRepository.java | 27 + .../repository/RecordingRepository.java | 37 + .../repository/RegistrationRepository.java | 120 + .../repository/ReminderRuleRepository.java | 37 + .../repository/SpeakRequestRepository.java | 43 + .../repository/StudentAdminRepository.java | 393 ++ .../webinar/repository/TaskRepository.java | 27 + .../webinar/repository/UserRepository.java | 27 + .../webinar/repository/WebinarRepository.java | 147 + .../repository/WebinarSpeakerRepository.java | 16 + .../settings/ConfigCheckController.java | 129 + .../softspm/webinar/settings/SettingKey.java | 643 ++ .../softspm/webinar/settings/SettingType.java | 29 + .../softspm/webinar/settings/SettingView.java | 22 + .../webinar/settings/SettingsController.java | 109 + .../webinar/settings/SettingsService.java | 169 + .../settings/SettingsStartupReport.java | 202 + .../webinar/settings/TestEmailRequest.java | 11 + .../softspm/webinar/settings/TestResult.java | 18 + .../webinar/settings/TestSmsRequest.java | 9 + .../com/softspm/webinar/sms/SmsService.java | 155 + .../softspm/webinar/stats/DashboardView.java | 32 + .../webinar/stats/StatsController.java | 37 + .../softspm/webinar/stats/StatsService.java | 321 + .../webinar/stats/WebinarStatsView.java | 72 + .../webinar/storage/BrandingController.java | 181 + .../webinar/storage/FileStorageService.java | 241 + .../webinar/summary/RecordingNotifier.java | 184 + .../softspm/webinar/summary/SummaryData.java | 68 + .../webinar/summary/SummaryMailBuilder.java | 160 + .../webinar/summary/SummaryQueueWriter.java | 45 + .../webinar/summary/SummaryScheduler.java | 227 + .../webinar/summary/SummarySender.java | 105 + .../webinar/summary/SummaryService.java | 371 ++ .../webinar/video/DailyVideoProvider.java | 370 ++ .../webinar/video/JitsiVideoProvider.java | 212 + .../webinar/video/LiveKitEgressService.java | 315 + .../webinar/video/LiveKitVideoProvider.java | 249 + .../webinar/video/NoneVideoProvider.java | 90 + .../webinar/video/PublicJoinController.java | 119 + .../com/softspm/webinar/video/RoomRole.java | 53 + .../webinar/video/VideoAdminController.java | 266 + .../softspm/webinar/video/VideoProvider.java | 149 + .../webinar/video/VideoProviderFactory.java | 137 + .../softspm/webinar/video/VideoService.java | 381 ++ .../webinar/video/ZoomVideoProvider.java | 511 ++ .../com/softspm/webinar/web/ApiError.java | 16 + .../webinar/web/ApiExceptionHandler.java | 113 + .../softspm/webinar/web/LegalDocuments.java | 296 + .../webinar/web/NotFoundException.java | 9 + .../webinar/web/PreviewController.java | 140 + .../webinar/web/PublicConfigController.java | 57 + .../softspm/webinar/web/PublicConfigView.java | 19 + .../softspm/webinar/web/PublicController.java | 90 + .../webinar/web/PublicLegalController.java | 80 + .../webinar/web/ValidationException.java | 9 + .../webinar/webinar/ConsentTextView.java | 21 + .../webinar/webinar/PublicWebinarService.java | 117 + .../webinar/webinar/PublicWebinarView.java | 46 + .../webinar/RegistrationAvailability.java | 53 + .../webinar/webinar/SlugGenerator.java | 68 + .../webinar/WebinarAdminController.java | 330 + .../webinar/webinar/WebinarCopyService.java | 265 + .../webinar/webinar/WebinarRequest.java | 96 + .../webinar/webinar/WebinarService.java | 363 ++ .../softspm/webinar/webinar/WebinarView.java | 91 + backend/src/main/resources/application.yml | 101 + .../src/main/resources/db/migration/.gitkeep | 1 + .../V10__summary_once_per_recipient.sql | 26 + .../V11__consent_texts_diacritics.sql | 30 + .../migration/V12__recording_notification.sql | 21 + .../V13__recordings_and_materials.sql | 115 + .../db/migration/V14__attendance.sql | 93 + .../V15__contacts_and_invitations.sql | 179 + .../db/migration/V16__certificates.sql | 102 + .../db/migration/V17__speak_requests.sql | 52 + .../resources/db/migration/V18__open_mic.sql | 26 + .../resources/db/migration/V19__banners.sql | 167 + .../main/resources/db/migration/V1__init.sql | 112 + .../V20__video_provider_per_webinar.sql | 47 + .../db/migration/V21__contact_accounts.sql | 67 + .../resources/db/migration/V22__courses.sql | 253 + .../resources/db/migration/V23__quizzes.sql | 180 + .../resources/db/migration/V24__orders.sql | 116 + .../db/migration/V25__page_sections.sql | 61 + .../db/migration/V26__course_certificates.sql | 103 + .../V27__page_sections_sample_flag.sql | 23 + .../V28__registration_moderation.sql | 20 + .../db/migration/V29__open_video.sql | 20 + .../db/migration/V2__consent_texts.sql | 17 + .../db/migration/V3__users_login_metadata.sql | 12 + .../db/migration/V4__reminder_rules.sql | 50 + .../V5__webinar_content_and_media.sql | 42 + .../V6__video_rooms_and_speakers.sql | 58 + .../db/migration/V7__interactions.sql | 88 + .../db/migration/V8__tasks_and_offers.sql | 93 + .../db/migration/V9__webinar_summary.sql | 35 + .../main/resources/fonts/DejaVuSans-Bold.ttf | Bin 0 -> 708920 bytes .../src/main/resources/fonts/DejaVuSans.ttf | Bin 0 -> 759720 bytes .../templates/certificate/course.html | 171 + .../templates/certificate/default.html | 164 + .../mail/academy-account-exists.html | 90 + .../templates/mail/academy-account-exists.txt | 20 + .../mail/academy-password-reset.html | 90 + .../templates/mail/academy-password-reset.txt | 19 + .../templates/mail/academy-verify.html | 90 + .../templates/mail/academy-verify.txt | 18 + .../templates/mail/already-registered.html | 106 + .../templates/mail/already-registered.txt | 23 + .../templates/mail/certificate-copy.html | 47 + .../templates/mail/certificate-copy.txt | 15 + .../templates/mail/course-completed.html | 56 + .../templates/mail/course-completed.txt | 17 + .../resources/templates/mail/invitation.html | 47 + .../resources/templates/mail/invitation.txt | 12 + .../resources/templates/mail/order-paid.html | 98 + .../resources/templates/mail/order-paid.txt | 21 + .../templates/mail/recording-available.html | 39 + .../templates/mail/recording-available.txt | 15 + .../mail/registration-confirmation.html | 113 + .../mail/registration-confirmation.txt | 23 + .../resources/templates/mail/reminder.html | 109 + .../resources/templates/mail/reminder.txt | 23 + .../templates/mail/summary-host.html | 81 + .../resources/templates/mail/summary-host.txt | 37 + .../templates/mail/summary-participant.html | 64 + .../templates/mail/summary-participant.txt | 25 + .../webinar/academy/CourseModelTest.java | 266 + .../academy/LessonProgressRulesTest.java | 109 + .../academy/PublicAcademySecurityTest.java | 128 + .../webinar/academy/StudentTokenTest.java | 151 + .../softspm/webinar/banner/BannerTest.java | 228 + .../webinar/calendar/IcsGeneratorTest.java | 128 + .../webinar/calendar/IcsSampleWriterTest.java | 55 + .../certificate/CertificateNumberTest.java | 95 + .../certificate/CertificatePdfTest.java | 155 + .../CourseCertificateOwnerTest.java | 135 + .../certificate/CourseCertificatePdfTest.java | 158 + .../TransactionalSelfInvocationTest.java | 323 + .../webinar/content/HtmlSanitizerTest.java | 129 + .../webinar/course/CourseProgressTest.java | 60 + .../interaction/PlainTextSanitizerTest.java | 119 + .../webinar/interaction/SpeakRequestTest.java | 212 + .../StompSubscriptionAuthTest.java | 109 + .../webinar/interaction/TaskCapacityTest.java | 209 + .../webinar/mail/MailAttachmentTest.java | 108 + .../softspm/webinar/mail/MailPreviewTest.java | 187 + .../webinar/mail/SafeDeliveryTest.java | 190 + .../softspm/webinar/media/FileAccessTest.java | 168 + .../media/ZoomWebhookVerifierTest.java | 153 + .../webinar/payment/AutopayHashTest.java | 184 + .../webinar/payment/AutopayItnTest.java | 276 + .../webinar/quiz/QuizAttemptRulesTest.java | 117 + .../webinar/quiz/QuizSecurityTest.java | 110 + .../RegistrationConcurrencyTest.java | 226 + .../reminder/ReminderSchedulerTest.java | 253 + .../reminder/ReminderTextBuilderTest.java | 114 + .../webinar/settings/SettingDefaultsTest.java | 221 + .../webinar/settings/SettingSelectTest.java | 91 + .../softspm/webinar/summary/SummaryTest.java | 341 ++ .../webinar/video/ProviderTokenTest.java | 316 + .../softspm/webinar/video/RoomRoleTest.java | 141 + .../web/PublicConfigControllerTest.java | 94 + .../web/PublicLegalControllerTest.java | 121 + .../webinar/RegistrationAvailabilityTest.java | 119 + .../webinar/webinar/WebinarCopyTest.java | 216 + .../webinar/webinar/WebinarSearchTest.java | 139 + .../src/test/resources/application-test.yml | 34 + .../test/resources/junit-platform.properties | 5 + docker-compose.prod.yml | 284 + docker-compose.yml | 232 + docs/Akademia Wodociagowca - Wariant 1a.html | 392 ++ docs/Akademia Wodociagowca - Wariant 1b.html | 392 ++ ...r-platform_Instrukcja_Uruchomienia_PL.html | 1514 +++++ docs/img/03-logowanie.png | Bin 0 -> 20908 bytes docs/img/03-pulpit.png | Bin 0 -> 68095 bytes docs/img/04-ustawienia-aplikacja.png | Bin 0 -> 78390 bytes docs/img/05-ustawienia-email.png | Bin 0 -> 110712 bytes docs/img/06-formularz-podstawowe.png | Bin 0 -> 95149 bytes docs/img/07-formularz-termin.png | Bin 0 -> 65968 bytes docs/img/08-formularz-zapisy.png | Bin 0 -> 75263 bytes docs/img/09-lista-webinarow-draft.png | Bin 0 -> 75923 bytes docs/img/10-tresc-strony.png | Bin 0 -> 120771 bytes docs/img/11-wideo-pokoj.png | Bin 0 -> 107938 bytes docs/img/12-prelegent-formularz.png | Bin 0 -> 102118 bytes docs/img/12b-prelegent-dodany.png | Bin 0 -> 88526 bytes docs/img/13-przypomnienia.png | Bin 0 -> 85876 bytes docs/img/14-lista-opublikowany.png | Bin 0 -> 74651 bytes docs/img/15-strona-publiczna.png | Bin 0 -> 232381 bytes docs/img/16-formularz-zapisu-wypelniony.png | Bin 0 -> 239377 bytes docs/img/17-potwierdzenie-zapisu.png | Bin 0 -> 48860 bytes docs/img/18-karta-webinaru.png | Bin 0 -> 130464 bytes docs/img/19-zapisani.png | Bin 0 -> 82832 bytes docs/img/20-przedpokoj.png | Bin 0 -> 43302 bytes docs/img/21-sala-uczestnika.png | Bin 0 -> 45297 bytes docs/img/23-uczestnik-czat.png | Bin 0 -> 49542 bytes docs/img/24-panel-z-interakcjami.png | Bin 0 -> 83022 bytes docs/img/25-statystyki.png | Bin 0 -> 113736 bytes docs/img/26-zapytania.png | Bin 0 -> 54999 bytes frontend/.dockerignore | 3 + frontend/.gitignore | 24 + frontend/Dockerfile | 24 + frontend/index.html | 33 + frontend/nginx.conf | 172 + frontend/package-lock.json | 5306 +++++++++++++++++ frontend/package.json | 51 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/src/App.tsx | 403 ++ frontend/src/academy/AcademyBits.tsx | 204 + frontend/src/academy/AcademyHomePage.tsx | 507 ++ frontend/src/academy/AcademyLayout.tsx | 309 + frontend/src/academy/AcademyLoginPage.tsx | 135 + frontend/src/academy/AcademyRegisterPage.tsx | 330 + frontend/src/academy/AdminHint.tsx | 142 + frontend/src/academy/CheckoutPage.tsx | 265 + frontend/src/academy/CourseListPage.tsx | 352 ++ frontend/src/academy/CoursePage.tsx | 446 ++ frontend/src/academy/CoursePlayerPage.tsx | 553 ++ frontend/src/academy/HomeWebinarCard.tsx | 97 + frontend/src/academy/PanelMovedNotice.tsx | 90 + frontend/src/academy/PaymentReturnPage.tsx | 148 + frontend/src/academy/QuizPage.tsx | 581 ++ frontend/src/academy/RequireStudent.tsx | 32 + frontend/src/academy/StudentAuthContext.tsx | 108 + frontend/src/academy/StudentDashboardPage.tsx | 601 ++ frontend/src/api/academyClient.ts | 104 + frontend/src/api/client.ts | 94 + frontend/src/api/publicClient.ts | 47 + frontend/src/api/types.ts | 1371 +++++ frontend/src/auth/AuthContext.tsx | 102 + frontend/src/auth/Guards.tsx | 47 + frontend/src/components/BrandMark.tsx | 53 + frontend/src/components/ContentEditor.tsx | 370 ++ frontend/src/components/CookieBanner.tsx | 94 + frontend/src/components/FormTabs.tsx | 72 + frontend/src/components/Icon.tsx | 151 + frontend/src/components/ImageEditor.tsx | 695 +++ frontend/src/components/Layout.tsx | 308 + frontend/src/components/MediaField.tsx | 89 + frontend/src/components/MediaPicker.tsx | 156 + frontend/src/components/Modal.tsx | 112 + frontend/src/components/Pagination.tsx | 74 + frontend/src/components/SortableHeader.tsx | 58 + frontend/src/components/Toasts.tsx | 94 + frontend/src/components/ui.tsx | 320 + frontend/src/index.css | 53 + frontend/src/lib/dates.ts | 209 + frontend/src/lib/labels.ts | 122 + frontend/src/lib/navigation.ts | 150 + frontend/src/lib/reminders.ts | 96 + frontend/src/lib/routes.ts | 98 + frontend/src/lib/safePath.ts | 21 + frontend/src/lib/sanitizeHtmlPreview.ts | 52 + frontend/src/lib/useBanners.ts | 131 + frontend/src/lib/useDocumentMeta.ts | 52 + frontend/src/lib/useInteractions.ts | 336 ++ frontend/src/lib/useLegal.ts | 44 + frontend/src/lib/usePublicConfig.ts | 84 + frontend/src/lib/useSpeakRequest.ts | 129 + frontend/src/main.tsx | 40 + frontend/src/pages/DashboardPage.tsx | 226 + frontend/src/pages/HealthPage.tsx | 57 + frontend/src/pages/LoginPage.tsx | 176 + frontend/src/pages/NotFoundPage.tsx | 42 + .../src/pages/banners/BannerFormDialog.tsx | 397 ++ frontend/src/pages/banners/BannerSection.tsx | 260 + frontend/src/pages/banners/BannersPage.tsx | 28 + .../src/pages/contacts/ContactFormDialog.tsx | 235 + .../pages/contacts/ContactImportDialog.tsx | 321 + .../src/pages/contacts/ContactListPage.tsx | 384 ++ frontend/src/pages/contacts/GroupsDialog.tsx | 101 + .../pages/courses/CertificateOverviewPage.tsx | 593 ++ .../courses/CourseCertificateSection.tsx | 434 ++ .../src/pages/courses/CourseDetailPage.tsx | 337 ++ frontend/src/pages/courses/CourseFormPage.tsx | 436 ++ frontend/src/pages/courses/CourseListPage.tsx | 403 ++ .../src/pages/courses/CourseStatsSection.tsx | 207 + .../src/pages/courses/CurriculumSection.tsx | 430 ++ frontend/src/pages/courses/LessonEditor.tsx | 324 + frontend/src/pages/courses/ProgressPage.tsx | 355 ++ frontend/src/pages/courses/QuizEditor.tsx | 840 +++ .../src/pages/courses/StudentDetailPage.tsx | 584 ++ .../src/pages/courses/StudentListPage.tsx | 343 ++ frontend/src/pages/courses/courseLabels.ts | 89 + frontend/src/pages/orders/OrderListPage.tsx | 380 ++ frontend/src/pages/public/BannerSlot.tsx | 160 + .../pages/public/CertificateVerifyPage.tsx | 120 + .../src/pages/public/InteractionPanel.tsx | 376 ++ frontend/src/pages/public/JitsiRoom.tsx | 88 + frontend/src/pages/public/LegalPage.tsx | 37 + frontend/src/pages/public/LiveKitRoom.tsx | 85 + .../src/pages/public/MeetingActionBar.tsx | 246 + frontend/src/pages/public/MeetingLobby.tsx | 88 + frontend/src/pages/public/MeetingPage.tsx | 568 ++ .../src/pages/public/MyRegistrationPage.tsx | 343 ++ frontend/src/pages/public/OfferTab.tsx | 233 + frontend/src/pages/public/PublicLayout.tsx | 109 + .../src/pages/public/PublicWebinarPage.tsx | 183 + .../src/pages/public/RecordingTemplate.tsx | 97 + .../src/pages/public/RegistrationForm.tsx | 311 + frontend/src/pages/public/TasksTab.tsx | 173 + frontend/src/pages/public/UnsubscribePage.tsx | 136 + frontend/src/pages/public/ZoomRoom.tsx | 106 + .../src/pages/settings/AppSettingsPage.tsx | 21 + .../src/pages/settings/BrandingSection.tsx | 164 + .../pages/settings/BrandingSettingsPage.tsx | 5 + .../pages/settings/CompanySettingsPage.tsx | 46 + .../pages/settings/ConsentSettingsPage.tsx | 33 + .../pages/settings/HomePageSettingsPage.tsx | 361 ++ .../src/pages/settings/MailSettingsPage.tsx | 49 + .../pages/settings/ReminderSettingsPage.tsx | 287 + .../src/pages/settings/SafeModeNotice.tsx | 65 + .../src/pages/settings/SettingsLayout.tsx | 78 + frontend/src/pages/settings/SettingsPane.tsx | 28 + .../src/pages/settings/SettingsSection.tsx | 161 + .../src/pages/settings/SmsSettingsPage.tsx | 47 + frontend/src/pages/settings/TestBlock.tsx | 80 + .../src/pages/settings/VideoSettingsPage.tsx | 125 + frontend/src/pages/settings/useSettings.ts | 41 + frontend/src/pages/users/UserListPage.tsx | 401 ++ .../src/pages/webinars/CertificateSection.tsx | 344 ++ frontend/src/pages/webinars/FilesSection.tsx | 462 ++ .../src/pages/webinars/HostConsolePage.tsx | 734 +++ .../src/pages/webinars/InvitationSection.tsx | 265 + frontend/src/pages/webinars/OfferSection.tsx | 257 + frontend/src/pages/webinars/OffersPage.tsx | 66 + frontend/src/pages/webinars/ProviderHelp.tsx | 138 + .../src/pages/webinars/ProviderPicker.tsx | 248 + .../pages/webinars/RegistrationListPage.tsx | 395 ++ .../src/pages/webinars/ReminderSection.tsx | 193 + frontend/src/pages/webinars/SpeakQueue.tsx | 353 ++ frontend/src/pages/webinars/StatsSection.tsx | 276 + frontend/src/pages/webinars/VideoSection.tsx | 269 + .../src/pages/webinars/WebinarFormPage.tsx | 637 ++ .../src/pages/webinars/WebinarListPage.tsx | 575 ++ .../src/pages/webinars/WebinarPreviewPage.tsx | 508 ++ frontend/src/vite-env.d.ts | 9 + frontend/tailwind.config.js | 137 + frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 7 + invite-przyklad.ics | 185 + livekit/livekit.yaml | 78 + scripts/backup.sh | 164 + scripts/check-config.sh | 238 + scripts/restore.sh | 122 + 609 files changed, 94880 insertions(+) create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 DEPLOYMENT_CHECKLIST.md create mode 100644 README.md create mode 100644 Webinar-platform.code-workspace create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/softspm/webinar/WebinarApplication.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/AcademyController.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/AcademyDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/AcademyMailService.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/AcademyService.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/AuthenticatedStudent.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/LearningController.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/LearningDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/LearningService.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/LessonMediaController.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/PageSectionBootstrap.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/PageSectionController.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/PublicAcademyController.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/PublicAcademyDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/PublicAcademyService.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/StudentAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/StudentAdminDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/StudentAdminService.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/StudentAuthenticationFilter.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/StudentCsvWriter.java create mode 100644 backend/src/main/java/com/softspm/webinar/academy/StudentParticipationService.java create mode 100644 backend/src/main/java/com/softspm/webinar/attendance/AttendanceScheduler.java create mode 100644 backend/src/main/java/com/softspm/webinar/attendance/AttendanceService.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/AdminBootstrap.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/AuthController.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/AuthService.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/AuthenticatedUser.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/ChangePasswordRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/JwtProperties.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/JwtService.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/LoginAttemptService.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/LoginRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/LoginResponse.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/ResetPasswordResponse.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/UnifiedLoginDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/UnifiedLoginService.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/UserAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/UserRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/UserService.java create mode 100644 backend/src/main/java/com/softspm/webinar/auth/UserView.java create mode 100644 backend/src/main/java/com/softspm/webinar/banner/BannerAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/banner/BannerEventWriter.java create mode 100644 backend/src/main/java/com/softspm/webinar/banner/BannerRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/banner/BannerService.java create mode 100644 backend/src/main/java/com/softspm/webinar/banner/BannerView.java create mode 100644 backend/src/main/java/com/softspm/webinar/banner/PublicBannerView.java create mode 100644 backend/src/main/java/com/softspm/webinar/banner/WebinarBannerController.java create mode 100644 backend/src/main/java/com/softspm/webinar/calendar/IcsGenerator.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CertificateAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CertificateCsvWriter.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CertificateOverviewDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CertificateOverviewService.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CertificatePdfRenderer.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CertificateService.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CertificateView.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CourseCertificateMailer.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CourseCertificateSender.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/CourseCertificateService.java create mode 100644 backend/src/main/java/com/softspm/webinar/certificate/PublicCertificateController.java create mode 100644 backend/src/main/java/com/softspm/webinar/config/AsyncConfig.java create mode 100644 backend/src/main/java/com/softspm/webinar/config/CertificateTemplateConfig.java create mode 100644 backend/src/main/java/com/softspm/webinar/config/MailTemplateConfig.java create mode 100644 backend/src/main/java/com/softspm/webinar/config/RestAccessDeniedHandler.java create mode 100644 backend/src/main/java/com/softspm/webinar/config/RestAuthenticationEntryPoint.java create mode 100644 backend/src/main/java/com/softspm/webinar/config/SchedulingConfig.java create mode 100644 backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/ContactAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/ContactCsvWriter.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/ContactImportService.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/ContactRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/ContactService.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/ContactView.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/InvitationAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/InvitationRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/InvitationSender.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/InvitationService.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/InvitationView.java create mode 100644 backend/src/main/java/com/softspm/webinar/contact/PublicUnsubscribeController.java create mode 100644 backend/src/main/java/com/softspm/webinar/content/HtmlSanitizer.java create mode 100644 backend/src/main/java/com/softspm/webinar/content/ImageTypeDetector.java create mode 100644 backend/src/main/java/com/softspm/webinar/content/MediaController.java create mode 100644 backend/src/main/java/com/softspm/webinar/content/MediaService.java create mode 100644 backend/src/main/java/com/softspm/webinar/content/MediaView.java create mode 100644 backend/src/main/java/com/softspm/webinar/content/PlainTextSanitizer.java create mode 100644 backend/src/main/java/com/softspm/webinar/content/VideoTypeDetector.java create mode 100644 backend/src/main/java/com/softspm/webinar/course/CourseAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/course/CourseCompletionService.java create mode 100644 backend/src/main/java/com/softspm/webinar/course/CourseCopyService.java create mode 100644 backend/src/main/java/com/softspm/webinar/course/CourseDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/course/CourseEnrollmentService.java create mode 100644 backend/src/main/java/com/softspm/webinar/course/CourseService.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/AppSetting.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/AttendanceSession.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/AttendanceSource.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Banner.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/BannerEvent.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/BannerEventType.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/BannerPlacement.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Certificate.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/CertificateCounter.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ChatMode.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Consent.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ConsentType.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Contact.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ContactGroup.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ContactGroupMember.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ContactSource.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Course.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/CourseLevel.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/CourseModule.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/CourseStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Enrollment.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/EnrollmentStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Interaction.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/InteractionStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/InteractionType.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/InteractionVote.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Invitation.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/InvitationStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Lesson.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/LessonProgress.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/LessonProgressStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/LessonType.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Material.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/MediaFile.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Notification.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/NotificationChannel.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/NotificationStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/OfferTopic.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Order.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/OrderStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/PageSection.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/PageSectionType.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/PaymentNotification.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Quiz.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/QuizAnswer.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/QuizAttempt.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/QuizAttemptAnswer.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/QuizQuestion.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Recording.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/RecordingStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Registration.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/RegistrationRoomRole.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/RegistrationStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ReminderRule.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/SpeakRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/SpeakRequestStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Task.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/User.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/UserRole.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Visibility.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/Webinar.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/WebinarSpeaker.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/WebinarStatus.java create mode 100644 backend/src/main/java/com/softspm/webinar/health/HealthController.java create mode 100644 backend/src/main/java/com/softspm/webinar/health/HealthResponse.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/InteractionPrincipal.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/InteractionView.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/ModeratorInteractionView.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/OfferService.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/PublicInteractionController.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/SalesNotifier.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestService.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestView.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/StompAuthChannelInterceptor.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/TaskOfferAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/TaskService.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/TaskView.java create mode 100644 backend/src/main/java/com/softspm/webinar/interaction/WebSocketConfig.java create mode 100644 backend/src/main/java/com/softspm/webinar/mail/MailService.java create mode 100644 backend/src/main/java/com/softspm/webinar/mail/NotificationService.java create mode 100644 backend/src/main/java/com/softspm/webinar/mail/SafeDelivery.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/FileAccessGuard.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/MaterialService.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/MaterialView.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/MediaAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/PublicFileController.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/RangeResponder.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/RecordingService.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/RecordingView.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/VideoWebhookController.java create mode 100644 backend/src/main/java/com/softspm/webinar/media/ZoomWebhookVerifier.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/AutopayConfig.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/AutopayGatewayClient.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/AutopayHash.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/AutopayItnController.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/AutopayItnParser.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/AutopayItnService.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/OrderAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/OrderDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/OrderFulfilmentService.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/OrderMailService.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/OrderService.java create mode 100644 backend/src/main/java/com/softspm/webinar/payment/OrderStudentController.java create mode 100644 backend/src/main/java/com/softspm/webinar/quiz/QuizAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/quiz/QuizAttemptService.java create mode 100644 backend/src/main/java/com/softspm/webinar/quiz/QuizDtos.java create mode 100644 backend/src/main/java/com/softspm/webinar/quiz/QuizScheduler.java create mode 100644 backend/src/main/java/com/softspm/webinar/quiz/QuizService.java create mode 100644 backend/src/main/java/com/softspm/webinar/quiz/QuizStudentController.java create mode 100644 backend/src/main/java/com/softspm/webinar/registration/MyRegistrationView.java create mode 100644 backend/src/main/java/com/softspm/webinar/registration/RegistrationCsvWriter.java create mode 100644 backend/src/main/java/com/softspm/webinar/registration/RegistrationRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/registration/RegistrationResult.java create mode 100644 backend/src/main/java/com/softspm/webinar/registration/RegistrationService.java create mode 100644 backend/src/main/java/com/softspm/webinar/registration/RegistrationView.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ReminderAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ReminderRuleRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ReminderRuleService.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ReminderRuleView.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ReminderScheduler.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ReminderSender.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ReminderTextBuilder.java create mode 100644 backend/src/main/java/com/softspm/webinar/reminder/ScheduledSendView.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/AppSettingRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/AttendanceSessionRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/BannerEventRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/BannerRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/CertificateCounterRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/CertificateRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/ConsentRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/ContactGroupMemberRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/ContactGroupRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/ContactRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/CourseModuleRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/CourseRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/EnrollmentRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/InteractionRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/InteractionVoteRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/InvitationRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/LessonProgressRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/LessonRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/MaterialRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/MediaFileRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/NotificationRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/OfferTopicRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/OrderRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/PageSectionRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/PaymentNotificationRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/QuizAnswerRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/QuizAttemptAnswerRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/QuizAttemptRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/QuizQuestionRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/QuizRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/RecordingRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/RegistrationRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/ReminderRuleRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/SpeakRequestRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/StudentAdminRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/TaskRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/UserRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/WebinarRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/WebinarSpeakerRepository.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/ConfigCheckController.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/SettingKey.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/SettingType.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/SettingView.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/SettingsController.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/SettingsService.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/SettingsStartupReport.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/TestEmailRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/TestResult.java create mode 100644 backend/src/main/java/com/softspm/webinar/settings/TestSmsRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/sms/SmsService.java create mode 100644 backend/src/main/java/com/softspm/webinar/stats/DashboardView.java create mode 100644 backend/src/main/java/com/softspm/webinar/stats/StatsController.java create mode 100644 backend/src/main/java/com/softspm/webinar/stats/StatsService.java create mode 100644 backend/src/main/java/com/softspm/webinar/stats/WebinarStatsView.java create mode 100644 backend/src/main/java/com/softspm/webinar/storage/BrandingController.java create mode 100644 backend/src/main/java/com/softspm/webinar/storage/FileStorageService.java create mode 100644 backend/src/main/java/com/softspm/webinar/summary/RecordingNotifier.java create mode 100644 backend/src/main/java/com/softspm/webinar/summary/SummaryData.java create mode 100644 backend/src/main/java/com/softspm/webinar/summary/SummaryMailBuilder.java create mode 100644 backend/src/main/java/com/softspm/webinar/summary/SummaryQueueWriter.java create mode 100644 backend/src/main/java/com/softspm/webinar/summary/SummaryScheduler.java create mode 100644 backend/src/main/java/com/softspm/webinar/summary/SummarySender.java create mode 100644 backend/src/main/java/com/softspm/webinar/summary/SummaryService.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/DailyVideoProvider.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/JitsiVideoProvider.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/NoneVideoProvider.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/RoomRole.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/VideoAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/VideoProvider.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/VideoProviderFactory.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/VideoService.java create mode 100644 backend/src/main/java/com/softspm/webinar/video/ZoomVideoProvider.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/ApiError.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/ApiExceptionHandler.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/LegalDocuments.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/NotFoundException.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/PreviewController.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/PublicConfigController.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/PublicConfigView.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/PublicController.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/PublicLegalController.java create mode 100644 backend/src/main/java/com/softspm/webinar/web/ValidationException.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/ConsentTextView.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/PublicWebinarService.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/PublicWebinarView.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/RegistrationAvailability.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/SlugGenerator.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/WebinarAdminController.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/WebinarCopyService.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/WebinarRequest.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/WebinarService.java create mode 100644 backend/src/main/java/com/softspm/webinar/webinar/WebinarView.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/main/resources/db/migration/.gitkeep create mode 100644 backend/src/main/resources/db/migration/V10__summary_once_per_recipient.sql create mode 100644 backend/src/main/resources/db/migration/V11__consent_texts_diacritics.sql create mode 100644 backend/src/main/resources/db/migration/V12__recording_notification.sql create mode 100644 backend/src/main/resources/db/migration/V13__recordings_and_materials.sql create mode 100644 backend/src/main/resources/db/migration/V14__attendance.sql create mode 100644 backend/src/main/resources/db/migration/V15__contacts_and_invitations.sql create mode 100644 backend/src/main/resources/db/migration/V16__certificates.sql create mode 100644 backend/src/main/resources/db/migration/V17__speak_requests.sql create mode 100644 backend/src/main/resources/db/migration/V18__open_mic.sql create mode 100644 backend/src/main/resources/db/migration/V19__banners.sql create mode 100644 backend/src/main/resources/db/migration/V1__init.sql create mode 100644 backend/src/main/resources/db/migration/V20__video_provider_per_webinar.sql create mode 100644 backend/src/main/resources/db/migration/V21__contact_accounts.sql create mode 100644 backend/src/main/resources/db/migration/V22__courses.sql create mode 100644 backend/src/main/resources/db/migration/V23__quizzes.sql create mode 100644 backend/src/main/resources/db/migration/V24__orders.sql create mode 100644 backend/src/main/resources/db/migration/V25__page_sections.sql create mode 100644 backend/src/main/resources/db/migration/V26__course_certificates.sql create mode 100644 backend/src/main/resources/db/migration/V27__page_sections_sample_flag.sql create mode 100644 backend/src/main/resources/db/migration/V28__registration_moderation.sql create mode 100644 backend/src/main/resources/db/migration/V29__open_video.sql create mode 100644 backend/src/main/resources/db/migration/V2__consent_texts.sql create mode 100644 backend/src/main/resources/db/migration/V3__users_login_metadata.sql create mode 100644 backend/src/main/resources/db/migration/V4__reminder_rules.sql create mode 100644 backend/src/main/resources/db/migration/V5__webinar_content_and_media.sql create mode 100644 backend/src/main/resources/db/migration/V6__video_rooms_and_speakers.sql create mode 100644 backend/src/main/resources/db/migration/V7__interactions.sql create mode 100644 backend/src/main/resources/db/migration/V8__tasks_and_offers.sql create mode 100644 backend/src/main/resources/db/migration/V9__webinar_summary.sql create mode 100644 backend/src/main/resources/fonts/DejaVuSans-Bold.ttf create mode 100644 backend/src/main/resources/fonts/DejaVuSans.ttf create mode 100644 backend/src/main/resources/templates/certificate/course.html create mode 100644 backend/src/main/resources/templates/certificate/default.html create mode 100644 backend/src/main/resources/templates/mail/academy-account-exists.html create mode 100644 backend/src/main/resources/templates/mail/academy-account-exists.txt create mode 100644 backend/src/main/resources/templates/mail/academy-password-reset.html create mode 100644 backend/src/main/resources/templates/mail/academy-password-reset.txt create mode 100644 backend/src/main/resources/templates/mail/academy-verify.html create mode 100644 backend/src/main/resources/templates/mail/academy-verify.txt create mode 100644 backend/src/main/resources/templates/mail/already-registered.html create mode 100644 backend/src/main/resources/templates/mail/already-registered.txt create mode 100644 backend/src/main/resources/templates/mail/certificate-copy.html create mode 100644 backend/src/main/resources/templates/mail/certificate-copy.txt create mode 100644 backend/src/main/resources/templates/mail/course-completed.html create mode 100644 backend/src/main/resources/templates/mail/course-completed.txt create mode 100644 backend/src/main/resources/templates/mail/invitation.html create mode 100644 backend/src/main/resources/templates/mail/invitation.txt create mode 100644 backend/src/main/resources/templates/mail/order-paid.html create mode 100644 backend/src/main/resources/templates/mail/order-paid.txt create mode 100644 backend/src/main/resources/templates/mail/recording-available.html create mode 100644 backend/src/main/resources/templates/mail/recording-available.txt create mode 100644 backend/src/main/resources/templates/mail/registration-confirmation.html create mode 100644 backend/src/main/resources/templates/mail/registration-confirmation.txt create mode 100644 backend/src/main/resources/templates/mail/reminder.html create mode 100644 backend/src/main/resources/templates/mail/reminder.txt create mode 100644 backend/src/main/resources/templates/mail/summary-host.html create mode 100644 backend/src/main/resources/templates/mail/summary-host.txt create mode 100644 backend/src/main/resources/templates/mail/summary-participant.html create mode 100644 backend/src/main/resources/templates/mail/summary-participant.txt create mode 100644 backend/src/test/java/com/softspm/webinar/academy/CourseModelTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/academy/LessonProgressRulesTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/academy/PublicAcademySecurityTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/academy/StudentTokenTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/banner/BannerTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/calendar/IcsGeneratorTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/calendar/IcsSampleWriterTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/certificate/CertificateNumberTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/certificate/CertificatePdfTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/certificate/CourseCertificateOwnerTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/certificate/CourseCertificatePdfTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/config/TransactionalSelfInvocationTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/content/HtmlSanitizerTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/course/CourseProgressTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/interaction/PlainTextSanitizerTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/interaction/SpeakRequestTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/interaction/StompSubscriptionAuthTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/interaction/TaskCapacityTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/mail/MailAttachmentTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/mail/MailPreviewTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/mail/SafeDeliveryTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/media/FileAccessTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/media/ZoomWebhookVerifierTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/payment/AutopayHashTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/payment/AutopayItnTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/quiz/QuizAttemptRulesTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/quiz/QuizSecurityTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/registration/RegistrationConcurrencyTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/reminder/ReminderSchedulerTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/reminder/ReminderTextBuilderTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/settings/SettingDefaultsTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/settings/SettingSelectTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/summary/SummaryTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/video/ProviderTokenTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/video/RoomRoleTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/web/PublicConfigControllerTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/web/PublicLegalControllerTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/webinar/RegistrationAvailabilityTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/webinar/WebinarCopyTest.java create mode 100644 backend/src/test/java/com/softspm/webinar/webinar/WebinarSearchTest.java create mode 100644 backend/src/test/resources/application-test.yml create mode 100644 backend/src/test/resources/junit-platform.properties create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/Akademia Wodociagowca - Wariant 1a.html create mode 100644 docs/Akademia Wodociagowca - Wariant 1b.html create mode 100644 docs/Webinar-platform_Instrukcja_Uruchomienia_PL.html create mode 100644 docs/img/03-logowanie.png create mode 100644 docs/img/03-pulpit.png create mode 100644 docs/img/04-ustawienia-aplikacja.png create mode 100644 docs/img/05-ustawienia-email.png create mode 100644 docs/img/06-formularz-podstawowe.png create mode 100644 docs/img/07-formularz-termin.png create mode 100644 docs/img/08-formularz-zapisy.png create mode 100644 docs/img/09-lista-webinarow-draft.png create mode 100644 docs/img/10-tresc-strony.png create mode 100644 docs/img/11-wideo-pokoj.png create mode 100644 docs/img/12-prelegent-formularz.png create mode 100644 docs/img/12b-prelegent-dodany.png create mode 100644 docs/img/13-przypomnienia.png create mode 100644 docs/img/14-lista-opublikowany.png create mode 100644 docs/img/15-strona-publiczna.png create mode 100644 docs/img/16-formularz-zapisu-wypelniony.png create mode 100644 docs/img/17-potwierdzenie-zapisu.png create mode 100644 docs/img/18-karta-webinaru.png create mode 100644 docs/img/19-zapisani.png create mode 100644 docs/img/20-przedpokoj.png create mode 100644 docs/img/21-sala-uczestnika.png create mode 100644 docs/img/23-uczestnik-czat.png create mode 100644 docs/img/24-panel-z-interakcjami.png create mode 100644 docs/img/25-statystyki.png create mode 100644 docs/img/26-zapytania.png create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/academy/AcademyBits.tsx create mode 100644 frontend/src/academy/AcademyHomePage.tsx create mode 100644 frontend/src/academy/AcademyLayout.tsx create mode 100644 frontend/src/academy/AcademyLoginPage.tsx create mode 100644 frontend/src/academy/AcademyRegisterPage.tsx create mode 100644 frontend/src/academy/AdminHint.tsx create mode 100644 frontend/src/academy/CheckoutPage.tsx create mode 100644 frontend/src/academy/CourseListPage.tsx create mode 100644 frontend/src/academy/CoursePage.tsx create mode 100644 frontend/src/academy/CoursePlayerPage.tsx create mode 100644 frontend/src/academy/HomeWebinarCard.tsx create mode 100644 frontend/src/academy/PanelMovedNotice.tsx create mode 100644 frontend/src/academy/PaymentReturnPage.tsx create mode 100644 frontend/src/academy/QuizPage.tsx create mode 100644 frontend/src/academy/RequireStudent.tsx create mode 100644 frontend/src/academy/StudentAuthContext.tsx create mode 100644 frontend/src/academy/StudentDashboardPage.tsx create mode 100644 frontend/src/api/academyClient.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/publicClient.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/auth/AuthContext.tsx create mode 100644 frontend/src/auth/Guards.tsx create mode 100644 frontend/src/components/BrandMark.tsx create mode 100644 frontend/src/components/ContentEditor.tsx create mode 100644 frontend/src/components/CookieBanner.tsx create mode 100644 frontend/src/components/FormTabs.tsx create mode 100644 frontend/src/components/Icon.tsx create mode 100644 frontend/src/components/ImageEditor.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/MediaField.tsx create mode 100644 frontend/src/components/MediaPicker.tsx create mode 100644 frontend/src/components/Modal.tsx create mode 100644 frontend/src/components/Pagination.tsx create mode 100644 frontend/src/components/SortableHeader.tsx create mode 100644 frontend/src/components/Toasts.tsx create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/dates.ts create mode 100644 frontend/src/lib/labels.ts create mode 100644 frontend/src/lib/navigation.ts create mode 100644 frontend/src/lib/reminders.ts create mode 100644 frontend/src/lib/routes.ts create mode 100644 frontend/src/lib/safePath.ts create mode 100644 frontend/src/lib/sanitizeHtmlPreview.ts create mode 100644 frontend/src/lib/useBanners.ts create mode 100644 frontend/src/lib/useDocumentMeta.ts create mode 100644 frontend/src/lib/useInteractions.ts create mode 100644 frontend/src/lib/useLegal.ts create mode 100644 frontend/src/lib/usePublicConfig.ts create mode 100644 frontend/src/lib/useSpeakRequest.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/HealthPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/NotFoundPage.tsx create mode 100644 frontend/src/pages/banners/BannerFormDialog.tsx create mode 100644 frontend/src/pages/banners/BannerSection.tsx create mode 100644 frontend/src/pages/banners/BannersPage.tsx create mode 100644 frontend/src/pages/contacts/ContactFormDialog.tsx create mode 100644 frontend/src/pages/contacts/ContactImportDialog.tsx create mode 100644 frontend/src/pages/contacts/ContactListPage.tsx create mode 100644 frontend/src/pages/contacts/GroupsDialog.tsx create mode 100644 frontend/src/pages/courses/CertificateOverviewPage.tsx create mode 100644 frontend/src/pages/courses/CourseCertificateSection.tsx create mode 100644 frontend/src/pages/courses/CourseDetailPage.tsx create mode 100644 frontend/src/pages/courses/CourseFormPage.tsx create mode 100644 frontend/src/pages/courses/CourseListPage.tsx create mode 100644 frontend/src/pages/courses/CourseStatsSection.tsx create mode 100644 frontend/src/pages/courses/CurriculumSection.tsx create mode 100644 frontend/src/pages/courses/LessonEditor.tsx create mode 100644 frontend/src/pages/courses/ProgressPage.tsx create mode 100644 frontend/src/pages/courses/QuizEditor.tsx create mode 100644 frontend/src/pages/courses/StudentDetailPage.tsx create mode 100644 frontend/src/pages/courses/StudentListPage.tsx create mode 100644 frontend/src/pages/courses/courseLabels.ts create mode 100644 frontend/src/pages/orders/OrderListPage.tsx create mode 100644 frontend/src/pages/public/BannerSlot.tsx create mode 100644 frontend/src/pages/public/CertificateVerifyPage.tsx create mode 100644 frontend/src/pages/public/InteractionPanel.tsx create mode 100644 frontend/src/pages/public/JitsiRoom.tsx create mode 100644 frontend/src/pages/public/LegalPage.tsx create mode 100644 frontend/src/pages/public/LiveKitRoom.tsx create mode 100644 frontend/src/pages/public/MeetingActionBar.tsx create mode 100644 frontend/src/pages/public/MeetingLobby.tsx create mode 100644 frontend/src/pages/public/MeetingPage.tsx create mode 100644 frontend/src/pages/public/MyRegistrationPage.tsx create mode 100644 frontend/src/pages/public/OfferTab.tsx create mode 100644 frontend/src/pages/public/PublicLayout.tsx create mode 100644 frontend/src/pages/public/PublicWebinarPage.tsx create mode 100644 frontend/src/pages/public/RecordingTemplate.tsx create mode 100644 frontend/src/pages/public/RegistrationForm.tsx create mode 100644 frontend/src/pages/public/TasksTab.tsx create mode 100644 frontend/src/pages/public/UnsubscribePage.tsx create mode 100644 frontend/src/pages/public/ZoomRoom.tsx create mode 100644 frontend/src/pages/settings/AppSettingsPage.tsx create mode 100644 frontend/src/pages/settings/BrandingSection.tsx create mode 100644 frontend/src/pages/settings/BrandingSettingsPage.tsx create mode 100644 frontend/src/pages/settings/CompanySettingsPage.tsx create mode 100644 frontend/src/pages/settings/ConsentSettingsPage.tsx create mode 100644 frontend/src/pages/settings/HomePageSettingsPage.tsx create mode 100644 frontend/src/pages/settings/MailSettingsPage.tsx create mode 100644 frontend/src/pages/settings/ReminderSettingsPage.tsx create mode 100644 frontend/src/pages/settings/SafeModeNotice.tsx create mode 100644 frontend/src/pages/settings/SettingsLayout.tsx create mode 100644 frontend/src/pages/settings/SettingsPane.tsx create mode 100644 frontend/src/pages/settings/SettingsSection.tsx create mode 100644 frontend/src/pages/settings/SmsSettingsPage.tsx create mode 100644 frontend/src/pages/settings/TestBlock.tsx create mode 100644 frontend/src/pages/settings/VideoSettingsPage.tsx create mode 100644 frontend/src/pages/settings/useSettings.ts create mode 100644 frontend/src/pages/users/UserListPage.tsx create mode 100644 frontend/src/pages/webinars/CertificateSection.tsx create mode 100644 frontend/src/pages/webinars/FilesSection.tsx create mode 100644 frontend/src/pages/webinars/HostConsolePage.tsx create mode 100644 frontend/src/pages/webinars/InvitationSection.tsx create mode 100644 frontend/src/pages/webinars/OfferSection.tsx create mode 100644 frontend/src/pages/webinars/OffersPage.tsx create mode 100644 frontend/src/pages/webinars/ProviderHelp.tsx create mode 100644 frontend/src/pages/webinars/ProviderPicker.tsx create mode 100644 frontend/src/pages/webinars/RegistrationListPage.tsx create mode 100644 frontend/src/pages/webinars/ReminderSection.tsx create mode 100644 frontend/src/pages/webinars/SpeakQueue.tsx create mode 100644 frontend/src/pages/webinars/StatsSection.tsx create mode 100644 frontend/src/pages/webinars/VideoSection.tsx create mode 100644 frontend/src/pages/webinars/WebinarFormPage.tsx create mode 100644 frontend/src/pages/webinars/WebinarListPage.tsx create mode 100644 frontend/src/pages/webinars/WebinarPreviewPage.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 invite-przyklad.ics create mode 100644 livekit/livekit.yaml create mode 100644 scripts/backup.sh create mode 100644 scripts/check-config.sh create mode 100644 scripts/restore.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e877e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,83 @@ +# Konfiguracja instancji. +# Skopiuj do .env i dostosuj. Plik .env nie trafia do repozytorium. +# Kazdy klient ma wlasna instancje i wlasny .env. + +# --- Baza danych --- +# Nazwa bazy tworzonej przy pierwszym starcie kontenera postgres +DB_NAME=webinar +# Uzytkownik bazy +DB_USER=webinar +# Haslo uzytkownika bazy. W produkcji ustaw wlasne. +DB_PASSWORD=webinar +# Port bazy wystawiony na hoscie, przydatny do podlaczenia klientem SQL +DB_PORT_EXPOSED=5432 + +# --- JWT --- +# Sekret do podpisywania tokenow HS384. WYMAGANY, minimum 32 znaki. +# Aplikacja nie wystartuje bez niego ani z sekretem krotszym niz 32 znaki. +# Nie da sie go zmienic przez panel, wylacznie tutaj. +# +# UWAGA: ponizsza wartosc to PLACEHOLDER do lokalnego uruchomienia i jest +# publicznie znana (ten plik jest w repozytorium). Kto ja zna, moze wykuc +# dowolny token, w tym token administratora. Na kazdej instancji osiagalnej +# z sieci wygeneruj wlasny, na przyklad: openssl rand -base64 48 +JWT_SECRET=zmien-ten-sekret-w-srodowisku-produkcyjnym-min-32-znaki +# Czas waznosci tokenu w minutach +JWT_EXPIRATION_MINUTES=120 + +# --- Pierwsze konto administratora --- +# Uzywane tylko przy pierwszym starcie, gdy tabela users jest pusta. +# Gdy ADMIN_PASSWORD jest puste, haslo zostanie wylosowane i wypisane +# jednorazowo w logu kontenera backend. +ADMIN_EMAIL=admin@localhost +ADMIN_PASSWORD= + +# --- Dokumentacja API --- +# Swagger UI jest domyslnie wylaczony. Na produkcji zostaw false. +SWAGGER_ENABLED=false + +# --- MinIO --- +# Klucz dostepu, jednoczesnie login do konsoli MinIO +MINIO_ACCESS_KEY=minioadmin +# Klucz tajny, jednoczesnie haslo do konsoli MinIO. Minimum 8 znakow. +MINIO_SECRET_KEY=minioadmin +# Bucket na nagrania, materialy i zalaczniki +MINIO_BUCKET=webinar +# Port API MinIO wystawiony na hoscie +MINIO_PORT_EXPOSED=9000 +# Port konsoli MinIO wystawiony na hoscie +MINIO_CONSOLE_PORT_EXPOSED=9001 + +# --- Poczta --- +# Host serwera SMTP +MAIL_HOST=localhost +# Port serwera SMTP +MAIL_PORT=1025 +# Login SMTP, pusty gdy serwer nie wymaga uwierzytelnienia +MAIL_USER= +# Haslo SMTP +MAIL_PASSWORD= +# Adres nadawcy zaproszen i przypomnien +MAIL_FROM=webinar@localhost +# Czy serwer SMTP wymaga uwierzytelnienia +MAIL_SMTP_AUTH=false +# Czy wymuszac STARTTLS +MAIL_SMTP_STARTTLS=false + +# --- Wideo --- +# Wybor implementacji VideoProvider. Na tym etapie brak implementacji. +VIDEO_PROVIDER=none + +# --- Aplikacja --- +# Dozwolone originy CORS, rozdzielone przecinkiem +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8081 +# Port backendu wystawiony na hoscie +BACKEND_PORT_EXPOSED=8080 +# Port frontendu wystawiony na hoscie, pod nim dostepna jest aplikacja +FRONTEND_PORT_EXPOSED=8081 + +# --- Logowanie --- +# Poziom logow dla calej aplikacji +LOG_LEVEL_ROOT=INFO +# Poziom logow dla kodu aplikacji +LOG_LEVEL_APP=DEBUG diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..a85d5a3 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,164 @@ +# Konfiguracja instancji produkcyjnej. +# +# Skopiuj do .env.production i uzupelnij. Plik z realnymi wartosciami NIE trafia +# do repozytorium i nie moze byc kopiowany miedzy klientami. +# +# cp .env.production.example .env.production +# chmod 600 .env.production +# +# Wartosci sekretow sa celowo puste. Aplikacja nie wystartuje bez JWT_SECRET, +# a pozostale puste sekrety uniemozliwia dzialanie odpowiednich funkcji. + +# ============================================================================= +# BAZA DANYCH +# ============================================================================= + +# Nazwa bazy tworzonej przy pierwszym starcie kontenera postgres. +DB_NAME=webinar + +# Uzytkownik bazy. +DB_USER=webinar + +# Haslo uzytkownika bazy. WYMAGANE. Wygeneruj losowe, na przyklad: +# openssl rand -base64 24 +# Baza nie jest wystawiona poza siec Dockera, ale haslo i tak musi byc mocne. +DB_PASSWORD= + +# ============================================================================= +# JWT +# ============================================================================= + +# Sekret podpisujacy tokeny. WYMAGANE, minimum 32 znaki. +# Aplikacja NIE WYSTARTUJE bez tej wartosci ani z krotsza niz 32 znaki. +# Nie da sie go zmienic przez panel, wylacznie tutaj. Wygeneruj: +# openssl rand -base64 48 +# Zmiana sekretu uniewaznia wszystkie wydane tokeny, czyli wylogowuje wszystkich. +JWT_SECRET= + +# Czas waznosci tokenu w minutach. +JWT_EXPIRATION_MINUTES=120 + +# ============================================================================= +# PIERWSZE KONTO ADMINISTRATORA +# ============================================================================= +# Uzywane tylko przy pierwszym starcie, gdy tabela users jest pusta. +# Po utworzeniu konta te zmienne nie maja juz znaczenia. + +# Adres e-mail pierwszego administratora. +ADMIN_EMAIL= + +# Haslo pierwszego administratora. Minimum 10 znakow. +# Gdy zostawisz puste, haslo zostanie wylosowane i wypisane JEDEN RAZ w logu: +# docker compose -f docker-compose.prod.yml logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO" +# Zalecane: zostaw puste i odczytaj z logu, wtedy haslo nie lezy w pliku. +ADMIN_PASSWORD= + +# ============================================================================= +# MINIO +# ============================================================================= +# Nagrania, materialy i zalaczniki. Nie jest wystawione poza siec Dockera. + +# Klucz dostepu, jednoczesnie login do konsoli MinIO. WYMAGANE. +MINIO_ACCESS_KEY= + +# Klucz tajny, jednoczesnie haslo do konsoli. WYMAGANE, minimum 8 znakow. +# Wygeneruj: openssl rand -base64 24 +MINIO_SECRET_KEY= + +# Bucket na pliki instancji. +MINIO_BUCKET=webinar + +# ============================================================================= +# APLIKACJA +# ============================================================================= + +# Publiczny adres instancji, z protokolem, bez ukosnika na koncu. +# Uzywany do budowania linkow w mailach i w zajawkach dla komunikatorow. +# Musi byc adresem HTTPS widocznym dla uczestnika, nie adresem wewnetrznym. +# Przyklad: https://webinary.klient.pl +APP_PUBLIC_URL= + +# Dozwolone originy CORS, rozdzielone przecinkiem. Zwykle to samo co APP_PUBLIC_URL. +CORS_ALLOWED_ORIGINS= + +# Port, na ktorym frontend nasluchuje na petli lokalnej serwera. +# Reverse proxy klienta przekazuje tu ruch. Zmien, gdy port jest zajety. +FRONTEND_PORT_EXPOSED=8081 + +# Znacznik wersji obrazow, przydatny przy wycofywaniu zmian. +APP_VERSION=latest + +# ============================================================================= +# POCZTA I BRAMKA SMS +# ============================================================================= +# +# Tych ustawien NIE MA w tym pliku i nie nalezy ich tu dodawac. +# +# Konfiguracja SMTP i bramki SMS odbywa sie wylacznie przez panel +# administracyjny, w sekcji Ustawienia. Wartosci trafiaja do tabeli app_settings +# i maja pierwszenstwo przed zmiennymi srodowiskowymi. Haslo SMTP i klucz API +# bramki sa oznaczone jako sekrety: nigdy nie wracaja w odpowiedzi API, a puste +# pole w formularzu oznacza brak zmiany. +# +# Powody, dla ktorych nie trzymamy ich w .env: +# - zmiana nie wymaga restartu kontenera ani dostepu do serwera, +# - klient moze poprawic konfiguracje poczty samodzielnie, +# - sekrety nie leza w pliku na dysku obok reszty konfiguracji, +# - panel ma wbudowany test wysylki, ktory od razu pokazuje blad serwera SMTP. +# +# Po pierwszym uruchomieniu: zaloguj sie do panelu, wejdz w Ustawienia, +# uzupelnij sekcje "Konto e-mail (SMTP)" i "Bramka SMS", zapisz, a nastepnie +# uzyj bloku "Test konfiguracji" pod kazda sekcja. + +# ============================================================================= +# WIDEO +# ============================================================================= + +# Wybor implementacji VideoProvider. Brak implementacji na tym etapie. +# Domyslny dostawca instancji: NONE, LIVEKIT, JITSI albo DAILY. +# Pojedynczy webinar moze wybrac wlasnego w formularzu. +VIDEO_PROVIDER=NONE + +# Adres serwera LiveKit widziany przez przegladarke uczestnika. +# Klucze API ustawia sie w panelu, tak samo jak dane SMTP: nie ma ich tutaj, +# zeby zmiana nie wymagala restartu ani dostepu do serwera. +LIVEKIT_URL= + +# ----------------------------------------------------------------------------- +# PROFIL LIVEKIT: wlasny serwer wideo +# ----------------------------------------------------------------------------- +# Wypelnij wylacznie, gdy stawiasz serwer u siebie: +# docker compose --profile livekit up -d +# +# Klucz i sekret musza byc te same tutaj i w panelu (Ustawienia > Wideo): +# tutaj konfiguruja serwer, w panelu sluza do podpisywania tokenow. +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= + +# Porty serwera. UDP jest **glowna droga ruchu audio i wideo**: bez otwarcia +# tego zakresu uczestnicy dolacza do pokoju, ale nie beda sie slyszec. +LIVEKIT_PORT=7880 +LIVEKIT_TCP_PORT=7881 +LIVEKIT_UDP_RANGE=50000-50100 + +# TURN: przekazuje ruch uczestnikom za restrykcyjnym NAT-em, czyli w praktyce +# wiekszosci osob w sieciach firmowych. Bez niego czesc uczestnikow nie +# zobaczy i nie uslyszy nikogo. +TURN_REALM=webinar.twojadomena.pl +TURN_USER=webinar +TURN_PASSWORD= +TURN_MIN_PORT=49160 +TURN_MAX_PORT=49200 + +# ============================================================================= +# DOKUMENTACJA I LOGI +# ============================================================================= + +# Swagger UI. Na produkcji zostaw false. +SWAGGER_ENABLED=false + +# Poziom logow calej aplikacji. +LOG_LEVEL_ROOT=INFO + +# Poziom logow kodu aplikacji. Na produkcji INFO, DEBUG tylko przy diagnozie. +LOG_LEVEL_APP=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20bcfd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Konfiguracja instancji, nigdy w repozytorium. +# Wykluczamy KAZDY plik .env* i dopuszczamy z powrotem tylko szablony bez +# sekretow. Wzorzec .env.production/.env.local jest juz objety przez .env*, +# ale zostawiamy je jawnie dla czytelnosci. +.env +.env.* +.env.production +.env.local +# Kopie zapasowe .env zawieraja komplet sekretow: JWT_SECRET, DB_PASSWORD, +# MINIO_SECRET_KEY, ADMIN_PASSWORD. Bez tego wzorca .env.backup-* wchodzil +# do repozytorium przy pierwszym `git add`. +.env.backup-* +# Szablony sa jedynymi wpisami .env*, ktore maja byc w repozytorium. +# Musza stac PO wykluczeniach, inaczej reguly wyzej znowu by je wycialy. +!.env.example +!.env.production.example + +# Kopie zapasowe zawieraja sekrety i dane osobowe uczestnikow +backups/ + +# Backend +backend/target/ + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# IDE +.idea/ +*.iml +.vscode/ + +# System +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c20efb2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,94 @@ +# Historia zmian + +Format wedlug [Keep a Changelog](https://keepachangelog.com/pl/1.1.0/), +wersjonowanie wedlug [SemVer](https://semver.org/lang/pl/). + +## [1.0.0] - 2026-08-31 + +Pierwsze wydanie produkcyjne. Zakres obejmuje pelny cykl zycia wydarzenia +szkoleniowego oraz platforme kursow online. + +### Webinary + +- Tworzenie i edycja wydarzen z terminem, limitem miejsc i strefa czasowa +- Publiczna strona zapisu z trescia budowana w edytorze wizualnym +- Zapis uczestnika ze zgodami, potwierdzenie e-mail z zalacznikiem kalendarza +- Rezygnacja z udzialu przez wlasny link uczestnika +- Kopiowanie webinaru przy szkoleniach cyklicznych +- Harmonogram przypomnien e-mail i SMS, konfigurowalny per wydarzenie +- Podsumowanie po webinarze dla uczestnikow i zestawienie dla prowadzacego +- Eksport listy zapisanych do CSV + +### Spotkanie na zywo + +- Wspolny ekran: obraz, czat, pytania, zadania i zapytania ofertowe +- Trzy role w pokoju: prelegent, uczestnik, uczestnik z glosem +- Kolejka zgloszen do zabrania glosu i tryb otwartego mikrofonu +- Moderacja czatu i pytan z panelu prowadzenia +- Glosowanie na pytania, jeden glos na osobe +- Banery i reklamy w sali, ze statystyka wyswietlen i klikniec +- Odnotowywanie obecnosci na podstawie sesji, nie pojedynczej flagi + +### Wideo + +- Wymienny dostawca przez interfejs `VideoProvider`: NONE, LiveKit, Jitsi, Daily +- Tryb NONE jest rownoprawny: obraz w zewnetrznym narzedziu, interakcje u nas +- Wlasny serwer LiveKit w profilu Compose, wraz z TURN, Redisem i Egress +- Nagrania i materialy we wlasnym skladzie MinIO, z kontrola widocznosci +- Obsluga naglowka Range, czyli przewijanie nagran + +### Akademia + +- Kursy z podzialem na moduly i lekcje, piec rodzajow lekcji +- Konta kursantow niezalezne od kont panelu, z wlasnym haslem i tokenem +- Publiczna czesc: strona glowna, lista kursow, strona kursu, cennik +- Odtwarzacz z liczeniem postepu, prog ukonczenia sprawdzany po stronie serwera +- Quiz konczacy kurs, z losowanym zestawem pytan i limitem czasu +- Platnosci Autopay z obsluga notyfikacji ITN +- Panel kursantow, postepow i statystyk kursu + +### Certyfikaty + +- Certyfikaty uczestnictwa w webinarze i ukonczenia kursu, jedna numeracja +- Plik PDF generowany raz i przechowywany, z osadzona czcionka +- Publiczna weryfikacja po numerze, zwracajaca minimum danych +- Uniewaznianie z notatka, przekrojowy ekran dla calej instancji + +### Kontakty + +- Baza kontaktow niezalezna od zapisow na wydarzenia +- Grupy, import z CSV z podgladem przed wykonaniem +- Zaproszenia na webinar ze sledzeniem skutecznosci +- Wypisanie z marketingu w dwoch krokach + +### Bezpieczenstwo i eksploatacja + +- Uwierzytelnianie JWT, role ADMIN i MODERATOR, blokada po nieudanych logowaniach +- Jedno logowanie dla panelu i Akademii, rozstrzygane po stronie serwera +- Tryb bezpieczny wysylki e-mail i SMS, **domyslnie wlaczony** +- Limity godzinowe wysylki, chroniace przed lawina po bledzie w danych +- Wypis stanu konfiguracji krytycznej przy starcie aplikacji +- Skrypt `check-config.sh` odmawiajacy startu przy niebezpiecznej konfiguracji +- Skrypty kopii zapasowej i odtworzenia, obejmujace baze i pliki +- Migracje schematu wylacznie przez Flyway + +### Interfejs + +- Panel administracyjny pod prefiksem `/panel`, korzen nalezy do goscia +- System projektowy HydroNexis, tokeny w `tailwind.config.js` +- Kazdy ekran dziala na telefonie, od 320 px bez przewijania w poziomie +- Paginacja na kazdej liscie, domyslnie 25 wierszy + +--- + +## Zasady wersjonowania + +- **Wersja glowna** (1.x.x -> 2.0.0): zmiana wymagajaca dzialania przy + aktualizacji, na przyklad recznej migracji danych albo zmiany konfiguracji. +- **Wersja pomocnicza** (1.0.x -> 1.1.0): nowa funkcja, aktualizacja bez + dodatkowych czynnosci. +- **Poprawka** (1.0.0 -> 1.0.1): naprawa bledu, bez zmiany zachowania. + +Wersje ustawia sie w **dwoch miejscach**: `backend/pom.xml` i +`frontend/package.json`. Obie musza byc zgodne, bo wersja backendu trafia do +odpowiedzi `/api/health` i sluzy do ustalenia, co faktycznie dziala u klienta. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7321955 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2622 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Projekt + +Platforma do organizacji szkolen i webinarow. Obsluguje pelny cykl zycia wydarzenia: + +- rejestracja uczestnika +- przypomnienia mailowe +- spotkanie online +- nagranie +- pytania od uczestnikow +- zapisy na zadania powstale podczas webinaru + +## Model wdrozenia + +Osobna instancja dla kazdego klienta, na jego serwerze i jego domenie. + +- jedno repozytorium, jedna wersja kodu +- roznice miedzy klientami wylacznie w konfiguracji (zmienne srodowiskowe) +- zadnych galezi per klient +- zadnych danych klienta w obrazie Docker + +## Stack + +### Backend + +- Java 21, Spring Boot 3.4.1 +- Maven systemowy (`mvn`), bez wrappera - nie dodawac `mvnw` +- Spring Web (REST), base path API: `/api` +- Spring Security, stateless JWT HS256, biblioteka jjwt +- Spring Data JPA / Hibernate, `spring.jpa.hibernate.ddl-auto: validate` +- Flyway jako jedyne zrodlo schematu bazy, migracje w `db/migration/Vn__opis.sql` +- PostgreSQL 16, bez PostGIS +- MinIO (S3-compatible) na nagrania, materialy i zalaczniki +- springdoc-openapi (Swagger UI) +- Lombok +- Spring Mail do wysylki zaproszen i przypomnien +- WebSocket ze STOMP do czatu, pytan na zywo i zgloszen do zabrania glosu + +### Frontend + +- React 18 + TypeScript, Vite 6 +- React Router 6 +- TanStack Query 5 +- axios z interceptorem dokladajacym JWT +- Tailwind CSS 3, tokeny designu w `tailwind.config.js` wedlug systemu HydroNexis +- Lucide (`lucide-react`) jako ikony, przez komponent `Icon` z jawna mapa nazw +- IBM Plex Sans do interfejsu, IBM Plex Mono do danych i terminow + +### Infrastruktura + +- Docker Compose: postgres + minio + backend + frontend +- nginx serwuje SPA i proxuje `/api` do backendu +- cala konfiguracja instancji przez zmienne srodowiskowe w `.env` + +## Wideo + +Aplikacja nie jest przywiazana do jednego dostawcy wideo. W backendzie interfejs `VideoProvider` z operacjami: + +- utworzenie pokoju +- wygenerowanie tokenu dostepu dla uczestnika +- pobranie nagrania +- obsluga webhooka + +Implementacja wybierana zmienna `VIDEO_PROVIDER`. Kazdy klient ma wlasne konto i wlasne klucze u dostawcy. + +### Role w pokoju + +Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem: + +| Rola | Uprawnienia | +| --- | --- | +| prelegent | publikuje obraz i dzwiek | +| uczestnik | tylko odbiera, ma czat i pytania | +| uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora | + +Wideo uczestnikow jest zawsze wylaczone na poziomie uprawnien. + +## Zasady pracy + +- Zmiany schematu bazy wylacznie przez nowa migracje Flyway. Nigdy przez edycje istniejacej migracji ani przez Hibernate. +- Zadnych sekretow, kluczy ani adresow w kodzie. Wszystko przez zmienne srodowiskowe. +- Komentarze i dokumentacja po polsku, nazwy klas, pol i endpointow po angielsku. +- Nie uzywac znaku em dash ani en dash. Wylacznie zwykly minus. +- Tresc widoczna dla uzytkownika koncowego pisana jest **poprawna polszczyzna, z ogonkami**. + Zasada pisania bez znakow diakrytycznych dotyczy wylacznie komentarzy w kodzie, + dokumentacji i nazw technicznych. Szczegoly nizej, w sekcji "Polskie znaki". +- Nie dodawac bibliotek spoza powyzszej listy bez pytania. Zatwierdzone wyjatki: + TipTap (`@tiptap/react`, `starter-kit`, rozszerzenia obrazka, linku i wyrownania), + `@tailwindcss/typography` oraz `jsoup` po stronie backendu. + +### Dane nietykalne + +**Tabela `app_settings` i konto administratora sa nietykalne.** + +- **Nigdy `docker compose down -v`.** Flaga `-v` kasuje wolumeny, czyli baze i MinIO + razem z cala konfiguracja instancji. Odtworzenie wymaga recznego wpisania na nowo + danych SMTP, kluczy bramki SMS i kluczy dostawcy wideo, ktorych nie ma nigdzie + indziej: `.env` ich nie zawiera, bo konfiguracja poczty i SMS idzie wylacznie + przez panel. Do zatrzymania sluzy `docker compose down` bez flagi. +- **Nigdy nie kasowac wolumenu bazy ani wolumenu MinIO** zadna inna droga + (`docker volume rm`, `docker system prune -a --volumes`). +- **Zadnego `DELETE`, `TRUNCATE` ani `DROP` obejmujacego `app_settings` lub `users`.** + Dotyczy to takze polecen bez klauzuli `WHERE` i takich, ktore obejmuja te tabele + posrednio, przez kasowanie kaskadowe. +- **Czyszczenie danych testowych ogranicza sie do tabel operacyjnych:** `webinars` + i wszystko, co od nich zalezy kaskadowo (`registrations`, `consents`, + `interactions`, `interaction_votes`, `tasks`, `offer_topics`, `reminder_rules`, + `recordings`, `materials`), oraz `notifications` i `media_files`. +- **Przed kazdym krokiem, ktory moze wyslac maila albo SMS, sprawdz faktyczny stan + konfiguracji w bazie**, zamiast polegac na tym, co zostalo ustalone wczesniej + w rozmowie. Ustawienia zmieniaja sie przez panel, bez restartu aplikacji, wiec + wartosc sprzed kilku minut moze juz nie obowiazywac. Dotyczy to zwlaszcza + `MAIL_SAFE_MODE`, `MAIL_SAFE_RECIPIENT`, `SMS_SAFE_MODE`, `SMS_SAFE_RECIPIENT` + i `SMS_ENABLED`: + + ```bash + docker exec webinar-postgres psql -U webinar -d webinar -c \ + "SELECT \"key\", value FROM app_settings WHERE \"key\" LIKE '%SAFE%' OR \"key\"='SMS_ENABLED';" + ``` + +- **Dane testowe z terminem w przeszlosci wystarcza, zeby scheduler ruszyl.** + Zadanie podsumowan raz wyslalo z tego powodu 12 realnych wiadomosci przez + produkcyjny SMTP klienta. Zakladajac dane testowe, dawaj terminy w przyszlosci + albo najpierw sprawdz tryb bezpieczny. + +### Polskie znaki + +**Wszystko, co czyta czlowiek, ma pelne znaki diakrytyczne** (ą ć ę ł ń ó ś ź ż). +Audyt z 2026-08-29 znalazl okolo 240 napisow w interfejsie i 100 komunikatow +backendu napisanych bez ogonkow, bo zasada "bez diakrytykow" zostala bledem +rozciagnieta z komentarzy na cala tresc produktu. + +- **Z ogonkami:** etykiety i naglowki w panelu, komunikaty bledow zwracane przez + API (`ResponseStatusException`, `message` w Bean Validation), szablony maili, + strony publiczne, `aria-label`, `title`, `alt`, teksty w `confirm()`, opisy + ustawien z `SettingKey` (trafiaja do panelu przez `SettingsController`), + tresci zgod, zajawka Open Graph w `index.html`. +- **Bez ogonkow:** komentarze w kodzie, ten plik, nazwy klas, pol i endpointow, + slugi, klucze ustawien (`MAIL_HOST`, `DEFAULT_REMINDERS`), enumy backendu + (`DRAFT`, `CONFIRMED`), nazwy ikon, wartosci `tone` i `variant`, klasy CSS, + sciezki tras, nazwy plikow eksportu. +- **Logi zostaja bez ogonkow.** `log.info` i pokrewne czyta administrator + w terminalu, gdzie kodowanie bywa przypadkowe. To nie jest tresc dla + uzytkownika. +- **Opisy springdoc (`@Operation`, `@Schema`) zostaja bez ogonkow.** To + dokumentacja techniczna, nie interfejs produktu. +- **Tresc SMS nigdy nie dostaje ogonkow.** Przelaczaja bramke na UCS-2, gdzie + limit spada ze 160 do 70 znakow i jedna wiadomosc rozbija sie na kilka + platnych czesci. Ta sama zasada dotyczy `ReminderTextBuilder`. +- **Kodowanie:** wszystkie pliki w UTF-8 bez BOM. Java dziedziczy + `project.build.sourceEncoding=UTF-8` ze `spring-boot-starter-parent`, + wiec nie trzeba go dopisywac, ale nie wolno tego nadpisac. +- **Tresc zgod poprawia sie migracja, bez podnoszenia `CONSENT_VERSION`** + (V11), bo zmienia sie wylacznie pisownia, a nie znaczenie oswiadczenia. + Podniesienie wersji sugerowaloby, ze uczestnik zgodzil sie na cos innego, + i wymagaloby zbierania zgod od nowa. Migracja aktualizuje tylko wpisy + o dokladnie starej tresci domyslnej, wiec nie nadpisuje tego, co + administrator zmienil w panelu. +- Nowa etykieta pisana jest poprawnie **od razu**. Poprawianie tego audytem + po fakcie kosztuje wielokrotnie wiecej niz napisanie dobrze za pierwszym razem. + +### Kopiowanie webinaru + +Ikona kopiowania w wierszu listy tworzy nowy webinar na podstawie istniejacego +(`WebinarCopyService`, `POST /admin/webinars/{id}/copy`). To najczestsza czynnosc +przy szkoleniach cyklicznych, wiec granica "co sie kopiuje" jest tu regula, a nie +szczegolem implementacji. + +- **Kopiujemy pomysl na wydarzenie, nie slad po tym, ze sie odbylo.** Przechodzi + tresc strony, tresc zaproszenia, ustawienia certyfikatow, reguly przypomnien, + tematy zapytan i materialy. Nie przechodza zapisy, obecnosc, interakcje, + nagrania, certyfikaty, pokoj u dostawcy ani `summary_sent_at`. Skopiowanie + czegokolwiek z drugiej listy oznaczaloby uczestnikow zapisanych na wydarzenie, + ktore sie nie odbylo, albo certyfikaty z cudzego szkolenia. +- **Termin nie jest przepisywany, ale nie moze tez zostac pusty.** `starts_at` + i `ends_at` maja NOT NULL, a `WebinarRequest` wymaga obu pol. Kopia dostaje + **termin zastepczy**: pelna godzina za tydzien, o dlugosci przepisanej + z oryginalu. Data w przyszlosci, a nie w przeszlosci, bo przy przypadkowej + publikacji zapisy maja zostac otwarte, a nie zamknac sie natychmiast. + Status DRAFT i tak wymusza swiadome opublikowanie. +- **Plik materialu jest kopiowany w skladzie, nie wspoldzielony.** Wspolny obiekt + sprawialby, ze usuniecie materialu przy jednej edycji psuje druga, a podmiana + pliku zmienia go wstecz w szkoleniu juz zakonczonym. +- **Blad kopiowania jednego pliku nie przerywa calosci.** Kopia webinaru jest + przydatna takze bez jednego zalacznika; odtwarzanie wszystkiego od zera + kosztuje znacznie wiecej. Nieudany plik idzie do logu. +- **Flaga `enabled` reguly przypomnienia przechodzi bez zmian.** Regula wylaczona + swiadomie ma zostac wylaczona takze w kopii. +- Po skopiowaniu panel przechodzi od razu do edycji kopii i mowi wprost, ze termin + jest tymczasowy. Pilnuje tego `WebinarCopyTest`, ktory sprawdza obie strony + granicy: co ma sie znalezc w kopii i czego w niej byc nie moze. + +### Wyszukiwanie na listach + +Lista webinarow ma wyszukiwanie po tytule, lista zapisanych po nazwisku, imieniu +i adresie. Oba filtruja **w bazie**, w tym samym zapytaniu co paginacja. + +- **`cast(:parametr as ...)` przy KAZDYM parametrze opcjonalnym jest obowiazkowe.** + Sterownik wysyla `null` bez typu, a PostgreSQL nie wywnioskuje go z samego + `? is null`. Przy tekscie konczy sie to bledem "function lower(bytea) does not + exist", przy dacie "could not determine data type of parameter". W obu + przypadkach **cala lista przestaje dzialac dokladnie wtedy, gdy filtr nie jest + uzywany**, czyli w najzwyklejszym uzyciu. Blad wszedl na instancje, bo zaden + test nie wolal listy bez frazy; pilnuje tego teraz `WebinarSearchTest`. +- **Nowy test listy zaczyna sie od przypadku bez filtru.** Wariant z fraza dziala + nawet wtedy, gdy wariant pusty jest zepsuty, wiec sama fraza niczego nie chroni. +- Sortowanie po liczbie zapisanych **nadal nie jest mozliwe**, bo licznik powstaje + osobnym zapytaniem do juz pobranej strony. Ta sama regula co przy kolumnie + "Zapisani". Sortowanie idzie po tytule, terminie, statusie i dacie dodania. +- Lista zapisanych sortuje sie takze **po obecnosci** (`attended`), a kolumna + Obecnosc jest widoczna w tabeli i na karcie mobilnej. +- Zmiana frazy cofa na pierwsza strone, tak samo jak zmiana kazdego innego filtru. + +### Potwierdzenia operacji nieodwracalnych + +**Kazda operacja kasujaca pyta o potwierdzenie, a pytanie nazywa konkretny obiekt.** +"Czy na pewno?" bez nazwy nie daje uzytkownikowi niczego, na czym moglby oprzec +decyzje. + +- **Usuniecie webinaru pokazuje, ile przepadnie.** Panel pobiera najpierw + `GET /admin/webinars/{id}/deletion-impact` (liczba zapisow i certyfikatow) + i dopiero z tymi liczbami pyta. Przy webinarze z setka zapisow i wydanymi + certyfikatami odpowiedz jest zupelnie inna niz przy pustym szkicu. +- **Pytanie podpowiada odwolanie jako lagodniejsza alternatywe.** W wiekszosci + przypadkow uzytkownik chce zamknac zapisy, a nie skasowac dane. +- Usuniecie webinaru kasuje kaskadowo zapisy, zgody, obecnosc, interakcje, + nagrania, materialy, zaproszenia i certyfikaty. **Pliki w MinIO zostaja**: + utrata pliku jest gorsza niz zajete miejsce. + +### Ustawienia o zamknietym zbiorze wartosci i wskazujace na plik + +`SettingType` ma poza STRING, INT, BOOLEAN i PASSWORD takze **SELECT** i **MEDIA**. + +- **SELECT pokazuje liste wyboru, a backend odrzuca wartosc spoza listy** + (`SettingKey.accepts`, walidacja w `SettingsService.update`). Panel jest wygoda, + bariera jest po stronie backendu: endpoint zapisu da sie wywolac z pominieciem + panelu, a literowka w nazwie dostawcy wideo cicho przelaczalaby instancje + w tryb NONE. Dopuszczalne wartosci ida do panelu w polu `allowedValues`. +- **Kazde ustawienie o zamknietym zbiorze wartosci ma byc typu SELECT.** + Dopisanie wartosci do enuma bez dopisania jej do `allowedValues` sprawia, + ze nie da sie jej wybrac. +- **MEDIA pokazuje wybor pliku z biblioteki** (`MediaField`, opakowanie + `MediaPicker`): miniatura biezacego wyboru, wybor, wyczyszczenie i wgranie + nowego. Wartoscia jest identyfikator pliku, a zaden uzytkownik panelu nie ma + go skad wziac recznie. +- **Logo i ikona instancji celowo tego nie uzywaja.** `APP_LOGO_URL` + i `APP_FAVICON_URL` maja wlasny edytor z kadrowaniem (`ImageEditor`), + a ich wartoscia jest adres, nie identyfikator. + +### Numer certyfikatu + +Wzor `CERTIFICATE_NUMBER_FORMAT` obsluguje `{rok}`, `{miesiac}`, `{numer}` +i `{prefiks}`, gdzie prefiks pochodzi z `CERTIFICATE_NUMBER_PREFIX`. + +- **Pusty prefiks usuwa znacznik wraz z sasiadujacym separatorem.** Samo + podstawienie pustego ciagu dawaloby "/2026/1", czyli numer z wiszacym + ukosnikiem. Numer trafia na dokument i sluzy do weryfikacji publicznej, wiec + nie moze wygladac na uszkodzony tylko dlatego, ze instancja nie uzywa prefiksu. + Sam prefiks zlozony ze spacji liczy sie jak brak prefiksu. +- Pilnuje tego `CertificateNumberTest`, ktory sprawdza takze warianty + z myslnikiem i spacja jako separatorem. + +### Znaczniki czasu w encjach + +**Encja ustawia `created_at` sama, w `@PrePersist`.** Wypelnianie go w kazdym +miejscu tworzacym rekord z osobna wystarczy raz przeoczyc, zeby zapis konczyl sie +naruszeniem NOT NULL. Zdarzylo sie to na `OfferTopic` przy kopiowaniu webinaru: +encja jako jedyna nie miala `@PrePersist`, a wszystkie dotychczasowe miejsca +tworzenia ustawialy znacznik recznie. Warunek `if (createdAt == null)` pozwala +nadal podac wartosc jawnie, gdy jest potrzebna. + +### Lista webinarow: zakladki i sortowanie + +`/webinary` ma dwie zakladki, Aktywne i Zakonczone, oraz sortowanie po kolumnach. + +- **Podzial idzie po `endsAt`, nie po statusie `FINISHED`.** Nic w aplikacji tego + statusu nie ustawia, wiec filtr po nim dawalby zawsze pusta liste. Ta sama regula + obowiazuje na stronie publicznej, gdzie webinar po `endsAt` jest juz odbyty. + Gdyby kiedys pojawil sie proces ustawiajacy `FINISHED`, warto to ujednolicic, + ale wtedy swiadomie, nie przez podmiane filtru. +- **Domyslne sortowanie to `createdAt` malejaco**, czyli ostatnio dodane na gorze. +- **Pole sortowania jest na bialej liscie** (`SORTABLE` w kontrolerze). Nazwa pola + trafia do zapytania JPA, wiec przekazanie parametru wprost pozwalaloby sortowac + po polach spoza listy albo wywolac blad ujawniajacy budowe encji. Wartosc spoza + listy cicho wraca do sortowania domyslnego, bez bledu. +- **Kolumna "Zapisani" celowo nie jest sortowalna.** Liczba zapisanych jest doliczana + osobnym zapytaniem do juz pobranej strony, wiec baza nie potrafi po niej sortowac, + a sortowanie samej biezacej strony wprowadzaloby w blad. +- Kolumna "Dodano" jest ukryta ponizej `2xl`. Zmierzone: przy 1280px jej dolozenie + rozpycha tabele ponad szerokosc panelu i kolumna akcji wymaga przewijania. +- **Termin na liscie jest w dwoch liniach i bez nazwy strefy** (`formatDateTimeParts`). + W jednej linii z nazwa strefy kolumna zajmowala ponad 300px i wypychala akcje poza + ekran. Strefa zostaje jawna tam, gdzie ma znaczenie: w formularzu webinaru, + na liscie zapisanych i na stronie publicznej, gdzie uzytkownik moze byc w innej + strefie niz webinar. Nie uzywac tu `formatWithZone`. +- **Tabela przewija sie w poziomie wewnatrz wlasnego kontenera** (`overflow-x-auto`). + Bez tego kolumna akcji byla nieosiagalna przy waskim oknie. Strona nadal nie + przewija sie w poziomie, zgodnie z zasada responsywnosci. + +### Samowywolanie metod transakcyjnych: test zamiast reguly + +`@Transactional` na metodzie wolanej z tej samej klasy nie dziala, bo +samowywolanie omija proxy Springa. **Ten sam blad wystapil trzy razy** +(`JwtService`, `OrderService`, `CourseCertificateMailer`) i za kazdym razem +ujawnil sie dopiero na uruchomionej instancji: kod kompiluje sie poprawnie +i dobrze wyglada przy czytaniu. Sama regula w tym pliku nie zapobiegla, bo +wymaga, zeby ktos o niej pamietal dokladnie w chwili pisania. + +Pilnuje tego teraz `TransactionalSelfInvocationTest`. + +- **Czyta bajtkod, nie zrodla.** Interesuje nas faktyczne wywolanie, a nie to, + jak wyglada w tekscie. ASM pochodzi ze `spring-core`, ktore i tak jest + w zaleznosciach: **zadna biblioteka nie zostala dolozona**. +- **`this.metoda()` i `self.metoda()` maja w bajtkodzie tego samego + wlasciciela**, bo pole `self` jest typu tej samej klasy. Rozni je odbiorca + wywolania, a ten lezy na stosie **pod argumentami**: + `this.f(x)` to `ALOAD 0, ALOAD 1, INVOKEVIRTUAL`, a `self.f(x)` to + `ALOAD 0, GETFIELD self, ALOAD 1, INVOKEVIRTUAL`. Patrzenie na ostatnia + instrukcje daje wiec zla odpowiedz przy kazdej metodzie z argumentem: test + musi sledzic stos operandow i cofnac sie o tyle pozycji, ile argumentow ma + wolana metoda. +- **Test zglasza wylacznie to, co zmienia zachowanie:** `REQUIRES_NEW` (bez + proxy nowa transakcja nie powstaje, a to cala jego rola) oraz zapis wolany + z metody **nietransakcyjnej** (bez proxy nie ma transakcji wcale). Metoda + `readOnly` wolana z innej metody transakcyjnej dolacza do istniejacej + transakcji i jest nieszkodliwa; takich wywolan sa dziesiatki, wiec ich + zglaszanie utopiloby prawdziwe znaleziska w szumie. +- **Test zostal sprawdzony w obie strony.** Pierwsza wersja dawala 12 falszywych + alarmow, druga PRZEPUSZCZALA prawdziwy blad. Wyszlo to dopiero po celowym + przywroceniu samowywolania: zielony test niczego nie dowodzi, dopoki nie + zobaczy sie, jak czerwienieje na prawdziwym przypadku. +- **Lista wyjatkow (`ALLOWED`) jest pusta i ma taka zostac.** Wpis tam to + swiadoma decyzja z uzasadnieniem, a nie sposob na uciszenie testu. + +Audyt przy okazji znalazl **dwa dotad niewykryte bledy**: + +- **`RecordingService`**: `downloadFromProvider` celowo nie jest transakcyjne + (pobieranie trwa minutami), ale wolalo przez `this` cztery metody + `@Transactional`. Zadna nie miala wlasnej transakcji, wiec zmiana stanu + nagrania nie zapisywala sie wcale. Byly przy tym `protected`, czyli + niewidoczne przez proxy CGLIB: podniesione do `public`. +- **`SummaryScheduler`**: `planSummaries` jest `@Scheduled` i nietransakcyjne, + a wolalo `queueSummary` przez `this`. Wpisy w kolejce i znacznik + `summary_sent_at` przestawaly byc atomowe, czyli dokladnie ten rozjazd, przed + ktorym ostrzega sekcja o podsumowaniach. + +### Granica transakcji: encje nie opuszczaja serwisu + +**Encje JPA nigdy nie opuszczaja warstwy serwisu.** Kontroler dostaje wylacznie +obiekty widoku, skladane wewnatrz transakcji. Watki asynchroniczne i powiadomienia +dostaja zwykle wartosci, nigdy encje. + +- **`spring.jpa.open-in-view` jest ustawione na `false` i ma takie zostac.** Przy + `true` sesja Hibernate zyje az do wyrenderowania odpowiedzi, wiec leniwe pole + doczytuje sie po cichu i blad nie ujawnia sie w testach, tylko pozniej, gdy + ktos zmieni mapowanie albo doda watek asynchroniczny. Wylaczone `open-in-view` + sprawia, ze naruszenie granicy widac od razu. +- **Objaw jest mylacy.** `LazyInitializationException` poza transakcja daje blad + 500, a front, ktory kazdy nieudany request tlumaczy jako wygasla sesje, pokazuje + wylogowanie albo "Wymagane uwierzytelnienie". Szukanie takiego bledu w warstwie + logowania nie ma sensu. +- **Mapowanie encja -> widok robi metoda serwisu z `@Transactional`,** nie statyczne + `View.of(entity)` wolane z kontrolera. Metoda `of` sama w sobie jest w porzadku, + ale wolno ja wolac **tylko** wewnatrz transakcji. +- **`@Transactional` na metodzie wolanej z tej samej klasy nie dziala.** Samowywolanie + omija proxy Springa, wiec adnotacja na prywatnej metodzie pomocniczej albo na + metodzie kontrolera wolanej przez `this` jest bez znaczenia. Gdy naprawde trzeba + wywolac wlasna metode transakcyjna, wstrzykuje sie `@Lazy` referencje na samego + siebie (tak robi `SummarySender`). +- **Powiadomienia dostaja rekord z wartosciami.** `SalesNotifier` przyjmuje + `OfferService.SubmitResult`, nie `Interaction`. Watek `@Async` startuje po + zamknieciu transakcji, wiec przekazana tam encja jest juz odlaczona i pierwsze + leniwe pole wywala wysylke. +- Miejsca, w ktorych to juz raz zawiodlo, i tak samo zawiedzie znowu: `MediaView` + (`uploadedBy`, `webinar`), `InteractionView` (autor), `TaskView` (webinar), + `OfferService.describe` (zapis i webinar), `SummaryData` (wszystko). + +### Katalog ustawien: brak wartosci ma znaczyc wariant bezpieczniejszy + +**Kazdy klucz w `SettingKey` ma jawnie rozstrzygniete, co znaczy brak wartosci.** +Sluzy do tego pole `Requirement` z trzema wartosciami: `HAS_DEFAULT` (klucz ma +wartosc domyslna), `REQUIRED` (bez konfiguracji funkcja nie dziala i tak ma byc), +`OPTIONAL_EMPTY` (pusta wartosc jest poprawnym stanem docelowym). + +- **Powstalo po realnym bledzie.** `MAIL_SAFE_MODE` przez piec krokow nie dzialal, + bo nie mial wartosci domyslnej. Brak wpisu w bazie czytal sie przez + `Boolean.parseBoolean("")` jako `false`, czyli tryb bezpieczny **wylaczony**, + a raport twierdzil, ze jest wlaczony. Kod kompilowal sie i dobrze wygladal. +- **Wartosc domyslna musi byc wariantem bezpieczniejszym.** Brak konfiguracji nie + moze prowadzic do wysylki na prawdziwe adresy, przyjecia platnosci, ujawnienia + danych ani pominiecia zabezpieczenia. Lepiej, zeby cos nie zadzialalo. +- **Typ BOOLEAN i INT ZAWSZE ma wartosc domyslna.** Pusty napis czyta sie jako + `false`, a `Integer.parseInt("")` wywala wyjatek w metodzie, ktora ma wlasnie + obsluzyc uszkodzona wartosc. Ani jedno, ani drugie nie moze byc skutkiem + braku wpisu w bazie. +- **Wartosc domyslna SELECT musi byc na jego wlasnej liscie.** Inaczej panel + pokazuje liste, w ktorej biezaca wartosc nie istnieje. +- **Klucz REQUIRED czyta sie przez `SettingsService.getRequired`,** ktore rzuca + `MissingConfigurationException` zamiast oddac pusty napis. Sklejenie linku + z pustego prefiksu dawalo adres w rodzaju `/w/szkolenie`: w mailu martwy + u kazdego odbiorcy, a wygladajacy na wyslany poprawnie. Dotyczylo to + `MailService` i `InvitationSender`, czyli takze linku wypisania, ktory musi + dzialac z powodow prawnych. +- **Gdzie brak linku jest lepszy niz blad, zwracamy null.** `SummaryService` + pomija odnosnik do nagrania i materialow, gdy nie ma adresu publicznego: + podsumowanie bez linku nadal ma sens, podsumowanie z linkiem donikad nie. +- Pilnuje tego `SettingDefaultsTest`, sprawdzajac takze, ze tryby bezpieczne sa + domyslnie **wlaczone**, a funkcje o realnych skutkach (`AUTOPAY_ENABLED`, + `SMS_ENABLED`, `SALES_SMS_ENABLED`, `VIDEO_DELETE_AFTER_DOWNLOAD`) domyslnie + **wylaczone**. Test zostal sprawdzony w obie strony: po dopisaniu klucza bez + wartosci domyslnej faktycznie czerwienieje i **wywala build**. + +### Wypis konfiguracji przy starcie + +`SettingsStartupReport` wypisuje po starcie stan ustawien krytycznych: oba tryby +bezpieczne wraz z adresami zastepczymi, limity godzinowe, `APP_PUBLIC_URL`, +dane SMTP, dostawce wideo i tryb Autopay. + +- **Po to, zeby stan bylo widac bez zagladania do bazy.** Blad + `MAIL_SAFE_MODE` przetrwal piec krokow glownie dlatego, ze poznanie stanu + faktycznego wymagalo swiadomego zapytania bazy i wiedzy, czego szukac. +- **Sekrety wylacznie jako "ustawiony" albo "BRAK".** Log trafia do terminala, + do plikow i czesto do systemu zbierania logow, wiec wpisanie tam klucza API + oznacza jego wyciek do miejsca, ktorego nikt nie traktuje jak sejfu. +- **Ostrzezenia ida na poziomie WARN** i nazywaja skutek, nie ustawienie: + wylaczony tryb bezpieczny mowi "poczta idzie do PRAWDZIWYCH odbiorcow", + Autopay w trybie produkcyjnym mowi o prawdziwych pieniadzach, a Jitsi bez JWT + o pokoju otwartym dla kazdego, kto zna adres. +- **`ApplicationReadyEvent`, nie `@PostConstruct`:** ustawienia czytaja z bazy, + ktora przy tworzeniu beanow moze nie miec jeszcze wykonanych migracji Flyway. +- **Wypis nie ma polskich znakow**, zgodnie z regula dotyczaca logow. + +### Tryb bezpieczny wysylki + +`MAIL_SAFE_MODE` i `SMS_SAFE_MODE`, **domyslnie wlaczone**, kieruja cala wysylke na +jeden adres z `MAIL_SAFE_RECIPIENT` / `SMS_SAFE_RECIPIENT`. Do tego bezpiecznik +ilosciowy: `MAIL_RATE_LIMIT_PER_HOUR` (100) i `SMS_RATE_LIMIT_PER_HOUR` (20). + +- **Powstalo po realnym incydencie.** Zadanie podsumowan potraktowalo webinary + z danymi testowymi jako zakonczone i wyslalo 12 wiadomosci przez produkcyjny + SMTP klienta, w tym 10 na nieistniejace adresy `@example.test`. Dane testowe + z terminem w przeszlosci wystarcza, zeby scheduler ruszyl. +- **Domyslnie wlaczony, i to jest swiadome.** Swiezo postawiona instancja ma NIE + wysylac do prawdziwych ludzi, dopoki ktos nie zdecyduje inaczej. Odwrotna + domyslna wartosc sprawia, ze kazda kopia produkcji zaczyna od wysylania maili + do uczestnikow z bazy. +- **Jeden punkt egzekwowania: `MailService.deliver`.** Wysylka jest rozproszona po + szesciu klasach, wiec przekierowanie dokladane w kazdej z osobna oznaczaloby, ze + **nowa sciezka domyslnie omija zabezpieczenie**. Nigdy nie wolac `sender.send(...)` + wprost, zawsze przez `deliver`. Dla SMS rownowaznikiem jest `SmsService.send`. +- **Wpis w `notifications` zachowuje prawdziwego odbiorce**, nie zastepczego. + Dziennik dokumentuje zamiar wysylki; podmiana adresu sprawilaby, ze po wylaczeniu + trybu nie da sie odtworzyc, kto co dostal. +- **Tryb wlaczony bez ustawionego adresu wstrzymuje wysylke**, zamiast przepuscic ja + do prawdziwego odbiorcy. Cicha wysylka przy wlaczonym zabezpieczeniu jest + najgorszym z mozliwych zachowan: administrator jest przekonany, ze nic nie wychodzi. +- **Limit daje QUEUED, nigdy FAILED.** Wiadomosc ma pojsc, tylko pozniej, wiec + `RateLimitExceededException` jest lapany osobno od zwyklych bledow. Wpis juz + bedacy w kolejce zostaje nietkniety, a wysylka natychmiastowa dostaje + `markQueued` z `scheduled_at`, zeby scheduler ja podjal. +- **Prefiks SMS bez ogonkow.** Tekst prefiksu wlicza sie do limitu 160 znakow, a + diakrytyki przelaczaja bramke na UCS-2, gdzie limit spada do 70. +- Pilnuje tego `SafeDeliveryTest`, w tym przypadek "tryb wlaczony, brak adresu" + sprawdzajacy, ze wiadomosc **nie** poszla do prawdziwego odbiorcy. + +### Przekrojowy ekran certyfikatow + +Trasa `/kursy/certyfikaty`, endpointy `/api/admin/certificates/**`. Jedna lista +dla calej instancji, obejmujaca certyfikaty za kursy i za webinary. + +- **Ekran sluzy PRZEGLADANIU I OBSLUDZE, nie wydawaniu.** Wydawanie zostaje na + karcie kursu i karcie webinaru, gdzie widac kontekst: kto ukonczyl, kto byl + obecny, komu dokument sie nalezy. Tutaj tego kontekstu nie ma, wiec byloby + wydawaniem na slepo. +- **Jedno zapytanie z UNION, nie dwa listy scalane w pamieci.** Ekran pokazuje + liste posortowana po dacie wydania i stronicowana; dwa osobne zapytania + trzeba by scalac i sortowac po stronie aplikacji, a wtedy paginacja przestaje + dzialac: nie da sie wziac dwudziestej piatej strony z dwoch list, nie + pobierajac ich w calosci. +- **Filtr zrodla niesie DWIE wartosci**: identyfikator i to, czy chodzi o kurs. + Kursy i webinary maja wlasne sekwencje, wiec sam identyfikator mieszalby + dokumenty o tym samym numerze z obu tabel. +- **Przedzial dat obejmuje CALY dzien koncowy.** Uzytkownik wpisujacy + "do 31 grudnia" oczekuje, ze certyfikat wydany tego dnia o 14:00 sie zmiesci, + wiec `to` idzie do zapytania przesuniete o dobe, z porownaniem ostrym. +- **PONOWNA WYSYLKA SIEGA PO PLIK ZE SKLADU, NIGDY NIE RENDERUJE NA NOWO.** + W wierszu zapisane sa numer i data, ale tytul zrodla, liczba godzin, szablon + i logo juz nie: ponowne renderowanie wzieloby ich **biezace** wartosci, wiec + odbiorca dostalby ten sam numer z inna trescia niz dokument, ktory ma + w teczce, a weryfikacja publiczna potwierdzalaby cos innego, niz trzyma + w reku pracodawca. Doszlaby druga rzecz: renderowanie moze sie nie powiesc + (uszkodzony wlasny szablon organizatora), a ponowna wysylka jest operacja + wsparcia, wykonywana zwykle wtedy, gdy cos juz poszlo nie tak. +- **Brak pliku w skladzie NIE generuje zastepczego dokumentu.** Wiadomosc idzie + z samym odnosnikiem, a odpowiedz niesie `withAttachment = false` i komunikat, + zeby wsparcie wiedzialo, co odbiorca faktycznie dostal. Wygenerowanie innego + pliku pod tym samym numerem byloby gorsze niz brak zalacznika. +- **Uniewaznionego certyfikatu nie wysylamy.** Odbiorca dostalby dokument, + ktory nie potwierdza juz niczego. +- **Uniewaznienie z tego ekranu WYMAGA NOTATKI**, inaczej niz z karty kursu + i webinaru: tam widac, czego dokument dotyczyl, tutaj nie. Dokument mogl juz + trafic do pracodawcy, wiec po miesiacu musi dac sie ustalic, dlaczego + przestal byc wazny. Samo uniewaznienie robi `CertificateService.revoke`: + to ta sama operacja, nie druga kopia. +- **Filtr zrodel pokazuje wylacznie kursy i webinary z wlaczonymi + certyfikatami.** Reszta nie ma czego pokazac, a dluga lista wyboru + z pozycjami bez wynikow utrudnia szukanie. +- **`CAST(:x AS ...)` przy KAZDYM parametrze opcjonalnym**, takze w zapytaniu + zliczajacym. Sprawdzone na zywo: lista bez filtru i kazdy filtr osobno. + +### Nawigacja: zadna pozycja nie jest juz `planned` + +Po dodaniu ekranu certyfikatow **wszystkie dwanascie pozycji menu prowadzi do +istniejacych widokow**. Flaga `planned` zostala w typie `NavItem`, bo przyda sie +przy nastepnym widoku budowanym etapami, ale nie ma juz zadnego uzycia. + +- **Zdjecie flagi to zmiana w DWOCH miejscach**: wpis w `navigation.ts` i trasa + w `App.tsx`. Sprawdzenie, ze kazda pozycja ma trase, robi sie porownaniem + wszystkich wartosci `to:` z deklaracji z `path=` w `App.tsx`. +- **Kazda pozycja menu jest teraz JEDNOLINIJKOWA.** Wczesniej trzy byly + sformatowane wieloliniowo i podmiana jednolinijkowa ominela jedna z nich: + ekran Postepy dzialal, a pozycja menu byla wyszarzona. Wieloliniowe zostaly + wylacznie obiekty obszarow (`webinary`, `akademia`), ktore maja po piec pol + i inaczej sie nie da. + +### Certyfikaty ukonczenia kursu + +Rozszerzenie istniejacego mechanizmu (V26), a nie drugi mechanizm. Kolumny +`course_id` i `enrollment_id` w `certificates`, endpointy +`/admin/courses/{id}/certificates/**`, szablon `certificate/course.html`, +mail `course-completed`. + +- **JEDNA TABELA, DWA ZRODLA.** Certyfikat z kursu to ten sam dokument co + z webinaru: ten sam numer z tej samej puli, ten sam plik w skladzie, ta sama + weryfikacja publiczna i to samo uniewaznienie. Druga tabela oznaczalaby dwie + numeracje, dwie sciezki weryfikacji i dwa miejsca do poprawienia przy kazdej + zmianie regulaminu. +- **Dokladnie jedno zrodlo, wymuszone CHECKiem** (`ck_certificates_owner`): + albo webinar z rejestracja, albo kurs z zapisem, nigdy oboje. Ta sama zasada + co przy autorze interakcji. Bez tego blad w kodzie dawalby dokument bez + wlasciciela albo z dwoma, a weryfikacja publiczna nie wiedzialaby, co on + potwierdza. Sprawdzone na zywo: baza odrzuca oba przypadki. +- **`webinar_id` i `registration_id` musialy stac sie nullowalne.** Wiersze + istniejace maja je wypelnione i takie zostaja, czego pilnuje CHECK. +- **Numeracja jest WSPOLNA i taka ma zostac.** Numer sluzy do weryfikacji + publicznej, wiec ma byc unikalny w calej instancji. Licznik, blokada wiersza + i `formatNumber` pozostaja w `CertificateService`; `CourseCertificateService` + siega po `nextNumber()` przez `@Lazy` referencje. +- **`{typ}` we wzorze numeru zostal ROZWAZONY I ODRZUCONY.** Nie dodaje nic do + unikalnosci, a tworzy realne ryzyko: administrator dopisujacy znacznik po + wydaniu pierwszych dokumentow dostaje dwa numery roznice sie wylacznie nim, + przy wspolnym liczniku, a certyfikaty juz lezace w teczkach przestaja + pasowac do biezacego wzoru. Zrodlo widac na samym dokumencie (tytul kursu + kontra tytul webinaru) i w weryfikacji publicznej (`forCourse`). +- **Osobny szablon `certificate/course.html`.** Dokument mowi co innego: nie + "wzial udzial w szkoleniu", tylko "ukonczyl kurs", i moze podac wynik + sprawdzianu. Uklad, kolory i typografia sa celowo takie same: to ten sam + dokument tej samej instytucji, a nie druga marka. +- **Wlasny szablon organizatora dziala w obu zrodlach.** Zmienne `webinarTitle` + i `webinarDate` sa dostepne takze przy kursie, obok `courseTitle` + i `completedDate`: szablon napisany kiedys dla webinaru nie pokazuje pustych + miejsc po przeniesieniu na kurs. Tresc przechodzi przez ten sam + `HtmlSanitizer` co tresc strony. +- **Wynik quizu to NAJLEPSZE zdane podejscie**, nie ostatnie: kursant mogl + podchodzic kilka razy, a dokument ma pokazywac wynik, ktorym zaliczyl. + Kurs bez quizu daje null i certyfikat po prostu nie podaje procentu; zero + wygladaloby jak oblany test. +- **Wydanie automatyczne dzieje sie w TEJ SAMEJ transakcji co zmiana statusu + na COMPLETED.** Zaliczenie bez dokumentu i dokument bez zaliczenia sa + jednakowo zle. **Blad wydania nie cofa zaliczenia**: kursant zrobil swoje, + a brakujacy dokument administrator wyda recznie. +- **Mail kolejkuje sie dopiero PO zatwierdzeniu transakcji** (`afterCommit`). + Wpis powstajacy w tej samej transakcji niosl by wiadomosc o certyfikacie, + ktorego przy wycofaniu w bazie nie ma. +- **`afterCommit` wola metode PRZEZ PROXY (`@Lazy self`).** `@Transactional` + na metodzie wolanej z tej samej klasy nie dziala, a tutaj bylo to szczegolnie + grozne: `afterCommit` biegnie po zamknieciu transakcji, wiec bez wlasnej + transakcji zapis konczyl sie bledem "no transaction is in progress" i mail + **nie trafial do kolejki wcale**. Zdarzylo sie to realnie i wylapal to + dopiero test na dzialajacej instancji, bo kompilacja i testy jednostkowe + tego nie widza. +- **"Dokladnie raz" pochodzi z bazy**: unikalny indeks czesciowy + `uq_notifications_course_certificate` na `(enrollment_id, template)`. + Dotychczasowe indeksy opieraly sie na `webinar_id`, ktore przy kursie jest + nullem, a NULL-e w PostgreSQL nie koliduja, wiec gwarancja by zniknela. +- **Szablon `course-completed` dopisany do `NOT_FOR_REMINDER_SENDER`**, ale NIE + do `TEMPLATES`: ma wlasny wysylacz (`CourseCertificateSender`), a nie + `SummarySender`. Pomylenie tych list konczy sie oznaczeniem wiadomosci jako + FAILED, zanim wlasciwy wysylacz ja zobaczy. +- **Zalacznik PDF idzie przez TEN SAM `MimeMessageHelper`, ktory zbudowal + tresc.** Drugi helper na tej samej wiadomosci tworzy pusty multipart/related + i nadpisuje wypelniony, przez co wysylka konczy sie bledem "Empty multipart" + dopiero przy zapisie do strumienia. Ta sama pulapka co przy zalaczniku ICS. +- **Plik do zalacznika bierzemy ze SKLADU, nie renderujemy na nowo.** + Certyfikat jest dokumentem: zalacznik ma byc dokladnie tym samym plikiem, + ktory kursant pobierze z pulpitu. Brak pliku nie blokuje wiadomosci, bo + niesie ona takze odnosnik. +- **Dostep do pliku sprawdza WLASCICIELA ZAPISU, nie udzial w kursie.** + `belongsToContact` porownuje kontakt zapisu z zalogowanym kursantem: inaczej + kazdy kursant pobralby certyfikaty wszystkich kolegow z grupy i poznal ich + dane. Sprawdzone na zywo: obcy kursant tego samego kursu dostaje 404. +- **Kursant idzie sesja, uczestnik webinaru tokenem.** Konto Akademii nie ma + tokenu zapisu z maila, bo nie przechodzilo przez formularz webinaru. + Przy okazji naprawiony **istniejacy blad**: link do certyfikatu webinaru na + pulpicie kursanta byl budowany bez tokenu i pobranie konczylo sie 404 mimo + zalogowania. Adres sklada teraz backend, bo tylko on zna token. +- **Wydanie recznie bez ukonczenia kursu WYMAGA NOTATKI.** Bez niej po miesiacu + nie da sie ustalic, dlaczego dokument powstal i kto o tym zdecydowal. + Warunek jest w serwisie, nie w walidacji pola, bo zalezy od drugiego pola + (`force`). +- **Weryfikacja publiczna obsluguje oba zrodla jedna odpowiedzia.** + `subjectTitle` niesie tytul webinaru albo kursu, a `forCourse` pozwala + stronie dobrac etykiete. Zwraca nadal **minimum**: numer, tytul, date, imie + z inicjalem i waznosc. Pilnuje tego `CourseCertificateOwnerTest`, sprawdzajac + strukture rekordu, zeby dopisanie adresu e-mail zlamalo test od razu. +- **Pulpit kursanta ma JEDNA sekcje na oba zrodla.** Kursant nie mysli + kategoriami "to bylo wydarzenie, a to kurs": ma jedno miejsce ze wszystkimi + dokumentami, a zrodlo rozroznia odznaka. +- Pilnuje tego `CourseCertificatePdfTest`, ktory czyta tekst z gotowego pliku + PDF i sprawdza, ze polskie znaki tam sa, ze dokument mowi o **ukonczeniu**, + a nie o udziale, oraz ze kurs bez quizu nie pokazuje wyniku. + +### Certyfikaty uczestnictwa + +Tabele `certificates` i `certificate_counters` (V16), plik PDF w MinIO, +renderowanie przez OpenHTMLtoPDF. + +- **Certyfikat to dokument, nie widok danych.** Plik powstaje **raz** i lezy + w skladzie. Wydany dzis ma wygladac tak samo za rok, niezaleznie od tego, czy + ktos zmienil tytul webinaru, szablon albo logo. Generowanie na nowo przy + kazdym pobraniu sprawialoby, ze dokument w teczce uczestnika rozni sie od + tego, ktory pobierze pracodawca. +- **Czcionka musi byc osadzona w pliku.** DejaVu Sans lezy w + `resources/fonts/`, w repozytorium, zeby build byl powtarzalny. Bez osadzenia + polskie znaki znikaja u odbiorcy, ktory jej nie ma, i "Wiśniewski" staje sie + "Winiewski". Certyfikat z bledem w nazwisku jest bezuzyteczny. +- **Nie deklarowac czcionki przez `@font-face` w CSS.** Rodzina jest + rejestrowana w kodzie przez `PdfRendererBuilder.useFont`. Deklaracja + `@font-face` z adresem wzglednym **przykrywa** ta rejestracje: renderer + probuje pobrac plik wzgledem `baseUri` (pustego, bo dokument nie siega po + siec), nie znajduje go i podstawia czcionke zastepcza. Objawia sie to tym, + ze **kazdy ogonek w PDF zamienia sie w krzyzyk**. Zdarzylo sie to realnie. +- **Rozmiar strony podaje `@page` w szablonie**, nie kod Javy: OpenHTMLtoPDF + czyta go stamtad, a ustawienie w Javie nie ma wplywu na uklad. +- **HTML przechodzi przez jsoup przed renderowaniem.** Renderer wymaga + poprawnego XML, a pojedynczy niezamkniety znacznik w tresci wlasnej + organizatora wywracalby generowanie calego dokumentu. +- **Numer nadawany pod blokada wiersza** (`certificate_counters`, + `SELECT ... FOR UPDATE`), w osobnej transakcji `REQUIRES_NEW`. Dokladnie ten + sam wzorzec co limit miejsc: bez blokady dwa rownoczesne wydania dostalyby + ten sam numer, a numer sluzy do weryfikacji publicznej. +- **Licznik w osobnej tabeli, nie `max(number)`.** Numer zawiera separatory + i format konfigurowalny przez administratora, wiec wyciaganie z niego liczby + byloby zawodne po kazdej zmianie wzoru. +- **Numer w parametrze, nie w sciezce.** Domyslny wzor to `{rok}/{numer}`, + czyli `2026/1`; ukosnik rozbilby sciezke na dwa segmenty i weryfikacja + zawsze dawalaby 404. Trasa frontu uzywa z tego samego powodu wildcardu + `/certyfikat/*`. +- **Dostep do pliku jest wezszy niz przy nagraniach.** Nagranie jest wspolne + dla webinaru, wiec tam wystarcza token dowolnego uczestnika. Certyfikat to + **dokument jednej osoby**, z jej imieniem i nazwiskiem, wiec token musi + wskazywac wlasnie ja (`CertificateService.belongsToToken`). Sama + przynaleznosc do webinaru pozwalalaby kazdemu uczestnikowi pobrac certyfikaty + wszystkich pozostalych i poznac ich dane. Wykryte przy sprawdzaniu na zywo. +- **Weryfikacja publiczna zwraca minimum:** numer, tytul, date, imie + z pierwsza litera nazwiska i informacje o waznosci. **Nigdy nie wolno dopisac + tu adresu e-mail, pelnego nazwiska ani linku do pliku**: endpoint jest + otwarty dla calego internetu. +- **Uniewaznienie ustawia `revoked_at`, nie kasuje wiersza ani pliku.** + Dokument mogl juz trafic do teczki uczestnika albo do pracodawcy, wiec + weryfikacja ma pokazac "niewazny", a nie "nie znaleziono". Uniewazniony + certyfikat znika za to ze strony uczestnika i z maila: link do dokumentu + bez waznosci wprowadzalby w blad. +- **Wydanie automatyczne dzieje sie PRZED kolejkowaniem podsumowania.** Mail + niesie link do pobrania, wiec dokument musi juz istniec, gdy wiadomosc + powstaje. Blad wydawania nie moze zablokowac podsumowania. +- **Automat wydaje wylacznie uczestnikom z `attended`.** Certyfikat dla osoby + bez odnotowanej obecnosci jest decyzja organizatora, a nie automatu; panel + pyta o nia wprost przez `force`. +- **Dwa silniki Thymeleaf wymagaja `@Primary`.** Autokonfiguracja Springa + wstrzykuje `SpringTemplateEngine` do `thymeleafViewResolver` i wymaga + jednoznacznego kandydata: bez `@Primary` na `mailTemplateEngine` kontekst + nie wstaje. `autowireCandidate = false` **nie jest rozwiazaniem**, bo + wyklucza bean takze z wstrzykiwania przez `@Qualifier`. +- **Uzywac `SpringTemplateEngine`, nie czystego `TemplateEngine`.** Czysty + silnik wylicza wyrazenia przez OGNL, ktorego Spring Boot nie dolacza: + `NoClassDefFoundError: ognl/PropertyAccessor` przy pierwszym renderowaniu. +- Pilnuje tego `CertificatePdfTest`, ktory czyta tekst z gotowego pliku PDF + i sprawdza, ze polskie znaki tam sa, takze w szablonie wlasnym organizatora. + +### Platnosci Autopay + +Tabele `orders` i `payment_notifications` (V24). Endpoint ITN pod +`/api/webhooks/autopay/itn`, panel w obszarze Akademia, pozycja Zamowienia. + +- **ITN wymaga publicznej domeny z waznym certyfikatem**, wiec pelnego + przeplywu **nie da sie sprawdzic na localhost**: operator sam laczy sie + z naszym serwerem. Lokalnie sprawdzone jest wszystko, co dzieje sie **po** + odebraniu komunikatu. Lista rzeczy do sprawdzenia po wdrozeniu jest w README. +- **Adresy srodowisk sa zaszyte w kodzie, nie w ustawieniach.** Pomylka + oznaczalaby przyjmowanie prawdziwych pieniedzy na srodowisku testowym albo + transakcje testowe na produkcji, ktore nigdy sie nie rozlicza. Wybor idzie + przez `AUTOPAY_MODE`, ktore jest lista zamknieta. +- **Skrot: wartosci pol rozdzielone `|`, na koncu klucz wspoldzielony.** + Puste i nieprzekazane pola opcjonalne sa pomijane **RAZEM Z SEPARATOREM**. + To najczestsze zrodlo bledow w tej integracji: wstawienie samej kreski daje + inny skrot, a bramka odrzuca zadanie **bez podania przyczyny**. Pilnuje tego + `AutopayHashTest` na przykladzie wprost z dokumentacji. +- **Powrot kursanta z bramki NICZEGO nie potwierdza.** To zwykle przekierowanie + przegladarki, ktore da sie wywolac recznie wpisujac adres. Dostep nadaje + **wylacznie** komunikat ITN ze statusem SUCCESS, po weryfikacji podpisu, + identyfikatora serwisu, kwoty i waluty. +- **FAILURE NIE odbiera dostepu, gdy wczesniej byl SUCCESS.** Ten sam OrderID + moze miec kilka transakcji, kazda z wlasnym remoteID: nieudana proba karta, + potem udany przelew. FAILURE dotyczacy innej proby nie jest cofnieciem + platnosci, a odebranie dostepu zabraloby kurs osobie, ktora zaplacila. + Dokumentacja opisuje ten przypadek wprost. +- **Logika biznesowa wykonuje sie wylacznie przy PIERWSZYM SUCCESS.** Operator + ponawia notyfikacje az do potwierdzenia, wiec powtorki sa **normalnym trybem + pracy**. Kolejne komunikaty potwierdzamy, ale nie powtarzamy akcji. +- **PENDING i FAILURE potwierdzamy bez zmiany dostepu.** Bez potwierdzenia + bramka ponawialaby komunikat w nieskonczonosc. +- **Kazdy komunikat trafia do dziennika, takze odrzucony.** Wpis powstaje przed + weryfikacja, wiec odrzucenie nie kasuje sladu. Przy sporze o platnosc to + jedyny dowod na to, co bramka przyslala i kiedy. +- **Wolanie bramki jest POZA transakcja zapisu zamowienia.** Gdyby bylo + w srodku, wyjatek z bramki wycofalby transakcje razem z zamowieniem, a numer + przepadlby mimo ze zostal juz wyslany na zewnatrz: kursant dostalby blad, + a w panelu nie byloby sladu, ze cos probowal kupic. **Zdarzylo sie to realnie + przy pierwszym uruchomieniu.** Sluzy do tego `@Lazy` referencja na samego + siebie, bo `@Transactional` na metodzie wolanej z tej samej klasy nie dziala. +- **Awaria bramki zostawia zamowienie w NEW**, gotowe do ponowienia na tym samym + numerze. Nie ma sciezki zapasowej z formularzem: druga droga platnosci + oznaczalaby drugi format podpisu do utrzymania. +- **Parser ITN ma twardo wylaczone XXE.** Endpoint jest otwarty, bo operator nie + ma jak sie uwierzytelnic inaczej niz skrotem, wiec parser dostaje dokumenty od + nieuwierzytelnionego nadawcy: bez tego spreparowany komunikat odczytalby pliki + serwera albo wymusil zadania wychodzace. +- **`order_number` jest unikalny NA ZAWSZE.** Trafia do operatora jako OrderID; + ponowne uzycie skierowaloby cudza notyfikacje do niewlasciwego kursanta. +- **Zamowienie jest dokumentem finansowym.** Kurs i kontakt wiaza sie przez + `ON DELETE SET NULL`, a nie kaskade: zamowienie przezywa skasowanie kursu. + Kwota jest przepisana w chwili zlozenia, wiec pozniejsza zmiana ceny jej nie + rusza. +- **Kwoty porownuje sie wartoscia, nie tekstem.** "490.00" i "490.0" to ta sama + kwota, a porownanie napisow odrzucaloby poprawna platnosc przez sam zapis. +- **Kurs darmowy zapisuje od reki, platny dopiero po ITN.** Zakladanie + zamowienia na zero zlotych oznaczaloby przeprowadzanie kursanta przez bramke + po nic, a zapis przed platnoscia dawalby dostep osobie, ktora nie zaplacila. +- **Filtr IP jest opcjonalny i domyslnie wylaczony.** Adresy operatora bywaja + zmieniane, a wpisanie nieaktualnych zablokowaloby wszystkie platnosci. Glowna + bariera jest podpis. Komunikat z niedozwolonego adresu i tak trafia do + dziennika. +- **Reczne nadanie dostepu wymaga notatki.** Bez niej po miesiacu nie da sie + ustalic, czy pieniadze wplynely i kto to sprawdzil. + +### Quiz konczacy kurs + +Tabele `quizzes`, `quiz_questions`, `quiz_answers`, `quiz_attempts`, +`quiz_attempt_answers` (V23). Endpointy `/api/admin/courses/{id}/lessons/{id}/quiz/**` +oraz `/api/academy/lessons/{id}/quiz/**`. + +- **Quiz nalezy do lekcji typu QUIZ, nie do kursu.** Ma wtedy swoje miejsce + w programie: widac, w ktorym momencie wypada, i przesuwa sie razem z reszta. + Jedna lekcja to jeden quiz, czego pilnuje unikalny indeks. +- **POPRAWNE ODPOWIEDZI NIE OPUSZCZAJA SERWERA PRZED KONCEM PODEJSCIA.** + Widok kursanta (`StudentAnswerView`, `StudentQuestionView`) **nie ma pola + `correct` ani wyjasnienia w typie**: to nie jest ukrywanie warunkowe, tylko + osobny rekord. Gdyby oba widoki byly jednym typem z polem ustawianym + warunkowo, wystarczyloby raz przeoczyc warunek, zeby klucz wyszedl w zwyklym + JSON-ie. Pilnuje tego `QuizSecurityTest`, sprawdzajac strukture typu + **i** zserializowany JSON. Test zostal zweryfikowany celowym przeciekiem. +- **Zestaw pytan losowany raz, przy starcie, i zapisany** w + `quiz_attempts.question_ids`. Odswiezenie strony nie zmienia testu, a ponowny + start zwraca **to samo** podejscie zamiast bledu: kursant wracajacy z telefonu + ma zobaczyc ten sam zestaw i pozostaly czas. +- **Zapisany zestaw jest jedynym zrodlem prawdy przy ocenianiu.** Odpowiedz na + pytanie spoza niego jest odrzucana, wiec podstawienie cudzego identyfikatora + niczego nie daje. Odpowiedz nalezaca do innego pytania takze: inaczej dalo by + sie wskazac poprawna odpowiedz z sasiedniego pytania. +- **Limit czasu liczy sie od `started_at`, po stronie serwera.** Przegladarka + moze klamac o uplywie czasu, wiec jej zegar sluzy wylacznie do pokazania. + Podejscie po czasie zostaje zamkniete z wynikiem z odpowiedzi zapisanych do + tej pory, niezaleznie od tego, co przyszle klient. +- **Odpowiedzi zapisywane na biezaco**, a nie dopiero przy zakonczeniu: kursant, + ktoremu padnie przegladarka, nie traci calego testu, a limit czasu ma z czego + policzyc wynik. +- **Pytanie pominiete liczy sie jako bledne**, bo mianownikiem jest liczba pytan + w zestawie, a nie liczba udzielonych odpowiedzi. Inaczej kursant odpowiadajacy + na jedno pytanie z dziesieciu mialby sto procent. `answer_id` rowne null + odroznia jednak pominiecie od braku wiersza, wiec widac, ktore pytania minal. +- **`quiz_attempt_answers.correct` wyliczane przy zapisie odpowiedzi**, a nie + przy ocenianiu: zmiana klucza po fakcie nie zmienia juz wystawionej oceny. +- **Jedno podejscie w toku na pare quiz-zapis**, wymuszone unikalnym indeksem + czesciowym. Bez tego dwie karty przegladarki zalozylyby dwa podejscia + z roznymi zestawami pytan. +- **Podejscia porzucone zamyka zadanie `@Scheduled`.** Kursant, ktory zamknal + karte, nie przysle juz zadnego zadania, a jego podejscie blokowaloby kolejne. +- **Odstep miedzy podejsciami liczy sie od ZAKONCZENIA poprzedniego**, nie od + jego startu: inaczej dlugi test sam wyczerpywalby odstep. +- **Regula "dokladnie jedna odpowiedz poprawna i minimum dwie odpowiedzi" nie + jest CHECKiem w bazie.** Warunek dotyczy **zbioru wierszy**, a PostgreSQL + widzi przy sprawdzaniu jeden wiersz; wyzwalacz oznaczalby druga implementacje + tej samej reguly, rozjezdzajaca sie z ta w serwisie. Regula siedzi w + `QuizService` przy zapisie i jest sprawdzana **ponownie przy publikacji + kursu**: pytanie moglo stac sie niepoprawne przez skasowanie odpowiedzi obok. +- **`pass_threshold_percent` rowne null oznacza dziedziczenie progu z kursu**, + a nie prog zerowy. Prog zerowy jest ustalona wartoscia: quiz zaliczany przy + kazdym wyniku to swiadoma decyzja. Rozstrzyga `Quiz.effectiveThreshold`. +- **`question_count` rowne null oznacza wszystkie aktywne pytania**, wiec + dopisanie pytania od razu powieksza test. Liczba wieksza niz pula jest + odrzucana przy zapisie: test krotszy niz zapowiedziany nie dalby zadnego sygnalu. +- **Pytanie wylaczone (`active = false`) zostaje w bazie**, ale nie trafia do + losowania. Kasowanie pytania uzytego w zakonczonych podejsciach zabieraloby + im tresc. +- **`show_correct_answers` warto wylaczyc przy quizie powtarzanym**: inaczej + pierwsze podejscie sluzy do zebrania klucza, a drugie do jego przepisania. +- **Import CSV pomija bledny wiersz, a nie przerywa calosci.** Plik z setka + pytan i jedna literowka ma wniesc dziewiecdziesiat dziewiec pytan. Format: + `tresc;odpowiedz;*poprawna;#wyjasnienie`, separatorem srednik. +- **Warunek zaliczenia kursu:** wszystkie lekcje **wymagane** ukonczone oraz + kazdy quiz w lekcji wymaganej zdany. Quiz w lekcji nieobowiazkowej nie blokuje + zaliczenia. Wtedy `enrollments.status` przechodzi na COMPLETED i ustawia sie + `completed_at`. Zdanie quizu oznacza jego lekcje jako ukonczona. +- **Statystyki licza sie z podejsc zakonczonych**: trwajace nie maja wyniku, + a wliczanie ich zanizaloby srednia. Pytania sortowane od najczesciej mylonych, + bo to one wymagaja poprawy tresci albo kursu. +- **Podglad w panelu pokazuje pytania bez klucza**, dokladnie tak jak zobaczy je + kursant: pytanie oczywiste dla autora bywa niejasne dla odbiorcy. + +### Zarzadzanie kursami w panelu + +Endpointy `/api/admin/courses/**`, ekrany pod `/kursy` w obszarze Akademia. +Bez czesci publicznej, bez zapisow kursantow i bez quizu: te dochodza osobno. + +- **Publikacja wymaga co najmniej jednej lekcji.** Kurs bez lekcji wygladalby + publicznie jak pusta strona. Ta sama zasada co przy webinarze, ktorego nie da + sie opublikowac bez terminu. +- **Archiwizacja zamiast kasowania.** Kurs znika z oferty, ale zapisani + zachowuja dostep: kurs, za ktory ktos zaplacil, nie moze zniknac dlatego, ze + przestal byc oferowany. +- **Kolejnosc zmienia sie przez przekazanie CALEJ listy identyfikatorow** + (`PUT .../modules/order`, `PUT .../modules/{id}/lessons/order`), a nie przez + pojedyncze przesuwanie. Przy przeciaganiu i tak znamy docelowy uklad, a zapis + "przesun o jeden" wymagalby tylu zadan, ile pozycji przeskoczyl element. + Zapis idzie w jednej transakcji: polowicznie zapisana kolejnosc dawalaby dwa + moduly na tej samej pozycji. +- **Lista do zmiany kolejnosci musi zawierac dokladnie te same identyfikatory.** + Lista krotsza cicho zostawilaby czesc pozycji ze stara kolejnoscia, a obca + pozycja wywrocilaby zapis wyjatkiem o pustej referencji. +- **Modul i lekcja musza nalezec do kursu z adresu.** Sama znajomosc + identyfikatora nie moze pozwalac na zmiany w cudzym programie, wiec + `requireModule` i `requireLesson` sprawdzaja przynaleznosc, a nie tylko byt. +- **Przeniesienie lekcji miedzy modulami idzie przez zmiane `moduleId` + w edycji**, nie przez upuszczenie. Zmiana modulu i kolejnosci naraz mylilaby + sie z samym przestawieniem, a lekcja ladowalaby w nieoczekiwanym miejscu. +- **Adres wideo wylacznie http i https**, sprawdzane w serwisie. `javascript:` + wykonalby sie w kontekscie naszej strony, a `data:` pozwolilby podstawic + dowolny dokument pod nasza domena. Ta sama regula co przy `target_url` banera. +- **Postep procentowy liczy sie WYLACZNIE z lekcji wymaganych.** Materialy + dodatkowe nie wchodza ani do licznika, ani do mianownika: gdyby wchodzily, + dolozenie jednego bonusu obnizaloby postep wszystkim, ktorzy juz skonczyli + kurs. Kurs bez lekcji wymaganych daje 0%, a nie dzielenie przez zero. + Zaokraglenie idzie w dol, zeby 99,7% nie pokazywalo sie jako ukonczone. + Pilnuje tego `CourseProgressTest`. +- **Kopiowanie kursu przepisuje program, nie postep.** Przechodza moduly, + lekcje, opis, okladka, cennik i ustawienia certyfikatu. Nie przechodza zapisy + ani postep: oznaczalyby kursantow zapisanych na kurs, ktorego nie zaczeli. + Kopia jest zawsze DRAFT z zamknietymi zapisami. +- **Pliki lekcji sa wspoldzielone z kopia, nie kopiowane.** Inaczej niz przy + materialach webinaru: tam plik bywa podmieniany per wydarzenie, a tu lekcja + wskazuje nagranie z biblioteki, ktora **jest wspolna z zalozenia**. Kopiowanie + gigabajtowego nagrania przy kazdej kopii kursu byloby kosztem bez pokrycia. +- **Kolumny "Zapisani" i "Lekcje" nie sa sortowalne.** Obie liczby powstaja + osobnym zapytaniem zbiorczym do juz pobranej strony, wiec baza nie potrafi po + nich sortowac. Ta sama regula co przy kolumnie "Zapisani" na liscie webinarow. +- **Eksport CSV nie jest stronicowany** i escapuje wartosci zaczynajace sie od + `=`, `+`, `-`, `@`, tak samo jak eksport zapisow na webinar. +- **Przeciaganie korzysta z natywnego HTML5 drag and drop, bez biblioteki.** + Swiadoma decyzja: dziala w kazdej przegladarce, nic nie dochodzi do + `package.json` i kod przenosi sie miedzy platformami bez zaleznosci. +- **Przyciski gora i dol nie sa dodatkiem.** Natywne przeciaganie jest + **nieosiagalne z klawiatury**, a na ekranie dotykowym niewygodne. Bez tych + przyciskow osoba nieuzywajaca myszy nie zmienilaby kolejnosci w ogole. Kazdy + ma etykiete `aria-label` mowiaca, co przesuwa, bo sama strzalka nic czytnikowi + ekranu nie mowi. +- **Pola lekcji zaleza od jej rodzaju**, a wartosci pol nieuzywanych nie sa + czyszczone przy przelaczaniu: ktos, kto pomylil sie w wyborze i wraca, nie + traci wpisanej tresci. Do bazy i tak idzie tylko to, co pasuje do rodzaju. +- **Lekcja QUIZ jest na razie pozycja bez zawartosci**, z informacja wprost + w oknie edycji. Pytania, odpowiedzi i wyniki dostana wlasny model. + +### Kursanci i postepy w panelu + +Ekrany `/kursy/kursanci`, `/kursy/kursanci/:id` i `/kursy/postepy`, endpointy +pod `/api/admin/academy/**`, statystyki kursu jako zakladka na jego karcie. + +- **BEZ PODGLADU HASLA I BEZ LOGOWANIA SIE JAKO KURSANT.** Haslo jest skrotem + BCrypt i nie da sie go odczytac, a wejscie na cudze konto sprawiloby, ze slad + w dzienniku nie odroznia dzialania kursanta od dzialania administratora: + przy sporze o dostep do platnego kursu to jedyny dowod. Wsparcie dostaje + zamiast tego reset hasla i ponowny link potwierdzajacy, czyli tyle, ile + potrzeba, zeby kogos odblokowac bez przejmowania jego tozsamosci. +- **Kursant to kontakt z haslem** (`password_hash IS NOT NULL`), zgodnie + z modelem kont. Lista nie pokazuje wpisow marketingowych, mimo ze leza w tej + samej tabeli. +- **Osobne repozytorium `StudentAdminRepository`,** a nie dopisanie do + `ContactRepository`: tamto obsluguje baze marketingowa, a te zapytania + dotycza waskiego podzbioru z innymi filtrami i licznikami. +- **`CAST(:x AS text)` przy KAZDYM parametrze opcjonalnym**, takze + `CAST(:x AS boolean)` i `CAST(:x AS bigint)`. Bez tego lista przestaje + dzialac dokladnie wtedy, gdy filtr nie jest uzywany. Sprawdzone na zywo: + lista bez filtru, kazdy filtr osobno i wartosc spoza bialej listy sortowania. +- **Liczniki kursow i certyfikatow NIE sa sortowalne.** Powstaja osobnym + zapytaniem zbiorczym do juz pobranej strony, wiec baza nie ma po czym + sortowac. Ta sama regula co przy kolumnie "Zapisani" na liscie webinarow. + Sortowanie idzie po nazwisku, adresie, dacie rejestracji i ostatnim logowaniu, + z bialej listy w kontrolerze. +- **Znacznik czasu z zapytania natywnego wymaga konwersji.** Sterownik oddaje + `timestamptz` jako `Instant`, a nie `OffsetDateTime`; rzutowanie wprost daje + `ClassCastException` **dopiero na dzialajacej instancji**, bo kompilator widzi + tam `Object`. Objaw jest mylacy: blad 500 front tlumaczy jako wygasla sesje + i pokazuje "Wymagane uwierzytelnienie". Sluzy do tego `asMoment`. + Zdarzylo sie to realnie przy pierwszym uruchomieniu tego ekranu. +- **Ostatnia aktywnosc to `GREATEST` ze zmiany postepu lekcji i startu podejscia + do quizu.** Kursant rozwiazujacy test jest aktywny, choc zadnej lekcji wtedy + nie domyka; liczenie samego `lesson_progress.updated_at` pokazywaloby go jako + porzuconego. +- **Prog bezczynnosci jest ustawieniem** (`ACADEMY_INACTIVITY_DAYS`, domyslnie + 14), a nie liczba w kodzie: przy kursie tygodniowym tydzien ciszy znaczy co + innego niz przy rocznym. Front pobiera prog z backendu i nie powtarza go + u siebie. +- **Kurs UKONCZONY nie jest "nieaktywny".** Kursant nie ma juz nic do zrobienia, + wiec brak ruchu jest tam stanem naturalnym, a nie sygnalem do reakcji. +- **Reczne zapisanie na kurs WYMAGA NOTATKI.** Zapis omija platnosc i akceptacje + regulaminu, wiec po miesiacu musi dac sie wyjasnic, czy kursant zaplacil + przelewem, czy dostal dostep przez pomylke. Warunek jest w serwisie, + nie w walidacji pola. +- **Zgody pokazujemy z CALA historia i wersjami.** Sa dopisywane, nigdy + nadpisywane, wiec kursant ma ich kilka na ten sam typ; obowiazujaca jest + oznaczona, bo bez tego nie da sie odczytac stanu biezacego z samej listy. +- **Wszystkie statystyki licza sie w bazie**, zapytaniami agregujacymi + (`FILTER`, `LATERAL`, CTE), tak samo jak przy statystykach webinaru. +- **Zdawalnosc quizu rowna null oznacza kurs bez sprawdzianu**, a nie zero: + zero sugerowaloby, ze nikt nie zdal. Ta sama zasada co przy cenie null + oznaczajacej kurs darmowy. +- **"Przestali na lekcji" to co innego niz "nie ukonczyli jej".** Liczymy + kursantow, ktorzy doszli do lekcji i nie ukonczyli ANI JEJ, ANI ZADNEJ + POZNIEJSZEJ: lekcja, ktora ktos minal i wrocil do niej pozniej, nie jest + miejscem porzucenia. Bez tej roznicy liczba nic nie znaczy. +- **Wykresy ladowane leniwie** (`@mui/x-charts`), tak samo jak przy + statystykach webinaru: sekcja importuje wykresy statycznie, a strona + nadrzedna laduje sekcje przez `lazy`. Kolory podane wprost, bo biblioteka + nie czyta klas Tailwinda. +- **Zdjecie flagi `planned` to zmiana w DWOCH miejscach**: wpis w + `navigation.ts` i trasa w `App.tsx`. Przy Postepach flaga zostala za pierwszym + podejsciem, bo wpis byl sformatowany wieloliniowo i nie zlapala go podmiana + jednolinijkowa. Ekran dzialal, a pozycja menu byla wyszarzona; wylapal to + dopiero test w przegladarce. + +### Model kursow Akademii + +Tabele `courses`, `course_modules`, `lessons`, `enrollments`, `lesson_progress` +(V22). Encje, repozytoria i enumy. Kontrolery i widoki dochodza osobno. + +- **Hierarchia: kurs -> modul -> lekcja.** Modul jest warstwa posrednia, a nie + ozdoba: kurs bez podzialu to lista kilkudziesieciu lekcji, w ktorej nie widac + struktury, a przeniesienie calego bloku wymaga przenumerowania wszystkiego. +- **Jedna tabela na wszystkie rodzaje lekcji.** Nagranie, tekst, plik, webinar + i sprawdzian maja ten sam cykl zycia, te sama kolejnosc i ten sam postep. + Osobne tabele oznaczalyby piec razy powtorzony ten sam zestaw kolumn i piec + zapytan przy skladaniu jednego programu. +- **`webinar_id` wylacznie przy lekcji typu WEBINAR**, co wymusza ograniczenie + CHECK. Bez niego literowka w kodzie przypielaby wydarzenie do lekcji tekstowej. + To realizuje ustalenie, ze **webinar moze byc lekcja kursu**. +- **Skasowanie webinaru odpina lekcje, nie kasuje jej** (`ON DELETE SET NULL`). + Wydarzenie moze zniknac, ale program kursu ma zostac caly: lekcja wyrwana + ze srodka zmienialaby numeracje i psula postep kursantow. +- **Brak ceny (`price_gross IS NULL`) oznacza kurs darmowy, a nie cene zerowa.** + To dwie rozne decyzje: kurs celowo bezplatny i kurs, ktoremu ceny jeszcze nie + ustalono. Sluzy do tego `Course.isFree()`. +- **Dwa zrodla wideo w lekcji, w osobnych polach.** `media_id` to plik we + wlasnym skladzie, `external_video_url` to adres zewnetrzny. Przy jednym polu + nie dalo by sie odroznic identyfikatora pliku od adresu. +- **`enrollment_open` to intencja administratora**, nigdy zapisywana + automatycznie. Dokladnie ta sama regula co przy webinarze. +- **Postep wisi na zapisie, nie na kontakcie.** Po wypisaniu i ponownym zapisie + widac, ze to nowe podejscie, a nie dalszy ciag poprzedniego. +- **Jeden zapis na pare kurs-kontakt i jeden wiersz postepu na pare + zapis-lekcja**, wymuszone unikalnymi indeksami. Sprawdzenie w kodzie nie + wystarcza: dwie karty przegladarki przechodza je jednoczesnie, a odtwarzacz + wysylajacy sygnal co kilkanascie sekund zakladalby nowy wiersz przy kazdym. + `DataIntegrityViolationException` jest tu **spodziewany**. +- **Do progu zaliczenia licza sie wylacznie lekcje wymagane** (`required`). + Material dodatkowy nie moze decydowac o certyfikacie. +- **Dostep sprawdza `Enrollment.grantsAccess()`, nie sam status.** Status + EXPIRED ustawia zadanie w tle, wiec miedzy jego przebiegami wiersz jest + jeszcze ACTIVE mimo minionej daty. Ukonczenie kursu **nie** odbiera dostepu + do materialow. +- **`access_expires_at` rowne null to dostep bezterminowy.** Brak daty i data + w przyszlosci to dwie rozne rzeczy. +- **Kategoria jest tekstem, nie tabela slownikowa.** Nie wiadomo jeszcze, czy + kategorie beda mialy wlasne strony i kolejnosc; tabela na zapas kosztuje join + przy kazdym listowaniu, a jej dodanie pozniej jest prosta migracja. +- **QUIZ jest w ograniczeniu CHECK od poczatku**, mimo ze obslugi nie ma: + dodanie jej nie bedzie wymagalo migracji zmieniajacej typ kolumny. Pytania, + odpowiedzi i wyniki dostana wlasne tabele. **Platnosci tak samo**: tutaj sa + wylacznie pola cennika (`price_gross`, `currency`), a nie fakt zaplaty. + Nie zakladamy z gory ani modelu pytan, ani modelu rozliczenia. +- Pilnuje tego `CourseModelTest`, ktory sprawdza takze **liste bez filtru**, + zgodnie z regula, ze nowy test listy zaczyna sie od tego przypadku. + +### Podzial menu panelu na obszary + +Menu ma przelacznik obszaru (Webinary, Akademia), pod nim pozycje wybranego +obszaru, a na dole osobna grupe pozycji wspolnych dla instancji. + +- **Cala nawigacja jest deklaracja w `lib/navigation.ts`.** Obszar, trasa, + ikona i wymagana rola sa **danymi**, a komponent menu tylko je renderuje. + Dodanie pozycji to zmiana jednej linii, a nie dopisanie warunku w JSX. + Dzieki temu regula "kto to widzi" siedzi w jednym miejscu i da sie ja + sprawdzic wzrokiem, zamiast szukac po `isAdmin &&` rozsypanych po kodzie. +- **Rola jako lista (`roles`), nie flaga `adminOnly`.** Rol moze przybyc, + a wtedy flaga wymagalaby przepisania kazdego uzycia. +- **Pozycja `planned` jest wyszarzona i nie jest linkiem.** Widok jeszcze nie + istnieje, wiec klikniecie prowadziloby do pustego ekranu. Zdjecie flagi po + dodaniu widoku jest zmiana jednego slowa. +- **Wybor obszaru przezywa zamkniecie przegladarki** (`localStorage`), takze + gdy zostal wymuszony trasa: inaczej powrot po restarcie pokazywalby inny + obszar niz ostatnio. +- **Wejscie na trase drugiego obszaru przelacza obszar**, zeby menu zgadzalo + sie z zawartoscia. Sciezki wspolne (Ustawienia, Kontakty) nie naleza do + zadnego obszaru i zostawiaja wybor bez zmian: inaczej wejscie w Ustawienia + przerzucaloby uzytkownika na obszar domyslny. +- **Zaleznoscia efektu przelaczajacego obszar jest wylacznie sciezka, nigdy + `area`.** Ze stanem na liscie zaleznosci efekt wykonuje sie ponownie zaraz + po recznym przelaczeniu i **cofa je** do obszaru wynikajacego z trasy. + Objaw jest mylacy: przycisk wyglada na niedzialajacy, chociaz stan zmienia + sie poprawnie i natychmiast wraca. Zdarzylo sie to realnie. +- **Trasy kursow w panelu ida pod `/kursy`, nie `/akademia`.** Ten drugi + prefiks nalezy juz do publicznej czesci kursanta (logowanie, rejestracja, + potwierdzenie adresu), wiec panel wszedlby mu w droge. + +### Strona glowna: pusta sekcja i tresc startowa + +Sekcje strony glownej (prowadzacy, opinie, FAQ, webinary, cennik) sa ukrywane, +gdy nie maja tresci. **Dla goscia to poprawne, dla administratora nie.** + +- **Gosc nie widzi pustej sekcji.** Naglowek "Opinie kursantow" nad niczym + wyglada gorzej niz brak sekcji. +- **ADMIN i MODERATOR widza w jej miejscu placeholder** (`AdminSectionHint`) + z informacja, czego brakuje, i odnosnikiem do panelu. Bez tego administrator + **nie ma jak zauwazyc, ze cos jest do uzupelnienia**: strona nie rozni sie + od kompletnej, a swieza instancja wyglada na zepsuta. Placeholder nie jest + renderowany dla goscia wcale, a nie ukrywany stylem: nie ma go takze + w zrodle strony. +- **Pusta lista kursow i webinarow zostaje widoczna dla goscia** z komunikatem + "pojawia sie wkrotce", a administrator dostaje pod nim odnosnik do + utworzenia pierwszej pozycji (`AdminEmptyHint`). +- **Brak kursow platnych ukrywa cennik takze administratorowi.** To nie jest + tresc do uzupelnienia, tylko skutek tego, ze instancja oferuje wylacznie + szkolenia bezplatne: placeholder sugerowalby, ze czegos brakuje. + +**Tresc startowa zaklada `PageSectionBootstrap`**, tym samym wzorcem co pierwsze +konto administratora. + +- **Zakladamy WYLACZNIE pytania FAQ**, i to o dzialanie platformy, a nie + o branze klienta: jak dziala zapis, czym jest certyfikat, czy kurs ma termin. + Te odpowiedzi sa prawdziwe na kazdej instancji. +- **NIE zakladamy opinii ani prowadzacych.** Wymyslona opinia jest nieprawda + o nieistniejacej osobie i predzej czy pozniej trafi na produkcje, bo nikt jej + nie usunie: wyglada jak tresc, a nie jak zaslepka. +- **Kazdy wpis ma flage `is_sample`** (V27) i znacznik "przyklad" widoczny + wylacznie dla rol panelu. **Edycja wpisu zdejmuje flage**: od tej chwili + tresc jest decyzja czlowieka. +- **Warunkiem ponownego zalozenia jest ustawienie `HOME_SECTIONS_SEEDED`, + a nie pustka w tabeli.** Pierwsza wersja sprawdzala samo `count() > 0` + i **przykladowe pytania wracaly przy kazdym restarcie** po tym, jak + administrator je skasowal: jedynym sposobem, zeby sie ich pozbyc, byloby + zostawienie jednego wpisu. Znacznik siedzi w ustawieniach wlasnie po to, + zeby przezyl skasowanie tabeli tresci. Wylapal to test na uruchomionej + instancji, bo z czytania kodu warunek wyglada poprawnie. + +### Uklad strony glownej wedlug makiety + +Makiety: `docs/Akademia Wodociagowca - Wariant 1a.html` i `1b.html`. Sa to +pakiety samorozpakowujace sie: tresc siedzi w JSON-owym stringu w ostatnich +liniach pliku, wiec do porownania trzeba ja najpierw wydobyc, a nie ogladac +`grep`-em po znacznikach. + +- **Sekcje maja naprzemienne tla** (`Band`): biale, szare, ciemne granatowe + przy prowadzacych, szare przy cenniku. Bez tego strona byla plaska i nie bylo + widac granic sekcji. **Tlo idzie na pelna szerokosc okna, tresc zostaje + w kontenerze**: dlatego `AcademyLayout` ma tryb `bleed`, ktory oddaje + `children` bez wlasnego opakowania. W kontenerze pas koloru urywalby sie na + 1200 px i wygladal jak przypadkowy prostokat. +- **Hero jest dwukolumnowe** (`1.1fr / 1fr`): tresc po lewej, panel wizualny po + prawej. Panel jest ozdoba, wiec ponizej `lg` znika calkiem, a nie zjezdza pod + tresc: na telefonie zabieralby ekran, nic nie wnoszac. +- **Cztery liczniki**: godziny materialu, kursanci, ukonczone szkolenia, kursy. + Godziny licza sie z `duration_minutes` lekcji **z kursow opublikowanych**, + z pominieciem lekcji bez podanej dlugosci: null znaczy tam "nie wiem", + a nie "zero minut". +- **Zdawalnosci z makiety NIE MA.** Policzona z kilku podejsc jest przypadkowa, + a przy zerze zdanych zniechecalaby. Wroci, gdy bedzie co usredniac. +- **Opinie i FAQ stoja obok siebie** w dwoch kolumnach, tak jak w makiecie. +- **Prowadzacy sa na ciemnym tle** (`gradient-depth`), kafelki z inicjalami albo + zdjeciem, tekst wprowadzajacy po lewej. + +### Karta webinaru na stronie glownej nie zapisuje + +Makieta pokazuje w karcie webinaru pole adresu i przycisk "Zapisz sie". **Tak +sie nie da**, i to nie jest kwestia wygody: + +- `registrations.first_name` i `last_name` maja **NOT NULL**, a endpoint + oznacza je `@NotBlank`: zapis z samym adresem zostanie odrzucony, +- zgoda na regulamin musi byc zapisana **z wersja tresci**, wiec trzeba pokazac + te tresc, a nie tylko checkbox. + +Rozwazone byly dwa wyjscia. **Checkbox przy polu** odpada, bo i tak brakuje +imienia i nazwiska: karta urosla by do czteropolowego formularza wyzszego niz +sama karta, a regula zapisu (limit miejsc pod blokada wiersza, wersjonowanie +zgod, stany zamkniete) zostalaby zaimplementowana drugi raz obok tej na stronie +webinaru. + +**Wybrano przeniesienie adresu**: karta niesie go parametrem `?email=` na +strone webinaru, gdzie formularz startuje z wypelnionym polem. Przycisk mowi +**"Przejdz do zapisu"**, a nie "Zapisz sie": przycisk, ktory nie robi tego, co +obiecuje, jest gorszy niz jedno klikniecie wiecej. Pod polem jest zdanie +uprzedzajace, czego zabraknie na nastepnym ekranie. + +### Stopka i znak instancji + +- **Stopka jest widoczna zawsze**, takze przy pustej konfiguracji. Poprzednia + wersja renderowala sie poprawnie, ale bez ustawien kurczyla sie do paska + 45 px z jednym zdaniem: technicznie byla, wizualnie jej nie bylo. Uklad + wedlug wariantu **1a** (ciemne tlo, trzy kolumny, pasek praw autorskich), + bo miesci odnosniki serwisowe i dane spolki; wariant 1b ma stopke + jednolinijkowa, ktora wypycha te dane do tresci strony. +- **Pusty adres regulaminu ukrywa sam odnosnik, nie cala stopke.** +- **Brak logo pokazuje sama nazwe marki, bez grafiki zastepczej.** Wczesniej + przy pustym `APP_LOGO_URL` pojawiala sie kropla w kolorze marki: wygladala + jak logo klienta, choc nim nie byla, wiec instancja bez wgranego logo + prezentowala sie cudzym znakiem. + +### Liczniki na stronie glownej licza to, co gosc widzi + +Licznik na stronie publicznej jest **obietnica**: gosc, ktory widzi "12 +webinarow", spodziewa sie ich na liscie. + +- **Webinary: opublikowane Z TERMINEM W PRZYSZLOSCI** (`countUpcoming`). + Wczesniej liczylo wszystkie PUBLISHED, wiec instancja z jednym zakonczonym + webinarem pokazywala w naglowku "1 webinar", a zaraz pod spodem pusta sekcje + nadchodzacych: **ta sama strona przeczyla sama sobie**. Warunek musi byc ten + sam co w `findUpcoming`. +- **Kursanci: konta z POTWIERDZONYM adresem.** Konto bez potwierdzenia nie ma + dostepu do materialow, wiec nie jest kursantem, tylko proba rejestracji. Bez + tego warunku liczbe widoczna dla kazdego goscia dalo sie zawyzyc z zewnatrz + rejestracjami na zmyslone adresy. +- **Kursy i lekcje: wylacznie z kursow opublikowanych.** +- **Zero sie nie pokazuje.** Licznik "0 kursow" na stronie zachecajacej do + nauki chwali sie pustka. Filtrowanie jest przed renderowaniem, nie w samym + komponencie: siatka `grid-cols-4` zostawialaby po ukrytym liczniku pusta + kolumne. Gdy znikna wszystkie, znika tez kreska nad nimi. + +### Publiczna czesc Akademii + +Trasy `/akademia` (strona glowna, kursy, webinary, cennik), `/kurs/:slug`, +`/kurs/:slug/nauka`, `/kurs/:slug/zakup` i `/kurs/:slug/platnosc`. Endpointy +publiczne pod `/api/public/academy/**`, kursanta pod `/api/academy/**`. + +- **Tresc sekcji statycznych trzyma tabela `page_sections` (V25), nie + `app_settings`.** Prowadzacy, opinia i pytanie FAQ to POWTARZALNE REKORDY + o kilku polach, a `app_settings` trzyma jedna wartosc pod jednym kluczem. + Upchniecie ich tam wymagaloby albo wlasnego formatu ("imie|rola|opis;..."), + co lamie zasade "zaden format techniczny nie moze byc tym, co uzytkownik + wpisuje recznie", albo JSON-a w polu tekstowym, co odbiera walidacje. Doszlaby + jeszcze sztywna liczba pozycji: czterej prowadzacy to albo cztery klucze na + pole (`EXPERT_1_NAME` ... `EXPERT_4_BIO`), albo lista w jednej wartosci, + a dodanie piatego wymagaloby wdrozenia. +- **Wartosci naprawde pojedyncze zostaja w `app_settings`** (`ACADEMY_HERO_TITLE`, + `ACADEMY_FOOTER_TEXT`, `ACADEMY_TERMS_URL`): jeden napis pod jednym kluczem, + nie ma czego numerowac. Panel edytuje jedno i drugie w zakladce + Ustawienia -> Strona glowna. +- **WIDOK PUBLICZNY LEKCJI NIE MA POLA Z TRESCIA.** `PublicLessonView` nie + zawiera `contentHtml` ani `mediaId` **w typie**, tak samo jak widok kursanta + w quizie nie ma pola `correct`. Uzycie tu `CourseDtos.LessonView` z panelu + oznaczaloby, ze cala tresc platnego kursu jest w zwyklym JSON-ie, zanim + ktokolwiek za niego zaplaci. Pilnuje tego `PublicAcademySecurityTest`, + sprawdzajac strukture typu **i** zserializowany JSON; test zostal + zweryfikowany celowym przeciekiem. +- **Flaga `preview` jest sprawdzana przy pobieraniu tresci, nie tylko przy + skladaniu programu.** Identyfikator lekcji jest w adresie, wiec bez tego + znajomosc samego numeru otwieralaby dowolna lekcje. Sprawdzamy takze + przynaleznosc lekcji do kursu ze sciezki: inaczej dalo by sie obejrzec + zapowiedz cudzego kursu przez podstawiony slug. +- **Stan przycisku akcji rozstrzyga backend** (`CourseAction`), nie + przegladarka. Skladanie go we froncie z ceny, zapisu i zamowienia byloby + druga implementacja reguly zapisu, rozjezdzajaca sie z ta po stronie serwera. + Zapis z aktywnym dostepem wygrywa ze wszystkim: kursant, ktory kupil kurs, + wchodzi do niego takze po zamknieciu zapisow i po archiwizacji. +- **Kurs ARCHIVED jest widoczny, ale bez mozliwosci zapisu.** Adres mogl trafic + do materialow albo do wyszukiwarki, wiec 404 po zdjeciu z oferty wygladaloby + na awarie. DRAFT daje 404, tak samo jak przy webinarach. +- **PROG UKONCZENIA WIDEO SPRAWDZA SERWER.** Przegladarka przysyla liczbe + obejrzanych sekund, a nie decyzje "ukonczone": inaczej jedno podstawione + zadanie zaliczaloby caly kurs, a przy kursie z certyfikatem oznaczaloby to + dokument wystawiony za nic. Prog jest ustawieniem + (`ACADEMY_VIDEO_COMPLETE_PERCENT`, domyslnie 90), liczonym z dlugosci lekcji + zapisanej w bazie. **Lekcja bez podanej dlugosci nie domyka sie sama**: nie ma + do czego porownac, a zgadywanie oznaczaloby zaliczanie po kilku sekundach albo + nigdy. Pilnuje tego `LessonProgressRulesTest`, wolajac prawdziwa metode + serwisu, a nie kopie wzoru. +- **Lekcja ze sprawdzianem nie da sie oznaczyc przyciskiem.** Zalicza sie + wylacznie zdaniem quizu, inaczej dalo by sie ominac warunek zaliczenia kursu. +- **Postep liczy sie WYLACZNIE z lekcji wymaganych**, ta sama metoda + (`CourseEnrollmentService.percent`) co lista w panelu. Metoda jest `public` + wlasnie po to: dwie kopie rozjechalyby sie przy pierwszej poprawce. +- **Warunek zaliczenia kursu siedzi w `CourseCompletionService`, w jednym + miejscu.** Kurs domyka sie z dwoch stron: po zdaniu quizu i po oznaczeniu + ostatniej lekcji w odtwarzaczu. Wczesniej regula byla prywatna metoda + `QuizAttemptService`; zostawienie jej tam oznaczaloby druga kopie w serwisie + postepu i dwa rozne warunki zaliczenia tego samego kursu. +- **Kurs bez lekcji wymaganych nie zalicza sie sam.** Inaczej zapis na kurs + zlozony z samych materialow dodatkowych bylby ukonczony w chwili zapisania. +- **Obsluga naglowka Range jest wspolna** (`RangeResponder`). Nagrania webinarow + i wideo lekcji musza przewijac sie tak samo, a druga kopia stu linii parsowania + zakresow rozjechalaby sie przy pierwszej poprawce. +- **Plik lekcji ma wlasna kontrole dostepu, osobna od `FileAccessGuard`.** Tamten + zna wylacznie zapisy na webinary i widocznosc nagrania; tutaj warunkiem jest + zapis na kurs zawierajacy te lekcje i aktywny dostep. Doklejanie tego do guarda + oznaczaloby jedna metode odpowiadajaca na dwa rozne pytania. +- **Token kursanta idzie do pliku TAKZE parametrem `?token=`.** Element `video` + pobiera plik wlasnym zadaniem, do ktorego przegladarka nie pozwala dolozyc + naglowka `Authorization`. Ta sama droga co przy nagraniach webinaru. Naglowek + ma pierwszenstwo, gdy zadanie przeszlo przez filtr kursanta. +- **Odmowa dostepu do pliku i lekcji daje 404, nie 403**, tak samo jak przy + nagraniach: przy 403 dalo by sie sprawdzic, ktore kursy i pliki istnieja. +- **Biezaca lekcja jest w adresie** (`?lekcja=`), a nie w stanie komponentu: + dzieki temu da sie ja odswiezyc, wrocic do niej przyciskiem wstecz i podeslac + linkiem na inne urzadzenie. +- **Klucz zapytania o kurs zawiera identyfikator kursanta.** Bez tego po + zalogowaniu strona pokazywalaby przycisk z pamieci podrecznej goscia, czyli + "Zaloguj sie, zeby sie zapisac" osobie juz zalogowanej. +- **Strona powrotu z bramki NICZEGO nie potwierdza.** To zwykle przekierowanie + przegladarki, ktore da sie wywolac recznie, wiec ekran nie mowi "zaplacono", + tylko odpytuje serwer co piec sekund i czeka na ITN. Odpytywanie zatrzymuje + sie po nadaniu dostepu i po dziesieciu minutach: zakladka zostawiona na noc + nie ma odpytywac bez konca. +- **Cennik pokazuje ceny FAKTYCZNYCH kursow, a nie planow abonamentowych.** + Makieta proponowala trzy pakiety miesieczne, ale platforma rozlicza sie za + kurs: jedno zamowienie, jedna platnosc, dostep bezterminowy. Cennik obiecujacy + abonament, ktorego nie da sie kupic, wprowadzalby w blad. +- **Liczniki w naglowku licza sie z bazy**, a liczba kursantow to kontakty + **z zalozonym kontem**, nie wszystkie kontakty: w bazie marketingowej sa takze + osoby, ktore nigdy sie nie zarejestrowaly. +- **Osobny layout (`AcademyLayout`), nie `PublicLayout`.** Tamten jest waski + (`max-w-2xl`) i zaprojektowany pod jeden formularz zapisu z linku w mailu; + strona glowna pokazuje siatke kursow i potrzebuje `max-w-container`. + Szerokosc ustawia sie raz, w layoucie. +- **Publiczna czesc Akademii ladowana jest leniwie**, tak samo jak wykresy + i biblioteki wideo. Panel i Akademia to dwie rozne aplikacje dla dwoch roznych + osob; bez podzialu glowny pakiet rosl z 859 kB do 1734 kB. +- **Ikona nie zastepuje etykiety tekstowej.** Komponent `Icon` ma na stale + `aria-hidden`, wiec sama klodka przy lekcji platnej nie mowi czytnikowi ekranu + niczego. Etykieta idzie tekstem (`sr-only sm:not-sr-only`). Wykryte przy + sprawdzaniu na zywo. +- **Pusty adres regulaminu UKRYWA odnosnik** w stopce, zamiast prowadzic pod + pusta sciezke: odnosnik donikad jest gorszy niz jego brak. + +### Konta kursantow (Akademia) + +Kolumny konta dolozone do `contacts` (V21), endpointy `/api/academy/**`, +pulpit kursanta pod `/moje-konto`. + +- **Kursant to kontakt z haslem, a nie czwarta tabela z ludzmi.** W bazie sa juz + trzy miejsca opisujace osobe: `users` (panel), `registrations` (zapis na jedno + wydarzenie) i `contacts` (baza marketingowa). Kursant to ta sama osoba co + kontakt: dostaje zaproszenia, ma zgody i wlasne wypisanie. Granica jest jedna + kolumna: `password_hash IS NULL` to zwykly wpis marketingowy, + `password_hash IS NOT NULL` to kursant. +- **`contacts` i `users` to dwa rozne swiaty uprawnien i dwa niezalezne hasla.** + Ta sama osoba moze miec oba konta na jeden adres; zmiana hasla w panelu nie + rusza konta kursanta i odwrotnie. Wspolne haslo oznaczaloby, ze wyciek hasla + kursanta otwiera panel administracyjny. +- **Token niesie pole `typ` i to ono egzekwuje granice.** Obie tabele maja + wlasne sekwencje identyfikatorow, wiec kursant o id 7 i administrator o id 7 + to dwie rozne osoby. Filtr panelu bierze `subject` wprost i szuka po nim + w `users`, wiec **bez tego pola podpisany token kursanta otworzylby + /api/admin/\*\*** kontu, ktore nigdy nie mialo tam wstepu. Pilnuje tego + `StudentTokenTest`, ktory sprawdza obie strony granicy. +- **Podpis tokenu (`signWith`) musi zostac we wspolnym budowniczym.** Przy + wydzielaniu wspolnej czesci obu tokenow latwo go zgubic, a wtedy biblioteka + wydaje token z naglowkiem `alg=none`: kazde zadanie konczy sie 401, a sam + token da sie ulozyc bez znajomosci sekretu. Zdarzylo sie to realnie. +- **Filtry rejestruje sie wzgledem `UsernamePasswordAuthenticationFilter`.** + Podanie tam wlasnej klasy filtra jako punktu odniesienia nie dziala: lancuch + nie ma takiej pozycji, filtr do niego nie trafia i **kazde zadanie kursanta + konczy sie 401 mimo poprawnego tokenu**. Zdarzylo sie to realnie. +- **Rejestracja na zajety adres daje te sama odpowiedz co sukces** i mail + o istniejacym koncie, dokladnie tak jak przy zapisach na webinar. Haslo nie + jest przy tym nadpisywane: inaczej znajomosc samego adresu pozwalalaby przejac + cudze konto przez formularz rejestracji. +- **Adres istniejacy jako kontakt bez hasla jest uzupelniany, nie odrzucany.** + Osoba, ktora dostawala od nas maile albo zapisala sie na webinar, ma moc + zalozyc konto na ten sam adres, bez powstawania drugiego kontaktu. +- **Bez potwierdzenia adresu logowanie dziala, ale dostep do kursow nie** + (`Contact.hasCourseAccess`). Inaczej wystarczyloby podac cudzy adres, zeby + dostac sie do jego materialow. +- **Token resetu hasla wygasa po godzinie i jest jednorazowy.** Link zostaje + w skrzynce na zawsze, wiec token bez terminu waznosci dziala jak drugie, + trwale haslo. Odebranie linku dowodzi dostepu do skrzynki, wiec reset przy + okazji potwierdza adres. +- **Odpowiedz na prosbe o reset jest taka sama dla adresu istniejacego + i nieistniejacego**, inaczej formularz sluzylby do sprawdzania, kto ma konto. +- **Uczestnictwo laczy sie z kontem po adresie e-mail, nie kluczem obcym.** + Zapisy powstaly przed kontami i istnieja takze dla osob, ktore konta nigdy nie + zaloza; dopisywanie im teraz `contact_id` wymagaloby migracji danych + historycznych i rozstrzygniecia, co zrobic z adresami bez dopasowania. +- **Dotychczasowe wejscie tokenem z maila zostaje nietkniete.** Konto jest + wygoda dla osob, ktore chca miec wszystko w jednym miejscu, a nie nowym + warunkiem dostepu. Pulpit pokazuje ten sam token zapisu, ktory kursant ma + w mailu, a kontrole dostepu do plikow nadal robi `FileAccessGuard`. +- **Adres istniejacy w obu swiatach pokazuje wybor sciezki** (`alsoStaff` + w odpowiedzi logowania). Nie decydujemy za uzytkownika: administrator zapisany + na webinar ma dostac sie i do panelu, i do wlasnego pulpitu. Wejscie do panelu + wymaga osobnego zalogowania, bo hasla sa niezalezne. +- **Zgody kursanta zapisuje `ContactService.recordConsent`,** to samo wejscie + co dla panelu. Zgody sa dopisywane, nigdy nadpisywane, a "ma zgode" znaczy + **najnowsza** zgode danego typu. +- **Wiadomosci Akademii ida przez `MailService.deliver`,** jak cala reszta + poczty: inaczej nowa sciezka domyslnie omijalaby tryb bezpieczny i limit + godzinowy. Kazda zostawia wpis w `notifications` z **prawdziwym** odbiorca. +- **Token kursanta ma wlasny klucz w localStorage** (`akademia.token`). Wspolny + klucz oznaczalby, ze zalogowanie sie do Akademii wyrzuca administratora + z panelu w drugiej karcie. +- **Komentarz blokowy `/* */` nie moze sie zagniezdzac w migracji.** Blok + wewnatrz `ALTER TABLE` zamyka komentarz zewnetrzny przedwczesnie, PostgreSQL + zglasza "unterminated /* comment", a **cala migracja nie robi nic, mimo ze + Flyway zapisuje ja jako wykonana**. W jednym poleceniu uzywac wylacznie + komentarzy liniowych `--`. Zdarzylo sie to realnie przy V21. + +### Kontakty i zaproszenia + +Tabele `contacts`, `contact_groups`, `contact_group_members`, `invitations` (V15). +Domyka pierwszy etap sciezki: najpierw informacja z linkiem, potem rejestracja. + +- **Kontakt to nie zapis na webinar.** Kontakt zyje niezaleznie od wydarzen: + dostaje zaproszenia na kolejne webinary, ma wlasne zgody i wlasne wypisanie. + Zapis dotyczy jednego wydarzenia. Dlatego kontakty sa osobnym dzialem panelu, + a nie zakladka webinaru. +- **Adres jest tozsamoscia kontaktu, bez wzgledu na wielkosc liter.** Unikalny + indeks funkcyjny na `lower(email)`: "Jan@Firma.pl" i "jan@firma.pl" to ta sama + skrzynka, wiec import nie zaklada dwoch kontaktow, a wypisanie obejmuje oba + zapisy. +- **Zgody kontaktow leza w tej samej tabeli `consents`**, z nullowalnym + `contact_id` i ograniczeniem CHECK na dokladnie jednego wlasciciela. Jedna + tabela, bo to ta sama rzecz: oswiadczenie osoby z trescia, wersja i data. + Osobna oznaczalaby dwa miejsca do sprawdzenia przy kazdym pytaniu "czy wolno + wyslac". +- **"Ma zgode" znaczy najnowsza zgode.** Zgody sa dopisywane, nigdy nadpisywane, + wiec kontakt ma ich kilka. Wyraza to `DISTINCT ON (contact_id) ... ORDER BY + granted_at DESC` w zapytaniu natywnym. **W JPQL sie nie da**: `limit` + w podzapytaniu nie nalezy do gramatyki i aplikacja nie wstaje. To nie jest + kwestia stylu, tylko jedyny dzialajacy sposob. +- **`unsubscribed_at` jest osobno od zgody, mimo ze wypisanie ja cofa.** Zgody + daja slad rozliczalny, a ta kolumna daje jednoznaczna i tania odpowiedz na + pytanie "czy wolno mu cokolwiek wyslac", bez szukania najnowszej zgody + kazdego kontaktu przy kazdej wysylce. +- **Wypisanie wymaga dwoch krokow: GET pokazuje, POST wykonuje.** Samo otwarcie + linku nie moze wypisywac, bo skanery poczty i podglad linkow w kliencie + odwiedzaja adresy z wiadomosci automatycznie. +- **Wypisanie dotyczy marketingu, nie obslugi zapisanego webinaru.** Potwierdzenie, + przypomnienie i podsumowanie ida dalej: to obsluga zamowionej uslugi. Strona + wypisania mowi to wprost, zeby uczestnik nie sadzil, ze traci powiadomienia + o wydarzeniu, na ktore sam sie zapisal. +- **Import ma podglad przed wykonaniem.** Pomylka w mapowaniu kolumn oznaczalaby + setki kontaktow z nazwiskiem w polu firmy, a odkrecanie tego recznie kosztuje + wielokrotnie wiecej niz jedno spojrzenie na piec przykladowych wierszy. +- **Duplikat aktualizuje, nie tworzy drugiego, i uzupelnia zamiast zastepowac.** + Plik importu bywa ubozszy niz to, co juz mamy: pusta kolumna nie moze + wyczyscic telefonu wpisanego wczesniej recznie. +- **Import nie przywraca wypisanych.** Zgoda z importu omija kontakt + z `unsubscribed_at`: to najprostsza droga do wysylania wbrew wyraznej odmowie. +- **Zapis na webinar dopisuje kontakt ze zrodlem REGISTRATION**, przenoszac + zgode marketingowa, gdy uczestnik ja wyrazil. Odmowa przy zapisie **nie + cofa** wczesniejszej zgody kontaktu: to dwa osobne oswiadczenia. Blad tej + synchronizacji nie moze wywrocic zapisu, wiec jest lapany i logowany. +- **Zaproszenia ida przez istniejaca kolejke `notifications`**, wiec tryb + bezpieczny, limit godzinowy, ponawianie i slad bledu dzialaja tak samo jak + przy reszcie poczty. Zaproszenie na cala baze to najwiekszy jednorazowy ruch + w aplikacji, wiec bezpiecznik zadziala tu najczesciej. +- **Nowy szablon z wlasnym wysylaczem dopisuje sie do + `SummaryScheduler.NOT_FOR_REMINDER_SENDER`**, a do `TEMPLATES` tylko wtedy, + gdy ma go obsluzyc `SummarySender`. Te dwie listy sa rozne: pierwsza mowi, + czego `ReminderSender` ma nie dotykac, druga co bierze `SummarySender`. + Pomylenie ich sprawia, ze zaproszenia zostaja oznaczone FAILED, zanim + wlasciwy wysylacz je zobaczy. +- **Jedno zaproszenie na pare (webinar, kontakt)**, czego pilnuje unikalny + indeks. Wysylka powtorzona po dolozeniu kontaktow do grupy nie moze zaprosic + drugi raz tych, ktorzy juz dostali wiadomosc. +- **Warunki sprawdzane ponownie tuz przed wysylka.** Miedzy zakolejkowaniem + a wyslaniem kontakt mogl sie wypisac, a zaproszenie do kogos, kto wlasnie + zrezygnowal, jest dokladnie tym, czemu wypisanie ma zapobiegac. +- **Podglad liczby odbiorcow pokazuje liczbe PO odfiltrowaniu** wypisanych, + bez zgody i juz zaproszonych. Liczba wybranych kontaktow bez tego filtru + wprowadzalaby w blad przed nieodwracalna wysylka. +- **Link zaproszenia niesie `?zaproszenie={id}`**, a backend sprawdza, czy + zaproszenie dotyczy **tego** webinaru: identyfikator jest w adresie, wiec + kazdy moze go podmienic, a bledne powiazanie zafalszowaloby skutecznosc + innej wysylki. +- **`markRegistered` ma `REQUIRES_NEW`.** Jest wolane z `afterCommit`, czyli + po zamknieciu transakcji zapisu; bez nowej transakcji zmiana nie zapisuje sie + wcale, a zaproszenie zostaje bez `registered_at` mimo udanego zapisu. +- Skutecznosc liczona **po wyslanych**, nie po zakolejkowanych: zaproszenie, + ktore nie wyszlo, nie mialo szansy przyniesc zapisu. +- **Kazda wiadomosc marketingowa ma stopke z linkiem wypisania.** Maile + transakcyjne jej nie maja i nie podlegaja wypisaniu. + +### Obecnosc i statystyki + +Tabela `attendance_sessions` (V14). Flaga `registrations.attended` wyliczana +z sumy sesji, nigdy wpisywana recznie. + +- **Sesja, a nie flaga.** Ludzie wchodza i wychodza, traca polaczenie, wracaja + z telefonu. Dopiero suma sesji mowi, ile ktos naprawde byl obecny, i pozwala + narysowac wykres obecnosci w czasie. +- **Dwie drogi rejestracji, jedna tabela.** Webhook dostawcy o wejsciu i wyjsciu + oraz sygnal z przegladarki co minute. Roznia sie wylacznie polem `source`, + wiec statystyki nie musza wiedziec, skad dane pochodza. Sygnal z przegladarki + dziala takze w trybie NONE, gdzie zadnego dostawcy nie ma. +- **Sygnal idzie istniejacym polaczeniem WebSocket** (`/app/heartbeat`), nie + osobnym zadaniem HTTP: polaczenie i tak jest otwarte przez cale spotkanie, + a osobne zadanie wymagaloby wlasnego uwierzytelnienia przy kazdym strzale. + Tozsamosc pochodzi z sesji, tak samo jak przy czacie. +- **Sygnal przedluza sesje, nie zaklada nowej.** Idzie co minute, wiec nowa + sesja przy kazdym dawalaby setki wierszy na uczestnika. Chroni przed tym + takze unikalny indeks czesciowy na jedna otwarta sesje: uczestnik z dwoma + otwartymi kartami nie liczy sie podwojnie. +- **Sesje porzucone zamyka zadanie `@Scheduled`.** Przegladarka zamknieta bez + wylogowania nie wysyla niczego, wiec bez tego sesja zostalaby otwarta na + zawsze i zawyzala czas. Czas zamkniecia to **ostatni sygnal**, a nie chwila + biezaca: uczestnik byl obecny do momentu, w ktorym przestal odpowiadac. +- **`ATTENDANCE_TIMEOUT_MINUTES` musi byc wyrazne wiekszy niz odstep sygnalu.** + Przy jednej minucie chwilowa utrata polaczenia zamykalaby sesje i rozbijala + obecnosc na dziesiatki kawalkow. +- **Flaga `attended` jest przeliczana takze przy odczycie** statystyk i przy + eksporcie CSV. Sesja moze powstac droga, ktora nie przechodzi przez + zamkniecie (import danych, webhook do wpisu utworzonego wczesniej), a przy + sesji wciaz otwartej czas rosnie bez zadnego zdarzenia. Bez tego liczba + obecnych na ekranie rozjezdza sie z kolumna w pliku. +- **Wszystkie statystyki licza sie w bazie**, zapytaniami agregujacymi: + `FILTER` do licznikow, `generate_series` do wykresu obecnosci i do zapisow + dziennych. Wczytanie kilkuset uczestnikow i kilkuset wiadomosci do pamieci + tylko po to, zeby je policzyc, jest zauwazalnie wolniejsze. +- **`generate_series` daje takze dni i minuty zerowe.** Bez tego dzien bez + zapisow znikalby z wykresu, a linia laczylaby punkty ponad luka i sugerowala + ciaglosc, ktorej nie bylo. +- **Wykres obecnosci konczy sie na chwili biezacej**, gdy webinar jeszcze trwa: + rysowanie do przyszlego `endsAt` dawaloby dluga linie zer. +- **`@mui/x-charts` jest ladowane leniwie** (`lazy` + `Suspense`), bo wazy + wiecej niz cala reszta panelu, a jest potrzebne na dwoch ekranach. Bez + podzialu kazde wejscie do panelu pobieraloby wykresy: glowny pakiet urosl + z 795 kB do 1294 kB. Kolory wykresow sa podane wprost, bo biblioteka nie + czyta klas Tailwinda; musza odpowiadac tokenom z `tailwind.config.js`. +- **Ekran glowny panelu to `/`**, nie lista webinarow. Pokazuje wylacznie + rzeczy, na ktore mozna zareagowac: co sie zbliza i co czeka na obsluge. + Pozycja menu ma `end`, inaczej podswietlalaby sie na kazdym ekranie. + +### Nagrania i materialy we wlasnym skladzie + +Tabele `recordings` i `materials` (V13), pliki w MinIO, serwowane przez backend. + +- **Wlasny sklad, bo adresy u dostawcy wygasaja.** Nagranie ma zyc dluzej niz + umowa z dostawca, wiec plik trafia do wolumenu objetego kopia zapasowa. + Pola `recording_url` i `materials_url` **zostaja** jako alternatywa dla + instancji trzymajacych pliki poza systemem (dysk sieciowy, YouTube). +- **Pierwszenstwo ma wlasny sklad.** Mail podsumowujacy i powiadomienie + o nagraniu najpierw szukaja nagrania READY o widocznosci innej niz PRIVATE, + a dopiero gdy go nie ma, siegaja po `recording_url`. Kolejnosc jest w jednym + miejscu: `SummaryService.recordingLinkFor`. +- **MinIO zostaje niewystawiony na zewnatrz.** Pliki ida przez + `/api/public/recordings/{id}` i `/api/public/materials/{id}`, wiec kazde + pobranie przechodzi przez kontrole widocznosci, a klucz obiektu nie opuszcza + serwera. Serwowanie wprost z MinIO dawaloby dostep kazdemu, kto pozna adres, + **z pominieciem reguly widocznosci**. +- **Obsluga naglowka Range jest wymogiem, nie dodatkiem.** Bez niej przewijanie + nagrania nie dziala: przegladarka prosi o zakres bajtow, a serwer oddajacy + zawsze calosc zmusza ja do pobierania pliku od poczatku przy kazdym skoku. + Zakres otwarty (`bytes=0-`) jest ograniczany do 4 MB na odpowiedz, zeby jedno + zadanie nie zajelo watku na caly czas pobierania dwugigabajtowego pliku. + Zakres poza plikiem musi dawac **416 z prawdziwym rozmiarem**, nie 200 z caloscia. +- **Widocznosc: PRIVATE, REGISTERED (domyslna), PUBLIC.** Domyslnie REGISTERED, + a nie PUBLIC: nagranie zawiera wypowiedzi uczestnikow i pytania z imionami, + wiec udostepnienie go calemu internetowi musi byc swiadoma decyzja. + Regula siedzi w jednym miejscu (`FileAccessGuard`) i obsluguje oba rodzaje + plikow: rozdzielenie jej oznaczaloby, ze poprawka w jednym omija drugie. +- **Token musi dotyczyc TEGO webinaru.** Wazny token z innego webinaru nie + otwiera nagrania, a token po rezygnacji przestaje dzialac natychmiast: zapis + jest sprawdzany przy kazdym zadaniu, a nie raz na sesje. +- **Odmowa daje 404, nie 403.** Istnienie pliku o danym identyfikatorze samo + w sobie jest informacja: przy 403 dalo by sie sprawdzic, ktore webinary maja + nagrania. Z tego samego powodu webhook ze zlym sekretem dostaje 404. +- **Uzywac `NotFoundException`, nie `ResponseStatusException`.** Ten drugi nie ma + handlera w `ApiExceptionHandler`, wiec wraca do warstwy Security i wychodzi + jako **401**, czyli front pokazuje wylogowanie zamiast "nie znaleziono". +- **Typ nagrania z sygnatury bajtowej** (`VideoTypeDetector`), nigdy z rozszerzenia + ani naglowka od klienta. Dopuszczone MP4 i WEBM, bo tylko te odtwarza element + `video` bez dodatkowych bibliotek. +- **Przy materialach lista zablokowanych rozszerzen, nie biala lista.** Obrazek + w tresci strony jest **osadzany**, wiec tam obowiazuje biala lista. Material + jest **pobierany** i moze byc dowolnym dokumentem roboczym, ktorego nie da sie + z gory przewidziec. Chronia go dwa zabezpieczenia niezalezne od nazwy pliku: + `Content-Disposition: attachment` i `nosniff` przy kazdym serwowaniu. +- **Pliki ida strumieniowo, nigdy przez `byte[]`.** `FileStorageService.putStream` + i `openRange`; wariant z tablica bajtow trzyma caly plik w pamieci sterty, + wiec nagranie 2 GB przewrociloby aplikacje. +- **Limity musza byc spojne w trzech miejscach:** `RECORDING_MAX_SIZE_MB` + (ustawienie), `spring.servlet.multipart.max-file-size` (kontener) i + `client_max_body_size` w nginx. Zbyt niski ktorykolwiek z nich daje blad 413 + albo 500 zamiast czytelnego komunikatu. +- **Webhook `/api/webhooks/video/{provider}` jest otwarty**, bo dostawca nie ma + konta w panelu. Uwierzytelnia go `VIDEO_WEBHOOK_SECRET`, porownywany w czasie + stalym. **Pusty sekret odrzuca wszystkie zgloszenia**: brak konfiguracji nie + moze oznaczac braku kontroli. +- **Webhook odpowiada 200 od razu, pobiera w tle.** Dostawcy ponawiaja zgloszenie, + gdy nie dostana odpowiedzi w kilka sekund, a pobranie trwa minutami. Przed + podwojnym pobraniem chroni unikalny indeks `uq_recordings_provider_id` + oraz stan DOWNLOADING. +- **`VIDEO_DELETE_AFTER_DOWNLOAD` jest domyslnie wylaczone.** Kasowanie u dostawcy + jest nieodwracalne, wiec administrator wlacza je dopiero, gdy sprawdzi, ze + pobieranie dziala. +- **Sciezki nagran u dostawcy i we wlasnym skladzie sa rozne.** + `/admin/webinars/{id}/provider-recordings` pokazuje, co dostawca ma u siebie, + `/admin/webinars/{id}/recordings` co mamy my. Wczesniej obie byly takie same + i kontekst Springa nie wstawal z bledem "Ambiguous mapping". +- Pilnuje tego `FileAccessTest`: kazdy poziom widocznosci, token z obcego + webinaru, token po rezygnacji oraz plik wykonywalny podszywajacy sie pod MP4. + +### Powiadomienie o nagraniu + +`POST /admin/webinars/{id}/notify-recording`, wywolywane przyciskiem w zakladce +"Po webinarze". Osobna wysylka, bo nagranie czesto pojawia sie po podsumowaniu. + +- **Osobno od podsumowania, bo podsumowania nie da sie powtorzyc.** Idzie + automatycznie godzine po zakonczeniu, a montaz trwa dluzej; `summary_sent_at` + blokuje ponowne wyslanie i slusznie, bo uczestnik nie ma dostac dwa razy tej + samej wiadomosci. +- **Dokladnie raz na odbiorce, z bazy.** Unikalny indeks czesciowy + `uq_notifications_recording_recipient` (V12), dokladnie tak jak przy + przypomnieniach i podsumowaniach. `DataIntegrityViolationException` jest + **spodziewany** i oznacza "juz zakolejkowane". +- **Tylko uczestnicy CONFIRMED**, tak samo jak przy podsumowaniu. +- **Szablon `recording-available` musi byc w `SummaryScheduler.TEMPLATES`.** + Bez tego `ReminderSender` bierze go jako "wszystko poza podsumowaniami", + odrzuca jako wpis bez powiazanej reguly i oznacza FAILED, zanim `SummarySender` + zdazy go wyslac. Nowy szablon dopisuje sie **do tej jednej listy**. +- Przycisk dziala na **zapisanym** adresie nagrania, nie na tresci pola: inaczej + powiadomienie niosloby link sprzed edycji. + +### Podsumowanie po webinarze + +Zadanie `@Scheduled` znajduje webinary zakonczone wczesniej niz `SUMMARY_DELAY_MINUTES` +temu (domyslnie 60) i bez znacznika `webinars.summary_sent_at`, po czym kolejkuje maile: +uczestnikom podziekowanie z lista zadan i linkami, prowadzacemu zestawienie. + +- **Wpisy trafiaja do istniejacej kolejki `notifications`,** nie do osobnego + mechanizmu. Dzieki temu ponawianie, statusy i slad bledu dzialaja tak samo jak + przy potwierdzeniach i przypomnieniach, a administrator oglada wszystko w jednym + miejscu. +- **Znacznik `summary_sent_at` ustawia sie w tej samej transakcji, co wpisy.** + Rozdzielenie tych dwoch krokow daje albo podsumowanie wyslane dwa razy, albo + cisze, zaleznie od tego, gdzie przerwie sie przebieg. +- **Sam znacznik nie wystarcza, "dokladnie raz" pochodzi z bazy.** Znacznik jest + sprawdzany na poczatku przebiegu, a ustawiany na koncu, wiec dwa rownolegle + przebiegi przechodza kontrole jednoczesnie i oba kolejkuja komplet wpisow. + Zdarzylo sie to realnie: dwa webinary dostaly po dwa komplety utworzone + w odstepie 17 milisekund. Chroni przed tym dopiero unikalny indeks czesciowy + `uq_notifications_summary_recipient` na `(webinar_id, template, recipient)`, + dokladnie tak jak przy przypomnieniach. `DataIntegrityViolationException` + przy wstawianiu jest **spodziewana** i oznacza "juz zakolejkowane". +- **Kazdy wpis zapisuje sie w osobnej transakcji** (`SummaryQueueWriter`, + `REQUIRES_NEW` przez osobny komponent, bo samowywolanie omija proxy). Zapis + calej partii jednym `saveAll` sprawialby, ze kolizja na jednym odbiorcy + wycofuje podsumowania wszystkich pozostalych. +- Pilnuje tego `SummaryTest.concurrentRunsDoNotDuplicate`, ktory pusza osiem + rownoleglych przebiegow na tym samym webinarze. +- **Kolejka jest wspolna, wysylacze sa dwa, i kazdy bierze wylacznie swoje szablony.** + `SummarySender` bierze `SummaryScheduler.TEMPLATES`, `ReminderSender` bierze + wszystko poza ta lista. Bez tego wykluczenia `ReminderSender` lapal zestawienie + prowadzacego, odrzucal je jako "wpis kolejki bez powiazanego zapisu" i oznaczal + FAILED, zanim `SummarySender` zdazyl je wyslac. Nowy szablon podsumowania dopisuje + sie **do tej jednej listy**, nie do warunkow w obu zapytaniach. +- **Osobny wysylacz, a nie rozszerzenie `ReminderSender`.** Tamten wymaga powiazanego + zapisu, a zestawienie dla prowadzacego zadnego nie ma, i pomija webinary zakonczone, + a podsumowanie idzie wlasnie po zakonczeniu. +- **Podsumowanie dostaje tylko uczestnik ze statusem CONFIRMED.** Osoba, ktora + zrezygnowala, nie ma dostac maila po wydarzeniu. +- **Zestawienie prowadzacego zawiera dane kontaktowe, wiec idzie wylacznie na adresy + z ustawien** (`SALES_NOTIFICATION_EMAIL`, `MAIL_CONTACT_RECIPIENT`), nigdy na adres + wziety z formularza czy z zapytania. +- **Tresc sprawdza sie renderowaniem szablonu w tescie,** nie wysylka. `SummaryMailBuilder` + ma wydzielone `renderParticipant` i `renderHost` zwracajace `Rendered`, wiec + `SummaryTest` oglada gotowy tekst bez SMTP. Test pilnuje takze, ze w wyniku nie ma + nierozwinietych `${...}` ani `th:`, czyli ze uczestnik nie dostanie surowego szablonu. +- **Godzina w mailu jest przeliczona na strefe webinaru** (`atZoneSameInstant`), nie + podana jako surowe UTC. + +### Ekran spotkania + +`/spotkanie/:token` to **jeden ekran**: wideo i wszystkie interakcje razem. +Trasa jest wspolna dla uczestnika i prelegenta, bo organizator nie musi +pamietac, ktory link komu wyslal; front probuje kolejno obu endpointow. + +- **Dwa etapy: przedpokoj i sala.** `MeetingLobby` pokazuje tytul, termin + i **imie z zapisu do potwierdzenia**, dopiero potem wchodzi sie na sale. + Link otwiera sie bez logowania, wiec bez tego ekranu osoba z przekazanym + linkiem zaczyna wystepowac pod cudzym nazwiskiem, nie majac szansy tego + zauwazyc. Prelegent widzi to samo z oznaczeniem roli, bez ograniczenia + okna czasowego. +- **Pasek akcji jest nad panelem, nie w zakladce.** Prosba o glos i przyciski + oferty musza byc osiagalne niezaleznie od tego, co uczestnik oglada: + schowane w zakladce sa bezuzyteczne dokladnie wtedy, gdy sa potrzebne. +- **Zapytanie ofertowe otwiera sie w oknie**, bez opuszczania sali. Po wyslaniu + przycisk pokazuje potwierdzenie: bez widocznej zmiany uczestnik klika trzeci + raz, a handlowiec dostaje trzy zgloszenia od tej samej osoby. Kolejne + zapytanie wymaga swiadomego klikniecia w "Wyslij kolejne". +- **"Prosze o kontakt" to ten sam endpoint, bez pola tresci.** Uczestnik + w trakcie webinaru czesto nie ma pomyslu, co napisac, a wymuszanie tresci + konczy sie tym, ze nie wysyla nic. Tresc jest podstawiana zdaniem mowiacym + wprost, o co chodzi. **Zgoda CONTACT_SALES jest wymagana tak samo w obu + trybach**: uproszczenie dotyczy tego, ile uczestnik pisze, a nie tego, + na co sie zgadza. +- **Licznik przy zakladce pokazuje nowe pozycje od ostatniego zajrzenia**, + a nie laczna liczbe wpisow. Laczna liczba przy czacie rosnie caly czas + i po kwadransie nie niesie zadnej informacji. +- **W trybie NONE nadal nie przekierowujemy.** Zamiast osadzonego wideo jest + przycisk otwierajacy `join_url` w nowej karcie, a panel interakcji zostaje + u nas. To jest sedno tego trybu: bez dostawcy platforma dalej ma sens. + +### Zgloszenia do zabrania glosu + +Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket +`/topic/webinars/{id}/speak`. + +- **Osobna tabela, nie kolejny typ w `interactions`.** Zgloszenie nie jest + wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum. + Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma. +- **Glos wynika z tokenu, nie ze zgloszenia.** Przyznanie ustawia + `registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero token + wydany u dostawcy. Gdyby wystarczal stan w przegladarce, podmiana odpowiedzi + pozwalalaby mowic bez zgody moderatora. **Po zmianie roli front pobiera + token na nowo**: bez tego uczestnik widzi napis "masz glos" i ma zamkniety + mikrofon. +- **Zgloszenie i rola zmieniaja sie w jednej transakcji.** Rozdzielenie daje + uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty, + prowadzacy nie wie dlaczego) albo odwrotnie. +- **Jedno zgloszenie w toku na uczestnika**, wymuszone unikalnym indeksem + czesciowym na statusach PENDING i GRANTED. Sprawdzenie w kodzie nie + wystarcza: dwie karty przegladarki przechodza je jednoczesnie. + `DataIntegrityViolationException` jest **spodziewany** i oznacza "juz zglosil". +- **Odmowa przed przyznaniem to DECLINED, odebranie po przyznaniu to REVOKED.** + Prowadzacy ma w historii widziec, czy uczestnik w ogole doszedl do glosu. +- **Wycofanie przyznanego glosu takze cofa role**, inaczej mikrofon zostaje + otwarty mimo rezygnacji uczestnika. +- Kolejka sortuje sie **po czasie zgloszenia**, najstarsze na gorze, i pokazuje, + od kiedy ktos czeka. To regula, a nie estetyka: przy innym porzadku uczestnik + czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila. +- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen. + +### Tryb otwartego mikrofonu + +Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia. + +- **Flaga, a nie kolejny tryb obok `chat_mode`.** To decyzja zero-jedynkowa: + albo kazdy moze mowic od razu, albo trzeba poprosic o glos. +- **Domyslnie wylaczony, i to jest swiadome.** Otwarty mikrofon na webinarze + dla stu osob oznacza sto otwartych mikrofonow naraz. Ma sens przy warsztacie + dla kilkunastu osob, wiec prowadzacy wlacza go swiadomie. Wlaczenie pyta + o potwierdzenie, wylaczenie nie: otwarcie mikrofonow calej sali jest slyszalne + natychmiast i trudno to cofnac po fakcie. +- **Decyzja zapada przy wydawaniu tokenu** (`VideoService.joinAsParticipant`), + a nie w przegladarce: samo odblokowanie przycisku niczego nie daje, bo dostawca + odrzuci publikacje bez uprawnienia w tokenie. +- **Rola w zapisie zostaje nietknieta.** Tryb podnosi uprawnienie wylacznie + w wydawanym tokenie, wiec wylaczenie go zamyka mikrofony wszystkim, ale + **nie odbiera glosu** osobom, ktorym przyznano go indywidualnie: ich + uprawnienie wynika z `room_role`. +- **Wlaczenie nie kasuje kolejki.** Prowadzacy wlacza tryb na czas dyskusji + i wylacza przed dalsza czescia prezentacji; skasowanie zgloszen odbieraloby + miejsce osobom czekajacym od kwadransa. Przy wlaczonym trybie kolejka jest + nieaktywna, a nie nieaktualna. +- **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to + `canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery. +- Przelaczenie idzie tematem `speak`, zeby otwarte sale pobraly token na nowo: + bez tego uprawnienie zmienia sie w bazie, a przegladarki dzialaja na starym. + +### Sygnal dzwiekowy w panelu prowadzenia + +- **Skladany w WebAudio, nie wczytywany z pliku.** Chodzi o jeden krotki ton, + a plik oznaczalby zasob do wgrania i sciezke do utrzymania. +- **Domyslnie wlaczony, wylaczalny, wybor zapamietany w przegladarce.** + Zapytanie ofertowe jest zgloszeniem, ktorego prowadzacy najbardziej nie chce + przegapic, a w trakcie mowienia nie patrzy na panel. +- **Stan przelacznika czytany z referencji, nie ze stanu Reacta.** Efekt + WebSocketu widzi wartosc z chwili podlaczenia, wiec przelacznik nie dzialalby + bez zerwania polaczenia. +- Przegladarki blokuja dzwiek do pierwszej interakcji uzytkownika, wiec blad + jest tu spodziewany i pomijany. + +### Banery i reklamy w sali + +Tabele `banners` i `banner_events` (V19), kolumna `interactions.source_banner_id`. +Dzial `/banery` dla ADMIN i zakladka na karcie webinaru. + +- **Baner globalny i baner webinaru to jedna tabela.** `webinar_id` rowne null + oznacza baner instancji. Ten sam cykl zycia, te same statystyki i to samo + okno czasowe, wiec osobna tabela oznaczalaby dwa razy ten sam zestaw kolumn. + Panel uzywa **tego samego komponentu** (`BannerSection`), parametryzowanego + webinarem, dokladnie tak jak `OfferSection`. +- **Grafika wylacznie z biblioteki mediow.** Baner wskazuje `media_id`, nigdy + adres. Obcy obrazek na sali ujawnia adresy IP wszystkich uczestnikow + wlascicielowi obcego serwera i pozwala ich sledzic miedzy webinarami. +- **`target_url` tylko http i https.** `javascript:` wykonalby sie w kontekscie + naszej strony, a `data:` pozwolilby podstawic dowolny dokument pod nasza + domena. Bariera jest w `BannerService`, bo endpoint zapisu da sie wywolac + z pominieciem panelu. +- **`content_html` czyszczone tym samym `HtmlSanitizer` co tresc strony.** + Do bazy trafia wylacznie wersja oczyszczona, wiec front nie czysci jej drugi raz. +- **Dwa warianty okna czasowego, bo dwa rozne pytania.** `starts_at`/`ends_at` + to "od kiedy do kiedy kampania trwa", sensowne przy banerze globalnym. + `show_from_minute`/`show_to_minute` to "w ktorej minucie prezentacji", odporne + na zmiane terminu webinaru. Oba obowiazuja jednoczesnie, gdy oba sa ustawione. +- **Wybor banerow robi baza, nie przegladarka.** Oddanie wszystkich banerow + i odsianie ich we froncie oznaczaloby, ze uczestnik widzi w zrodle strony + kampanie, ktora ma ruszyc za tydzien. +- **Front odpytuje okresowo, nie czeka na WebSocket.** Baner "od 40. minuty" + zmienia stan, bo **mija czas**, a nie dlatego, ze ktos cos kliknal: nie ma + zdarzenia do rozeslania. Serwer musialby pilnowac zegara osobno dla kazdego + webinaru, co jest wieksza maszyneria niz jedno lekkie zapytanie na minute. +- **Odstep rotacji wraca razem z banerami, nie przez `/public/config`.** Tamten + endpoint jest jawny dla calego internetu i kazde pole trzeba tam dopisywac + swiadomie; `BANNER_ROTATION_SECONDS` jest potrzebne wylacznie na sali. +- **Rotacja jest per miejsce, nie per sala.** Baner boczny i pas pod wideo nie + musza zmieniac sie w tej samej chwili. +- **OVERLAY musi dac sie zamknac**, bo jako jedyny zaslania obraz. Zamkniety nie + wraca do konca sesji. +- Prelegent nie dostaje banerow: reklama jest kierowana do uczestnikow, + a prowadzacemu zabieralaby miejsce na obraz. + +### Zliczanie zdarzen banera + +- **IMPRESSION raz na uczestnika i baner**, co wymusza unikalny indeks czesciowy + `uq_banner_events_impression`. Baner rotuje i wraca na ekran wielokrotnie, wiec + liczenie kazdego pojawienia sie zawyzaloby statystyke kilkudziesieciokrotnie + i czynilo klikalnosc bezuzyteczna. **CLICK celowo nie ma takiego indeksu**: + powtorne klikniecie jest realnym zainteresowaniem. +- **Zapis idzie przez `BannerEventWriter`, ktory NAJPIERW SPRAWDZA, czy wpis + istnieje.** To odwrotna kolejnosc niz przy podsumowaniach i wymaga wyjasnienia: + naruszenie ograniczenia oznacza transakcje jako **rollback-only zanim wyjatek + do nas dotrze**, wiec samo zlapanie `DataIntegrityViolationException` konczy + sie `UnexpectedRollbackException` przy zatwierdzaniu, czyli bledem 500 zamiast + cichego pominiecia. Dotyczy to takze `REQUIRES_NEW`: nowa transakcja jest + wlasna, ale rownie skutecznie zepsuta. Wyjatek zostaje wylacznie jako + zabezpieczenie wyscigu. **Zdarzylo sie to realnie i wylapal to test.** +- **`registration_id` jest nullowalne**, bo zdarzenie moze pochodzic od + prelegenta, ktory nie ma wiersza w `registrations`. + +### Baner a zapytanie ofertowe + +- **`offer_topic_id` wylacznie przy banerze webinaru.** Tematy naleza do + konkretnego wydarzenia (`offer_topics.webinar_id` NOT NULL), wiec baner + globalny wskazywalby temat cudzego webinaru. Pilnuje tego ograniczenie CHECK + w bazie i walidacja w serwisie, ktora sprawdza takze, czy temat nalezy do + **tego** webinaru: bez tego znajomosc samego identyfikatora pozwalalaby + skierowac zapytanie do niewlasciwego handlowca. +- **Klikniecie otwiera formularz oferty w oknie paska akcji**, a nie wlasne + okno banera: tam juz jest obsluga zgody i stan po wyslaniu, a drugie takie + okno oznaczaloby dwie kopie tej samej logiki. +- **Temat z banera jest podstawiany, ale nie wymuszany.** Uczestnik moze go + zmienic: baner mowi, czego zapytanie prawdopodobnie dotyczy, ale to uczestnik + wie, o co chce zapytac. Podstawiamy go tylko, gdy taki temat nadal istnieje + w tym webinarze: lista mogla sie zmienic po utworzeniu banera. +- **`interactions.source_banner_id` jest jedynym wiarygodnym sladem**, skad + przyszlo zapytanie. Liczenie po zbieznosci tematu mieszaloby zapytania + z banera z tymi wyslanymi z zakladki Oferta i zawyzaloby skutecznosc reklamy. + `ON DELETE SET NULL`, bo zapytanie ma przetrwac skasowanie kampanii. + +### Statystyki banerow + +- **Sekcja obejmuje banery webinaru ORAZ globalne**, bo uczestnik widzi jedne + i drugie; pominiecie globalnych oznaczaloby, ze polowa reklam nie ma statystyki. +- **Bez filtru okna czasowego i bez filtru aktywnosci** (`findActiveForStats`). + Statystyka opisuje to, co sie wydarzylo, wiec skutecznosc zakonczonej kampanii + ma byc widoczna wtedy, gdy jest potrzebna do oceny. +- **Klikalnosc liczy backend**, w `BannerView` i w `StatsService`. Liczenie jej + w przegladarce oznaczaloby drugie miejsce, ktore moze sie rozjechac. + Baner bez wyswietlen ma zero, a nie wartosc udajaca pomiar. +- Zapytania i zdarzenia ida **zapytaniami zbiorczymi** (`countEventsByBannerIds`, + `countOffersByBanner`), a nie po dwa na kazdy baner: ten sam wzorzec co przy + liczbie zapisanych na liscie webinarow. + +### Interakcje w trakcie spotkania + +Czat i pytania w jednej tabeli `interactions`, wspolnej dla wszystkich zgloszen +uczestnika. `TASK_SIGNUP` i `OFFER_REQUEST` sa juz w ograniczeniu CHECK, mimo ze +obsluga dojdzie pozniej: ich dodanie nie bedzie wymagalo migracji zmieniajacej typ. + +- **Jedna tabela, nie cztery.** Czat, pytanie, zapis na zadanie i zapytanie ofertowe + maja tego samego autora, tresc, status i czas. Osobne tabele oznaczalyby czterokrotnie + powtorzony ten sam zestaw kolumn i indeksow. +- **Autor to albo `registration_id`, albo `speaker_id`, nigdy oba.** Wymusza to CHECK + w bazie: blad w kodzie dawalby zgloszenia bez autora albo z dwoma, a jedno i drugie + psuje moderacje. +- **Uwierzytelnienie WebSocket przy CONNECT, nie przy kazdej wiadomosci.** Token zapisu + idzie raz w naglowku, wynik zostaje w sesji. Wiadomosc niesie **sama tresc**: gdyby + niosla identyfikator autora, kazdy podalby sie za kogo innego. +- Prowadzacy nie ma tokenu zapisu, bo nie jest uczestnikiem. Laczy sie naglowkiem + `X-Panel-Token` z tym samym JWT co przy zwyklych zadaniach panelu. +- **`enableSimpleBroker` musi obejmowac takze `/queue`.** Komunikaty do jednej osoby + (limit tempa, odmowa pisania) ida na `/user/queue/errors`; bez tego prefiksu broker + po cichu je porzuca i uczestnik nie wie, dlaczego wiadomosc nie poszla. +- **Autor prezentowany jako imie i pierwsza litera nazwiska, nigdy adres e-mail.** + Czat widza wszyscy obecni, a adres pozwalalby zaczepiac ich poza platforma. +- **Tresc uczestnika czyszczona `PlainTextSanitizer`**, czyli `Safelist.none()`: zaden + znacznik nie przechodzi. Adresy zostaja czytelnym tekstem, ale **nie sa klikalne** - + klikalny link na czacie to najprostsza droga do rozeslania phishingu calej sali. +- **Glosy w osobnej tabeli z unikalnym indeksem na parze.** Licznik przy pytaniu nie + powstrzymalby jednej osoby przed wielokrotnym glosowaniem. `DataIntegrityViolationException` + przy wyscigu jest spodziewana i oznacza "juz glosowal", a nie blad. +- **Pytanie ze statusem NEW nie wychodzi w publicznym API.** To jedyna bariera przed + pokazaniem niezatwierdzonej tresci; panel prowadzacego ma osobny temat moderacji. +- **Usuniecie wiadomosci to status REJECTED, nie skasowanie wiersza.** Moderator ma miec + slad, co usunal. +- **Widok do rozeslania sklada sie w transakcji** (`InteractionService.view`). Autor jest + pobierany leniwie, wiec mapowanie encji poza transakcja konczy sie + LazyInitializationException, czyli bledem 500 zamiast wiadomosci na czacie. +- **W trybie NONE strona spotkania nie przekierowuje automatycznie.** Obraz jest + w zewnetrznym narzedziu, ale czat i pytania zostaja u nas; wyrzucenie uczestnika + odcieloby go od nich, a wielu klientow zostaje na tym trybie na stale. + +### Zadania i zapytania ofertowe + +Oba typy korzystaja z tabeli `interactions`. `TASK_SIGNUP` i `OFFER_REQUEST` byly +w ograniczeniu CHECK od poczatku, wiec ten krok nie wymagal zmiany typu kolumny. +Nowe tabele opisuja tylko to, czego w zgloszeniu nie ma: `tasks` i `offer_topics`. + +- **`task_id` wypelniane wylacznie dla `TASK_SIGNUP`**, wymuszone ograniczeniem CHECK. + Bez niego literowka w kodzie przypielaby wiadomosc czatu do zadania. +- **Limit miejsc pod blokada wiersza zadania** (`findByIdForUpdate`), dokladnie tak samo + jak przy zapisie na webinar. Chroni to `TaskCapacityTest`, gdzie 10 rownoczesnych + zgloszen na 3 miejsca konczy sie dokladnie trzema wierszami. +- **Wycofanie to status REJECTED, nie skasowanie wiersza.** Miejsce wraca do puli, bo + licznik pomija REJECTED, a prowadzacy zachowuje slad, ze ktos sie zglosil i wycofal. + Powrot po wycofaniu ozywia istniejacy wiersz: unikalny indeks nie pozwala na drugi. +- **Zapytanie ofertowe nigdy nie trafia na strumien publiczny.** Nie ma endpointu, ktory + zwracalby cudze zapytania: ida wylacznie na temat moderacji i mailem do handlowca. + Sprawdza to `offers_test`, ktory oglada wszystkie cztery zakladki drugiego uczestnika. +- **Bez zgody `CONTACT_SALES` zapytania nie da sie wyslac.** Zgoda jest sprawdzana przed + zapisem czegokolwiek, zapisywana z wersja tresci i **dopisywana, nigdy nadpisywana**: + kazde zapytanie ma wlasny slad zgody. +- **Telefon z zapytania nie nadpisuje numeru z zapisu.** Osobna kolumna `contact_phone`: + nadpisanie tamtego zmienialoby odbiorce przypomnien SMS. +- **Temat zapytania kopiowany jako tekst**, a nie wskazywany kluczem obcym: lista tematow + moze sie zmienic, a zapytanie sprzed miesiaca ma dalej mowic, czego dotyczylo. +- **Powiadomienie handlowca jest asynchroniczne**, a `SubmitResult` niesie zwykle wartosci, + nie encje: wysylka idzie na osobnym watku, gdzie sesji Hibernate juz nie ma. +- Zapytania maja **osobny licznik** w panelu prowadzenia, wyraznie oddzielony od pytan: + to kontakt handlowy, a nie pytanie do prowadzacego. +- Eksport CSV zawiera wersje zgody i date jej wyrazenia, bo to one dokumentuja podstawe + kontaktu. Wartosci zaczynajace sie od `=`, `+`, `-`, `@` sa poprzedzane apostrofem. + +### Dostawcy wideo: co gwarantuje serwer, a co tylko interfejs + +**To jest najwazniejsza tabela w tym pliku, jesli chodzi o wideo.** Roznica +miedzy dostawcami nie jest kwestia wygody, tylko tego, czy uprawnienia sa +egzekwowane, czy jedynie deklarowane. + +| Dostawca | Uprawnienia egzekwuje | Rozroznia audio/wideo | Nagrywanie | +| --- | --- | --- | --- | +| **LIVEKIT** | serwer, na podstawie podpisanego tokenu | **tak** (`CanPublishSources`) | wymaga kontenerow Egress i Redis | +| **DAILY** | serwer | tak | pobierane od dostawcy po zakonczeniu | +| **JITSI** z JWT | serwer, ale **tylko role moderatora** | **nie** | reczne wgranie pliku | +| **JITSI** bez JWT | **nikt: pokoj otwarty** | nie | reczne wgranie pliku | +| **NONE** | nie dotyczy, obraz poza platforma | nie dotyczy | reczne wgranie pliku | + +- **Nagrywanie przez Egress nie jest zweryfikowane end-to-end.** Potwierdzone + jest wszystko, co do niego prowadzi: Egress laczy sie z Redisem, przyjmuje + zadanie, dolacza do pokoju i zestawia media po UDP, a szablon sie wykonuje. + Proces zatrzymuje sie na tym, ze szablon nie subskrybuje zadnej sciezki + i nie wola `START_RECORDING`, wiec Egress przerywa nagranie z kodem 412. + Do domkniecia potrzebny jest publikujacy uczestnik w tej samej sieci co + Egress, czego nie da sie zestawic na Docker Desktop przy jednym `NODE_IP`. + **Reczne wgranie pliku dziala i pozostaje droga awaryjna.** +- **Egress zostaje w sieci Compose. Nigdy `network_mode: host` na Docker + Desktop**: kontener trafia wtedy do sieci maszyny wirtualnej, jego kandydaci + ICE maja puste adresy IP, serwer odrzuca je jako `[filtered]`, a polaczenie + konczy sie `failed`. `network_mode: bridge` jest rownie zly: odlacza kontener + od sieci Compose i przestaje on widziec `redis` oraz `livekit` po nazwie. +- **Sam profil `livekit` bez Egress i Redisa NIE nagrywa.** Serwer LiveKit + przyjmuje zadanie nagrania i przekazuje je Egress **przez Redisa**; bez tych + dwoch uslug zadanie nie ma odbiorcy, a nagrywanie konczy sie timeoutem przy + poprawnie dzialajacym serwerze i dzialajacych spotkaniach. Oba kontenery sa + w tym samym profilu, wiec instancja bez wideo ich nie uruchamia. +- **Egress uruchamia prawdziwa przegladarke i otwiera nia strone szablonu.** + Domyslny szablon pobiera z internetu, wiec instancja w sieci zamknietej nie + nagra niczego; dlatego wskazujemy wlasny (`/nagrywanie` we froncie). + Szablon musi zawolac `window.START_RECORDING()`, inaczej Egress przerywa + nagranie z kodem 412 "Start signal not received". +- **Przy LiveKit uczestnik bez prawa publikacji nie wlaczy kamery**, chocby + podmienil kod strony albo wszedl wlasnym klientem: serwer odrzuci strumien. + SPEAKER dostaje `CanPublishSources(["microphone"])`, wiec **brak "camera" + na tej liscie jest jedynym miejscem**, w ktorym zabraniamy obrazu. Pilnuje + tego `ProviderTokenTest`. +- **Przy Jitsi bez JWT role sa umowne.** Pokoj jest otwarty dla kazdego, kto + zna adres, a ukrycie przycisku kamery w `interfaceConfigOverwrite` to jedyne, + co dzieli uczestnika od publikacji. Wystarczy otworzyc ten sam pokoj wprost + na domenie Jitsi, zeby to obejsc. **Panel ostrzega o tym wprost** przy + wyborze dostawcy, bo to nie jest szczegol konfiguracji. +- **Jitsi nie ma odpowiednika uprawnienia "publikuj tylko audio".** Serwer + rozroznia wylacznie moderatora, wiec VIEWER i SPEAKER dostaja tam identyczny + token, a roznice robi w calosci interfejs. Kto potrzebuje gwarancji, wybiera + LiveKit. +- **Nazwa pokoju Jitsi zawiera slug, nie samo id.** Przy otwartym serwerze + nazwa do odgadniecia oznacza obcych ludzi na szkoleniu. +- Nagrywanie Jitsi wymaga komponentu Jibri, ktorego nie zakladamy: to osobna + usluga z wlasna przegladarka na kazde nagranie. Implementacja zwraca **pusta + liste**, a nie wyjatek: brak nagran u dostawcy to normalny stan i nie moze + wywracac ekranu w panelu. + +### Wybor dostawcy: dwie kolumny, nie jedna + +`webinars.video_provider` i `webinars.room_provider` to **dwie rozne rzeczy** +i pomylenie ich kosztuje otwarty, platny pokoj u dostawcy, o ktorym nikt juz +nie pamieta. + +- **`video_provider` to wybor administratora.** Null oznacza "uzyj dostawcy + z ustawien instancji", dzieki czemu zmiana jednego ustawienia pociaga za soba + wszystkie webinary, ktore niczego nie wybraly. +- **`room_provider` to stan faktyczny**: u kogo pokoj zostal zalozony. + Ustawiane przez kod, nigdy przez czlowieka. +- **Rozbieznosc jest sygnalem, nie bledem.** Znaczy "zmieniono dostawce, pokoj + wciaz stoi u poprzedniego". Panel pyta wtedy o potwierdzenie i zamyka stary + pokoj (`POST /admin/webinars/{id}/video-provider/migrate`). +- **Rozstrzyganie siedzi w `VideoProviderFactory`, w jednym miejscu.** Trzy + metody odpowiadaja na trzy rozne pytania: + `forWebinar` (kto **ma** obslugiwac), `ofExistingRoom` (u kogo pokoj + **faktycznie stoi**), `instanceDefault` (dostawca instancji). +- **Operacje na istniejacym pokoju ida do `providerOfRoom`, nie `providerFor`.** + Dotyczy zamykania pokoju, nagran i webhookow. Uzycie tam wybranego dostawcy + zamknelo by pokoj u kogos, kto go nie ma, a prawdziwy zostalby otwarty. +- Odpowiedz `POST /room` podaje dostawce **tego webinaru**, nie instancji. + Bylo odwrotnie i panel pokazywal nieprawde przy webinarze z wlasnym wyborem. + +### LiveKit: wydajnosc i nagrywanie + +- **Subskrypcja ograniczona do aktywnych mowcow jest wymogiem, nie strojeniem.** + Przy otwartym mikrofonie i stu uczestnikach kazda przegladarka odbieralaby sto + strumieni audio naraz, a ruch rosnie liniowo z liczba uczestnikow. + `adaptiveStream` wstrzymuje strumienie spoza kadru, `dynacast` wylacza warstwy, + ktorych nikt nie oglada, a `LIVEKIT_ACTIVE_SPEAKERS` ogranicza liczbe + odbieranych mowcow. +- **Egress pisze prosto do naszego MinIO** przez API zgodne z S3, wiec nie ma + etapu pobierania od dostawcy. `forcePathStyle` jest obowiazkowe: MinIO nie + obsluguje adresowania po nazwie kubelka w domenie. +- **Wpis w `recordings` powstaje od razu**, ze statusem DOWNLOADING, a nie + dopiero po webhooku: panel ma pokazac "nagrywanie trwa", a nie pusta liste + przez cale szkolenie. +- **`LIVEKIT_URL` jest adresem WebSocket** (`wss://`), bo taki potrzebuje + przegladarka. API serwera slucha pod tym samym adresem po HTTP, wiec kod + zamienia schemat zamiast wymagac dwoch ustawien opisujacych to samo miejsce. +- **Zakladanie pokoju nie wysyla zadnego zadania.** LiveKit i Jitsi tworza pokoj + przy pierwszym wejsciu z waznym tokenem. Sprawdzamy za to konfiguracje przy + zakladaniu, zeby administrator zobaczyl brak klucza wtedy, a nie dowiedzial + sie o nim od uczestnika minute przed startem. + +### Biblioteki wideo we froncie ladowane leniwie + +`@livekit/components-react` i `@jitsi/react-sdk` sa ladowane przez `lazy`, +tak samo jak `@mui/x-charts`. Bez podzialu glowny pakiet rosl z 859 kB do +1552 kB, a biblioteki sa potrzebne **wylacznie na sali**: panel i strona zapisu +otwieraja sie znacznie czesciej niz spotkanie. + +**Nie budujemy wlasnego interfejsu wideo.** `VideoConference` z LiveKit daje +uklad kafelkow, aktywnego mowce i wybor urzadzen. Nasze zostaje to, czego +dostawca nie ma: czat, pytania, zadania, zapytania ofertowe, banery i pasek akcji. + +### Profil livekit w Compose + +Serwer LiveKit i coturn sa w profilu `livekit`, **wylaczonym domyslnie**: +instancja w trybie NONE albo na Jitsi nie ma uruchamiac zbednych uslug. +Wlaczenie: `docker compose --profile livekit up -d`. + +- **Nagrywanie to osobne uslugi.** Profil `livekit` zawiera cztery kontenery: + serwer, coturn, **Redis** i **Egress**. Same spotkania dzialaja bez dwoch + ostatnich; nagrywanie bez nich nie dziala wcale. +- **`use_external_ip: true` nadpisuje `NODE_IP`.** Przy uruchomieniu lokalnym + serwer rozglasza publiczny adres lacza i media sie nie zestawiaja, mimo ze + uczestnik wchodzi do pokoju i widzi liste obecnych. Do testow na jednej + maszynie trzeba ustawic `false`. +- **Wersja serwera musi nadazac za `livekit-client`.** Klient 2.22 laczy sie + sciezka `/rtc/v1`, ktorej serwer 1.8 nie zna: sala wyglada na dzialajaca, + ale nie plynie ani obraz, ani dzwiek. Obraz przypiety do `v1.13`. +- **Zakres UDP 50000-50100 musi byc otwarty.** To glowna droga audio i wideo. + Bez niego uczestnicy dolacza do pokoju i zobacza liste obecnych, ale nie beda + sie slyszec: to najczestsza przyczyna zgloszenia "wideo nie dziala" przy + poprawnie skonfigurowanym serwerze. +- **coturn dziala w `network_mode: host`**, bo TURN musi widziec prawdziwe + adresy IP. Za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne + kontenera, pod ktore nikt z zewnatrz sie nie polaczy. +- **Jitsi nie ma go w Compose.** Zakladamy serwer zewnetrzny, wskazywany domena: + wlasny Jitsi to kilkanascie uslug i osobne wdrozenie, a `meet.jit.si` dziala + bez konfiguracji. + +### Warstwa wideo + +Kontakt z dostawca przechodzi przez interfejs `VideoProvider`, a implementacje wybiera +ustawienie `VIDEO_PROVIDER`. Na start dwie: `NONE` i `DAILY`. + +- **Tryb NONE jest rownoprawny, nie awaryjny.** Instancja bez dostawcy ma w pelni + obslugiwac webinary: organizator wkleja link w `join_url`, a uczestnik jest tam + przekierowywany. Wielu klientow zostanie przy tym na stale, wiec nie wolno tego + traktowac jak zaslepki ani wymagac konfiguracji dostawcy. +- **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku + kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym + klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie. + **Wideo uczestnika jest wylaczone zawsze**, takze po podniesieniu do SPEAKER. +- `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`: + uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie + prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego + `RoomRoleTest`, ktory sprawdza kazda wartosc enuma. +- **Prelegent nie jest wierszem w `registrations`.** Nie przechodzi przez formularz + i nie wyraza zgod, wiec mieszanie tych dwoch rzeczy zafalszowaloby liczbe zapisanych + i statystyki zgod. +- **Prelegent wchodzi bez ograniczenia okna czasowego**, bo musi sprawdzic sprzet + przed uczestnikami. Uczestnik wchodzi od `ROOM_OPEN_BEFORE_MINUTES` przed startem + do `ROOM_OPEN_AFTER_MINUTES` po koncu. +- **Pokoj zamkniety daje 409 wraz z terminem otwarcia**, a nie 403: uczestnik ma prawo + wejsc, tylko jeszcze nie teraz. Front pokazuje odliczanie i sam odswieza strone. +- **`room_provider` jest zapisany przy webinarze.** Zmiana `VIDEO_PROVIDER` w ustawieniach + nie moze uniewaznic pokoi zalozonych u poprzedniego dostawcy. +- **Brak klucza API zglaszany jest przed wyslaniem zadania.** Administrator ma dostac + komunikat mowiacy, co ustawic, a nie blad 401 z biblioteki HTTP. +- Strona `/spotkanie/:token` probuje kolejno endpointu uczestnika i prelegenta, bo + organizator nie powinien pamietac, ktory link komu wyslal. +- Pokoj jest osadzany gotowym komponentem dostawcy w iframe. **Wlasnego interfejsu + wideo jeszcze nie budujemy**, tak samo jak czatu i pytan. + +### Szczegoly webinaru + +Ikona oka w wierszu listy otwiera `/webinary/:id` **w nowej karcie**. Strona ma +zakladki: Tresc strony, Zapisani, Zapytania ofertowe, Historia czatu. + +- **Wszystko, co nalezy do jednego webinaru, jest jego zakladka.** To regula + obowiazujaca kazde powiazanie, nie decyzja o jednym ekranie. Dane opisujace + konkretne wydarzenie (zapytania ofertowe, czat, w przyszlosci zadania, nagrania, + frekwencja) trafiaja tutaj, a nie na osobny ekran globalny. Uzytkownik pracuje + webinarami, wiec szukajac czegokolwiek o wydarzeniu wchodzi w to wydarzenie. +- **Ekran zbiorczy moze istniec obok, ale nie zamiast.** `/zapytania` zostaje dla + handlowca, ktory obsluguje zapytania ze wszystkich webinarow naraz i nie chce + wchodzic w kazde z osobna. Oba miejsca pokazuja **ten sam komponent** + (`OfferSection`), parametryzowany webinarem: obsluga zapytania wyglada tak samo + niezaleznie od drogi dojscia, a poprawka nie wymaga zmiany w dwoch plikach. + Przy zakladce konkretnego webinaru znika filtr po webinarze i nazwa webinaru + przy pozycji, bo jedno i drugie powtarza to, co jest w naglowku strony. +- **Historia czatu to archiwum, nie podglad na zywo.** Bez WebSocketu: archiwum + sie nie zmienia, a otwarte polaczenie po zakonczonym webinarze byloby + utrzymywane bez powodu. Panel prowadzenia (`/webinary/:id/prowadzenie`) zostaje + osobno, bo tam liczy sie biezaca moderacja, a nie czytanie zapisu rozmowy. +- **Archiwum czatu pokazuje takze wiadomosci usuniete przez moderatora**, wyraznie + oznaczone. Usuniecie to status REJECTED wlasnie po to, zeby zostal slad; archiwum + bez tych wiadomosci gubiloby jedyne miejsce, w ktorym ten slad widac. + Sluzy do tego `InteractionService.fullChatHistory`, osobne od `chatHistory` + dokladnie tak samo, jak `allQuestions` jest osobne od `publicQuestions`. + **Nie wolno podpiac `fullChatHistory` pod endpoint publiczny**: uczestnik ma + widziec wylacznie APPROVED. +- **Liczniki przy zakladkach pochodza z `WebinarView`**, a nie z osobnych zapytan + przy wejsciu na strone. `offerCount` jest doliczany tym samym wzorcem co + `registeredCount`: jedno zapytanie zbiorcze na cala strone listy + (`countOffersByWebinarIds`), zeby nie robic N+1. Z tego samego powodu, co przy + "Zapisani", **kolumna "Zapytania" nie jest sortowalna**. +- **Zapytania odrzucone nie licza sie do licznika.** Licznik ma mowic, ile jest do + obslugi albo obsluzonych, a nie ile kiedykolwiek przyszlo. +- **To osobna strona pod wlasnym adresem, nie okno modalne.** Dzieki temu da sie ja + zostawic obok listy, odswiezyc, dodac do zakladek i podeslac linkiem. +- **Ikona jest linkiem ``, nie przyciskiem.** Tylko wtedy dzialaja + srodkowy przycisk myszy, Ctrl+klik i menu kontekstowe przegladarki. `rel="noopener"` + odcina nowej karcie dostep do `window.opener`. +- Trasa `/webinary/:id` jest **po** `/webinary/nowy`, ale kolejnosc i tak nie decyduje: + React Router 6 wyzej ocenia segment staly niz parametr. Sprawdzone testem, zeby + zmiana kolejnosci tras w przyszlosci nie przeslonila formularza. +- **Podglad tylko czyta.** Nie ma tu zadnego zapisu ani nowego endpointu: okno + korzysta z `GET /admin/webinars/{id}/registrations`, czyli tego samego, co pelna + lista zapisanych. Dzieki temu sprawdzenie, kto sie zapisal, nie wymaga otwierania + formularza edycji i nie grozi przypadkowa zmiana. +- Tresc jest oczyszczona przy zapisie na backendzie, wiec `dangerouslySetInnerHTML` + dostaje to samo, co strona publiczna. Nie czyscimy jej drugi raz w przegladarce. +- **Zgody: pokazujemy tylko najnowsza kazdego typu.** Zgody sa dopisywane, nigdy + nadpisywane, wiec po ponownym zapisie jest ich kilka i pokazanie wszystkich + mylilo by co do stanu biezacego. +- Lista zapisanych w oknie jest stronicowana tak samo jak pelna, a link "Pelna lista + i eksport CSV" prowadzi tam, gdzie mozna pobrac calosc. + +### Formularz webinaru w zakladkach + +`/webinary/:id/edycja` i `/webinary/nowy` sa podzielone na zakladki: Podstawowe, +Tresc strony, Termin, Zapisy, Przypomnienia. Wczesniej wszystko bylo na jednej +dlugiej stronie. + +- **Zakladki przelaczaja widocznosc, nie trasy.** Formularz zostaje jeden, z jednym + przyciskiem zapisu, wiec przejscie miedzy zakladkami nie gubi niezapisanych zmian + i nie wymusza zapisywania po kawalku. Nie zamieniac tego na zagniezdzone trasy. +- **Blad walidacji na nieaktywnej zakladce musi byc widoczny.** Zakladka z bledem + dostaje kropke, a proba zapisu przeskakuje na pierwsza wadliwa. Bez tego komunikat + "Formularz zawiera bledy" wskazywalby na pole, ktorego nigdzie nie widac. + Pilnuje tego lista `fields` przy kazdej zakladce w `TABS`. +- Edytor tresci odtwarza sie z `form.contentHtml` przy ponownym zamontowaniu, wiec + odmontowanie przy zmianie zakladki nie kasuje niezapisanej tresci. +- Pasek zakladek przewija sie w poziomie wewnatrz wlasnego kontenera, tak samo jak + w ustawieniach. + +### Tresc strony zapisu i pliki + +Publiczna strona zapisu ma tresc budowana w edytorze wizualnym (TipTap), zapisywana +w `webinars.content_html`. Pliki wstawiane w tresc leza w `media_files` plus MinIO. + +- **`description` i `content_html` to dwie rozne rzeczy.** `description` to krotki opis + bez znacznikow: zajawka Open Graph dla robotow i tresc maili. `content_html` to dluga + tresc strony. Nie mieszac ich i nie zastepowac jednego drugim. +- **HTML jest czyszczony na backendzie przy kazdym zapisie**, przez `HtmlSanitizer` + (jsoup, biala lista). Do bazy trafia **wylacznie wersja oczyszczona**, oryginal jest + odrzucany. Endpoint zapisu mozna wywolac z pominieciem edytora, wiec ograniczenia + we froncie sa dla wygody, nie dla bezpieczenstwa. Chroni to `HtmlSanitizerTest`. +- **Biala lista, nigdy czarna.** Wycinanie "tego, co niebezpieczne" pomija warianty + w rodzaju `", null, null, + BannerPlacement.SIDE, null, null, null, null, 0, true)); + + assertThat(view.contentHtml()).contains("Promocja"); + assertThat(view.contentHtml()).doesNotContain("script"); + } + + @Test + @DisplayName("Okno w minutach decyduje, czy baner jest teraz na sali") + void minuteWindowFiltersBanners() { + banners.create(webinar.getId(), new BannerRequest("Widoczny " + suffix, media.getId(), + null, null, null, BannerPlacement.SIDE, null, null, 10, 40, 0, true)); + banners.create(webinar.getId(), new BannerRequest("Za wczesnie " + suffix, media.getId(), + null, null, null, BannerPlacement.SIDE, null, null, 50, 60, 0, true)); + banners.create(webinar.getId(), new BannerRequest("Juz minal " + suffix, media.getId(), + null, null, null, BannerPlacement.SIDE, null, null, 0, 10, 0, true)); + + assertThat(banners.activeFor(webinar.getId())) + .extracting(PublicBannerView::title) + .contains("Widoczny " + suffix) + .doesNotContain("Za wczesnie " + suffix, "Juz minal " + suffix); + } + + @Test + @DisplayName("Okno bezwzgledne i wylaczenie takze odsiewaja banery") + void absoluteWindowAndActiveFlag() { + banners.create(webinar.getId(), new BannerRequest("Po terminie " + suffix, media.getId(), + null, null, null, BannerPlacement.SIDE, + OffsetDateTime.now().minusDays(2), OffsetDateTime.now().minusDays(1), + null, null, 0, true)); + banners.create(webinar.getId(), new BannerRequest("Wylaczony " + suffix, media.getId(), + null, null, null, BannerPlacement.SIDE, null, null, null, null, 0, false)); + + assertThat(banners.activeFor(webinar.getId())) + .extracting(PublicBannerView::title) + .doesNotContain("Po terminie " + suffix, "Wylaczony " + suffix); + } + + @Test + @DisplayName("Baner globalny pokazuje sie na webinarze, ktory go nie zna") + void globalBannerAppearsOnWebinar() { + banners.create(null, request("Globalny " + suffix)); + + assertThat(banners.activeFor(webinar.getId())) + .extracting(PublicBannerView::title) + .contains("Globalny " + suffix); + } + + @Test + @DisplayName("Baner globalny nie moze wskazac tematu, bo temat nalezy do webinaru") + void globalBannerCannotHaveTopic() { + OfferTopic topic = new OfferTopic(); + topic.setWebinar(webinar); + topic.setName("Wdrozenie"); + topic.setSortOrder(1); + topic.setActive(true); + topic = topicRepository.save(topic); + + Long topicId = topic.getId(); + assertThatThrownBy(() -> banners.create(null, + new BannerRequest("Globalny z tematem", media.getId(), null, null, topicId, + BannerPlacement.SIDE, null, null, null, null, 0, true))) + .isInstanceOf(ValidationException.class); + + // przy banerze webinaru ten sam temat jest w porzadku + var ok = banners.create(webinar.getId(), + new BannerRequest("Z tematem " + suffix, media.getId(), null, null, topicId, + BannerPlacement.SIDE, null, null, null, null, 0, true)); + assertThat(ok.offerTopicName()).isEqualTo("Wdrozenie"); + } + + @Test + @DisplayName("Wyswietlenie liczy sie raz na uczestnika, klikniecie za kazdym razem") + void impressionOncePerParticipant() { + var banner = banners.create(webinar.getId(), request("Licznik " + suffix)); + + // rotacja przywraca baner na ekran wielokrotnie + for (int i = 0; i < 5; i++) { + banners.recordImpression(banner.id(), participant.getId()); + } + banners.recordClick(banner.id(), participant.getId()); + banners.recordClick(banner.id(), participant.getId()); + + var stats = banners.statsFor(List.of(banner.id())).get(banner.id()); + assertThat(stats[0]).as("wyswietlenia").isEqualTo(1); + assertThat(stats[1]).as("klikniecia").isEqualTo(2); + } + + @Test + @DisplayName("Okno odwrocone jest odrzucane z czytelnym komunikatem") + void rejectsInvertedWindow() { + assertThatThrownBy(() -> banners.create(webinar.getId(), + new BannerRequest("Odwrocone", media.getId(), null, null, null, + BannerPlacement.SIDE, null, null, 40, 10, 0, true))) + .isInstanceOf(ValidationException.class); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/calendar/IcsGeneratorTest.java b/backend/src/test/java/com/softspm/webinar/calendar/IcsGeneratorTest.java new file mode 100644 index 0000000..aa8db64 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/calendar/IcsGeneratorTest.java @@ -0,0 +1,128 @@ +package com.softspm.webinar.calendar; + +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import net.fortuna.ical4j.data.CalendarBuilder; +import net.fortuna.ical4j.model.Calendar; +import net.fortuna.ical4j.model.component.VEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test generatora ICS. Sprawdza, ze plik jest poprawnym kalendarzem + * i daje sie sparsowac z powrotem, a nie tylko ze zawiera oczekiwane napisy. + */ +class IcsGeneratorTest { + + private SettingsService settings; + private IcsGenerator generator; + + @BeforeEach + void setUp() { + settings = mock(SettingsService.class); + when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("powiadomienia@example.pl"); + when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("Gielda Komunalna"); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Gielda Komunalna"); + when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://webinar.example.pl"); + generator = new IcsGenerator(settings); + } + + @Test + void generujePoprawnyPlikIcsDlaWebinaru() throws Exception { + Webinar webinar = webinar(); + Registration registration = registration(); + + byte[] ics = generator.generate(webinar, registration); + String text = new String(ics, StandardCharsets.UTF_8); + + // naglowki wymagane przez klientow kalendarza + assertThat(text).contains("BEGIN:VCALENDAR"); + assertThat(text).contains("METHOD:REQUEST"); + assertThat(text).contains("VERSION:2.0"); + assertThat(text).contains("END:VCALENDAR"); + + // plik musi dac sie sparsowac przez ical4j + Calendar parsed = new CalendarBuilder().build(new ByteArrayInputStream(ics)); + VEvent event = parsed.getComponent("VEVENT").orElseThrow(); + + assertThat(event.getProperty("SUMMARY").orElseThrow().getValue()) + .isEqualTo("Webinar o zamowieniach publicznych"); + assertThat(event.getProperty("UID").orElseThrow().getValue()) + .isEqualTo("webinar-7-registration-42@webinar.example.pl"); + assertThat(event.getProperty("LOCATION").orElseThrow().getValue()) + .isEqualTo("https://meet.example.pl/abc"); + assertThat(event.getProperty("ORGANIZER").orElseThrow().getValue()) + .contains("powiadomienia@example.pl"); + } + + @Test + void uidJestStalyDlaTejSamejParyWebinarUczestnik() { + String first = new String(generator.generate(webinar(), registration()), StandardCharsets.UTF_8); + String second = new String(generator.generate(webinar(), registration()), StandardCharsets.UTF_8); + + assertThat(uidOf(first)).isEqualTo(uidOf(second)); + } + + @Test + void sequenceRosnieGdyWebinarZostalZmieniony() { + Webinar before = webinar(); + Webinar after = webinar(); + after.setUpdatedAt(before.getUpdatedAt().plusHours(1)); + + int sequenceBefore = sequenceOf(new String(generator.generate(before, registration()), StandardCharsets.UTF_8)); + int sequenceAfter = sequenceOf(new String(generator.generate(after, registration()), StandardCharsets.UTF_8)); + + assertThat(sequenceAfter).isGreaterThan(sequenceBefore); + } + + private Webinar webinar() { + Webinar webinar = new Webinar(); + webinar.setId(7L); + webinar.setSlug("zamowienia-publiczne"); + webinar.setTitle("Webinar o zamowieniach publicznych"); + webinar.setDescription("Omowienie zmian w ustawie."); + webinar.setStartsAt(OffsetDateTime.of(2026, 9, 15, 8, 0, 0, 0, ZoneOffset.UTC)); + webinar.setEndsAt(OffsetDateTime.of(2026, 9, 15, 10, 0, 0, 0, ZoneOffset.UTC)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setJoinUrl("https://meet.example.pl/abc"); + webinar.setUpdatedAt(OffsetDateTime.of(2026, 8, 1, 12, 0, 0, 0, ZoneOffset.UTC)); + return webinar; + } + + private Registration registration() { + Registration registration = new Registration(); + registration.setId(42L); + registration.setEmail("jan@example.pl"); + registration.setFirstName("Jan"); + registration.setLastName("Kowalski"); + registration.setConfirmationToken("token-abc"); + return registration; + } + + private String uidOf(String ics) { + return line(ics, "UID:"); + } + + private int sequenceOf(String ics) { + return Integer.parseInt(line(ics, "SEQUENCE:")); + } + + private String line(String ics, String prefix) { + return ics.lines() + .filter(l -> l.startsWith(prefix)) + .map(l -> l.substring(prefix.length()).trim()) + .findFirst() + .orElseThrow(() -> new AssertionError("Brak linii " + prefix)); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/calendar/IcsSampleWriterTest.java b/backend/src/test/java/com/softspm/webinar/calendar/IcsSampleWriterTest.java new file mode 100644 index 0000000..152d2a9 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/calendar/IcsSampleWriterTest.java @@ -0,0 +1,55 @@ +package com.softspm.webinar.calendar; + +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Zapisuje przykladowy plik ICS do obejrzenia w kliencie kalendarza. + * Uruchamiany tylko na zadanie: mvn test -Dics.sample.dir=/sciezka + */ +class IcsSampleWriterTest { + + @Test + @EnabledIfSystemProperty(named = "ics.sample.dir", matches = ".+") + void zapisujePrzykladowyPlik() throws Exception { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("powiadomienia@gieldakomunalna.pl"); + when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("Gielda Komunalna"); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Gielda Komunalna"); + when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://webinar.gieldakomunalna.pl"); + + Webinar webinar = new Webinar(); + webinar.setId(1L); + webinar.setSlug("zamowienia-publiczne-2026"); + webinar.setTitle("Zamowienia publiczne 2026 - co sie zmienia"); + webinar.setDescription("Praktyczne omowienie zmian w ustawie Prawo zamowien publicznych."); + webinar.setStartsAt(OffsetDateTime.of(2026, 9, 15, 8, 0, 0, 0, ZoneOffset.UTC)); + webinar.setEndsAt(OffsetDateTime.of(2026, 9, 15, 10, 0, 0, 0, ZoneOffset.UTC)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setJoinUrl("https://meet.gieldakomunalna.pl/zamowienia-2026"); + webinar.setUpdatedAt(OffsetDateTime.now()); + + Registration registration = new Registration(); + registration.setId(1L); + registration.setEmail("uczestnik@example.pl"); + registration.setFirstName("Jan"); + registration.setLastName("Kowalski"); + registration.setConfirmationToken("przykladowy-token"); + + byte[] ics = new IcsGenerator(settings).generate(webinar, registration); + Path target = Path.of(System.getProperty("ics.sample.dir"), "invite.ics"); + Files.write(target, ics); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/certificate/CertificateNumberTest.java b/backend/src/test/java/com/softspm/webinar/certificate/CertificateNumberTest.java new file mode 100644 index 0000000..eb7f69d --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/certificate/CertificateNumberTest.java @@ -0,0 +1,95 @@ +package com.softspm.webinar.certificate; + +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Skladanie numeru certyfikatu. + * + * Numer trafia na dokument i sluzy do weryfikacji publicznej, wiec nie moze + * wygladac na uszkodzony. Pulapka jest pusty prefiks: samo podstawienie + * pustego ciagu zostawia wiszacy separator ("/2026/1"), co u odbiorcy wyglada + * jak blad systemu. + */ +@SpringBootTest +@ActiveProfiles("test") +class CertificateNumberTest { + + @Autowired private CertificateService certificateService; + @Autowired private SettingsService settings; + + @AfterEach + void tearDown() { + // wracamy do wartosci domyslnych: kolejnosc testow jest losowa, wiec + // zostawione ustawienie zepsuloby inna klase testowa + settings.update(Map.of( + SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{rok}/{numer}", + SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "")); + } + + @Test + @DisplayName("Prefiks wchodzi w miejsce znacznika") + void prefixIsSubstituted() { + settings.update(Map.of( + SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}/{rok}/{numer}", + SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "SZK")); + + assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("SZK/2026/7"); + } + + @Test + @DisplayName("Pusty prefiks znika wraz z separatorem, numer wyglada jak dotad") + void emptyPrefixLeavesNoSeparator() { + settings.update(Map.of( + SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}/{rok}/{numer}", + SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "")); + + assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7"); + } + + @Test + @DisplayName("Pusty prefiks usuwa takze myslnik i spacje wokol znacznika") + void emptyPrefixHandlesOtherSeparators() { + settings.update(Map.of( + SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}-{rok}/{numer}", + SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "")); + assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7"); + + settings.update(Map.of( + SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks} {rok}/{numer}", + SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "")); + assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7"); + } + + @Test + @DisplayName("Same spacje w prefiksie licza sie jak brak prefiksu") + void blankPrefixCountsAsEmpty() { + settings.update(Map.of( + SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}/{rok}/{numer}", + SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), " ")); + + assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7"); + } + + @Test + @DisplayName("Wzor bez znacznika prefiksu dziala tak samo jak dotychczas") + void formatWithoutPrefixMarkerIsUnchanged() { + settings.update(Map.of( + SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{rok}/{miesiac}/{numer}", + SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "SZK")); + + String number = certificateService.formatNumber(2026, 7); + assertThat(number).startsWith("2026/").endsWith("/7"); + assertThat(number).doesNotContain("SZK"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/certificate/CertificatePdfTest.java b/backend/src/test/java/com/softspm/webinar/certificate/CertificatePdfTest.java new file mode 100644 index 0000000..a841645 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/certificate/CertificatePdfTest.java @@ -0,0 +1,155 @@ +package com.softspm.webinar.certificate; + +import com.softspm.webinar.config.CertificateTemplateConfig; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Generowanie certyfikatu do PDF. + * + * NAJWAZNIEJSZY TEST: POLSKIE ZNAKI + * ------------------------------------------------------------------------ + * Bez osadzonej czcionki diakrytyki znikaja u odbiorcy i "Wiśniewski" staje + * sie "Winiewski". Certyfikat z bledem w nazwisku jest bezuzyteczny, a blad + * ujawnilby sie dopiero u uczestnika, ktory otworzy plik. Test czyta tekst + * z gotowego PDF i sprawdza, ze ogonki tam sa. + */ +class CertificatePdfTest { + + private CertificatePdfRenderer renderer; + + @BeforeEach + void setUp() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(any(SettingKey.class))).thenReturn(""); + + renderer = new CertificatePdfRenderer( + new CertificateTemplateConfig().certificateTemplateEngine(), + settings); + } + + private CertificatePdfRenderer.CertificateData data(String participantName) { + return new CertificatePdfRenderer.CertificateData( + "2026/7", + participantName, + "Bezpieczeństwo danych w małej firmie", + "15 marca 2026", + "3", + "Platforma Szkoleniowa", + "https://example.test/certyfikat/2026/7", + null); + } + + /** Tekst wyciagniety z gotowego pliku PDF. */ + private String textOf(byte[] pdf) throws Exception { + try (var document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document); + } + } + + @Test + @DisplayName("polskie znaki przezywaja droge do pliku PDF") + void polishCharactersSurviveInPdf() throws Exception { + String html = renderer.renderHtml(data("Michał Wiśniewski-Łącki"), null); + byte[] pdf = renderer.renderPdf(html); + + String text = textOf(pdf); + + // pelen zestaw polskich znakow w nazwisku i tytule + assertThat(text).contains("Michał Wiśniewski-Łącki"); + assertThat(text).contains("Bezpieczeństwo danych w małej firmie"); + + // znak zapytania zamiast ogonka oznacza brak osadzonej czcionki + assertThat(text).doesNotContain("Winiewski"); + assertThat(text).doesNotContain("?????"); + } + + @Test + @DisplayName("plik jest poprawnym PDF w formacie A4 poziomo") + void producesLandscapeA4() throws Exception { + byte[] pdf = renderer.renderPdf(renderer.renderHtml(data("Anna Kowalska"), null)); + + assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-"); + + try (var document = Loader.loadPDF(pdf)) { + assertThat(document.getNumberOfPages()).isEqualTo(1); + + var box = document.getPage(0).getMediaBox(); + // A4 poziomo: 297mm x 210mm, czyli okolo 842 x 595 punktow + assertThat(box.getWidth()).isCloseTo(842f, org.assertj.core.data.Offset.offset(3f)); + assertThat(box.getHeight()).isCloseTo(595f, org.assertj.core.data.Offset.offset(3f)); + assertThat(box.getWidth()).isGreaterThan(box.getHeight()); + } + } + + @Test + @DisplayName("certyfikat niesie numer, date i liczbe godzin") + void containsKeyInformation() throws Exception { + String text = textOf(renderer.renderPdf(renderer.renderHtml(data("Jan Nowak"), null))); + + assertThat(text).contains("2026/7"); + assertThat(text).contains("15 marca 2026"); + assertThat(text).contains("3"); + assertThat(text).contains("Platforma Szkoleniowa"); + } + + @Test + @DisplayName("brak liczby godzin nie psuje dokumentu") + void worksWithoutHours() throws Exception { + var withoutHours = new CertificatePdfRenderer.CertificateData( + "2026/8", "Ewa Nowak", "Szkolenie bez godzin", "1 kwietnia 2026", + null, "Marka", null, null); + + String text = textOf(renderer.renderPdf(renderer.renderHtml(withoutHours, null))); + + assertThat(text).contains("Ewa Nowak"); + assertThat(text).doesNotContain("godz. szkoleniowych"); + } + + @Test + @DisplayName("wlasna tresc organizatora tez zachowuje polskie znaki") + void customTemplateKeepsPolishCharacters() throws Exception { + // wlasna tresc jest opakowywana w szkielet z osadzona czcionka; + // bez tego opakowania ogonki znikalyby wlasnie tutaj + String custom = "

Zaświadczenie o ukończeniu

Gratulacje dla uczestnika.

"; + + String text = textOf(renderer.renderPdf(renderer.renderHtml(data("Zofia Jabłońska"), custom))); + + assertThat(text).contains("Zaświadczenie o ukończeniu"); + assertThat(text).contains("Gratulacje"); + } + + @Test + @DisplayName("niezamkniety znacznik we wlasnej tresci nie wywraca generowania") + void tolerantToBrokenMarkup() throws Exception { + // jsoup domyka znaczniki przed renderowaniem, bo renderer wymaga XML + String broken = "

Tekst bez zamkniecia

Drugi akapit"; + + byte[] pdf = renderer.renderPdf(renderer.renderHtml(data("Test Testowy"), broken)); + + assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-"); + } + + @Test + @DisplayName("czcionka z polskimi znakami jest w zasobach projektu") + void fontIsBundled() throws Exception { + // build musi byc powtarzalny: czcionka lezy w repozytorium, a nie + // jest brana z systemu, ktory moze jej nie miec + Path font = Path.of("src/main/resources/fonts/DejaVuSans.ttf"); + assertThat(Files.exists(font)).isTrue(); + assertThat(Files.size(font)).isGreaterThan(100_000); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificateOwnerTest.java b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificateOwnerTest.java new file mode 100644 index 0000000..0ba09ab --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificateOwnerTest.java @@ -0,0 +1,135 @@ +package com.softspm.webinar.certificate; + +import com.softspm.webinar.domain.Certificate; +import com.softspm.webinar.domain.Contact; +import com.softspm.webinar.domain.Course; +import com.softspm.webinar.domain.Enrollment; +import com.softspm.webinar.repository.EnrollmentRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Kto moze otworzyc certyfikat kursu. + * + * DLACZEGO TO JEST OSOBNY TEST + * --------------------------------------------------------------------------- + * Certyfikat jest **dokumentem jednej osoby**, z jej imieniem i nazwiskiem. + * Gdyby wystarczal sam udzial w kursie, kazdy kursant pobralby certyfikaty + * wszystkich kolegow z grupy i poznal ich dane. Ta sama pulapka wyszla juz raz + * przy webinarach, przy sprawdzaniu na zywo. + * + * Reguly sprawdzamy na zachowaniu metody i na strukturze odpowiedzi + * publicznej, a nie na jednym przykladzie: zmiana warunku ma zlamac test + * natychmiast. + */ +class CourseCertificateOwnerTest { + + private CourseCertificateService serviceWith(EnrollmentRepository enrollments) { + return new CourseCertificateService( + null, null, enrollments, null, null, null, null, null, null, null, null); + } + + private Enrollment enrollmentOf(long enrollmentId, long contactId) { + Contact contact = new Contact(); + contact.setId(contactId); + Enrollment enrollment = new Enrollment(); + enrollment.setId(enrollmentId); + enrollment.setContact(contact); + return enrollment; + } + + @Test + @DisplayName("Wlasciciel zapisu otwiera swoj certyfikat") + void ownerCanOpen() { + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + when(enrollments.findById(10L)).thenReturn(Optional.of(enrollmentOf(10L, 7L))); + + assertThat(serviceWith(enrollments).belongsToContact(10L, 7L)).isTrue(); + } + + @Test + @DisplayName("Inny kursant TEGO SAMEGO kursu nie otwiera cudzego certyfikatu") + void otherStudentCannotOpen() { + /* + * Najwazniejszy przypadek. Kontakt 8 moze byc zapisany na ten sam kurs + * i miec wlasny certyfikat, ale ten nalezy do kontaktu 7. + */ + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + when(enrollments.findById(10L)).thenReturn(Optional.of(enrollmentOf(10L, 7L))); + + assertThat(serviceWith(enrollments).belongsToContact(10L, 8L)).isFalse(); + } + + @Test + @DisplayName("Niezalogowany nie otwiera niczego") + void anonymousCannotOpen() { + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + assertThat(serviceWith(enrollments).belongsToContact(10L, null)).isFalse(); + } + + @Test + @DisplayName("Nieistniejacy zapis nie otwiera niczego") + void missingEnrollmentCannotOpen() { + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + when(enrollments.findById(99L)).thenReturn(Optional.empty()); + + assertThat(serviceWith(enrollments).belongsToContact(99L, 7L)).isFalse(); + } + + @Test + @DisplayName("Weryfikacja publiczna nie wypuszcza danych osobowych") + void publicVerificationLeaksNothing() { + /* + * Endpoint weryfikacji jest otwarty dla calego internetu. Wolno mu + * oddac numer, tytul, date, imie z inicjalem i waznosc. Adres e-mail, + * pelne nazwisko ani link do pliku nie moga sie tam znalezc **nigdy**, + * takze przypadkiem, przy dokladaniu pola dla kursow. + * + * Test patrzy na strukture rekordu, wiec dodanie takiego pola zlamie + * go od razu, bez czekania na to, ze ktos pomysli o przypadku. + */ + List forbidden = List.of("email", "lastname", "url", "objectkey", + "downloadurl", "phone", "contactid"); + + List names = Arrays.stream( + CertificateService.PublicVerification.class.getRecordComponents()) + .map(RecordComponent::getName) + .map(name -> name.toLowerCase(Locale.ROOT)) + .toList(); + + assertThat(names).doesNotContainAnyElementsOf(forbidden); + // to, co ma tam byc + assertThat(names).contains("number", "subjecttitle", "participantname", "valid"); + } + + @Test + @DisplayName("Imie skraca sie do inicjalu nazwiska") + void shortNameHidesLastName() { + assertThat(CertificateService.shortName("Anna", "Kowalska")).isEqualTo("Anna K."); + assertThat(CertificateService.shortName("Łukasz", "Ćwikliński")).isEqualTo("Łukasz Ć."); + // brak nazwiska nie moze dac wiszacej kropki + assertThat(CertificateService.shortName("Anna", null)).isEqualTo("Anna"); + assertThat(CertificateService.shortName("Anna", " ")).isEqualTo("Anna"); + } + + @Test + @DisplayName("Certyfikat rozpoznaje swoje zrodlo") + void certificateKnowsItsSource() { + Certificate courseCertificate = new Certificate(); + courseCertificate.setCourse(new Course()); + assertThat(courseCertificate.isForCourse()).isTrue(); + + Certificate webinarCertificate = new Certificate(); + assertThat(webinarCertificate.isForCourse()).isFalse(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificatePdfTest.java b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificatePdfTest.java new file mode 100644 index 0000000..8762ac8 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificatePdfTest.java @@ -0,0 +1,158 @@ +package com.softspm.webinar.certificate; + +import com.softspm.webinar.config.CertificateTemplateConfig; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Certyfikat ukonczenia kursu jako gotowy plik PDF. + * + * DLACZEGO CZYTAMY TEKST Z PDF, A NIE Z HTML + * --------------------------------------------------------------------------- + * Miedzy szablonem a plikiem stoi renderer i osadzanie czcionek, czyli + * dokladnie te miejsca, w ktorych polskie znaki gina. Test sprawdzajacy HTML + * przechodzilby takze wtedy, gdy w gotowym dokumencie kazdy ogonek zamienia sie + * w krzyzyk, a certyfikat z bledem w nazwisku jest bezuzyteczny. + */ +class CourseCertificatePdfTest { + + private CertificatePdfRenderer renderer; + + @BeforeEach + void setUp() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(any(SettingKey.class))).thenReturn(""); + renderer = new CertificatePdfRenderer( + new CertificateTemplateConfig().certificateTemplateEngine(), settings); + } + + private CertificatePdfRenderer.CourseCertificateData data(String name, Integer score) { + return new CertificatePdfRenderer.CourseCertificateData( + "2026/12", + name, + "Plan bezpieczeństwa wody: ocena ryzyka", + "15 marca 2026", + "8", + score, + "Akademia Wodociągowca", + "https://example.test/certyfikat/2026/12", + null); + } + + private byte[] render(CertificatePdfRenderer.CourseCertificateData data, String custom) { + return renderer.renderPdf(renderer.renderCourseHtml(data, custom)); + } + + private String textOf(byte[] pdf) throws Exception { + try (var document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document); + } + } + + @Test + @DisplayName("Polskie znaki przezywaja droge do pliku PDF") + void polishCharactersSurvive() throws Exception { + String text = textOf(render(data("Michał Wiśniewski-Łącki", 92), null)); + + assertThat(text).contains("Michał Wiśniewski-Łącki"); + assertThat(text).contains("Plan bezpieczeństwa wody"); + assertThat(text).contains("Akademia Wodociągowca"); + // objaw zgubionej czcionki: nazwisko bez ogonkow albo same krzyzyki + assertThat(text).doesNotContain("Winiewski"); + assertThat(text).doesNotContain("?????"); + } + + @Test + @DisplayName("Dokument mowi o UKONCZENIU kursu, nie o udziale w webinarze") + void saysCourseCompleted() throws Exception { + String text = textOf(render(data("Anna Kowalska", 80), null)); + + /* + * Rozroznienie jest sednem osobnego szablonu. Certyfikat kursu + * potwierdza ukonczenie, a nie sama obecnosc: gdyby uzywal szablonu + * webinarowego, dokument mowilby "wzial udzial w szkoleniu", co przy + * kursie z quizem jest po prostu nieprawda. + */ + assertThat(text).contains("ukończył"); + assertThat(text).doesNotContain("wziął(-ęła) udział"); + } + + @Test + @DisplayName("Wynik sprawdzianu trafia na dokument") + void includesQuizScore() throws Exception { + String text = textOf(render(data("Anna Kowalska", 92), null)); + assertThat(text).contains("92"); + assertThat(text).contains("wynik sprawdzianu"); + } + + @Test + @DisplayName("Kurs bez sprawdzianu nie pokazuje wyniku") + void withoutQuizShowsNoScore() throws Exception { + /* + * Null, a nie zero. Zero na dokumencie wygladaloby jak oblany test, + * a kurs bez quizu po prostu nie ma czego podac. + */ + String text = textOf(render(data("Anna Kowalska", null), null)); + assertThat(text).doesNotContain("wynik sprawdzianu"); + assertThat(text).contains("Anna Kowalska"); + } + + @Test + @DisplayName("Numer i data sa na dokumencie") + void containsKeyInformation() throws Exception { + String text = textOf(render(data("Anna Kowalska", 75), null)); + assertThat(text).contains("2026/12"); + assertThat(text).contains("15 marca 2026"); + assertThat(text).contains("8"); + } + + @Test + @DisplayName("Wlasny szablon organizatora zachowuje polskie znaki") + void customTemplateKeepsPolishCharacters() throws Exception { + String custom = """ +

Zaświadczenie o ukończeniu

+

imię

+

kurs

+ """; + String text = textOf(render(data("Łukasz Ćwikliński", 88), custom)); + + assertThat(text).contains("Zaświadczenie o ukończeniu"); + assertThat(text).contains("Łukasz Ćwikliński"); + } + + @Test + @DisplayName("Wlasny szablon napisany dla webinaru dziala takze przy kursie") + void webinarStyleTemplateStillWorks() throws Exception { + /* + * Organizator, ktory napisal wlasny szablon dla webinarow, uzywa + * w nim `webinarTitle` i `webinarDate`. Te same nazwy sa dostepne przy + * kursie, wiec szablon nie pokazuje pustych miejsc po zmianie zrodla. + */ + String custom = """ +

tytuł

+

data

+ """; + String text = textOf(render(data("Anna Kowalska", 75), custom)); + + assertThat(text).contains("Plan bezpieczeństwa wody"); + assertThat(text).contains("15 marca 2026"); + } + + @Test + @DisplayName("Niezamkniete znaczniki nie wywracaja generowania") + void tolerantToBrokenMarkup() { + // jsoup domyka znaczniki przed renderowaniem, bo renderer wymaga XML + byte[] pdf = render(data("Anna Kowalska", 75), "

Tekst bez zamkniecia

Drugi akapit"); + assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/config/TransactionalSelfInvocationTest.java b/backend/src/test/java/com/softspm/webinar/config/TransactionalSelfInvocationTest.java new file mode 100644 index 0000000..caf6bb8 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/config/TransactionalSelfInvocationTest.java @@ -0,0 +1,323 @@ +package com.softspm.webinar.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.asm.ClassReader; +import org.springframework.asm.MethodVisitor; +import org.springframework.asm.Opcodes; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Wykrywa samowywolania metod transakcyjnych. + * + * DLACZEGO TEN TEST W OGOLE ISTNIEJE + * --------------------------------------------------------------------------- + * Ten sam blad wystapil TRZY RAZY: `@Transactional` na metodzie wolanej z tej + * samej klasy nie dziala, bo samowywolanie omija proxy Springa. Za kazdym razem + * objawil sie dopiero na uruchomionej instancji, nie w testach, bo kod + * kompiluje sie poprawnie i wyglada dobrze przy czytaniu. + * + * Regula w CLAUDE.md nie zapobiegla, bo wymaga, zeby ktos o niej pamietal + * dokladnie w chwili pisania. Ten test pamieta zawsze. + * + * JAK TO DZIALA + * --------------------------------------------------------------------------- + * Czyta **bajtkod**, a nie zrodla: interesuje nas faktyczne wywolanie, a nie + * to, jak wyglada w tekscie. Instrukcja `INVOKEVIRTUAL` albo `INVOKESPECIAL` + * na wlasnej klasie, poprzedzona zaladowaniem `this` (ALOAD 0), to dokladnie + * samowywolanie omijajace proxy. + * + * ASM pochodzi ze `spring-core` (`org.springframework.asm`), ktore i tak jest + * w zaleznosciach: **nie dokladamy zadnej biblioteki**. + * + * CZEGO TEST NIE ZGLASZA + * --------------------------------------------------------------------------- + * Metody `readOnly = true` wolane z innej metody transakcyjnej. Taka metoda + * dolacza do istniejacej transakcji i zachowuje sie poprawnie, a takich + * wywolan sa dziesiatki: zglaszanie ich utopiloby prawdziwe znaleziska + * w szumie. Groźne jest to, co zmienia zachowanie: REQUIRES_NEW, ktore bez + * proxy nie zaklada nowej transakcji, oraz zapis wolany z metody + * nietransakcyjnej, ktory bez proxy nie ma transakcji wcale. + */ +class TransactionalSelfInvocationTest { + + private static final String BASE_PACKAGE = "com/softspm/webinar"; + + /** + * Wyjatki od reguly, kazdy z uzasadnieniem. + * + * Wpis tutaj to swiadoma decyzja, a nie sposob na uciszenie testu. + */ + private static final Map ALLOWED = Map.of( + // brak wpisow: kazde znalezisko ma byc naprawione, nie odnotowane + ); + + private record Finding(String className, String caller, String callee, String annotation) { + @Override + public String toString() { + return "%s.%s() wola %s() [%s]".formatted( + className.substring(className.lastIndexOf('/') + 1), caller, callee, annotation); + } + } + + @Test + @DisplayName("Zadna metoda transakcyjna nie jest wolana przez this") + void noSelfInvocationOfTransactionalMethods() throws Exception { + List findings = new ArrayList<>(); + + for (Resource resource : classResources()) { + try (InputStream in = resource.getInputStream()) { + analyse(new ClassReader(in), findings); + } + } + + List reported = findings.stream() + .map(Finding::toString) + .filter(f -> !ALLOWED.containsKey(f)) + .sorted() + .toList(); + + assertThat(reported) + .as(""" + Samowywolanie metody transakcyjnej omija proxy Springa, wiec adnotacja \ + nie dziala. Napraw przez wstrzykniecie referencji na samego siebie \ + (@Lazy) i wolanie przez nia, tak jak robi to OrderService albo \ + SummarySender. Ten blad NIE ujawnia sie w kompilacji ani w zwyklych \ + testach, tylko na dzialajacej instancji.""") + .isEmpty(); + } + + // --- analiza bajtkodu --- + + private void analyse(ClassReader reader, List findings) { + String className = reader.getClassName(); + + // 1. ktore metody tej klasy sa transakcyjne i jak + Map transactional = new HashMap<>(); + // 2. ktore metody sa nietransakcyjne, zeby ocenic wagę znaleziska + Set allMethods = new HashSet<>(); + + reader.accept(new org.springframework.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + allMethods.add(name); + return new MethodVisitor(Opcodes.ASM9) { + @Override + public org.springframework.asm.AnnotationVisitor visitAnnotation( + String desc, boolean visible) { + if (desc.endsWith("/Transactional;")) { + // domyslnie zwykla transakcja; szczegoly z pol adnotacji + transactional.putIfAbsent(name, "Transactional"); + return new org.springframework.asm.AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visitEnum(String field, String d, String value) { + if ("propagation".equals(field)) { + transactional.put(name, value); + } + } + + @Override + public void visit(String field, Object value) { + if ("readOnly".equals(field) && Boolean.TRUE.equals(value)) { + transactional.put(name, "readOnly"); + } + } + }; + } + return null; + } + }; + } + }, ClassReader.SKIP_FRAMES); + + if (transactional.isEmpty()) { + return; + } + + // 3. szukamy wywolan wlasnych metod transakcyjnych + reader.accept(new org.springframework.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String caller, String descriptor, + String signature, String[] exceptions) { + /* + * ROZROZNIENIE `this` OD REFERENCJI NA SAMEGO SIEBIE + * ---------------------------------------------------------- + * Oba wywolania maja w bajtkodzie tego samego wlasciciela, bo + * pole `self` jest typu tej samej klasy. Rozni je to, CO jest + * odbiorca wywolania: + * + * this.metoda(x) -> ALOAD 0, ALOAD 1, INVOKEVIRTUAL + * self.metoda(x) -> ALOAD 0, GETFIELD self, ALOAD 1, INVOKEVIRTUAL + * + * Odbiorca lezy na stosie POD argumentami, wiec nie wystarczy + * patrzec na ostatnia instrukcje: przy metodzie z argumentami + * ostatnie jest ich ladowanie. Trzymamy wiec slad ostatnich + * instrukcji i cofamy sie o tyle pozycji, ile argumentow ma + * wolana metoda. + * + * Bez tego test przepuszczal prawdziwy blad, co wyszlo przy + * sprawdzeniu go celowo przywroconym samowywolaniem. + */ + return new MethodVisitor(Opcodes.ASM9) { + /** + * Slad tego, co trafilo na stos: true oznacza `this`. + * + * Uproszczony model: interesuje nas wylacznie, czy dana + * pozycja to `this`, a nie jej wartosc. + */ + private final java.util.ArrayDeque stack = new java.util.ArrayDeque<>(); + + private void push(boolean isThis) { + stack.push(isThis); + // slad ma byc krotki: glebiej i tak nie siegamy + while (stack.size() > 32) { + stack.removeLast(); + } + } + + @Override + public void visitVarInsn(int opcode, int varIndex) { + push(opcode == Opcodes.ALOAD && varIndex == 0); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String name, + String descriptor) { + if (opcode == Opcodes.GETFIELD) { + // zdejmuje referencje, kladzie wartosc pola + if (!stack.isEmpty()) { + stack.pop(); + } + push(false); + } else if (opcode == Opcodes.GETSTATIC) { + push(false); + } + } + + @Override + public void visitLdcInsn(Object value) { + push(false); + } + + @Override + public void visitIntInsn(int opcode, int operand) { + push(false); + } + + @Override + public void visitTypeInsn(int opcode, String type) { + push(false); + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, + String desc, boolean isInterface) { + boolean sameClass = owner.equals(className) + && (opcode == Opcodes.INVOKEVIRTUAL + || opcode == Opcodes.INVOKESPECIAL); + + if (sameClass && transactional.containsKey(name) && !name.equals(caller)) { + /* + * Odbiorca lezy pod argumentami. Liczymy sloty + * argumentow z deskryptora i siegamy o tyle glebiej. + */ + int args = argumentSlots(desc); + Boolean receiver = nth(args); + if (Boolean.TRUE.equals(receiver)) { + String annotation = transactional.get(name); + if (isDangerous(annotation, caller, transactional)) { + findings.add(new Finding(className, caller, name, annotation)); + } + } + } + + // zdejmujemy argumenty i odbiorce, kladziemy wynik + int toPop = argumentSlots(desc) + + (opcode == Opcodes.INVOKESTATIC ? 0 : 1); + for (int i = 0; i < toPop && !stack.isEmpty(); i++) { + stack.pop(); + } + if (!desc.endsWith(")V")) { + push(false); + } + } + + /** Element stosu na wskazanej glebokosci, liczac od wierzchu. */ + private Boolean nth(int depth) { + int i = 0; + for (Boolean value : stack) { + if (i++ == depth) { + return value; + } + } + return null; + } + }; + } + }, ClassReader.SKIP_FRAMES); + } + + /** + * Ile pozycji na stosie zajmuja argumenty metody. + * + * `long` i `double` zajmuja po dwa sloty w tablicy zmiennych lokalnych, + * ale na stosie operandow licza sie tu jako jedna wartosc, bo sledzimy + * wartosci, nie sloty. + */ + static int argumentSlots(String descriptor) { + int count = 0; + int i = 1; + while (i < descriptor.length() && descriptor.charAt(i) != ')') { + char c = descriptor.charAt(i); + if (c == 'L') { + i = descriptor.indexOf(';', i) + 1; + count++; + } else if (c == '[') { + i++; + } else { + i++; + count++; + } + } + return count; + } + + /** + * Czy to znalezisko zmienia zachowanie. + * + * REQUIRES_NEW zawsze: bez proxy nowa transakcja nie powstaje, a to jest + * cala jego rola. Zapis wolany z metody NIETRANSAKCYJNEJ takze: bez proxy + * nie ma zadnej transakcji, wiec zapis albo nie dziala, albo dziala + * przypadkiem, przez `open-in-view` (u nas wylaczone) lub autocommit. + * + * Metoda `readOnly` wolana z innej metody transakcyjnej jest nieszkodliwa: + * dolacza do istniejacej transakcji, dokladnie tak jak zrobilby to proxy + * przy propagacji REQUIRED. + */ + private boolean isDangerous(String annotation, String caller, Map transactional) { + if ("REQUIRES_NEW".equals(annotation) || "NOT_SUPPORTED".equals(annotation)) { + return true; + } + // zapis wolany z metody, ktora sama nie ma transakcji + boolean callerIsTransactional = transactional.containsKey(caller); + return !callerIsTransactional && !"readOnly".equals(annotation); + } + + private Resource[] classResources() throws IOException { + var resolver = new PathMatchingResourcePatternResolver(); + return resolver.getResources("classpath*:" + BASE_PACKAGE + "/**/*.class"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/content/HtmlSanitizerTest.java b/backend/src/test/java/com/softspm/webinar/content/HtmlSanitizerTest.java new file mode 100644 index 0000000..8030d50 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/content/HtmlSanitizerTest.java @@ -0,0 +1,129 @@ +package com.softspm.webinar.content; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Oczyszczanie tresci strony zapisu. + * + * To jedyna bariera miedzy trescia od klienta a baza, bo endpoint zapisu mozna + * wywolac z pominieciem edytora. Kazdy przypadek tutaj to konkretny sposob, + * w jaki dalo by sie wstrzyknac kod na publiczna strone. + */ +class HtmlSanitizerTest { + + private final HtmlSanitizer sanitizer = new HtmlSanitizer(); + + @Test + @DisplayName("Znacznik script jest wycinany razem z zawartoscia") + void removesScript() { + String result = sanitizer.sanitize("

Zapraszamy

"); + + assertThat(result).doesNotContain("script").doesNotContain("alert"); + assertThat(result).contains("Zapraszamy"); + } + + @Test + @DisplayName("Atrybuty on* nie przechodza, takze przy dozwolonym znaczniku") + void removesEventHandlers() { + String result = sanitizer.sanitize( + "\"x\""); + + assertThat(result).doesNotContain("onerror").doesNotContain("alert"); + // sam obrazek zostaje, bo adres jest dozwolony + assertThat(result).contains("/api/public/media/1"); + } + + @Test + @DisplayName("Adres javascript: w linku jest usuwany") + void removesJavascriptHref() { + String result = sanitizer.sanitize("
klik"); + + assertThat(result).doesNotContain("javascript"); + // tekst linku zostaje, znika sam adres + assertThat(result).contains("klik"); + } + + @Test + @DisplayName("iframe i style nie przechodza") + void removesIframeAndStyle() { + String result = sanitizer.sanitize( + "

Tresc

" + + ""); + + assertThat(result).doesNotContain("iframe").doesNotContain("style"); + assertThat(result).contains("Tresc"); + } + + @Test + @DisplayName("Obrazek z obcego serwera jest odrzucany") + void rejectsExternalImage() { + String result = sanitizer.sanitize( + "

a

\"x\""); + + assertThat(result).doesNotContain("obcy.example"); + } + + @Test + @DisplayName("Obrazek z wlasnego endpointu przechodzi razem z alt") + void keepsOwnImage() { + String result = sanitizer.sanitize( + "\"Wykres"); + + assertThat(result).contains("/api/public/media/42"); + assertThat(result).contains("Wykres zuzycia"); + } + + @Test + @DisplayName("Znaczniki tresci z edytora zostaja") + void keepsEditorMarkup() { + String result = sanitizer.sanitize( + "

Program

Czesc pierwsza

" + + "

Wazne i ciekawe

" + + "
  1. krok
" + + "
cytat

"); + + assertThat(result) + .contains("

").contains("

") + .contains("").contains("") + .contains("