Benachrichtigungen erweitert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m53s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

Emails korrigiert
This commit is contained in:
Torsten Schulz (local)
2026-06-14 01:05:19 +02:00
parent 4b699de853
commit 77aabef4a9
32 changed files with 646 additions and 920 deletions

7
.gitleaks.toml Normal file
View File

@@ -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$''',
]

View File

@@ -88,6 +88,13 @@ android {
versionName = androidVersionName versionName = androidVersionName
} }
lint {
disable += setOf(
"AutoboxingStateCreation",
"MutableCollectionMutableState",
)
}
signingConfigs { signingConfigs {
create("release") { create("release") {
if (hasReleaseSigning) { if (hasReleaseSigning) {

View File

@@ -232,7 +232,7 @@ data class ProfileVisibilityDto(
val showEmail: Boolean = true, val showEmail: Boolean = true,
val showPhone: Boolean = true, val showPhone: Boolean = true,
val showAddress: Boolean = false, val showAddress: Boolean = false,
val showBirthday: Boolean = true, val showBirthday: Boolean = false,
) )
data class ProfileUserDto( data class ProfileUserDto(
val id: String? = null, val id: String? = null,
@@ -329,6 +329,7 @@ data class MemberDto(
val editable: Boolean = false, val editable: Boolean = false,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
val loginRoles: List<String> = emptyList(), val loginRoles: List<String> = emptyList(),
) )
data class MembersResponse( data class MembersResponse(
@@ -729,6 +730,7 @@ interface ApiService {
val notes: String? = null, val notes: String? = null,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
) )
data class BulkImportRequest(val members: List<Map<String, String>>) data class BulkImportRequest(val members: List<Map<String, String>>)

View File

@@ -19,7 +19,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class ConnectivityMonitor @Inject constructor( class ConnectivityMonitor @Inject constructor(
@ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _online = MutableStateFlow(hasInternetAccess()) private val _online = MutableStateFlow(hasInternetAccess())

View File

@@ -51,8 +51,12 @@ object HarheimerNotifications {
.setContentIntent(createContentIntent(context, notificationId, data)) .setContentIntent(createContentIntent(context, notificationId, data))
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
return try {
NotificationManagerCompat.from(context).notify(notificationId, notification) NotificationManagerCompat.from(context).notify(notificationId, notification)
return true true
} catch (_: SecurityException) {
false
}
} }
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent { private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {

View File

@@ -90,7 +90,7 @@ class HomeViewModel @Inject constructor(
loading = false, loading = false,
heroImageUrl = data.heroImageUrl, heroImageUrl = data.heroImageUrl,
termine = data.termine termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true } .filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
.sortedBy { it.asDateTime() } .sortedBy { it.asDateTime() }
.take(3), .take(3),
spiele = data.spiele spiele = data.spiele

View File

@@ -59,7 +59,7 @@ data class RegisterFormState(
val birthDate: String = "", val birthDate: String = "",
val password: String = "", val password: String = "",
val passwordRepeat: String = "", val passwordRepeat: String = "",
val showBirthday: Boolean = true, val showBirthday: Boolean = false,
) )
data class RegisterUiState( data class RegisterUiState(

View File

@@ -24,7 +24,7 @@ data class ProfileFormState(
val showEmail: Boolean = true, val showEmail: Boolean = true,
val showPhone: Boolean = true, val showPhone: Boolean = true,
val showAddress: Boolean = false, val showAddress: Boolean = false,
val showBirthday: Boolean = true, val showBirthday: Boolean = false,
val currentPassword: String = "", val currentPassword: String = "",
val newPassword: String = "", val newPassword: String = "",
val confirmPassword: String = "", val confirmPassword: String = "",

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=25 ANDROID_VERSION_CODE=26
ANDROID_VERSION_NAME=0.9.20 ANDROID_VERSION_NAME=0.9.21
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

View File

@@ -550,6 +550,25 @@
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="showBirthday"
v-model="formData.showBirthday"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving || !canDisableBirthdayVisibility"
>
<label
for="showBirthday"
class="ml-2 block text-sm font-medium text-gray-700"
>
Geburtstag in Mitgliederliste und Benachrichtigungen anzeigen
</label>
</div>
<p class="-mt-3 text-xs text-gray-500">
Admins und Vorstand können die Sichtbarkeit nur ausschalten. Einschalten kann nur das Mitglied selbst im Profil.
</p>
<div <div
v-if="errorMessage" v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
@@ -846,7 +865,8 @@ const formData = ref({
address: '', address: '',
notes: '', notes: '',
isMannschaftsspieler: false, isMannschaftsspieler: false,
hasHallKey: false hasHallKey: false,
showBirthday: false
}) })
const canEdit = computed(() => { const canEdit = computed(() => {
@@ -861,6 +881,10 @@ const isBirthdateRequired = computed(() => {
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum) return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
}) })
const canDisableBirthdayVisibility = computed(() => {
return editingMember.value?.showBirthday === true
})
const filteredMembers = computed(() => { const filteredMembers = computed(() => {
if (!filterHasHallKey.value) return members.value if (!filterHasHallKey.value) return members.value
return members.value.filter(member => member.hasHallKey) return members.value.filter(member => member.hasHallKey)
@@ -880,7 +904,7 @@ const loadMembers = async () => {
const openAddModal = () => { const openAddModal = () => {
editingMember.value = null 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 showModal.value = true
errorMessage.value = '' errorMessage.value = ''
} }
@@ -896,7 +920,8 @@ const openEditModal = (member) => {
address: member.address || '', address: member.address || '',
notes: member.notes || '', notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true, isMannschaftsspieler: member.isMannschaftsspieler === true,
hasHallKey: member.hasHallKey === true hasHallKey: member.hasHallKey === true,
showBirthday: member.showBirthday === true
} }
showModal.value = true showModal.value = true
errorMessage.value = '' errorMessage.value = ''
@@ -914,7 +939,14 @@ const saveMember = async () => {
try { try {
await $fetch('/api/members', { await $fetch('/api/members', {
method: 'POST', 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() closeModal()
await loadMembers() await loadMembers()

View File

@@ -365,7 +365,7 @@ const visibility = ref({
showEmail: true, showEmail: true,
showPhone: true, showPhone: true,
showAddress: false, showAddress: false,
showBirthday: true showBirthday: false
}) })
const passwordData = ref({ const passwordData = ref({
@@ -568,4 +568,3 @@ useHead({
title: 'Mein Profil - Harheimer TC', title: 'Mein Profil - Harheimer TC',
}) })
</script> </script>

View File

@@ -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.')

View File

@@ -1,6 +1,5 @@
import { verifyRegistrationResponse } from '@simplewebauthn/server' import { verifyRegistrationResponse } from '@simplewebauthn/server'
import crypto from 'crypto' import crypto from 'crypto'
import nodemailer from 'nodemailer'
import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js' import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js'
import { getWebAuthnConfig } from '../../utils/webauthn-config.js' import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
import { consumePreRegistration } from '../../utils/webauthn-challenges.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 { writeAuditLog } from '../../utils/audit-log.js'
import { assertPasswordNotPwned } from '../../utils/hibp.js' import { assertPasswordNotPwned } from '../../utils/hibp.js'
import { getClientIp } from '../../utils/rate-limit.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 // Local fallback for Nitro globals when lint/run env doesn't provide them
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET')) 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 }) 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 { try {
const smtpUser = process.env.SMTP_USER await sendRegistrationNotification({ name, email, phone })
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: `
<h2>Neue Registrierung (Passkey)</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${name}</li>
<li><strong>E-Mail:</strong> ${email}</li>
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
<li><strong>Login:</strong> Passkey${password ? ' + Passwort (Fallback)' : ' (ohne Passwort)'}</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
})
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: email,
subject: 'Registrierung erhalten - Harheimer TC',
html: `
<h2>Registrierung erhalten</h2>
<p>Hallo ${name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
})
}
} catch (emailError) { } catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError) console.error('E-Mail-Versand fehlgeschlagen:', emailError)
} }

View File

@@ -49,7 +49,7 @@ export default defineEventHandler(async (event) => {
phone: phone || '', phone: phone || '',
geburtsdatum, geburtsdatum,
visibility: { visibility: {
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : false
}, },
role: 'mitglied', role: 'mitglied',
active: false, // Requires admin approval active: false, // Requires admin approval
@@ -80,4 +80,3 @@ export default defineEventHandler(async (event) => {
throw error throw error
} }
}) })

View File

@@ -72,14 +72,14 @@ export default defineEventHandler(async (event) => {
: true : true
if (!isAccepted) continue if (!isAccepted) continue
const vis = m.visibility || {} 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' }) candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' })
} }
for (const u of registeredUsers) { for (const u of registeredUsers) {
if (!u.active || isHiddenUser(u)) continue if (!u.active || isHiddenUser(u)) continue
const vis = u.visibility || {} 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' }) candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
} }

View File

@@ -1,6 +1,5 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path'
import { createContactRequest } from '../utils/contact-requests.js' import { createContactRequest } from '../utils/contact-requests.js'
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js' import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
import { sendNewContactRequestPush } from '../utils/push-notifications.js' import { sendNewContactRequestPush } from '../utils/push-notifications.js'
@@ -24,17 +23,39 @@ async function loadConfig() {
} }
} }
async function collectRecipients(config) { function envFlagEnabled(value) {
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' 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'] return ['tsschulz@tsschulz.de']
} }
const recipients = [] const recipients = []
// Vorstand // Vorstand: prefer active login users with the board role.
if (config?.vorstand && typeof config.vorstand === 'object') { 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)) { for (const member of Object.values(config.vorstand)) {
if (member?.email && typeof member.email === 'string' && member.email.trim()) { if (member?.email && typeof member.email === 'string' && member.email.trim()) {
recipients.push(member.email.trim()) recipients.push(member.email.trim())
@@ -73,10 +94,7 @@ async function collectRecipients(config) {
if (config?.website?.verantwortlicher?.email) { if (config?.website?.verantwortlicher?.email) {
return [config.website.verantwortlicher.email] return [config.website.verantwortlicher.email]
} }
if (process.env.SMTP_USER) { throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
return [process.env.SMTP_USER]
}
return ['j.dichmann@gmx.de']
} }
function createTransporter() { function createTransporter() {

View File

@@ -64,7 +64,8 @@ export default defineEventHandler(async (event) => {
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail), showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone), showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
// Address remains private by default // 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({ mergedMembers.push({
@@ -163,7 +164,8 @@ export default defineEventHandler(async (event) => {
mergedMembers[matchedManualIndex].visibility = { mergedMembers[matchedManualIndex].visibility = {
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail), showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone), 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 { } else {
@@ -186,7 +188,8 @@ export default defineEventHandler(async (event) => {
visibility: { visibility: {
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail), showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone), 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(', ')}`, notes: `Rolle(n): ${roles.join(', ')}`,
source: 'login', source: 'login',
@@ -226,7 +229,7 @@ export default defineEventHandler(async (event) => {
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail)) const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone)) const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress)) 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 { return {
id: member.id, id: member.id,
@@ -246,7 +249,7 @@ export default defineEventHandler(async (event) => {
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail), showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone), showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress), 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 // Privileged viewers (vorstand) always see contact fields
email: emailVisible ? member.email : undefined, email: emailVisible ? member.email : undefined,
phone: phoneVisible ? member.phone : undefined, phone: phoneVisible ? member.phone : undefined,

View File

@@ -1,5 +1,22 @@
import { getUserFromToken, hasAnyRole } from '../utils/auth.js' import { getUserFromToken, hasAnyRole, readUsers, writeUsers, normalizeUserEmail } from '../utils/auth.js'
import { saveMember } from '../utils/members.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) => { export default defineEventHandler(async (event) => {
try { try {
@@ -39,7 +56,7 @@ export default defineEventHandler(async (event) => {
} }
const body = await readBody(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) { if (!firstName || !lastName) {
throw createError({ throw createError({
@@ -56,6 +73,23 @@ export default defineEventHandler(async (event) => {
} }
try { 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({ await saveMember({
id: id || undefined, id: id || undefined,
firstName, firstName,
@@ -67,9 +101,21 @@ export default defineEventHandler(async (event) => {
notes: notes || '', notes: notes || '',
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true', isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true', hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true',
visibility: {
...(visibility && typeof visibility === 'object' ? visibility : {}),
showBirthday: nextShowBirthday
},
active: typeof active === 'boolean' ? active : true 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 { return {
success: true, success: true,
message: 'Mitglied erfolgreich gespeichert.' message: 'Mitglied erfolgreich gespeichert.'
@@ -98,4 +144,3 @@ export default defineEventHandler(async (event) => {
}) })
} }
}) })

View File

@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
email: user.email, email: user.email,
phone: user.phone || '', phone: user.phone || '',
geburtsdatum: user.geburtsdatum || '', geburtsdatum: user.geburtsdatum || '',
visibility: Object.assign({ showBirthday: true }, (user.visibility || {})) visibility: Object.assign({ showBirthday: false }, (user.visibility || {}))
} }
} }
} catch (error) { } catch (error) {

View File

@@ -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.')
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -7,6 +7,7 @@ import nodemailer from 'nodemailer'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getServerDataPath } from './paths.js' import { getServerDataPath } from './paths.js'
import { isHiddenUser, migrateUserRoles, readUsers } from './auth.js'
/** /**
* Gets the correct data path for config files * 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 * Gets email recipients based on membership type and environment
* @param {Object} data - Form data * @param {Object} data - Form data
* @param {Object} config - Configuration * @param {Object} config - Configuration
* @returns {Array<string>} Email addresses * @returns {Array<string>} Email addresses
*/ */
function getEmailRecipients(data, config) { async function collectBoardUserRecipients() {
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' 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 []
}
}
if (!isProduction) { async function getEmailRecipients(data, config) {
if (shouldUseDeveloperRecipients()) {
return ['tsschulz@tsschulz.de'] return ['tsschulz@tsschulz.de']
} }
const recipients = [] const recipients = await collectBoardUserRecipients()
// Config uses a 'vorstand' object with nested roles; collect all emails // Fallback for legacy installations where Vorstand members are only configured in config.json.
if (config.vorstand && typeof config.vorstand === 'object') { if (recipients.length === 0 && config.vorstand && typeof config.vorstand === 'object') {
Object.values(config.vorstand).forEach((member) => { Object.values(config.vorstand).forEach((member) => {
if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') { if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') {
recipients.push(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) // 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) recipients.push(config.trainer[0].email)
} }
@@ -69,11 +92,11 @@ function getEmailRecipients(data, config) {
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) { if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
recipients.push(config.website.verantwortlicher.email) recipients.push(config.website.verantwortlicher.email)
} else { } 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) { export async function sendMembershipEmail(data, pdfPath) {
try { try {
const config = await loadConfig() const config = await loadConfig()
const recipients = getEmailRecipients(data, config) const recipients = await getEmailRecipients(data, config)
// Create transporter // Create transporter
const transporter = createTransporter() const transporter = createTransporter()
@@ -167,7 +190,7 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
export async function sendRegistrationNotification(data) { export async function sendRegistrationNotification(data) {
try { try {
const config = await loadConfig() const config = await loadConfig()
const recipients = getEmailRecipients(data, config) const recipients = await getEmailRecipients(data, config)
// Create transporter // Create transporter
const transporter = createTransporter() const transporter = createTransporter()

View File

@@ -276,12 +276,23 @@ function parseBirthday(value) {
return null 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) { async function birthdaysOn(dateKey) {
const [, month, day] = dateKey.split('-').map(Number) const [, month, day] = dateKey.split('-').map(Number)
const [manualMembers, users] = await Promise.all([readMembers(), readUsers()]) const [manualMembers, users] = await Promise.all([readMembers(), readUsers()])
const people = [] const people = []
for (const member of manualMembers) { for (const member of manualMembers) {
if (member?.active === false) continue if (member?.active === false) continue
if (!hasBirthdayNotificationConsent(member)) continue
const birthday = parseBirthday(member.geburtsdatum || member.birthday) const birthday = parseBirthday(member.geburtsdatum || member.birthday)
if (birthday?.month === month && birthday?.day === day) { if (birthday?.month === month && birthday?.day === day) {
people.push(String(member.name || `${member.firstName || ''} ${member.lastName || ''}`.trim()).trim()) people.push(String(member.name || `${member.firstName || ''} ${member.lastName || ''}`.trim()).trim())
@@ -289,7 +300,7 @@ async function birthdaysOn(dateKey) {
} }
for (const user of users) { for (const user of users) {
if (isHiddenUser(user) || user?.active === false) continue if (isHiddenUser(user) || user?.active === false) continue
if (user.visibility?.showBirthday === false) continue if (!hasBirthdayNotificationConsent(user)) continue
const birthday = parseBirthday(user.geburtsdatum || user.birthday) const birthday = parseBirthday(user.geburtsdatum || user.birthday)
if (birthday?.month === month && birthday?.day === day) { if (birthday?.month === month && birthday?.day === day) {
people.push(userDisplayName(user)) 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({ results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({
title: 'Geburtstage heute', 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 }, data: { type: 'birthdays', date: dateKey },
predicate: (_user, settings) => settings.notificationTime === time && settings.birthdays, predicate: (_user, settings) => settings.notificationTime === time && settings.birthdays,
failureLabel: 'FCM Geburtstags-Push' failureLabel: 'FCM Geburtstags-Push'

View File

@@ -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) { function notificationIdFor(value) {
return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() 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 sent += 1
validTokens.push(entry) validTokens.push(entry)
} catch (error) { } catch (error) {
failed += 1 if (isStaleFcmTokenError(error)) {
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
if (/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message)) === false) {
validTokens.push(entry)
} else {
removed += 1 removed += 1
changed = true 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)
} }
} }
} }

116
tests/email-service.spec.ts Normal file
View File

@@ -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')
})
})

View File

@@ -51,12 +51,13 @@ import membersGetHandler from '../server/api/members.get.js'
import membersPostHandler from '../server/api/members.post.js' import membersPostHandler from '../server/api/members.post.js'
import membersDeleteHandler from '../server/api/members.delete.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 membersBulkHandler from '../server/api/members/bulk.post.js'
import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js' import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js'
describe('Members API Endpoints', () => { describe('Members API Endpoints', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([])
}) })
describe('GET /api/members', () => { describe('GET /api/members', () => {
@@ -100,6 +101,38 @@ describe('Members API Endpoints', () => {
expect(response.members[0].name).toBe('Anna Muster') 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 () => { it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
@@ -159,6 +192,8 @@ describe('Members API Endpoints', () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody) mockSuccessReadBody(baseBody)
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' }) authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([])
memberUtils.saveMember.mockResolvedValue(true) memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event) 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 () => { it('erlaubt vorstand beim Speichern', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody) mockSuccessReadBody(baseBody)
@@ -187,6 +292,7 @@ describe('Members API Endpoints', () => {
email: 'lisa@example.com' email: 'lisa@example.com'
}) })
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' }) authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.saveMember.mockResolvedValue(true) memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event) const response = await membersPostHandler(event)

View File

@@ -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')
})
})

View File

@@ -16,8 +16,25 @@ vi.mock('../server/utils/news.js', () => ({
readNews: vi.fn() 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 nodemailer = await import('nodemailer')
const newsUtils = await import('../server/utils/news.js') 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 contactHandler from '../server/api/contact.post.js'
import galerieHandler from '../server/api/galerie.get.js' import galerieHandler from '../server/api/galerie.get.js'
@@ -29,14 +46,17 @@ describe('Öffentliche API-Endpunkte', () => {
afterEach(() => { afterEach(() => {
delete process.env.NODE_ENV delete process.env.NODE_ENV
delete process.env.APP_ENV delete process.env.APP_ENV
delete process.env.DEBUG
}) })
beforeEach(() => { beforeEach(() => {
// Setze SMTP-Credentials für Tests // Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com' process.env.SMTP_USER = 'test@example.com'
process.env.SMTP_PASS = 'test-password' process.env.SMTP_PASS = 'test-password'
authUtils.readUsers.mockResolvedValue([])
vi.restoreAllMocks() vi.restoreAllMocks()
vi.clearAllMocks() vi.clearAllMocks()
authUtils.readUsers.mockResolvedValue([])
}) })
describe('POST /api/contact', () => { describe('POST /api/contact', () => {
@@ -78,6 +98,53 @@ describe('Öffentliche API-Endpunkte', () => {
to: 'tsschulz@tsschulz.de' 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', () => { describe('GET /api/galerie', () => {