-
-
-
-
-
-
- {{ formatDate(selectedNews.created) }}
-
-
- {{ selectedNews.title }}
-
-
-
-
+
+
+
+
+
-
-
-
- {{ selectedNews.content }}
-
+
+
+ {{ selectedNews.content }}
+
+
+
-
-
+
+
diff --git a/package.json b/package.json
index dc86fdc..fb7b88f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
- "version": "1.8.0",
+ "version": "1.8.1",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",
diff --git a/server/api/news.post.js b/server/api/news.post.js
index 9c32d16..f2fddc6 100644
--- a/server/api/news.post.js
+++ b/server/api/news.post.js
@@ -1,5 +1,6 @@
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
import { saveNews } from '../utils/news.js'
+import { sendNewNewsPush } from '../utils/push-notifications.js'
export default defineEventHandler(async (event) => {
try {
@@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => {
})
}
- await saveNews({
+ const newsEntry = {
id: id || undefined,
title,
content,
@@ -49,7 +50,13 @@ export default defineEventHandler(async (event) => {
expiresAt: expiresAt || undefined,
isHidden: isHidden || false,
author: user.name
- })
+ }
+ await saveNews(newsEntry)
+ if (!id && !newsEntry.isHidden) {
+ sendNewNewsPush(newsEntry).catch(error => {
+ console.error('News-Push konnte nicht gesendet werden:', error)
+ })
+ }
return {
success: true,
diff --git a/server/api/profile/notifications.get.js b/server/api/profile/notifications.get.js
new file mode 100644
index 0000000..8aeff9f
--- /dev/null
+++ b/server/api/profile/notifications.get.js
@@ -0,0 +1,24 @@
+import { verifyToken, getUserFromToken } from '../../utils/auth.js'
+import { notificationSettingsForUser } from '../../utils/notification-settings.js'
+
+function tokenFromEvent(event) {
+ return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
+}
+
+async function requireAuthenticatedUser(event) {
+ const token = tokenFromEvent(event)
+ if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
+ const decoded = verifyToken(token)
+ if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
+ const user = await getUserFromToken(token)
+ if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
+ return { token, decoded, user }
+}
+
+export default defineEventHandler(async (event) => {
+ const { user } = await requireAuthenticatedUser(event)
+ return {
+ success: true,
+ settings: notificationSettingsForUser(user)
+ }
+})
diff --git a/server/api/profile/notifications.put.js b/server/api/profile/notifications.put.js
new file mode 100644
index 0000000..0e4fd40
--- /dev/null
+++ b/server/api/profile/notifications.put.js
@@ -0,0 +1,34 @@
+import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
+import { sanitizeNotificationSettings } from '../../utils/notification-settings.js'
+
+function tokenFromEvent(event) {
+ return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
+}
+
+async function requireAuthenticatedUser(event) {
+ const token = tokenFromEvent(event)
+ if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
+ const decoded = verifyToken(token)
+ if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
+ const user = await getUserFromToken(token)
+ if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
+ return { token, decoded, user }
+}
+
+export default defineEventHandler(async (event) => {
+ const { decoded } = await requireAuthenticatedUser(event)
+ const body = await readBody(event)
+ const settings = sanitizeNotificationSettings(body?.settings || body || {})
+ const users = await readUsers()
+ const userIndex = users.findIndex(user => user.id === decoded.id)
+ if (userIndex === -1) {
+ throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
+ }
+ users[userIndex].notificationSettings = settings
+ await writeUsers(users)
+ return {
+ success: true,
+ message: 'Benachrichtigungseinstellungen gespeichert.',
+ settings
+ }
+})
diff --git a/server/api/profile/push-token.post.js b/server/api/profile/push-token.post.js
new file mode 100644
index 0000000..fe8c57d
--- /dev/null
+++ b/server/api/profile/push-token.post.js
@@ -0,0 +1,29 @@
+import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
+import { upsertPushToken } from '../../utils/push-notifications.js'
+
+function tokenFromEvent(event) {
+ return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
+}
+
+export default defineEventHandler(async (event) => {
+ const token = tokenFromEvent(event)
+ if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
+ const decoded = verifyToken(token)
+ if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
+ const sessionUser = await getUserFromToken(token)
+ if (!sessionUser) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
+ const body = await readBody(event)
+ if (!body?.token || typeof body.token !== 'string') {
+ throw createError({ statusCode: 400, message: 'Push-Token fehlt.' })
+ }
+ const users = await readUsers()
+ const userIndex = users.findIndex(user => user.id === decoded.id)
+ if (userIndex === -1) throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
+ upsertPushToken(users[userIndex], {
+ token: body.token,
+ platform: body.platform || 'android',
+ appVersion: body.appVersion || null
+ })
+ await writeUsers(users)
+ return { success: true, message: 'Push-Token gespeichert.' }
+})
diff --git a/server/utils/notification-settings.js b/server/utils/notification-settings.js
new file mode 100644
index 0000000..6d98feb
--- /dev/null
+++ b/server/utils/notification-settings.js
@@ -0,0 +1,55 @@
+export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
+ newNews: false,
+ newEvents: false,
+ eventsToday: false,
+ eventsTomorrow: false,
+ ownTeamMatches: false,
+ allTeamMatches: false,
+ birthdays: false,
+ newContactRequest: false,
+ newUserRegistration: false,
+ selectedTeamSlugs: [],
+ selectedTeamSeason: null,
+ notificationTime: '09:00'
+})
+
+function coerceBoolean(value) {
+ return value === true
+}
+
+export function sanitizeNotificationSettings(input = {}) {
+ const selectedTeamSlugs = Array.isArray(input.selectedTeamSlugs)
+ ? input.selectedTeamSlugs
+ .map(value => String(value || '').trim())
+ .filter(Boolean)
+ .slice(0, 50)
+ : []
+ const selectedTeamSeason = typeof input.selectedTeamSeason === 'string' && input.selectedTeamSeason.trim()
+ ? input.selectedTeamSeason.trim().slice(0, 30)
+ : null
+ const notificationTime = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(input.notificationTime || ''))
+ ? String(input.notificationTime)
+ : DEFAULT_NOTIFICATION_SETTINGS.notificationTime
+
+ return {
+ newNews: coerceBoolean(input.newNews),
+ newEvents: coerceBoolean(input.newEvents),
+ eventsToday: coerceBoolean(input.eventsToday),
+ eventsTomorrow: coerceBoolean(input.eventsTomorrow),
+ ownTeamMatches: coerceBoolean(input.ownTeamMatches),
+ allTeamMatches: coerceBoolean(input.allTeamMatches),
+ birthdays: coerceBoolean(input.birthdays),
+ newContactRequest: coerceBoolean(input.newContactRequest),
+ newUserRegistration: coerceBoolean(input.newUserRegistration),
+ selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
+ selectedTeamSeason,
+ notificationTime
+ }
+}
+
+export function notificationSettingsForUser(user) {
+ return sanitizeNotificationSettings({
+ ...DEFAULT_NOTIFICATION_SETTINGS,
+ ...(user?.notificationSettings || user?.notifications || {})
+ })
+}
diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js
new file mode 100644
index 0000000..39bb0d4
--- /dev/null
+++ b/server/utils/push-notifications.js
@@ -0,0 +1,165 @@
+import crypto from 'crypto'
+import { promises as fs } from 'fs'
+import { readUsers, writeUsers, isHiddenUser } from './auth.js'
+import { notificationSettingsForUser } from './notification-settings.js'
+
+const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
+const TOKEN_URL = 'https://oauth2.googleapis.com/token'
+const tokenCache = { accessToken: null, expiresAt: 0 }
+
+function base64Url(input) {
+ return Buffer.from(input).toString('base64url')
+}
+
+function projectIdFromServiceAccount(serviceAccount) {
+ return process.env.FCM_PROJECT_ID || serviceAccount.project_id
+}
+
+async function readServiceAccount() {
+ if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
+ return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
+ }
+ if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
+ const raw = await fs.readFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8')
+ return JSON.parse(raw)
+ }
+ return null
+}
+
+async function getAccessToken(serviceAccount) {
+ if (tokenCache.accessToken && tokenCache.expiresAt > Date.now() + 60_000) {
+ return tokenCache.accessToken
+ }
+ const now = Math.floor(Date.now() / 1000)
+ const assertion = [
+ base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })),
+ base64Url(JSON.stringify({
+ iss: serviceAccount.client_email,
+ scope: FCM_SCOPE,
+ aud: TOKEN_URL,
+ iat: now,
+ exp: now + 3600
+ }))
+ ].join('.')
+ const signature = crypto
+ .createSign('RSA-SHA256')
+ .update(assertion)
+ .sign(serviceAccount.private_key, 'base64url')
+ const response = await fetch(TOKEN_URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: `${assertion}.${signature}`
+ })
+ })
+ if (!response.ok) {
+ throw new Error(`FCM OAuth fehlgeschlagen: ${response.status}`)
+ }
+ const body = await response.json()
+ tokenCache.accessToken = body.access_token
+ tokenCache.expiresAt = Date.now() + Number(body.expires_in || 3600) * 1000
+ return tokenCache.accessToken
+}
+
+function pushTokensForUser(user) {
+ return Array.isArray(user.pushTokens)
+ ? user.pushTokens.filter(entry => entry?.token && entry.platform === 'android')
+ : []
+}
+
+export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
+ const normalizedToken = String(token || '').trim()
+ if (!normalizedToken) return user
+ const now = new Date().toISOString()
+ const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
+ const next = tokens.filter(entry => entry?.token !== normalizedToken)
+ next.push({
+ token: normalizedToken,
+ platform: String(platform || 'android').slice(0, 30),
+ appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
+ updatedAt: now,
+ createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
+ })
+ user.pushTokens = next.slice(-20)
+ return user
+}
+
+async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, data = {} }) {
+ const projectId = projectIdFromServiceAccount(serviceAccount)
+ if (!projectId) throw new Error('FCM project_id fehlt.')
+ const response = await fetch(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, {
+ method: 'POST',
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ 'content-type': 'application/json'
+ },
+ body: JSON.stringify({
+ message: {
+ token,
+ notification: { title, body },
+ data,
+ android: {
+ priority: 'high',
+ notification: {
+ channel_id: 'harheimer_tc_updates',
+ click_action: 'OPEN_NEWS'
+ }
+ }
+ }
+ })
+ })
+ if (!response.ok) {
+ const text = await response.text().catch(() => '')
+ throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`)
+ }
+}
+
+export async function sendNewNewsPush(news) {
+ const serviceAccount = await readServiceAccount()
+ if (!serviceAccount) {
+ console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
+ return { sent: 0, skipped: true }
+ }
+ const accessToken = await getAccessToken(serviceAccount)
+ const users = await readUsers()
+ let sent = 0
+ let changed = false
+ const title = 'Neue News'
+ const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
+ const data = {
+ type: 'news',
+ newsId: String(news.id || ''),
+ title,
+ body,
+ notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
+ }
+
+ for (const user of users) {
+ if (isHiddenUser(user)) continue
+ const settings = notificationSettingsForUser(user)
+ if (!settings.newNews) continue
+ const tokens = pushTokensForUser(user)
+ const validTokens = []
+ for (const entry of tokens) {
+ try {
+ await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data })
+ sent += 1
+ validTokens.push(entry)
+ } catch (error) {
+ console.error('FCM News-Push fehlgeschlagen:', error.message)
+ if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
+ validTokens.push(entry)
+ } else {
+ changed = true
+ }
+ }
+ }
+ if (validTokens.length !== tokens.length) {
+ user.pushTokens = validTokens
+ changed = true
+ }
+ }
+ if (changed) await writeUsers(users)
+ return { sent, skipped: false }
+}
diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts
index 57dc6fd..3d3e9bb 100644
--- a/tests/config-profile-endpoints.spec.ts
+++ b/tests/config-profile-endpoints.spec.ts
@@ -35,6 +35,9 @@ import configGetHandler from '../server/api/config.get.js'
import configPutHandler from '../server/api/config.put.js'
import profileGetHandler from '../server/api/profile.get.js'
import profilePutHandler from '../server/api/profile.put.js'
+import profileNotificationsGetHandler from '../server/api/profile/notifications.get.js'
+import profileNotificationsPutHandler from '../server/api/profile/notifications.put.js'
+import profilePushTokenHandler from '../server/api/profile/push-token.post.js'
const invalidCurrentPassword = ['invalid', 'test', 'pw'].join('-')
const validCurrentPassword = ['valid', 'test', 'pw'].join('-')
@@ -157,6 +160,113 @@ describe('Config & Profil Endpoints', () => {
})
})
+
+ describe('GET /api/profile/notifications', () => {
+ it('verlangt Authentifizierung', async () => {
+ const event = createEvent()
+
+ await expect(profileNotificationsGetHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ })
+
+ it('liefert Defaults plus gespeicherte Benachrichtigungseinstellungen', async () => {
+ const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
+ authUtils.verifyToken.mockReturnValue({ id: '1' })
+ authUtils.getUserFromToken.mockResolvedValue({
+ id: '1',
+ notificationSettings: {
+ eventsToday: true,
+ selectedTeamSlugs: ['herren-1', 'herren-1', ''],
+ selectedTeamSeason: '2025/2026',
+ notificationTime: '07:30'
+ }
+ })
+
+ const result = await profileNotificationsGetHandler(event)
+
+ expect(result.success).toBe(true)
+ expect(result.settings.eventsToday).toBe(true)
+ expect(result.settings.newEvents).toBe(false)
+ expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
+ expect(result.settings.notificationTime).toBe('07:30')
+ })
+ })
+
+ describe('PUT /api/profile/notifications', () => {
+ it('verlangt Authentifizierung', async () => {
+ const event = createEvent({ body: { eventsToday: true } })
+
+ await expect(profileNotificationsPutHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ })
+
+ it('speichert sanitizte Benachrichtigungseinstellungen am Benutzer', async () => {
+ const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
+ mockSuccessReadBody({
+ newEvents: true,
+ eventsToday: 'true',
+ birthdays: true,
+ selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
+ selectedTeamSeason: '2026/2027',
+ notificationTime: '25:99'
+ })
+ const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
+ authUtils.verifyToken.mockReturnValue({ id: '1' })
+ authUtils.getUserFromToken.mockResolvedValue(users[0])
+ authUtils.readUsers.mockResolvedValue(users)
+ authUtils.writeUsers.mockResolvedValue(true)
+
+ const result = await profileNotificationsPutHandler(event)
+
+ expect(result.success).toBe(true)
+ expect(result.settings.newEvents).toBe(true)
+ expect(result.settings.eventsToday).toBe(false)
+ expect(result.settings.birthdays).toBe(true)
+ expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
+ expect(result.settings.notificationTime).toBe('09:00')
+ expect(authUtils.writeUsers).toHaveBeenCalledWith([
+ expect.objectContaining({
+ id: '1',
+ notificationSettings: expect.objectContaining({
+ newEvents: true,
+ birthdays: true,
+ selectedTeamSeason: '2026/2027'
+ })
+ })
+ ])
+ })
+ })
+
+
+
+ describe('POST /api/profile/push-token', () => {
+ it('verlangt Authentifizierung', async () => {
+ const event = createEvent()
+ mockSuccessReadBody({ token: 'fcm-token' })
+
+ await expect(profilePushTokenHandler(event)).rejects.toMatchObject({ statusCode: 401 })
+ })
+
+ it('speichert Android-Push-Token am Benutzer', async () => {
+ const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
+ mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })
+ const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
+ authUtils.verifyToken.mockReturnValue({ id: '1' })
+ authUtils.getUserFromToken.mockResolvedValue(users[0])
+ authUtils.readUsers.mockResolvedValue(users)
+ authUtils.writeUsers.mockResolvedValue(true)
+
+ const result = await profilePushTokenHandler(event)
+
+ expect(result.success).toBe(true)
+ expect(authUtils.writeUsers).toHaveBeenCalledWith([
+ expect.objectContaining({
+ id: '1',
+ pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
+ })
+ ])
+ })
+ })
+
+
describe('PUT /api/profile', () => {
it('verlangt Authentifizierung', async () => {
const event = createEvent()
diff --git a/tests/news-endpoints.spec.ts b/tests/news-endpoints.spec.ts
index 67216e0..7f8b8ff 100644
--- a/tests/news-endpoints.spec.ts
+++ b/tests/news-endpoints.spec.ts
@@ -17,8 +17,13 @@ vi.mock('../server/utils/news.js', () => ({
deleteNews: vi.fn()
}))
+vi.mock('../server/utils/push-notifications.js', () => ({
+ sendNewNewsPush: vi.fn().mockResolvedValue({ sent: 1, skipped: false })
+}))
+
const authUtils = await import('../server/utils/auth.js')
const newsUtils = await import('../server/utils/news.js')
+const pushUtils = await import('../server/utils/push-notifications.js')
import newsGetHandler from '../server/api/news.get.js'
import newsPostHandler from '../server/api/news.post.js'
@@ -111,6 +116,29 @@ describe('News API Endpoints', () => {
expect(newsUtils.saveNews).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true })
)
+ expect(pushUtils.sendNewNewsPush).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true })
+ )
+ })
+
+ it('sendet keinen Push bei News-Update', async () => {
+ const event = adminEvent()
+ newsUtils.saveNews.mockResolvedValue(undefined)
+ mockSuccessReadBody({ id: 'existing-news', title: 'Update', content: 'Inhalt' })
+
+ await newsPostHandler(event)
+
+ expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled()
+ })
+
+ it('sendet keinen Push bei versteckten News', async () => {
+ const event = adminEvent()
+ newsUtils.saveNews.mockResolvedValue(undefined)
+ mockSuccessReadBody({ title: 'Intern', content: 'Inhalt', isHidden: true })
+
+ await newsPostHandler(event)
+
+ expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled()
})
it('setzt autor auf den angemeldeten Benutzer', async () => {