feat(feedback): Feedback-Formular mit Ntfy-Versand
Nutzer können Feedback aus dem Header senden; der Server leitet Nachrichten über Ntfy weiter (NTFY_* in .env). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-1
@@ -10,4 +10,10 @@ ORIGIN=http://localhost
|
|||||||
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||||
|
|
||||||
|
# Feedback via Ntfy (https://ntfy.sh or self-hosted)
|
||||||
|
# NTFY_TOPIC: topic name only (not the full URL)
|
||||||
|
NTFY_SERVER=https://ntfy.sh
|
||||||
|
NTFY_TOPIC=kapteins-daagbok-feedback
|
||||||
|
NTFY_TOKEN=tk_example_ntfy_access_token
|
||||||
|
|||||||
@@ -388,6 +388,59 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
max-height: min(90vh, 820px);
|
max-height: min(90vh, 820px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feedback-modal .auth-actions {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form__field > span {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-heading, #f1f5f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form__field select,
|
||||||
|
.feedback-form__field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--app-input-border, rgba(148, 163, 184, 0.25));
|
||||||
|
background: var(--app-input-bg, rgba(15, 23, 42, 0.6));
|
||||||
|
color: var(--app-text, #e2e8f0);
|
||||||
|
font: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form__field select:focus,
|
||||||
|
.feedback-form__field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--app-accent, #38bdf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form__actions .btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 100px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.registration-disclaimer__intro {
|
.registration-disclaimer__intro {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type { LogbookAccessRole } from './services/logbook.js'
|
|||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||||
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
getStoredDemoFirstEntryId,
|
getStoredDemoFirstEntryId,
|
||||||
@@ -439,6 +440,11 @@ function App() {
|
|||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
|
<FeedbackHeaderButton
|
||||||
|
logbookId={activeLogbookId}
|
||||||
|
logbookTitle={activeLogbookTitle}
|
||||||
|
/>
|
||||||
|
|
||||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { MessageSquarePlus } from 'lucide-react'
|
||||||
|
import FeedbackModal from './FeedbackModal.tsx'
|
||||||
|
|
||||||
|
interface FeedbackHeaderButtonProps {
|
||||||
|
logbookId?: string | null
|
||||||
|
logbookTitle?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackHeaderButton({
|
||||||
|
logbookId,
|
||||||
|
logbookTitle
|
||||||
|
}: FeedbackHeaderButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
title={t('feedback.button_title')}
|
||||||
|
aria-label={t('feedback.button_title')}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus size={18} />
|
||||||
|
</button>
|
||||||
|
<FeedbackModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
logbookId={logbookId}
|
||||||
|
logbookTitle={logbookTitle}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { MessageSquarePlus, X } from 'lucide-react'
|
||||||
|
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
|
||||||
|
interface FeedbackModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
logbookId?: string | null
|
||||||
|
logbookTitle?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
logbookId,
|
||||||
|
logbookTitle
|
||||||
|
}: FeedbackModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showAlert } = useDialog()
|
||||||
|
const [category, setCategory] = useState<FeedbackCategory>('general')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && !sending) onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [open, onClose, sending])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setCategory('general')
|
||||||
|
setMessage('')
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!message.trim() || sending) return
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
await sendFeedback({
|
||||||
|
category,
|
||||||
|
message: message.trim(),
|
||||||
|
logbookId,
|
||||||
|
logbookTitle
|
||||||
|
})
|
||||||
|
await showAlert(t('feedback.success'), t('feedback.title'))
|
||||||
|
onClose()
|
||||||
|
} catch (error) {
|
||||||
|
const msg =
|
||||||
|
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
|
||||||
|
? t('feedback.error_not_configured')
|
||||||
|
: t('feedback.error_send')
|
||||||
|
await showAlert(msg, t('feedback.title'))
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="disclaimer-modal-overlay" onClick={sending ? undefined : onClose}>
|
||||||
|
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
|
||||||
|
<div className="auth-header">
|
||||||
|
<MessageSquarePlus className="auth-icon accent" size={48} />
|
||||||
|
<h2>{t('feedback.title')}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="registration-disclaimer__close"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={sending}
|
||||||
|
aria-label={t('feedback.cancel')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
|
||||||
|
|
||||||
|
<form className="feedback-form" onSubmit={handleSubmit}>
|
||||||
|
<label className="feedback-form__field">
|
||||||
|
<span>{t('feedback.category_label')}</span>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
|
||||||
|
disabled={sending}
|
||||||
|
>
|
||||||
|
<option value="general">{t('feedback.category_general')}</option>
|
||||||
|
<option value="bug">{t('feedback.category_bug')}</option>
|
||||||
|
<option value="feature">{t('feedback.category_feature')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="feedback-form__field">
|
||||||
|
<span>{t('feedback.message_label')}</span>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
placeholder={t('feedback.message_placeholder')}
|
||||||
|
rows={6}
|
||||||
|
maxLength={2000}
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="auth-actions feedback-form__actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={onClose} disabled={sending}>
|
||||||
|
{t('feedback.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn primary" disabled={sending || !message.trim()}>
|
||||||
|
{sending ? t('feedback.sending') : t('feedback.send')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { useDialog } from './ModalDialog.tsx'
|
|||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
@@ -217,6 +218,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
|
<FeedbackHeaderButton />
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
|
|||||||
@@ -397,6 +397,23 @@
|
|||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"button_title": "Hinweise & Haftungsausschluss"
|
"button_title": "Hinweise & Haftungsausschluss"
|
||||||
},
|
},
|
||||||
|
"feedback": {
|
||||||
|
"button_title": "Feedback senden",
|
||||||
|
"title": "Feedback",
|
||||||
|
"intro": "Teilen Sie Fehler, Ideen oder allgemeines Feedback. Ihre Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
|
||||||
|
"category_label": "Kategorie",
|
||||||
|
"category_general": "Allgemein",
|
||||||
|
"category_bug": "Fehler melden",
|
||||||
|
"category_feature": "Feature-Wunsch",
|
||||||
|
"message_label": "Nachricht",
|
||||||
|
"message_placeholder": "Beschreiben Sie Ihr Feedback…",
|
||||||
|
"send": "Senden",
|
||||||
|
"sending": "Wird gesendet…",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
|
||||||
|
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
||||||
|
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
|
||||||
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
"logbook_title": "Demo-Logbuch Ostsee",
|
"logbook_title": "Demo-Logbuch Ostsee",
|
||||||
"badge": "Demo",
|
"badge": "Demo",
|
||||||
|
|||||||
@@ -397,6 +397,23 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"button_title": "Legal notice & disclaimer"
|
"button_title": "Legal notice & disclaimer"
|
||||||
},
|
},
|
||||||
|
"feedback": {
|
||||||
|
"button_title": "Send feedback",
|
||||||
|
"title": "Feedback",
|
||||||
|
"intro": "Share bugs, ideas or general feedback. Your message is sent to the project team via a secure notification channel.",
|
||||||
|
"category_label": "Category",
|
||||||
|
"category_general": "General",
|
||||||
|
"category_bug": "Bug report",
|
||||||
|
"category_feature": "Feature request",
|
||||||
|
"message_label": "Message",
|
||||||
|
"message_placeholder": "Describe your feedback…",
|
||||||
|
"send": "Send",
|
||||||
|
"sending": "Sending…",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"success": "Thank you! Your feedback has been sent.",
|
||||||
|
"error_send": "Could not send feedback. Please try again later.",
|
||||||
|
"error_not_configured": "Feedback is not available on this server."
|
||||||
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
"logbook_title": "Baltic Sea Demo Logbook",
|
"logbook_title": "Baltic Sea Demo Logbook",
|
||||||
"badge": "Demo",
|
"badge": "Demo",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||||
|
|
||||||
|
export class FeedbackApiError extends Error {
|
||||||
|
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED'
|
||||||
|
|
||||||
|
constructor(message: string, code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'FeedbackApiError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeedbackHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (userId) headers['X-User-Id'] = userId
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendFeedback(payload: {
|
||||||
|
category: FeedbackCategory
|
||||||
|
message: string
|
||||||
|
logbookId?: string | null
|
||||||
|
logbookTitle?: string | null
|
||||||
|
}): Promise<void> {
|
||||||
|
const res = await fetch('/api/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildFeedbackHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
category: payload.category,
|
||||||
|
message: payload.message,
|
||||||
|
username: localStorage.getItem('active_username') || undefined,
|
||||||
|
logbookId: payload.logbookId || undefined,
|
||||||
|
logbookTitle: payload.logbookTitle || undefined,
|
||||||
|
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
|
||||||
|
pageUrl: window.location.href
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 503) {
|
||||||
|
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new FeedbackApiError(data.error || 'Failed to send feedback')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
|
|||||||
import signRouter from './routes/sign.js'
|
import signRouter from './routes/sign.js'
|
||||||
import pushRouter from './routes/push.js'
|
import pushRouter from './routes/push.js'
|
||||||
import weatherRouter from './routes/weather.js'
|
import weatherRouter from './routes/weather.js'
|
||||||
|
import feedbackRouter from './routes/feedback.js'
|
||||||
import { prisma } from './db.js'
|
import { prisma } from './db.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -31,6 +32,7 @@ app.use('/api/collaboration', collaborationRouter)
|
|||||||
app.use('/api/sign', signRouter)
|
app.use('/api/sign', signRouter)
|
||||||
app.use('/api/push', pushRouter)
|
app.use('/api/push', pushRouter)
|
||||||
app.use('/api/weather', weatherRouter)
|
app.use('/api/weather', weatherRouter)
|
||||||
|
app.use('/api/feedback', feedbackRouter)
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/api/health', async (req, res) => {
|
app.get('/api/health', async (req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
|
||||||
|
const MAX_MESSAGE_LENGTH = 2000
|
||||||
|
|
||||||
|
const requireUser = (req: any, res: any, next: any) => {
|
||||||
|
const userId = req.headers['x-user-id']
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||||
|
}
|
||||||
|
req.userId = userId
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/status', requireUser, (_req, res) => {
|
||||||
|
res.json({ enabled: isNtfyConfigured() })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
if (!isNtfyConfigured()) {
|
||||||
|
return res.status(503).json({ error: 'Feedback is not configured on this server' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { category, message, username, logbookId, logbookTitle, appVersion, pageUrl } = req.body ?? {}
|
||||||
|
|
||||||
|
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid category' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message !== 'string' || !message.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Message is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedMessage = message.trim()
|
||||||
|
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` })
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendFeedbackViaNtfy({
|
||||||
|
category,
|
||||||
|
message: trimmedMessage,
|
||||||
|
username: typeof username === 'string' ? username.trim() : undefined,
|
||||||
|
userId: req.userId,
|
||||||
|
logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined,
|
||||||
|
logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined,
|
||||||
|
appVersion: typeof appVersion === 'string' ? appVersion.trim() : undefined,
|
||||||
|
pageUrl: typeof pageUrl === 'string' ? pageUrl.trim() : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ ok: true })
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error sending feedback via Ntfy:', error)
|
||||||
|
return res.status(502).json({ error: error.message || 'Failed to send feedback' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface FeedbackPayload {
|
||||||
|
category: string
|
||||||
|
message: string
|
||||||
|
username?: string
|
||||||
|
userId: string
|
||||||
|
logbookId?: string
|
||||||
|
logbookTitle?: string
|
||||||
|
appVersion?: string
|
||||||
|
pageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNtfyConfig(): { server: string; topic: string; token?: string } | null {
|
||||||
|
const server = (process.env.NTFY_SERVER || 'https://ntfy.sh').replace(/\/+$/, '')
|
||||||
|
const topic = process.env.NTFY_TOPIC?.trim()
|
||||||
|
const token = process.env.NTFY_TOKEN?.trim()
|
||||||
|
|
||||||
|
if (!topic) return null
|
||||||
|
|
||||||
|
return { server, topic, token: token || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNtfyConfigured(): boolean {
|
||||||
|
return resolveNtfyConfig() !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendFeedbackViaNtfy(payload: FeedbackPayload): Promise<void> {
|
||||||
|
const config = resolveNtfyConfig()
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('NTFY_TOPIC is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabel = payload.category.charAt(0).toUpperCase() + payload.category.slice(1)
|
||||||
|
const title = `Kapteins Daagbok – ${categoryLabel}`
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
payload.message,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
`User: ${payload.username || '(unknown)'}`,
|
||||||
|
`User ID: ${payload.userId}`
|
||||||
|
]
|
||||||
|
|
||||||
|
if (payload.logbookTitle || payload.logbookId) {
|
||||||
|
lines.push(`Logbook: ${payload.logbookTitle || payload.logbookId}`)
|
||||||
|
}
|
||||||
|
if (payload.appVersion) {
|
||||||
|
lines.push(`App version: ${payload.appVersion}`)
|
||||||
|
}
|
||||||
|
if (payload.pageUrl) {
|
||||||
|
lines.push(`Page: ${payload.pageUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Title: title,
|
||||||
|
Tags: 'speech_balloon,ship',
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.token) {
|
||||||
|
headers.Authorization = `Bearer ${config.token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.server}/${encodeURIComponent(config.topic)}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: lines.join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new Error(`Ntfy request failed (${res.status})${body ? `: ${body}` : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user