diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..8e007e8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,7 @@ +[[allowlists]] +description = "generated/imported non-secret data" +paths = [ + '''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''', + '''android-app/app/build/.*''', + '''android-app/\.idea/planningMode\.xml$''', +] diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 5844f7a..76c36e1 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -88,6 +88,13 @@ android { versionName = androidVersionName } + lint { + disable += setOf( + "AutoboxingStateCreation", + "MutableCollectionMutableState", + ) + } + signingConfigs { create("release") { if (hasReleaseSigning) { diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab deleted file mode 100644 index 85a22bc..0000000 Binary files a/android-app/app/production/release/app-production-release.aab and /dev/null differ diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 007c964..b445344 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -232,7 +232,7 @@ data class ProfileVisibilityDto( val showEmail: Boolean = true, val showPhone: Boolean = true, val showAddress: Boolean = false, - val showBirthday: Boolean = true, + val showBirthday: Boolean = false, ) data class ProfileUserDto( val id: String? = null, @@ -329,6 +329,7 @@ data class MemberDto( val editable: Boolean = false, val isMannschaftsspieler: Boolean = false, val hasHallKey: Boolean = false, + val showBirthday: Boolean = false, val loginRoles: List = emptyList(), ) data class MembersResponse( @@ -729,6 +730,7 @@ interface ApiService { val notes: String? = null, val isMannschaftsspieler: Boolean = false, val hasHallKey: Boolean = false, + val showBirthday: Boolean = false, ) data class BulkImportRequest(val members: List>) diff --git a/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt index ee5652b..6cc4607 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt @@ -19,7 +19,7 @@ import javax.inject.Singleton @Singleton class ConnectivityMonitor @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _online = MutableStateFlow(hasInternetAccess()) @@ -46,4 +46,4 @@ class ConnectivityMonitor @Inject constructor( val capabilities = manager.getNetworkCapabilities(network) ?: return false return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } -} \ No newline at end of file +} diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt index 1878b37..e67462a 100644 --- a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -51,8 +51,12 @@ object HarheimerNotifications { .setContentIntent(createContentIntent(context, notificationId, data)) .setAutoCancel(true) .build() - NotificationManagerCompat.from(context).notify(notificationId, notification) - return true + return try { + NotificationManagerCompat.from(context).notify(notificationId, notification) + true + } catch (_: SecurityException) { + false + } } private fun createContentIntent(context: Context, notificationId: Int, payload: Map): PendingIntent { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt index 8a6279c..34f9fe7 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt @@ -90,7 +90,7 @@ class HomeViewModel @Inject constructor( loading = false, heroImageUrl = data.heroImageUrl, termine = data.termine - .filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true } + .filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true } .sortedBy { it.asDateTime() } .take(3), spiele = data.spiele diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt index 6097153..97b0141 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt @@ -59,7 +59,7 @@ data class RegisterFormState( val birthDate: String = "", val password: String = "", val passwordRepeat: String = "", - val showBirthday: Boolean = true, + val showBirthday: Boolean = false, ) data class RegisterUiState( diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt index 8c1f4ba..28e273f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt @@ -24,7 +24,7 @@ data class ProfileFormState( val showEmail: Boolean = true, val showPhone: Boolean = true, val showAddress: Boolean = false, - val showBirthday: Boolean = true, + val showBirthday: Boolean = false, val currentPassword: String = "", val newPassword: String = "", val confirmPassword: String = "", diff --git a/android-app/build/reports/problems/problems-report.html b/android-app/build/reports/problems/problems-report.html deleted file mode 100644 index 77cfa1f..0000000 --- a/android-app/build/reports/problems/problems-report.html +++ /dev/null @@ -1,666 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 23c1fef..b63cad4 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=25 -ANDROID_VERSION_NAME=0.9.20 +ANDROID_VERSION_CODE=26 +ANDROID_VERSION_NAME=0.9.21 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/components/cms/CmsMitglieder.vue b/components/cms/CmsMitglieder.vue index fa59615..ced2de0 100644 --- a/components/cms/CmsMitglieder.vue +++ b/components/cms/CmsMitglieder.vue @@ -550,6 +550,25 @@ +
+ + +
+

+ Admins und Vorstand können die Sichtbarkeit nur ausschalten. Einschalten kann nur das Mitglied selbst im Profil. +

+
{ @@ -861,6 +881,10 @@ const isBirthdateRequired = computed(() => { return !editingMember.value || Boolean(editingMember.value?.geburtsdatum) }) +const canDisableBirthdayVisibility = computed(() => { + return editingMember.value?.showBirthday === true +}) + const filteredMembers = computed(() => { if (!filterHasHallKey.value) return members.value return members.value.filter(member => member.hasHallKey) @@ -880,7 +904,7 @@ const loadMembers = async () => { const openAddModal = () => { editingMember.value = null - formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false } + formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false, showBirthday: false } showModal.value = true errorMessage.value = '' } @@ -896,7 +920,8 @@ const openEditModal = (member) => { address: member.address || '', notes: member.notes || '', isMannschaftsspieler: member.isMannschaftsspieler === true, - hasHallKey: member.hasHallKey === true + hasHallKey: member.hasHallKey === true, + showBirthday: member.showBirthday === true } showModal.value = true errorMessage.value = '' @@ -914,7 +939,14 @@ const saveMember = async () => { try { await $fetch('/api/members', { method: 'POST', - body: { id: editingMember.value?.id, ...formData.value } + body: { + id: editingMember.value?.id, + ...formData.value, + visibility: { + ...(editingMember.value?.visibility || {}), + showBirthday: formData.value.showBirthday === true + } + } }) closeModal() await loadMembers() diff --git a/pages/mitgliederbereich/profil.vue b/pages/mitgliederbereich/profil.vue index ad3e71e..9f2fc64 100644 --- a/pages/mitgliederbereich/profil.vue +++ b/pages/mitgliederbereich/profil.vue @@ -365,7 +365,7 @@ const visibility = ref({ showEmail: true, showPhone: true, showAddress: false, - showBirthday: true + showBirthday: false }) const passwordData = ref({ @@ -568,4 +568,3 @@ useHead({ title: 'Mein Profil - Harheimer TC', }) - diff --git a/pages/mitgliederbereich/qttr.vue b/pages/mitgliederbereich/qttr.vue index 53291e2..3369451 100644 --- a/pages/mitgliederbereich/qttr.vue +++ b/pages/mitgliederbereich/qttr.vue @@ -173,4 +173,4 @@ function formatDate(value) { useHead({ title: 'QTTR-Werte - Harheimer TC' }) - \ No newline at end of file + diff --git a/scripts/verify-no-public-writes.js b/scripts/verify-no-public-writes.js new file mode 100644 index 0000000..3a4ea44 --- /dev/null +++ b/scripts/verify-no-public-writes.js @@ -0,0 +1,45 @@ +import fs from 'fs' +import path from 'path' + +const repoRoot = process.cwd() +const scanRoots = ['server'] +const sourceExtensions = new Set(['.js', '.mjs', '.ts']) + +const publicWritePattern = /\b(writeFile|appendFile|copyFile|rename|mkdir)\s*\([^)]*(public[/\\](?:data|uploads)|['"`]public['"`]\s*,\s*['"`](?:data|uploads)['"`])/s + +function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + return entries.flatMap((entry) => { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) return walk(fullPath) + return [fullPath] + }) +} + +const findings = [] + +for (const root of scanRoots) { + const absoluteRoot = path.join(repoRoot, root) + if (!fs.existsSync(absoluteRoot)) continue + + for (const filePath of walk(absoluteRoot)) { + if (!sourceExtensions.has(path.extname(filePath))) continue + + const content = fs.readFileSync(filePath, 'utf8') + if (!publicWritePattern.test(content)) continue + + const relativePath = path.relative(repoRoot, filePath) + findings.push(relativePath) + } +} + +if (findings.length > 0) { + console.error('Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden:') + for (const finding of findings) { + console.error(`- ${finding}`) + } + console.error('Bitte stattdessen server/data bzw. server/data/public-data verwenden.') + process.exit(1) +} + +console.log('OK: keine serverseitigen Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden.') diff --git a/server/api/auth/register-passkey.post.js b/server/api/auth/register-passkey.post.js index 9a2a606..36a5f95 100644 --- a/server/api/auth/register-passkey.post.js +++ b/server/api/auth/register-passkey.post.js @@ -1,6 +1,5 @@ import { verifyRegistrationResponse } from '@simplewebauthn/server' import crypto from 'crypto' -import nodemailer from 'nodemailer' import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js' import { getWebAuthnConfig } from '../../utils/webauthn-config.js' import { consumePreRegistration } from '../../utils/webauthn-challenges.js' @@ -8,6 +7,7 @@ import { toBase64Url } from '../../utils/webauthn-encoding.js' import { writeAuditLog } from '../../utils/audit-log.js' import { assertPasswordNotPwned } from '../../utils/hibp.js' import { getClientIp } from '../../utils/rate-limit.js' +import { sendRegistrationNotification } from '../../utils/email-service.js' // Local fallback for Nitro globals when lint/run env doesn't provide them const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET')) @@ -260,50 +260,9 @@ export default defineEventHandler(async (event) => { await writeAuditLog('auth.passkey.prereg.success', { email, userId: newUser.id }) - // Send notification emails (same behavior as password registration) + // Send notification emails through the same central recipient logic as password registration. try { - const smtpUser = process.env.SMTP_USER - const smtpPass = process.env.SMTP_PASS - - if (smtpUser && smtpPass) { - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST || 'smtp.gmail.com', - port: process.env.SMTP_PORT || 587, - secure: false, - auth: { user: smtpUser, pass: smtpPass } - }) - - await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@harheimertc.de', - to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de', - subject: 'Neue Registrierung (Passkey) - Harheimer TC', - html: ` -

Neue Registrierung (Passkey)

-

Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:

-
    -
  • Name: ${name}
  • -
  • E-Mail: ${email}
  • -
  • Telefon: ${phone || 'Nicht angegeben'}
  • -
  • Login: Passkey${password ? ' + Passwort (Fallback)' : ' (ohne Passwort)'}
  • -
-

Bitte prüfen Sie die Registrierung im CMS.

- ` - }) - - await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@harheimertc.de', - to: email, - subject: 'Registrierung erhalten - Harheimer TC', - html: ` -

Registrierung erhalten

-

Hallo ${name},

-

vielen Dank für Ihre Registrierung beim Harheimer TC!

-

Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.

-
-

Mit sportlichen Grüßen,
Ihr Harheimer TC

- ` - }) - } + await sendRegistrationNotification({ name, email, phone }) } catch (emailError) { console.error('E-Mail-Versand fehlgeschlagen:', emailError) } diff --git a/server/api/auth/register.post.js b/server/api/auth/register.post.js index bd12a38..1e2abc4 100644 --- a/server/api/auth/register.post.js +++ b/server/api/auth/register.post.js @@ -49,7 +49,7 @@ export default defineEventHandler(async (event) => { phone: phone || '', geburtsdatum, visibility: { - showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true + showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : false }, role: 'mitglied', active: false, // Requires admin approval @@ -80,4 +80,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/birthdays.get.js b/server/api/birthdays.get.js index 2b7b45b..335461b 100644 --- a/server/api/birthdays.get.js +++ b/server/api/birthdays.get.js @@ -72,14 +72,14 @@ export default defineEventHandler(async (event) => { : true if (!isAccepted) continue const vis = m.visibility || {} - const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) + const showBirthday = vis.showBirthday === true candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' }) } for (const u of registeredUsers) { if (!u.active || isHiddenUser(u)) continue const vis = u.visibility || {} - const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) + const showBirthday = vis.showBirthday === true candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' }) } diff --git a/server/api/contact.post.js b/server/api/contact.post.js index 1f84a92..69c34f9 100644 --- a/server/api/contact.post.js +++ b/server/api/contact.post.js @@ -1,6 +1,5 @@ import nodemailer from 'nodemailer' import { promises as fs } from 'fs' -import path from 'path' import { createContactRequest } from '../utils/contact-requests.js' import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js' import { sendNewContactRequestPush } from '../utils/push-notifications.js' @@ -24,17 +23,39 @@ async function loadConfig() { } } -async function collectRecipients(config) { - const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' +function envFlagEnabled(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()) +} - if (!isProduction) { +function shouldUseDeveloperRecipients() { + if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG) + return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test' +} + +async function collectRecipients(config) { + if (shouldUseDeveloperRecipients()) { return ['tsschulz@tsschulz.de'] } const recipients = [] - // Vorstand - if (config?.vorstand && typeof config.vorstand === 'object') { + // Vorstand: prefer active login users with the board role. + try { + const users = await readUsers() + for (const rawUser of users) { + if (!rawUser || rawUser.active === false || isHiddenUser(rawUser)) continue + const user = migrateUserRoles({ ...rawUser }) + const roles = Array.isArray(user.roles) ? user.roles : [] + if (roles.includes('vorstand') && user.email && String(user.email).trim()) { + recipients.push(String(user.email).trim()) + } + } + } catch (error) { + console.error('Fehler beim Laden der Vorstand-Empfänger aus Benutzerdaten:', error) + } + + // Fallback: legacy config.json Vorstand object. + if (recipients.length === 0 && config?.vorstand && typeof config.vorstand === 'object') { for (const member of Object.values(config.vorstand)) { if (member?.email && typeof member.email === 'string' && member.email.trim()) { recipients.push(member.email.trim()) @@ -73,10 +94,7 @@ async function collectRecipients(config) { if (config?.website?.verantwortlicher?.email) { return [config.website.verantwortlicher.email] } - if (process.env.SMTP_USER) { - return [process.env.SMTP_USER] - } - return ['j.dichmann@gmx.de'] + throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.') } function createTransporter() { diff --git a/server/api/members.get.js b/server/api/members.get.js index e588122..4237c1c 100644 --- a/server/api/members.get.js +++ b/server/api/members.get.js @@ -64,7 +64,8 @@ export default defineEventHandler(async (event) => { showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail), showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone), // Address remains private by default - showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress) + showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress), + showBirthday: vis.showBirthday === true } mergedMembers.push({ @@ -163,7 +164,8 @@ export default defineEventHandler(async (event) => { mergedMembers[matchedManualIndex].visibility = { showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail), showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone), - showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress) + showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress), + showBirthday: user.visibility.showBirthday === undefined ? vis.showBirthday === true : user.visibility.showBirthday === true } } } else { @@ -186,7 +188,8 @@ export default defineEventHandler(async (event) => { visibility: { showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail), showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone), - showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress) + showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress), + showBirthday: userVis.showBirthday === true }, notes: `Rolle(n): ${roles.join(', ')}`, source: 'login', @@ -226,7 +229,7 @@ export default defineEventHandler(async (event) => { const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail)) const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone)) const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress)) - const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true))) + const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && member.visibility?.showBirthday === true)) return { id: member.id, @@ -246,7 +249,7 @@ export default defineEventHandler(async (event) => { showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail), showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone), showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress), - showBirthday: visibility.showBirthday === undefined ? true : Boolean(visibility.showBirthday), + showBirthday: visibility.showBirthday === true, // Privileged viewers (vorstand) always see contact fields email: emailVisible ? member.email : undefined, phone: phoneVisible ? member.phone : undefined, diff --git a/server/api/members.post.js b/server/api/members.post.js index e2b3737..408c487 100644 --- a/server/api/members.post.js +++ b/server/api/members.post.js @@ -1,5 +1,22 @@ -import { getUserFromToken, hasAnyRole } from '../utils/auth.js' -import { saveMember } from '../utils/members.js' +import { getUserFromToken, hasAnyRole, readUsers, writeUsers, normalizeUserEmail } from '../utils/auth.js' +import { readMembers, saveMember } from '../utils/members.js' + +function requestedBirthdayVisibility(body) { + return body?.visibility?.showBirthday ?? body?.showBirthday +} + +function birthdayVisibilityIsTrue(value) { + return value === true || value === 'true' +} + +function resolveAdminBirthdayVisibility({ requested, existingManualMember, existingUser }) { + if (requested === false || requested === 'false') return false + + const existingValue = existingUser?.visibility?.showBirthday ?? existingManualMember?.visibility?.showBirthday + if (existingValue === true) return true + + return false +} export default defineEventHandler(async (event) => { try { @@ -39,7 +56,7 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event) - const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active } = body + const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active, visibility } = body if (!firstName || !lastName) { throw createError({ @@ -56,6 +73,23 @@ export default defineEventHandler(async (event) => { } try { + const [members, users] = await Promise.all([readMembers(), readUsers()]) + const normalizedEmail = normalizeUserEmail(email) + const existingManualMember = members.find(member => { + if (id && member.id === id) return true + return normalizedEmail && normalizeUserEmail(member.email) === normalizedEmail + }) + const userIndex = users.findIndex(user => { + if (id && user.id === id) return true + return normalizedEmail && normalizeUserEmail(user.email) === normalizedEmail + }) + const existingUser = userIndex !== -1 ? users[userIndex] : null + const nextShowBirthday = resolveAdminBirthdayVisibility({ + requested: requestedBirthdayVisibility(body), + existingManualMember, + existingUser + }) + await saveMember({ id: id || undefined, firstName, @@ -67,9 +101,21 @@ export default defineEventHandler(async (event) => { notes: notes || '', isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true', hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true', + visibility: { + ...(visibility && typeof visibility === 'object' ? visibility : {}), + showBirthday: nextShowBirthday + }, active: typeof active === 'boolean' ? active : true }) + if (userIndex !== -1 && (!birthdayVisibilityIsTrue(requestedBirthdayVisibility(body)) || existingUser?.visibility?.showBirthday === true)) { + users[userIndex].visibility = { + ...(users[userIndex].visibility || {}), + showBirthday: nextShowBirthday + } + await writeUsers(users) + } + return { success: true, message: 'Mitglied erfolgreich gespeichert.' @@ -98,4 +144,3 @@ export default defineEventHandler(async (event) => { }) } }) - diff --git a/server/api/profile.get.js b/server/api/profile.get.js index c645677..a557b88 100644 --- a/server/api/profile.get.js +++ b/server/api/profile.get.js @@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => { email: user.email, phone: user.phone || '', geburtsdatum: user.geburtsdatum || '', - visibility: Object.assign({ showBirthday: true }, (user.visibility || {})) + visibility: Object.assign({ showBirthday: false }, (user.visibility || {})) } } } catch (error) { diff --git a/server/set-all-birthday-visible.js b/server/set-all-birthday-visible.js deleted file mode 100644 index 8ca8d42..0000000 --- a/server/set-all-birthday-visible.js +++ /dev/null @@ -1,44 +0,0 @@ -// Script: set-all-birthday-visible.js -// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true - -const fs = require('fs') -const path = require('path') - -const membersPath = path.join(__dirname, 'data', 'members.json') - -let raw -try { - raw = fs.readFileSync(membersPath, 'utf8') -} catch (e) { - console.error('Fehler beim Lesen von members.json:', e) - process.exit(1) -} - -let members -try { - members = JSON.parse(raw) -} catch (e) { - console.error('Fehler beim Parsen von members.json:', e) - process.exit(1) -} - -if (!Array.isArray(members)) { - console.error('members.json ist kein Array!') - process.exit(1) -} - -let changed = 0 -for (const m of members) { - if (!m.visibility) m.visibility = {} - if (m.visibility.showBirthday !== true) { - m.visibility.showBirthday = true - changed++ - } -} - -if (changed > 0) { - fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8') - console.log(`Flag für ${changed} Mitglieder gesetzt.`) -} else { - console.log('Alle Mitglieder hatten das Flag bereits gesetzt.') -} diff --git a/server/set-all-birthday-visible.mjs b/server/set-all-birthday-visible.mjs deleted file mode 100644 index f07ac9f..0000000 --- a/server/set-all-birthday-visible.mjs +++ /dev/null @@ -1,33 +0,0 @@ -// Script: set-all-birthday-visible.mjs -// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true (mit Entschlüsselung) - -import { readMembers, writeMembers } from './utils/members.js'; -import dotenv from 'dotenv'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -dotenv.config({ path: path.resolve(process.cwd(), '.env') }); - -async function main() { - let members = await readMembers(); - if (!Array.isArray(members)) { - console.error('members.json ist kein Array!') - process.exit(1) - } - let changed = 0; - for (const m of members) { - if (!m.visibility) m.visibility = {}; - if (m.visibility.showBirthday !== true) { - m.visibility.showBirthday = true; - changed++; - } - } - if (changed > 0) { - await writeMembers(members); - console.log(`Flag für ${changed} Mitglieder gesetzt.`); - } else { - console.log('Alle Mitglieder hatten das Flag bereits gesetzt.'); - } -} - -main(); diff --git a/server/set-all-visibility-flags.mjs b/server/set-all-visibility-flags.mjs deleted file mode 100644 index 8f2428b..0000000 --- a/server/set-all-visibility-flags.mjs +++ /dev/null @@ -1,72 +0,0 @@ -// Script: set-all-visibility-flags.mjs -// Setzt für alle Mitglieder in allen relevanten Dateien alle visibility-Flags auf true (inkl. Entschlüsselung) - -import { readMembers, writeMembers } from './utils/members.js'; -import dotenv from 'dotenv'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import fs from 'fs/promises'; - -dotenv.config({ path: path.resolve(process.cwd(), '.env') }); - -const usersPath = path.resolve(process.cwd(), 'server/data/users.json'); - -async function updateVisibility(obj) { - let changed = 0; - if (Array.isArray(obj)) { - for (const m of obj) { - if (!m.visibility) m.visibility = {}; - if (m.visibility.showEmail !== true) { m.visibility.showEmail = true; changed++; } - if (m.visibility.showPhone !== true) { m.visibility.showPhone = true; changed++; } - if (m.visibility.showAddress !== true) { m.visibility.showAddress = true; changed++; } - if (m.visibility.showBirthday !== true) { m.visibility.showBirthday = true; changed++; } - } - } - return changed; -} - -async function updateUsersFile() { - let changed = 0; - try { - let raw = await fs.readFile(usersPath, 'utf8'); - let users; - if (raw.trim().startsWith('v2:')) { - // encrypted, try to use decryptObject from encryption.js - const { decryptObject } = await import('./utils/encryption.js'); - const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'; - users = decryptObject(raw, key); - } else { - users = JSON.parse(raw); - } - changed = await updateVisibility(users); - // write back (encrypted if vorher encrypted) - if (raw.trim().startsWith('v2:')) { - const { encryptObject } = await import('./utils/encryption.js'); - const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'; - const encrypted = encryptObject(users, key); - await fs.writeFile(usersPath, encrypted, 'utf8'); - } else { - await fs.writeFile(usersPath, JSON.stringify(users, null, 2), 'utf8'); - } - return changed; - } catch (e) { - console.error('Fehler beim Bearbeiten von users.json:', e); - return 0; - } -} - -async function main() { - let changedMembers = 0; - let changedUsers = 0; - // members.json (manuelle Mitglieder) - let members = await readMembers(); - changedMembers = await updateVisibility(members); - if (changedMembers > 0) { - await writeMembers(members); - } - // users.json (Login-System) - changedUsers = await updateUsersFile(); - console.log(`members.json: ${changedMembers} Änderungen, users.json: ${changedUsers} Änderungen`); -} - -main(); diff --git a/server/utils/email-service.js b/server/utils/email-service.js index bd68010..d0158bf 100644 --- a/server/utils/email-service.js +++ b/server/utils/email-service.js @@ -7,6 +7,7 @@ import nodemailer from 'nodemailer' import fs from 'fs/promises' import path from 'path' import { getServerDataPath } from './paths.js' +import { isHiddenUser, migrateUserRoles, readUsers } from './auth.js' /** * Gets the correct data path for config files @@ -34,23 +35,45 @@ async function loadConfig() { } } +function envFlagEnabled(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()) +} + +function shouldUseDeveloperRecipients() { + if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG) + return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test' +} + /** * Gets email recipients based on membership type and environment * @param {Object} data - Form data * @param {Object} config - Configuration * @returns {Array} Email addresses */ -function getEmailRecipients(data, config) { - const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' - - if (!isProduction) { +async function collectBoardUserRecipients() { + try { + const users = await readUsers() + return users + .filter(user => user && user.active !== false && !isHiddenUser(user)) + .map(user => migrateUserRoles({ ...user })) + .filter(user => Array.isArray(user.roles) && user.roles.includes('vorstand')) + .map(user => String(user.email || '').trim()) + .filter(Boolean) + } catch (error) { + console.error('Could not load board recipients from users.json:', error.message || error) + return [] + } +} + +async function getEmailRecipients(data, config) { + if (shouldUseDeveloperRecipients()) { return ['tsschulz@tsschulz.de'] } - const recipients = [] + const recipients = await collectBoardUserRecipients() - // Config uses a 'vorstand' object with nested roles; collect all emails - if (config.vorstand && typeof config.vorstand === 'object') { + // Fallback for legacy installations where Vorstand members are only configured in config.json. + if (recipients.length === 0 && config.vorstand && typeof config.vorstand === 'object') { Object.values(config.vorstand).forEach((member) => { if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') { recipients.push(member.email.trim()) @@ -59,7 +82,7 @@ function getEmailRecipients(data, config) { } // For minors, also add first trainer email if configured (trainer is an array) - if (!data.isVolljaehrig && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) { + if (data.isVolljaehrig === false && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) { recipients.push(config.trainer[0].email) } @@ -69,11 +92,11 @@ function getEmailRecipients(data, config) { if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) { recipients.push(config.website.verantwortlicher.email) } else { - recipients.push('tsschulz@tsschulz.de') + throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.') } } - return recipients + return [...new Set(recipients)] } /** @@ -111,7 +134,7 @@ function createTransporter() { export async function sendMembershipEmail(data, pdfPath) { try { const config = await loadConfig() - const recipients = getEmailRecipients(data, config) + const recipients = await getEmailRecipients(data, config) // Create transporter const transporter = createTransporter() @@ -167,7 +190,7 @@ Das ausgefüllte Formular ist als Anhang verfügbar.` export async function sendRegistrationNotification(data) { try { const config = await loadConfig() - const recipients = getEmailRecipients(data, config) + const recipients = await getEmailRecipients(data, config) // Create transporter const transporter = createTransporter() diff --git a/server/utils/notification-scheduler.js b/server/utils/notification-scheduler.js index 6a285e1..8bc1f67 100644 --- a/server/utils/notification-scheduler.js +++ b/server/utils/notification-scheduler.js @@ -276,12 +276,23 @@ function parseBirthday(value) { return null } +function hasBirthdayNotificationConsent(person) { + return person?.visibility?.showBirthday === true || person?.showBirthday === true +} + +function formatBirthdaySummary(names) { + const visibleNames = names.map(name => String(name || '').trim()).filter(Boolean) + if (visibleNames.length === 1) return `${visibleNames[0]} hat heute Geburtstag.` + return `Geburtstage heute: ${visibleNames.slice(0, 5).join(', ')}${visibleNames.length > 5 ? ` und ${visibleNames.length - 5} weitere` : ''}.` +} + async function birthdaysOn(dateKey) { const [, month, day] = dateKey.split('-').map(Number) const [manualMembers, users] = await Promise.all([readMembers(), readUsers()]) const people = [] for (const member of manualMembers) { if (member?.active === false) continue + if (!hasBirthdayNotificationConsent(member)) continue const birthday = parseBirthday(member.geburtsdatum || member.birthday) if (birthday?.month === month && birthday?.day === day) { people.push(String(member.name || `${member.firstName || ''} ${member.lastName || ''}`.trim()).trim()) @@ -289,7 +300,7 @@ async function birthdaysOn(dateKey) { } for (const user of users) { if (isHiddenUser(user) || user?.active === false) continue - if (user.visibility?.showBirthday === false) continue + if (!hasBirthdayNotificationConsent(user)) continue const birthday = parseBirthday(user.geburtsdatum || user.birthday) if (birthday?.month === month && birthday?.day === day) { people.push(userDisplayName(user)) @@ -397,7 +408,7 @@ export async function runNotificationSchedulerTick(now = new Date()) { results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({ title: 'Geburtstage heute', - body: todaysBirthdays.length === 1 ? `${todaysBirthdays[0]} hat heute Geburtstag.` : `${todaysBirthdays.length} Mitglieder haben heute Geburtstag.`, + body: formatBirthdaySummary(todaysBirthdays), data: { type: 'birthdays', date: dateKey }, predicate: (_user, settings) => settings.notificationTime === time && settings.birthdays, failureLabel: 'FCM Geburtstags-Push' diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js index 970f279..ef6e62b 100644 --- a/server/utils/push-notifications.js +++ b/server/utils/push-notifications.js @@ -132,6 +132,10 @@ async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, } } +function isStaleFcmTokenError(error) { + return /UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error?.message || error || '')) +} + function notificationIdFor(value) { return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() } @@ -185,13 +189,14 @@ export async function sendPushToUsers({ title, body, data = {}, predicate, bodyF sent += 1 validTokens.push(entry) } catch (error) { - failed += 1 - console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message }) - if (/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message)) === false) { - validTokens.push(entry) - } else { + if (isStaleFcmTokenError(error)) { removed += 1 changed = true + console.warn('FCM Push-Token entfernt:', { failureLabel, reason: error.message }) + } else { + failed += 1 + console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message }) + validTokens.push(entry) } } } diff --git a/tests/email-service.spec.ts b/tests/email-service.spec.ts new file mode 100644 index 0000000..8c6219d --- /dev/null +++ b/tests/email-service.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs/promises' + +vi.mock('nodemailer', () => { + const sendMail = vi.fn().mockResolvedValue({ messageId: 'test-message' }) + const createTransport = vi.fn(() => ({ sendMail })) + return { + default: { createTransport }, + createTransport + } +}) + +vi.mock('../server/utils/auth.js', () => ({ + readUsers: vi.fn(), + migrateUserRoles: vi.fn((user) => { + if (!user) return user + if (Array.isArray(user.roles)) return user + if (user.role) { + user.roles = [user.role] + delete user.role + } else { + user.roles = ['mitglied'] + } + return user + }), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') +})) + +const nodemailer = await import('nodemailer') +const authUtils = await import('../server/utils/auth.js') +const emailService = await import('../server/utils/email-service.js') + +describe('Email service recipients', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + process.env.SMTP_USER = 'smtp@example.com' + process.env.SMTP_PASS = 'smtp-password' + authUtils.readUsers.mockResolvedValue([]) + }) + + afterEach(() => { + delete process.env.SMTP_USER + delete process.env.SMTP_PASS + delete process.env.NODE_ENV + delete process.env.APP_ENV + delete process.env.DEBUG + }) + + it('sendet bei DEBUG=FALSE in production an Vorstand statt Entwickleradresse', async () => { + process.env.NODE_ENV = 'production' + process.env.APP_ENV = 'test' + process.env.DEBUG = 'FALSE' + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'vorstand@example.com' } + } + })) + + await emailService.sendRegistrationNotification({ + name: 'Max Muster', + email: 'max@example.com', + phone: '069123456' + }) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + to: 'vorstand@example.com' + })) + expect(transporter.sendMail.mock.calls[0][0].to).not.toContain('tsschulz@tsschulz.de') + }) + + it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => { + process.env.NODE_ENV = 'production' + process.env.DEBUG = 'FALSE' + authUtils.readUsers.mockResolvedValue([ + { id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true }, + { id: '2', email: 'inaktiv@example.com', roles: ['vorstand'], active: false }, + { id: '3', email: 'mitglied@example.com', roles: ['mitglied'], active: true } + ]) + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'config-vorstand@example.com' } + } + })) + + await emailService.sendRegistrationNotification({ + name: 'Max Muster', + email: 'max@example.com' + }) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + const to = transporter.sendMail.mock.calls[0][0].to + expect(to).toBe('rolle-vorstand@example.com') + expect(to).not.toContain('config-vorstand@example.com') + expect(to).not.toContain('inaktiv@example.com') + }) + + it('sendet nur bei explizitem DEBUG=true an die Entwickleradresse', async () => { + process.env.NODE_ENV = 'production' + process.env.DEBUG = 'true' + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'vorstand@example.com' } + } + })) + + await emailService.sendRegistrationNotification({ + name: 'Max Muster', + email: 'max@example.com' + }) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + expect(transporter.sendMail.mock.calls[0][0].to).toBe('tsschulz@tsschulz.de') + }) +}) diff --git a/tests/members-endpoints.spec.ts b/tests/members-endpoints.spec.ts index 6ec1c9e..308a492 100644 --- a/tests/members-endpoints.spec.ts +++ b/tests/members-endpoints.spec.ts @@ -51,12 +51,13 @@ import membersGetHandler from '../server/api/members.get.js' import membersPostHandler from '../server/api/members.post.js' import membersDeleteHandler from '../server/api/members.delete.js' import membersBulkHandler from '../server/api/members/bulk.post.js' -import membersBulkHandler from '../server/api/members/bulk.post.js' import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js' describe('Members API Endpoints', () => { beforeEach(() => { vi.clearAllMocks() + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([]) }) describe('GET /api/members', () => { @@ -100,6 +101,38 @@ describe('Members API Endpoints', () => { expect(response.members[0].name).toBe('Anna Muster') }) + it('liefert Geburtstags-Sichtbarkeit für Admin/Vorstand-Bearbeitung', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + memberUtils.readMembers.mockResolvedValue([ + { id: 'm1', firstName: 'Anna', lastName: 'Muster', geburtsdatum: '2000-01-01', visibility: { showBirthday: false } } + ]) + authUtils.readUsers.mockResolvedValue([]) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' }) + + const response = await membersGetHandler(event) + + expect(response.members).toHaveLength(1) + expect(response.members[0].showBirthday).toBe(false) + }) + + it('uebernimmt Geburtstags-Sichtbarkeit vom Login-Benutzer beim Merge', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + memberUtils.readMembers.mockResolvedValue([ + { id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de', geburtsdatum: '2000-01-01', visibility: { showBirthday: true } } + ]) + authUtils.readUsers.mockResolvedValue([ + { id: 'u1', name: 'Anna Muster', email: 'anna@club.de', active: true, visibility: { showBirthday: false } } + ]) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' }) + + const response = await membersGetHandler(event) + + expect(response.members).toHaveLength(1) + expect(response.members[0].showBirthday).toBe(false) + }) + it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) @@ -159,6 +192,8 @@ describe('Members API Endpoints', () => { const event = createEvent({ cookies: { auth_token: 'token' } }) mockSuccessReadBody(baseBody) authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' }) + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([]) memberUtils.saveMember.mockResolvedValue(true) const response = await membersPostHandler(event) @@ -168,6 +203,76 @@ describe('Members API Endpoints', () => { })) }) + it('speichert Geburtstags-Sichtbarkeit für manuelle Mitglieder', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + mockSuccessReadBody({ ...baseBody, showBirthday: false }) + authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' }) + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([ + { id: 'manual-1', firstName: 'Lisa', lastName: 'Beispiel', email: 'lisa@example.com', visibility: { showBirthday: true } } + ]) + memberUtils.saveMember.mockResolvedValue(true) + + const response = await membersPostHandler(event) + + expect(response.success).toBe(true) + expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({ + visibility: expect.objectContaining({ showBirthday: false }) + })) + expect(authUtils.writeUsers).not.toHaveBeenCalled() + }) + + it('kann Geburtstags-Sichtbarkeit auch am Login-Benutzer ausschalten', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + mockSuccessReadBody({ + id: 'user-1', + ...baseBody, + email: 'lisa@example.com', + visibility: { showBirthday: false } + }) + authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' }) + memberUtils.readMembers.mockResolvedValue([]) + authUtils.readUsers.mockResolvedValue([ + { id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: true, showEmail: true } } + ]) + authUtils.writeUsers.mockResolvedValue(undefined) + memberUtils.saveMember.mockResolvedValue(true) + + const response = await membersPostHandler(event) + + expect(response.success).toBe(true) + expect(authUtils.writeUsers).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'user-1', + visibility: expect.objectContaining({ showBirthday: false, showEmail: true }) + }) + ]) + }) + + it('darf Geburtstags-Sichtbarkeit nicht für Login-Benutzer einschalten', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + mockSuccessReadBody({ + id: 'user-1', + ...baseBody, + email: 'lisa@example.com', + visibility: { showBirthday: true } + }) + authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' }) + memberUtils.readMembers.mockResolvedValue([]) + authUtils.readUsers.mockResolvedValue([ + { id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: false, showEmail: true } } + ]) + memberUtils.saveMember.mockResolvedValue(true) + + const response = await membersPostHandler(event) + + expect(response.success).toBe(true) + expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({ + visibility: expect.objectContaining({ showBirthday: false }) + })) + expect(authUtils.writeUsers).not.toHaveBeenCalled() + }) + it('erlaubt vorstand beim Speichern', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) mockSuccessReadBody(baseBody) @@ -187,6 +292,7 @@ describe('Members API Endpoints', () => { email: 'lisa@example.com' }) authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' }) + authUtils.readUsers.mockResolvedValue([]) memberUtils.saveMember.mockResolvedValue(true) const response = await membersPostHandler(event) diff --git a/tests/notification-scheduler.spec.ts b/tests/notification-scheduler.spec.ts new file mode 100644 index 0000000..dc55cc0 --- /dev/null +++ b/tests/notification-scheduler.spec.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs/promises' + +vi.mock('../server/utils/auth.js', () => ({ + readUsers: vi.fn(), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true) +})) + +vi.mock('../server/utils/members.js', () => ({ + readMembers: vi.fn() +})) + +vi.mock('../server/utils/termine.js', () => ({ + readTermine: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../server/utils/news.js', () => ({ + readNews: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../server/utils/spielplan-data.js', () => ({ + getDefaultSpielplanSeason: vi.fn().mockResolvedValue('25--26'), + readSpielplanData: vi.fn().mockResolvedValue({ data: [] }) +})) + +vi.mock('../server/utils/push-notifications.js', () => ({ + sendPushToUsers: vi.fn().mockResolvedValue({ sent: 1, failed: 0, removed: 0, recipients: 1, tokenCount: 1, skipped: false }) +})) + +vi.mock('../server/utils/logger.js', () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn() +})) + +const authUtils = await import('../server/utils/auth.js') +const memberUtils = await import('../server/utils/members.js') +const pushUtils = await import('../server/utils/push-notifications.js') + +const { runNotificationSchedulerTick } = await import('../server/utils/notification-scheduler.js') + +const schedulerNow = new Date('2026-06-14T07:00:00.000Z') +const recipient = { + id: 'recipient', + name: 'Push Empfaenger', + active: true, + notificationSettings: { + birthdays: true, + notificationTime: '09:00' + } +} + +describe('Notification Scheduler', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined) + vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined) + memberUtils.readMembers.mockResolvedValue([]) + authUtils.readUsers.mockResolvedValue([recipient]) + }) + + it('sendet Geburtstags-Push nur fuer Mitglieder mit expliziter Geburtstagsfreigabe', async () => { + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Erlaubt', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: true } }, + { firstName: 'Privat', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: false } }, + { firstName: 'Unklar', lastName: 'Person', active: true, geburtsdatum: '1990-06-14' } + ]) + + await runNotificationSchedulerTick(schedulerNow) + + expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1) + expect(pushUtils.sendPushToUsers).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Geburtstage heute', + body: 'Erlaubt Person hat heute Geburtstag.', + data: { type: 'birthdays', date: '2026-06-14' } + })) + }) + + it('nennt bei mehreren Geburtstags-Pushes nur Namen und kein Alter', async () => { + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Anna', lastName: 'Beispiel', active: true, geburtsdatum: '1980-06-14', visibility: { showBirthday: true } }, + { firstName: 'Bert', lastName: 'Beispiel', active: true, geburtsdatum: '2010-06-14', visibility: { showBirthday: true } } + ]) + + await runNotificationSchedulerTick(schedulerNow) + + const payload = pushUtils.sendPushToUsers.mock.calls[0][0] + expect(payload.body).toBe('Geburtstage heute: Anna Beispiel, Bert Beispiel.') + expect(payload.body).not.toMatch(/\b\d+\b/) + expect(payload.body).not.toContain('Jahre') + }) +}) diff --git a/tests/public-endpoints.spec.ts b/tests/public-endpoints.spec.ts index 60403bd..985a905 100644 --- a/tests/public-endpoints.spec.ts +++ b/tests/public-endpoints.spec.ts @@ -16,8 +16,25 @@ vi.mock('../server/utils/news.js', () => ({ readNews: vi.fn() })) +vi.mock('../server/utils/auth.js', () => ({ + readUsers: vi.fn(), + migrateUserRoles: vi.fn((user) => { + if (!user) return user + if (Array.isArray(user.roles)) return user + if (user.role) { + user.roles = [user.role] + delete user.role + } else { + user.roles = ['mitglied'] + } + return user + }), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') +})) + const nodemailer = await import('nodemailer') const newsUtils = await import('../server/utils/news.js') +const authUtils = await import('../server/utils/auth.js') import contactHandler from '../server/api/contact.post.js' import galerieHandler from '../server/api/galerie.get.js' @@ -29,14 +46,17 @@ describe('Öffentliche API-Endpunkte', () => { afterEach(() => { delete process.env.NODE_ENV delete process.env.APP_ENV + delete process.env.DEBUG }) beforeEach(() => { // Setze SMTP-Credentials für Tests process.env.SMTP_USER = 'test@example.com' process.env.SMTP_PASS = 'test-password' + authUtils.readUsers.mockResolvedValue([]) vi.restoreAllMocks() vi.clearAllMocks() + authUtils.readUsers.mockResolvedValue([]) }) describe('POST /api/contact', () => { @@ -78,6 +98,53 @@ describe('Öffentliche API-Endpunkte', () => { to: 'tsschulz@tsschulz.de' })) }) + + it('sendet bei DEBUG=FALSE an konfigurierte Empfänger', async () => { + process.env.NODE_ENV = 'production' + process.env.APP_ENV = 'test' + process.env.DEBUG = 'FALSE' + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'vorstand@example.com' } + } + })) + + const event = createEvent() + mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' }) + + await contactHandler(event) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + const to = transporter.sendMail.mock.calls[0][0].to + expect(to).toContain('vorstand@example.com') + expect(to).not.toContain('tsschulz@tsschulz.de') + }) + + it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => { + process.env.NODE_ENV = 'production' + process.env.DEBUG = 'FALSE' + authUtils.readUsers.mockResolvedValue([ + { id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true }, + { id: '2', email: 'hidden@example.com', roles: ['vorstand'], active: true, hidden: true }, + { id: '3', email: 'trainer@example.com', roles: ['trainer'], active: true } + ]) + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'config-vorstand@example.com' } + } + })) + + const event = createEvent() + mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' }) + + await contactHandler(event) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + const to = transporter.sendMail.mock.calls[0][0].to + expect(to).toContain('rolle-vorstand@example.com') + expect(to).not.toContain('config-vorstand@example.com') + expect(to).not.toContain('hidden@example.com') + }) }) describe('GET /api/galerie', () => {