Compare commits
5 Commits
7ac2fd39da
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7ba583d8 | ||
|
|
645e4b9655 | ||
|
|
93b76d4446 | ||
|
|
2842c89bef | ||
|
|
dc08ae98ea |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -94,6 +94,8 @@ dist
|
||||
/android-app/**/build/
|
||||
/android-app/local.properties
|
||||
/android-app/gradle-local.properties
|
||||
# Android Play Store / release artifacts are generated locally or in CI.
|
||||
/android-app/**/*.aab
|
||||
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
|
||||
*.hprof
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -283,6 +283,7 @@ data class PushTokenRequest(
|
||||
val token: String,
|
||||
val platform: String = "android",
|
||||
val appVersion: String? = null,
|
||||
val installationId: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
|
||||
@@ -21,7 +21,7 @@ class HarheimerMessagingService : FirebaseMessagingService() {
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
serviceScope.launch {
|
||||
pushTokenRepository.registerToken(token)
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ object HarheimerNotifications {
|
||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||
"team_matches" -> Destinations.Spielplan.route
|
||||
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
|
||||
"birthdays" -> Destinations.MemberArea.route
|
||||
"contact_request" -> Destinations.CmsContactRequests.route
|
||||
"user_registration" -> Destinations.CmsBenutzer.route
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
@@ -15,16 +16,18 @@ class PushTokenRepository @Inject constructor(
|
||||
) {
|
||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
registerToken(token).getOrThrow()
|
||||
val installationId = FirebaseInstallations.getInstance().id.await()
|
||||
registerToken(token, installationId).getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun registerToken(token: String): Result<Unit> = runCatching {
|
||||
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching {
|
||||
if (token.isBlank()) return@runCatching
|
||||
retryOnNetworkFailure {
|
||||
val response = api.registerPushToken(
|
||||
PushTokenRequest(
|
||||
token = token,
|
||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||
installationId = installationId,
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
|
||||
@@ -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=35
|
||||
ANDROID_VERSION_NAME=0.9.30
|
||||
ANDROID_VERSION_CODE=36
|
||||
ANDROID_VERSION_NAME=0.10.0
|
||||
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.8.9",
|
||||
"version": "1.8.10",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Rang
|
||||
Vereinsrang<br>(QTTR / TTR)
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Spieler
|
||||
@@ -69,7 +69,9 @@
|
||||
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ row.rank ?? '–' }}
|
||||
<span :title="clubRankTitle(row)">
|
||||
{{ formatClubRank(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div :class="['font-medium', getPlayerNameClass(row)]">
|
||||
@@ -133,6 +135,20 @@ function isOwnRow(playerName) {
|
||||
return normalizeName(playerName) === current
|
||||
}
|
||||
|
||||
function formatClubRank(row) {
|
||||
const qttrRank = row.clubQttrRank
|
||||
const ttrRank = row.clubTtrRank
|
||||
if (qttrRank == null) return '–'
|
||||
return ttrRank == null ? String(qttrRank) : `${qttrRank} (${ttrRank})`
|
||||
}
|
||||
|
||||
function clubRankTitle(row) {
|
||||
if (row.clubQttrRank == null) return 'Keine QTTR-Platzierung verfügbar'
|
||||
return row.clubTtrRank == null
|
||||
? `QTTR-Platzierung im Verein: ${row.clubQttrRank}`
|
||||
: `QTTR-Platzierung im Verein: ${row.clubQttrRank}; TTR-Platzierung im Verein: ${row.clubTtrRank}`
|
||||
}
|
||||
|
||||
function getPlayerNameClass(row) {
|
||||
const minor = isMinor(row.birthdate)
|
||||
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
|
||||
|
||||
@@ -37,6 +37,26 @@ function buildBirthdateLookup(entries) {
|
||||
return lookup
|
||||
}
|
||||
|
||||
function addClubRanks(rows) {
|
||||
const addRankFor = (valueKey, rankKey) => {
|
||||
rows
|
||||
.filter((row) => row[valueKey] != null)
|
||||
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
|
||||
.forEach((row, index) => {
|
||||
row[rankKey] = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
addRankFor('currentQttr', 'clubQttrRank')
|
||||
addRankFor('currentTtr', 'clubTtrRank')
|
||||
|
||||
return rows.sort((a, b) =>
|
||||
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|
||||
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|
||||
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
|
||||
)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
if (!token || !verifyToken(token)) {
|
||||
@@ -73,16 +93,16 @@ export default defineEventHandler(async (event) => {
|
||||
].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean))
|
||||
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
|
||||
|
||||
const rankedRows = addClubRanks(Array.isArray(payload.rows) ? payload.rows.map(row => ({ ...row })) : [])
|
||||
|
||||
return {
|
||||
...payload,
|
||||
rows: Array.isArray(payload.rows)
|
||||
? payload.rows
|
||||
rows: rankedRows
|
||||
.filter(row => !hiddenNames.has(normalizeName(row.playerName)))
|
||||
.map((row) => ({
|
||||
...row,
|
||||
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
|
||||
}))
|
||||
: []
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
|
||||
@@ -22,7 +22,8 @@ export default defineEventHandler(async (event) => {
|
||||
upsertPushToken(users[userIndex], {
|
||||
token: body.token,
|
||||
platform: body.platform || 'android',
|
||||
appVersion: body.appVersion || null
|
||||
appVersion: body.appVersion || null,
|
||||
installationId: body.installationId || null
|
||||
})
|
||||
await writeUsers(users)
|
||||
return { success: true, message: 'Push-Token gespeichert.' }
|
||||
|
||||
@@ -85,16 +85,21 @@ function pushTokensForUser(user) {
|
||||
: []
|
||||
}
|
||||
|
||||
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
|
||||
export function upsertPushToken(user, { token, platform = 'android', appVersion = null, installationId = null }) {
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!normalizedToken) return user
|
||||
const normalizedInstallationId = String(installationId || '').trim().slice(0, 200) || null
|
||||
const now = new Date().toISOString()
|
||||
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
|
||||
const next = tokens.filter(entry => entry?.token !== normalizedToken)
|
||||
// A Firebase token can rotate for the same app installation. Retain tokens
|
||||
// from other devices, but replace the previous token of this installation.
|
||||
const next = tokens.filter(entry => entry?.token !== normalizedToken &&
|
||||
(!normalizedInstallationId || entry?.installationId !== normalizedInstallationId))
|
||||
next.push({
|
||||
token: normalizedToken,
|
||||
platform: String(platform || 'android').slice(0, 30),
|
||||
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
|
||||
installationId: normalizedInstallationId,
|
||||
updatedAt: now,
|
||||
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
|
||||
})
|
||||
@@ -329,6 +334,19 @@ export async function sendOwnTtrChangePush(changes = []) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
|
||||
return sendPushToUsers({
|
||||
title: 'QTTR-Liste aktualisiert',
|
||||
body: `Die aktuelle TTR- und QTTR-Liste wurde aktualisiert (${rowCount} Einträge).`,
|
||||
data: {
|
||||
type: 'qttr_list_updated',
|
||||
importedAt: importedAt || '',
|
||||
notificationId: notificationIdFor(`qttr-list:${importedAt || Date.now()}`)
|
||||
},
|
||||
failureLabel: 'FCM QTTR-Listen-Push'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendTestPushToUser(userId) {
|
||||
return sendPushToUsers({
|
||||
title: 'Harheimer TC: Push-Test',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
import { sendOwnTtrChangePush } from './push-notifications.js'
|
||||
import { sendOwnTtrChangePush, sendQttrListUpdatedPush } from './push-notifications.js'
|
||||
import { fetchClubRankings } from './mytischtennis-client.js'
|
||||
import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js'
|
||||
|
||||
@@ -67,6 +67,26 @@ function normalizeGender(value) {
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function addClubRanks(rows) {
|
||||
const addRankFor = (valueKey, rankKey) => {
|
||||
rows
|
||||
.filter((row) => row[valueKey] != null)
|
||||
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
|
||||
.forEach((row, index) => {
|
||||
row[rankKey] = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
addRankFor('currentQttr', 'clubQttrRank')
|
||||
addRankFor('currentTtr', 'clubTtrRank')
|
||||
|
||||
return rows.sort((a, b) =>
|
||||
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|
||||
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|
||||
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
|
||||
)
|
||||
}
|
||||
|
||||
function extractTableBlocks(html) {
|
||||
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
|
||||
}
|
||||
@@ -190,7 +210,7 @@ export async function importQttrValues(options = {}) {
|
||||
throw new Error('QTTR-Tabelle ist leer oder unvollständig')
|
||||
}
|
||||
|
||||
const parsedRows = rows.map((cells) => deriveQttrFields(headers, cells))
|
||||
const parsedRows = addClubRanks(rows.map((cells) => deriveQttrFields(headers, cells)))
|
||||
const payload = {
|
||||
format: 'harheimertc.qttr.v1',
|
||||
importedAt: new Date().toISOString(),
|
||||
@@ -243,7 +263,11 @@ async function importAuthenticatedTtrValues(connection) {
|
||||
})
|
||||
}).filter(row => row.playerName && (row.currentTtr != null || row.currentQttr != null))
|
||||
|
||||
addClubRanks(rows)
|
||||
|
||||
if (!rows.length) throw new Error('Keine verwertbaren TTR-Werte in der myTischtennis-Rangliste gefunden.')
|
||||
const previousQttrSignature = qttrListSignature(previousPayload.rows)
|
||||
const nextQttrSignature = qttrListSignature(rows)
|
||||
const importedAt = new Date().toISOString()
|
||||
const payload = {
|
||||
format: 'harheimertc.qttr.v2', importedAt,
|
||||
@@ -262,6 +286,21 @@ async function importAuthenticatedTtrValues(connection) {
|
||||
: []
|
||||
})
|
||||
await sendOwnTtrChangePush(changes)
|
||||
if (previousQttrSignature && previousQttrSignature !== nextQttrSignature) {
|
||||
await sendQttrListUpdatedPush({ importedAt, rowCount: rows.length })
|
||||
}
|
||||
await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null })
|
||||
return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload }
|
||||
}
|
||||
|
||||
function qttrListSignature(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return ''
|
||||
return rows
|
||||
.map(row => [
|
||||
String(row?.playerName || '').trim().toLocaleLowerCase('de-DE'),
|
||||
row?.rank ?? '',
|
||||
row?.currentQttr ?? ''
|
||||
].join('|'))
|
||||
.sort()
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ describe('Config & Profil Endpoints', () => {
|
||||
|
||||
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' })
|
||||
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })
|
||||
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue(users[0])
|
||||
@@ -260,7 +260,7 @@ describe('Config & Profil Endpoints', () => {
|
||||
expect(authUtils.writeUsers).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
id: '1',
|
||||
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
|
||||
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user