This commit is contained in:
29
backend/controllers/mobileFeedbackController.js
Normal file
29
backend/controllers/mobileFeedbackController.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
9
backend/routes/mobileFeedbackRoutes.js
Normal file
9
backend/routes/mobileFeedbackRoutes.js
Normal 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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -95,4 +95,43 @@ const sendFriendlyMatchInvitationEmail = async ({
|
||||
await transporter.sendMail(mailOptions);
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail };
|
||||
const escapeHtml = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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 };
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user