Fix in news, first android notification service
This commit is contained in:
@@ -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,
|
||||
|
||||
24
server/api/profile/notifications.get.js
Normal file
24
server/api/profile/notifications.get.js
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
34
server/api/profile/notifications.put.js
Normal file
34
server/api/profile/notifications.put.js
Normal file
@@ -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
|
||||
}
|
||||
})
|
||||
29
server/api/profile/push-token.post.js
Normal file
29
server/api/profile/push-token.post.js
Normal file
@@ -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.' }
|
||||
})
|
||||
55
server/utils/notification-settings.js
Normal file
55
server/utils/notification-settings.js
Normal file
@@ -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 || {})
|
||||
})
|
||||
}
|
||||
165
server/utils/push-notifications.js
Normal file
165
server/utils/push-notifications.js
Normal file
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user