Feedback-Window
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 50s

This commit is contained in:
Torsten Schulz (local)
2026-06-09 07:57:36 +02:00
parent f0142d5682
commit 16465fafc8
9 changed files with 208 additions and 3 deletions

View File

@@ -0,0 +1,29 @@
import User from '../models/User.js';
import { sendMobileFeedbackEmail } from '../services/emailService.js';
const clean = (value, max = 4000) => String(value ?? '').trim().slice(0, max);
export const sendMobileFeedback = async (req, res) => {
try {
const message = clean(req.body?.message, 5000);
if (!message) {
return res.status(400).json({ error: 'message_required' });
}
const user = req.user?.id ? await User.findByPk(req.user.id) : null;
await sendMobileFeedbackEmail({
message,
screen: clean(req.body?.screen, 200),
clubId: req.body?.clubId ?? null,
appVersion: clean(req.body?.appVersion, 80),
platform: clean(req.body?.platform, 80) || 'Android',
backendBaseUrl: clean(req.body?.backendBaseUrl, 300),
user: user ? { id: user.id, username: user.username, email: user.email } : { id: req.user?.id },
});
return res.status(200).json({ success: true });
} catch (error) {
console.error('[sendMobileFeedback] - error:', error);
return res.status(500).json({ error: 'internalerror' });
}
};

View File

@@ -0,0 +1,9 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { sendMobileFeedback } from '../controllers/mobileFeedbackController.js';
const router = express.Router();
router.post('/', authenticate, sendMobileFeedback);
export default router;

View File

@@ -67,6 +67,7 @@ import friendlyMatchSharedRoutes from './routes/friendlyMatchSharedRoutes.js';
import friendlyMatchInvitationRoutes from './routes/friendlyMatchInvitationRoutes.js';
import calendarRoutes from './routes/calendarRoutes.js';
import calendarEventRoutes from './routes/calendarEventRoutes.js';
import mobileFeedbackRoutes from './routes/mobileFeedbackRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -366,6 +367,7 @@ app.use('/api/friendly-matches', friendlyMatchRoutes);
app.use('/api/friendly-match-invitations', friendlyMatchInvitationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
app.use('/api/mobile-feedback', mobileFeedbackRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {

View File

@@ -95,4 +95,43 @@ const sendFriendlyMatchInvitationEmail = async ({
await transporter.sendMail(mailOptions);
};
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail };
const escapeHtml = (value) => String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const sendMobileFeedbackEmail = async ({
message,
screen,
clubId,
appVersion,
platform,
backendBaseUrl,
user,
}) => {
const mailOptions = {
from: process.env.EMAIL_USER,
to: 'tsschulz2001@gmail.com',
subject: `Android Feedback${screen ? ` - ${screen}` : ''}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 760px; margin: 0 auto;">
<h2 style="color:#1f2937;">Android Feedback</h2>
<p><strong>Seite:</strong> ${escapeHtml(screen || '-')}</p>
<p><strong>Verein-ID:</strong> ${escapeHtml(clubId ?? '-')}</p>
<p><strong>User:</strong> ${escapeHtml(user?.username || user?.email || user?.id || '-')}</p>
<p><strong>User-ID:</strong> ${escapeHtml(user?.id || '-')}</p>
<p><strong>App-Version:</strong> ${escapeHtml(appVersion || '-')}</p>
<p><strong>Plattform:</strong> ${escapeHtml(platform || 'Android')}</p>
<p><strong>Backend:</strong> ${escapeHtml(backendBaseUrl || '-')}</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;">
<h3 style="color:#1f2937;">Meldung</h3>
<div style="white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;">${escapeHtml(message)}</div>
</div>
`,
};
await transporter.sendMail(mailOptions);
};
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail };

View File

@@ -17,6 +17,7 @@ import de.tsschulz.tt_tagebuch.shared.api.ClubsApi
import de.tsschulz.tt_tagebuch.shared.api.DiaryApi
import de.tsschulz.tt_tagebuch.shared.api.DiaryMemberActivitiesApi
import de.tsschulz.tt_tagebuch.shared.api.DiaryMemberApi
import de.tsschulz.tt_tagebuch.shared.api.FeedbackApi
import de.tsschulz.tt_tagebuch.shared.api.GroupApi
import de.tsschulz.tt_tagebuch.shared.api.ParticipantsApi
import de.tsschulz.tt_tagebuch.shared.api.PredefinedActivitiesApi
@@ -88,6 +89,7 @@ class AppDependencies(context: Context) {
)
val publicAuthApi = PublicAuthApi(publicHttpClient)
val feedbackApi = FeedbackApi(client)
val authManager = AuthManager(
tokenProvider = tokenProvider,

View File

@@ -100,12 +100,14 @@ import androidx.compose.ui.unit.sp
import kotlin.math.max
import de.tsschulz.tt_tagebuch.app.AppDependencies
import de.tsschulz.tt_tagebuch.R
import de.tsschulz.tt_tagebuch.BuildConfig
import de.tsschulz.tt_tagebuch.app.pdf.sharePdfFile
import de.tsschulz.tt_tagebuch.app.pdf.writeMembersPhoneListPdf
import de.tsschulz.tt_tagebuch.app.pdf.writeTrainingDaySummaryPdf
import de.tsschulz.tt_tagebuch.app.pdf.writeTrainingPlanPdf
import de.tsschulz.tt_tagebuch.shared.api.memberProfileImagePath
import de.tsschulz.tt_tagebuch.shared.api.toAbsoluteUrl
import de.tsschulz.tt_tagebuch.shared.api.MobileFeedbackBody
import de.tsschulz.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
import de.tsschulz.tt_tagebuch.shared.api.models.canReadApprovals
import de.tsschulz.tt_tagebuch.shared.api.models.canReadClubPermissions
@@ -296,6 +298,8 @@ private fun MainTabs(
val visibleTabs = visibleMainTabs(clubState.currentPermissions)
val networkConnected by dependencies.networkConnectivity.connected.collectAsState()
var lastNetworkConnected by remember { mutableStateOf(true) }
var feedbackOpen by remember { mutableStateOf(false) }
val currentScreenTitle = tabTitle(selectedTab)
/** Tagebuch- und Mitglieder-Listen vorladen, sobald Verein + Rechte feststehen. */
LaunchedEffect(clubState.currentClubId, clubState.currentPermissions) {
@@ -494,6 +498,10 @@ private fun MainTabs(
settingsStammdatenRequest = settingsStammdatenRequest,
onConsumeSettingsStammdatenRequest = { settingsStammdatenRequest = null },
)
MobileFeedbackButton(
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
onClick = { feedbackOpen = true },
)
}
}
} else {
@@ -519,6 +527,10 @@ private fun MainTabs(
settingsStammdatenRequest = settingsStammdatenRequest,
onConsumeSettingsStammdatenRequest = { settingsStammdatenRequest = null },
)
MobileFeedbackButton(
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
onClick = { feedbackOpen = true },
)
}
BottomNavigation(
backgroundColor = MaterialTheme.colors.surface,
@@ -547,6 +559,92 @@ private fun MainTabs(
}
}
}
if (feedbackOpen) {
MobileFeedbackDialog(
dependencies = dependencies,
screenTitle = currentScreenTitle,
onDismiss = { feedbackOpen = false },
)
}
}
@Composable
private fun MobileFeedbackButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
OutlinedButton(
onClick = onClick,
modifier = modifier.heightIn(min = 40.dp),
) {
Text("Feedback")
}
}
@Composable
private fun MobileFeedbackDialog(
dependencies: AppDependencies,
screenTitle: String,
onDismiss: () -> Unit,
) {
val clubState by dependencies.clubManager.state.collectAsState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
var message by remember { mutableStateOf("") }
var sending by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!sending) onDismiss() },
title = { Text("Feedback senden") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Seite: $screenTitle", style = MaterialTheme.typography.caption)
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text("Was funktioniert nicht oder könnte besser sein?") },
minLines = 5,
enabled = !sending,
modifier = Modifier.fillMaxWidth(),
)
error?.let { Text(it, color = MaterialTheme.colors.error) }
}
},
confirmButton = {
TextButton(
enabled = !sending && message.trim().isNotBlank(),
onClick = {
scope.launch {
sending = true
error = null
val result = runCatching {
dependencies.feedbackApi.send(
MobileFeedbackBody(
message = message.trim(),
screen = screenTitle,
clubId = clubState.currentClubId,
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
backendBaseUrl = BuildConfig.BACKEND_BASE_URL,
),
)
}
sending = false
if (result.isSuccess) {
Toast.makeText(context, "Feedback gesendet", Toast.LENGTH_SHORT).show()
onDismiss()
} else {
error = result.exceptionOrNull()?.message ?: "Feedback konnte nicht gesendet werden."
}
}
},
) { Text(if (sending) "Sende..." else "Senden") }
},
dismissButton = {
TextButton(enabled = !sending, onClick = onDismiss) { Text("Abbrechen") }
},
)
}
@Composable

View File

@@ -1,7 +1,7 @@
[versions]
# composeApp (Play Store / „Über die App“-Build)
appVersionCode = "24"
appVersionName = "1.7.4"
appVersionCode = "25"
appVersionName = "1.7.5"
agp = "9.2.1"
android-compileSdk = "35"
android-minSdk = "24"

View File

@@ -0,0 +1,26 @@
package de.tsschulz.tt_tagebuch.shared.api
import de.tsschulz.tt_tagebuch.shared.api.http.AuthedHttpClient
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.serialization.Serializable
@Serializable
data class MobileFeedbackBody(
val message: String,
val screen: String,
val clubId: Int? = null,
val appVersion: String = "",
val platform: String = "Android",
val backendBaseUrl: String = "",
)
class FeedbackApi(
private val client: AuthedHttpClient,
) {
suspend fun send(body: MobileFeedbackBody) {
client.http.post("/api/mobile-feedback") {
setBody(body)
}
}
}