Benachrichtigungen erweitert
Emails korrigiert
This commit is contained in:
7
.gitleaks.toml
Normal file
7
.gitleaks.toml
Normal 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$''',
|
||||
]
|
||||
@@ -88,6 +88,13 @@ android {
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf(
|
||||
"AutoboxingStateCreation",
|
||||
"MutableCollectionMutableState",
|
||||
)
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
|
||||
Binary file not shown.
@@ -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<String> = 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<Map<String, String>>)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -51,8 +51,12 @@ object HarheimerNotifications {
|
||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
return try {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
return true
|
||||
true
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -550,6 +550,25 @@
|
||||
</label>
|
||||
</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
|
||||
v-if="errorMessage"
|
||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||
@@ -846,7 +865,8 @@ const formData = ref({
|
||||
address: '',
|
||||
notes: '',
|
||||
isMannschaftsspieler: false,
|
||||
hasHallKey: false
|
||||
hasHallKey: false,
|
||||
showBirthday: false
|
||||
})
|
||||
|
||||
const canEdit = computed(() => {
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
45
scripts/verify-no-public-writes.js
Normal file
45
scripts/verify-no-public-writes.js
Normal 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.')
|
||||
@@ -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: `
|
||||
<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>
|
||||
`
|
||||
})
|
||||
}
|
||||
await sendRegistrationNotification({ name, email, phone })
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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<string>} Email addresses
|
||||
*/
|
||||
function getEmailRecipients(data, config) {
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
tests/email-service.spec.ts
Normal file
116
tests/email-service.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
93
tests/notification-scheduler.spec.ts
Normal file
93
tests/notification-scheduler.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user