feat(pm): add bug-report button with screenshot annotation and admin panel

- New BugReport MongoDB model (comment, contact, userId, screenshot, pmState, status)
- POST /api/bug-reports — authenticated submit; emails emanuel@faktorxmensch.com on receipt
- GET/PATCH /api/admin/bug-reports + /[id] — admin list, detail with screenshot, status toggle
- /pm: "Bug" button in HUD captures viewport screenshot (html2canvas), shows annotation
  canvas where testers can draw arrows; submits comment + contact + state snapshot
- /admin: new "Bug Reports" tab with open-count badge, screenshot expand, "Erledigt" toggle,
  and "In /pm öffnen" link that restores captured engine state via ?restoreBugReport=<id>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-06-18 10:05:25 +02:00
parent bfca1832a8
commit ccab0e7116
10 changed files with 819 additions and 2 deletions

Binary file not shown.

View File

@@ -43,6 +43,10 @@
<v-tab value="invitations">Invitations</v-tab>
<v-tab value="waitlist">Waitlist</v-tab>
<v-tab value="logs">Transmissions</v-tab>
<v-tab value="bug-reports">
Bug Reports
<v-badge v-if="bugReportOpenCount > 0" :content="bugReportOpenCount" color="red" inline class="ml-1" />
</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
@@ -932,6 +936,145 @@
</section>
</v-window-item>
<!-- Bug Reports Tab -->
<v-window-item value="bug-reports">
<section class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold">Bug Reports</h2>
<p class="text-sm text-white/70">Fehlermeldungen von Testern aus der /pm-Seite.</p>
</div>
<div class="flex items-center gap-3">
<v-select
v-model="bugReportStatusFilter"
:items="[{ title: 'Offen', value: 'open' }, { title: 'Erledigt', value: 'resolved' }]"
label="Status"
density="comfortable"
variant="outlined"
color="cyan"
hide-details
class="w-40"
/>
<v-btn color="cyan" variant="tonal" :loading="bugReportLoading" @click="fetchBugReports(true)">
Laden
</v-btn>
</div>
</div>
<v-alert v-if="bugReportError" type="warning" variant="tonal" density="comfortable" class="bg-red-500/10 text-red-100">
{{ bugReportError }}
</v-alert>
<div v-if="bugReportLoading && !bugReports.length" class="py-12 text-center text-white/70">
<v-progress-circular indeterminate color="cyan" class="mb-4" />
<p>Bug Reports laden</p>
</div>
<div v-else class="space-y-4">
<div v-if="bugReports.length" class="space-y-4">
<v-card
v-for="report in bugReports"
:key="report.id"
class="border border-white/10 bg-black/40"
>
<v-card-text class="space-y-3">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<v-chip size="x-small" :color="report.status === 'open' ? 'red' : 'green'" variant="tonal">
{{ report.status === 'open' ? 'Offen' : 'Erledigt' }}
</v-chip>
<span class="text-xs text-white/40">{{ formatRelative(report.createdAt) }}</span>
<span v-if="report.hasScreenshot" class="text-xs text-cyan-300/60">
<v-icon size="12">mdi-image</v-icon> Screenshot
</span>
</div>
<p class="font-semibold text-white text-sm">{{ report.contact }}</p>
<p v-if="report.user" class="text-xs text-white/50">{{ report.user.email }}</p>
<p class="text-sm text-white/80 whitespace-pre-line">{{ report.comment }}</p>
<div v-if="report.pmState?.currentStateId" class="text-xs text-white/40 font-mono">
State: {{ report.pmState.currentStateId }} · Flow: {{ report.pmState.flowSlug || '' }}
</div>
</div>
<div class="flex flex-col gap-2 items-start md:items-end shrink-0">
<v-btn
v-if="report.status === 'open'"
size="small"
color="green"
variant="tonal"
prepend-icon="mdi-check"
:loading="bugReportResolving === report.id"
@click="resolveBugReport(report.id)"
>
Erledigt
</v-btn>
<v-btn
v-else
size="small"
color="orange"
variant="text"
prepend-icon="mdi-refresh"
:loading="bugReportResolving === report.id"
@click="reopenBugReport(report.id)"
>
Wieder öffnen
</v-btn>
<v-btn
v-if="report.hasScreenshot"
size="small"
variant="outlined"
color="cyan"
prepend-icon="mdi-image"
@click="loadBugReportDetail(report.id)"
>
Screenshot
</v-btn>
<v-btn
v-if="report.pmState?.flowSlug"
size="small"
variant="text"
color="cyan"
prepend-icon="mdi-play-circle-outline"
:href="`/pm?restoreBugReport=${report.id}`"
target="_blank"
>
In /pm öffnen
</v-btn>
</div>
</div>
<!-- Screenshot detail (loaded on demand) -->
<div v-if="bugReportDetailId === report.id">
<div v-if="bugReportDetailLoading" class="py-4 text-center text-white/60">
<v-progress-circular indeterminate color="cyan" size="24" />
</div>
<img
v-else-if="bugReportDetail?.screenshot"
:src="bugReportDetail.screenshot"
class="w-full rounded-xl border border-white/10"
alt="Screenshot"
/>
</div>
</v-card-text>
</v-card>
</div>
<p v-else class="py-12 text-center text-sm text-white/60">Keine Bug Reports vorhanden.</p>
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
<div class="text-xs text-white/50">
Seite {{ bugReportPagination.page }} von {{ bugReportPagination.pages }} · {{ bugReportPagination.total }} Reports
</div>
<div class="flex items-center gap-2">
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page <= 1" @click="changeBugReportPage(bugReportPagination.page - 1)">Zurück</v-btn>
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page >= bugReportPagination.pages" @click="changeBugReportPage(bugReportPagination.page + 1)">Weiter</v-btn>
</div>
</div>
</div>
</section>
</v-window-item>
</v-window>
</div>
@@ -1280,13 +1423,33 @@ interface CreateInviteResponse {
}
}
interface BugReportItem {
id: string
comment: string
contact: string
user?: { id: string; email: string; name?: string }
pmState?: { flowSlug?: string; scenarioId?: string; currentStateId?: string }
status: 'open' | 'resolved'
createdAt: string | null
hasScreenshot: boolean
}
interface BugReportDetail extends BugReportItem {
screenshot: string | null
}
interface BugReportsResponse {
items: BugReportItem[]
pagination: { total: number; page: number; pageSize: number; pages: number }
}
definePageMeta({ middleware: 'require-admin' })
useHead({ title: 'Admin • OpenSquawk' })
const auth = useAuthStore()
const api = useApi()
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs'>('overview')
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs' | 'bug-reports'>('overview')
const refreshing = ref(false)
const overview = ref<OverviewData | null>(null)
@@ -1890,11 +2053,104 @@ watch(activeTab, (tab) => {
fetchWaitlist(true)
} else if (tab === 'logs' && !sessionsLoaded.value) {
fetchSessions(true)
} else if (tab === 'bug-reports' && !bugReportsLoaded.value) {
fetchBugReports(true)
}
})
// ── Bug Reports ──────────────────────────────────────────────────────────────
const bugReports = ref<BugReportItem[]>([])
const bugReportPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 20 })
const bugReportLoading = ref(false)
const bugReportError = ref('')
const bugReportsLoaded = ref(false)
const bugReportStatusFilter = ref<'open' | 'resolved'>('open')
const bugReportOpenCount = ref(0)
const bugReportResolving = ref<string | null>(null)
const bugReportDetailId = ref<string | null>(null)
const bugReportDetail = ref<BugReportDetail | null>(null)
const bugReportDetailLoading = ref(false)
async function fetchBugReports(resetPage = false) {
if (resetPage) bugReportPagination.page = 1
bugReportLoading.value = true
bugReportError.value = ''
try {
const response = await api.get<BugReportsResponse>('/api/admin/bug-reports', {
query: { status: bugReportStatusFilter.value, page: bugReportPagination.page },
})
bugReports.value = response.items
Object.assign(bugReportPagination, response.pagination)
bugReportsLoaded.value = true
if (bugReportStatusFilter.value === 'open') bugReportOpenCount.value = response.pagination.total
} catch (error) {
bugReportError.value = extractErrorMessage(error, 'Bug Reports konnten nicht geladen werden.')
} finally {
bugReportLoading.value = false
}
}
async function loadBugReportDetail(id: string) {
if (bugReportDetailId.value === id) {
bugReportDetailId.value = null
bugReportDetail.value = null
return
}
bugReportDetailId.value = id
bugReportDetail.value = null
bugReportDetailLoading.value = true
try {
bugReportDetail.value = await api.get<BugReportDetail>(`/api/admin/bug-reports/${id}`)
} catch {
bugReportDetailId.value = null
} finally {
bugReportDetailLoading.value = false
}
}
async function resolveBugReport(id: string) {
bugReportResolving.value = id
try {
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'resolved' } })
const report = bugReports.value.find((r) => r.id === id)
if (report) { report.status = 'resolved'; bugReportOpenCount.value = Math.max(0, bugReportOpenCount.value - 1) }
if (bugReportStatusFilter.value === 'open') bugReports.value = bugReports.value.filter((r) => r.id !== id)
} catch (error) {
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
} finally {
bugReportResolving.value = null
}
}
async function reopenBugReport(id: string) {
bugReportResolving.value = id
try {
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'open' } })
const report = bugReports.value.find((r) => r.id === id)
if (report) { report.status = 'open'; bugReportOpenCount.value++ }
if (bugReportStatusFilter.value === 'resolved') bugReports.value = bugReports.value.filter((r) => r.id !== id)
} catch (error) {
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
} finally {
bugReportResolving.value = null
}
}
function changeBugReportPage(page: number) {
if (page < 1 || page > bugReportPagination.pages) return
bugReportPagination.page = page
fetchBugReports()
}
watch(bugReportStatusFilter, () => fetchBugReports(true))
// ────────────────────────────────────────────────────────────────────────────
onMounted(() => {
loadOverview(true)
// Load open bug report count for the badge
api.get<BugReportsResponse>('/api/admin/bug-reports', { query: { status: 'open', page: 1 } })
.then((r) => { bugReportOpenCount.value = r.pagination.total })
.catch(() => {})
})
</script>
<style scoped>

View File

@@ -467,6 +467,15 @@
</div>
<div class="hud-right">
<button
type="button"
class="btn ghost"
title="Fehler melden"
@click="openBugReport"
>
<v-icon size="18">mdi-bug-outline</v-icon>
<span class="btn-label">Bug</span>
</button>
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
<v-icon size="18">mdi-message-draw</v-icon>
<span class="btn-label">Feedback</span>
@@ -1198,6 +1207,107 @@
</v-card>
</v-dialog>
<!-- Bug Report Dialog -->
<v-dialog v-model="showBugReportDialog" max-width="680" scrollable>
<v-card class="bg-[#0b101d] border border-white/10 text-white">
<v-card-title class="flex items-center gap-2 text-base font-semibold pt-4 px-5">
<v-icon icon="mdi-bug-outline" color="#f87171" size="20" />
Fehler melden
</v-card-title>
<v-card-text class="space-y-4 px-5 pb-2">
<div v-if="bugReportSuccess" class="rounded-xl bg-emerald-500/10 border border-emerald-400/30 p-4 text-center">
<v-icon icon="mdi-check-circle-outline" color="emerald" size="32" class="mb-2" />
<p class="text-emerald-300 font-semibold">Danke! Bug Report wurde gesendet.</p>
</div>
<template v-else>
<div v-if="bugReportScreenshot" class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Screenshot Pfeile einzeichnen</p>
<v-btn
v-if="bugReportArrows.length > 0"
size="x-small"
variant="text"
color="red"
prepend-icon="mdi-undo"
@click="undoLastArrow"
>
Rückgängig
</v-btn>
</div>
<div class="relative rounded-xl overflow-hidden border border-white/10" style="line-height:0">
<img
ref="bugReportImgRef"
:src="bugReportScreenshot"
class="w-full block"
alt="Screenshot"
@load="setupAnnotationCanvas"
/>
<canvas
ref="bugReportCanvasRef"
class="absolute inset-0 w-full h-full cursor-crosshair select-none"
style="touch-action:none"
@mousedown="onCanvasMouseDown"
@mousemove="onCanvasMouseMove"
@mouseup="onCanvasMouseUp"
@mouseleave="onCanvasMouseLeave"
/>
</div>
</div>
<div v-else class="rounded-xl border border-white/10 bg-white/5 p-4 text-center text-sm text-white/50">
Kein Screenshot verfügbar
</div>
<v-textarea
v-model="bugReportComment"
label="Was ist kaputt? Was sollte stattdessen passieren?"
variant="outlined"
color="red"
rows="3"
auto-grow
maxlength="2000"
counter="2000"
hide-details="auto"
:disabled="bugReportLoading"
/>
<v-text-field
v-model="bugReportContact"
label="Dein Name / Kontakt"
variant="outlined"
color="red"
density="comfortable"
hide-details
:disabled="bugReportLoading"
/>
<v-alert
v-if="bugReportError"
type="error"
density="compact"
variant="tonal"
class="bg-red-500/10 text-red-200"
>
{{ bugReportError }}
</v-alert>
</template>
</v-card-text>
<v-card-actions v-if="!bugReportSuccess" class="justify-end gap-2 px-5 pb-4">
<v-btn variant="text" color="grey" :disabled="bugReportLoading" @click="showBugReportDialog = false">
Abbrechen
</v-btn>
<v-btn
color="red"
variant="flat"
:loading="bugReportLoading"
:disabled="!bugReportComment.trim()"
@click="submitBugReport"
>
Bug melden
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Transmission issue dialog -->
<v-dialog v-model="showTransmissionIssueDialog" max-width="420">
<v-card class="bg-[#0b101d] border border-white/10 text-white">
@@ -1242,7 +1352,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech';
import { useAuthStore } from '~/stores/auth'
@@ -1292,6 +1402,7 @@ const engine = useCommunicationsEngine()
const auth = useAuthStore()
const api = useApi()
const router = useRouter()
const route = useRoute()
const radioBackend = useRadioBackend()
const config = useRuntimeConfig()
@@ -1992,6 +2103,168 @@ const radioEffectsEnabled = ref(true)
const readbackEnabled = ref(false)
const debugMode = ref(true)
// ── Bug Report ───────────────────────────────────────────────────────────────
const showBugReportDialog = ref(false)
const bugReportComment = ref('')
const bugReportContact = ref('')
const bugReportScreenshot = ref<string | null>(null)
const bugReportArrows = ref<Array<{ fx: number; fy: number; tx: number; ty: number }>>([])
const bugReportLoading = ref(false)
const bugReportError = ref('')
const bugReportSuccess = ref(false)
const bugReportCanvasRef = ref<HTMLCanvasElement | null>(null)
const bugReportImgRef = ref<HTMLImageElement | null>(null)
let _arrowDrawing = false
let _arrowStart = { x: 0, y: 0 }
function setupAnnotationCanvas() {
const canvas = bugReportCanvasRef.value
const img = bugReportImgRef.value
if (!canvas || !img) return
canvas.width = img.clientWidth
canvas.height = img.clientHeight
}
function _drawArrow(ctx: CanvasRenderingContext2D, fx: number, fy: number, tx: number, ty: number) {
const headLen = 14
const angle = Math.atan2(ty - fy, tx - fx)
ctx.strokeStyle = '#ef4444'
ctx.fillStyle = '#ef4444'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(fx, fy)
ctx.lineTo(tx, ty)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(tx, ty)
ctx.lineTo(tx - headLen * Math.cos(angle - Math.PI / 6), ty - headLen * Math.sin(angle - Math.PI / 6))
ctx.lineTo(tx - headLen * Math.cos(angle + Math.PI / 6), ty - headLen * Math.sin(angle + Math.PI / 6))
ctx.closePath()
ctx.fill()
}
function _redrawAnnotations(preview?: { fx: number; fy: number; tx: number; ty: number }) {
const canvas = bugReportCanvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx, a.fy, a.tx, a.ty)
if (preview) {
ctx.globalAlpha = 0.55
_drawArrow(ctx, preview.fx, preview.fy, preview.tx, preview.ty)
ctx.globalAlpha = 1
}
}
function _canvasCoords(e: MouseEvent) {
const canvas = bugReportCanvasRef.value!
const rect = canvas.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function onCanvasMouseDown(e: MouseEvent) {
_arrowStart = _canvasCoords(e)
_arrowDrawing = true
}
function onCanvasMouseMove(e: MouseEvent) {
if (!_arrowDrawing) return
const { x, y } = _canvasCoords(e)
_redrawAnnotations({ fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y })
}
function onCanvasMouseUp(e: MouseEvent) {
if (!_arrowDrawing) return
_arrowDrawing = false
const { x, y } = _canvasCoords(e)
const dx = x - _arrowStart.x
const dy = y - _arrowStart.y
if (Math.sqrt(dx * dx + dy * dy) < 8) { _redrawAnnotations(); return }
bugReportArrows.value = [...bugReportArrows.value, { fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y }]
_redrawAnnotations()
}
function onCanvasMouseLeave() {
if (!_arrowDrawing) return
_arrowDrawing = false
_redrawAnnotations()
}
function undoLastArrow() {
bugReportArrows.value = bugReportArrows.value.slice(0, -1)
_redrawAnnotations()
}
async function openBugReport() {
bugReportError.value = ''
bugReportSuccess.value = false
bugReportComment.value = ''
bugReportArrows.value = []
bugReportScreenshot.value = null
bugReportContact.value = [auth.user?.name, auth.user?.email].filter(Boolean).join(' — ')
try {
const { default: html2canvas } = await import('html2canvas')
const c = await html2canvas(document.body, { scale: 0.55, useCORS: true, allowTaint: true, logging: false })
bugReportScreenshot.value = c.toDataURL('image/jpeg', 0.75)
} catch { /* Screenshot optional */ }
showBugReportDialog.value = true
}
async function submitBugReport() {
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte einen Kommentar eingeben.'; return }
bugReportLoading.value = true
bugReportError.value = ''
try {
let finalScreenshot: string | undefined
if (bugReportScreenshot.value) {
const img = bugReportImgRef.value
const src = new Image()
await new Promise<void>((res) => { src.onload = () => res(); src.src = bugReportScreenshot.value! })
const out = document.createElement('canvas')
out.width = src.naturalWidth; out.height = src.naturalHeight
const ctx = out.getContext('2d')!
ctx.drawImage(src, 0, 0)
if (bugReportArrows.value.length > 0 && img) {
const sx = src.naturalWidth / img.clientWidth
const sy = src.naturalHeight / img.clientHeight
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx * sx, a.fy * sy, a.tx * sx, a.ty * sy)
}
finalScreenshot = out.toDataURL('image/jpeg', 0.8)
}
const pmState = {
flowSlug: activeScenario.value?.startFlow || '',
scenarioId: activeScenario.value?.id || '',
currentStateId: (currentState.value as any)?.id || '',
variables: (vars as any)?.value || {},
flags: (flags as any)?.value || {},
flightContext: (flightContext as any)?.value || {},
communicationLog: ((log as any)?.value || [] as any[]).slice(-20),
}
await api.post('/api/bug-reports', {
comment: bugReportComment.value.trim(),
contact: bugReportContact.value.trim(),
screenshot: finalScreenshot,
pmState,
})
bugReportSuccess.value = true
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 2500)
} catch (err: any) {
bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.'
} finally {
bugReportLoading.value = false
}
}
// ────────────────────────────────────────────────────────────────────────────
// Pre-recording (rolling mic buffer) so the first ~1s of PTT speech isn't clipped
const prerecEnabled = ref(true)
const prerecSeconds = ref(1.0)
@@ -2151,6 +2424,27 @@ onMounted(async () => {
}
}
}
// Restore state from bug report (admin link: /pm?restoreBugReport=<id>)
const restoreId = route.query.restoreBugReport as string | undefined
if (restoreId) {
try {
const report = await api.get<any>(`/api/admin/bug-reports/${restoreId}`)
const state = report?.pmState
if (state) {
if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables)
if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags)
if (state.flowSlug) {
try {
await fetchRuntimeTree(state.flowSlug, config.public.radioBackendUrl as string)
} catch {}
}
error.value = `[Bug-Report-Restore] State: ${state.currentStateId || '?'} · Flow: ${state.flowSlug || '?'}`
}
} catch (err) {
console.warn('[PM] Bug report restore failed', err)
}
}
} finally {
restoringFromStorage = false
}

View File

@@ -22,6 +22,7 @@
"@pinia/nuxt": "^0.11.3",
"dotenv": "^17.3.1",
"fluent-ffmpeg": "^2.1.3",
"html2canvas": "^1.4.1",
"nodemailer": "^8.0.1",
"nuxt": "^4.3.1",
"nuxt-aos": "^1.2.6",

View File

@@ -0,0 +1,27 @@
import { defineEventHandler, getRouterParam, createError } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { BugReport } from '../../../models/BugReport'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const doc = await BugReport.findById(id).populate('userId', 'email name role').lean() as any
if (!doc) {
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
}
return {
id: String(doc._id),
comment: doc.comment,
contact: doc.contact,
user: doc.userId
? { id: String((doc.userId as any)._id), email: (doc.userId as any).email, name: (doc.userId as any).name }
: undefined,
screenshot: doc.screenshot || null,
pmState: doc.pmState || null,
status: doc.status,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
}
})

View File

@@ -0,0 +1,27 @@
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { BugReport } from '../../../models/BugReport'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const status = body?.status
if (status !== 'open' && status !== 'resolved') {
throw createError({ statusCode: 400, statusMessage: 'status must be "open" or "resolved"' })
}
const doc = await BugReport.findByIdAndUpdate(
id,
{ $set: { status } },
{ new: true },
).lean() as any
if (!doc) {
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
}
return { id: String(doc._id), status: doc.status }
})

View File

@@ -0,0 +1,78 @@
import { defineEventHandler, getQuery } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { BugReport } from '../../../models/BugReport'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const query = getQuery(event)
const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1)
const pageSize = 20
const skip = (page - 1) * pageSize
const status = String(query.status ?? 'open')
const filter: Record<string, any> = {}
if (status === 'open' || status === 'resolved') {
filter.status = status
}
const [docs, total] = await Promise.all([
BugReport.aggregate([
{ $match: filter },
{ $sort: { createdAt: -1 } },
{ $skip: skip },
{ $limit: pageSize },
{
$lookup: {
from: 'users',
localField: 'userId',
foreignField: '_id',
as: 'userDoc',
pipeline: [{ $project: { email: 1, name: 1, role: 1 } }],
},
},
{
$project: {
comment: 1,
contact: 1,
userId: 1,
userDoc: 1,
status: 1,
createdAt: 1,
'pmState.flowSlug': 1,
'pmState.scenarioId': 1,
'pmState.currentStateId': 1,
hasScreenshot: { $gt: [{ $strLenCP: { $ifNull: ['$screenshot', ''] } }, 0] },
},
},
]).exec(),
BugReport.countDocuments(filter),
])
const items = docs.map((doc: any) => ({
id: String(doc._id),
comment: doc.comment,
contact: doc.contact,
user: doc.userDoc?.[0]
? { id: String(doc.userDoc[0]._id), email: doc.userDoc[0].email, name: doc.userDoc[0].name }
: undefined,
pmState: {
flowSlug: doc.pmState?.flowSlug,
scenarioId: doc.pmState?.scenarioId,
currentStateId: doc.pmState?.currentStateId,
},
status: doc.status,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
hasScreenshot: Boolean(doc.hasScreenshot),
}))
return {
items,
pagination: {
total,
page,
pageSize,
pages: Math.ceil(total / pageSize) || 1,
},
}
})

View File

@@ -0,0 +1,44 @@
import { defineEventHandler, readBody, createError } from 'h3'
import { requireUserSession } from '../../utils/auth'
import { BugReport } from '../../models/BugReport'
import { sendMail } from '../../utils/notifications'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const body = await readBody(event)
const comment = String(body?.comment ?? '').trim()
if (!comment) {
throw createError({ statusCode: 400, statusMessage: 'Kommentar ist erforderlich' })
}
const contact = String(
body?.contact || [user.name, user.email].filter(Boolean).join(' — ')
).slice(0, 200)
const report = await BugReport.create({
comment: comment.slice(0, 4000),
contact,
userId: user._id,
screenshot: body?.screenshot || undefined,
pmState: body?.pmState || undefined,
})
const adminUrl = `${process.env.APP_URL || 'https://app.opensquawk.de'}/admin`
const stateInfo = body?.pmState?.currentStateId
? `State: ${body.pmState.currentStateId} (Flow: ${body.pmState.flowSlug || '—'})`
: ''
await sendMail({
to: 'emanuel@faktorxmensch.com',
subject: `[OpenSquawk Bug] ${contact}`,
html: `<h2>Neuer Bug Report</h2>
<p><strong>Von:</strong> ${contact}</p>
<p><strong>Kommentar:</strong><br>${comment.replace(/\n/g, '<br>')}</p>
${stateInfo ? `<p><strong>${stateInfo}</strong></p>` : ''}
<p><a href="${adminUrl}">Im Admin-Panel ansehen →</a></p>`,
text: `Bug Report von ${contact}\n\n${comment}\n${stateInfo}\n\nAdmin: ${adminUrl}`,
}).catch(() => {})
return { success: true, id: String(report._id) }
})

View File

@@ -0,0 +1,45 @@
import mongoose from 'mongoose'
export type BugReportStatus = 'open' | 'resolved'
export interface PmStateSnapshot {
flowSlug?: string
scenarioId?: string
currentStateId?: string
variables?: Record<string, any>
flags?: Record<string, boolean>
flightContext?: Record<string, any>
communicationLog?: any[]
}
export interface BugReportDocument extends mongoose.Document {
comment: string
contact: string
userId?: mongoose.Types.ObjectId
screenshot?: string
pmState?: PmStateSnapshot
status: BugReportStatus
createdAt: Date
}
const bugReportSchema = new mongoose.Schema<BugReportDocument>({
comment: { type: String, required: true, trim: true, maxlength: 4000 },
contact: { type: String, required: true, trim: true, maxlength: 200 },
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
screenshot: { type: String },
pmState: {
flowSlug: { type: String },
scenarioId: { type: String },
currentStateId: { type: String },
variables: { type: mongoose.Schema.Types.Mixed, default: {} },
flags: { type: mongoose.Schema.Types.Mixed, default: {} },
flightContext: { type: mongoose.Schema.Types.Mixed, default: {} },
communicationLog: { type: [mongoose.Schema.Types.Mixed], default: [] },
},
status: { type: String, enum: ['open', 'resolved'], default: 'open', index: true },
createdAt: { type: Date, default: () => new Date(), index: true },
})
export const BugReport =
(mongoose.models.BugReport as mongoose.Model<BugReportDocument> | undefined) ||
mongoose.model<BugReportDocument>('BugReport', bugReportSchema)

View File

@@ -4211,6 +4211,13 @@ __metadata:
languageName: node
linkType: hard
"base64-arraybuffer@npm:^1.0.2":
version: 1.0.2
resolution: "base64-arraybuffer@npm:1.0.2"
checksum: 10c0/3acac95c70f9406e87a41073558ba85b6be9dbffb013a3d2a710e3f2d534d506c911847d5d9be4de458af6362c676de0a5c4c2d7bdf4def502d00b313368e72f
languageName: node
linkType: hard
"base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
@@ -4827,6 +4834,15 @@ __metadata:
languageName: node
linkType: hard
"css-line-break@npm:^2.1.0":
version: 2.1.0
resolution: "css-line-break@npm:2.1.0"
dependencies:
utrie: "npm:^1.0.2"
checksum: 10c0/b2222d99d5daf7861ecddc050244fdce296fad74b000dcff6bdfb1eb16dc2ef0b9ffe2c1c965e3239bd05ebe9eadb6d5438a91592fa8648d27a338e827cf9048
languageName: node
linkType: hard
"css-select@npm:^5.1.0":
version: 5.2.2
resolution: "css-select@npm:5.2.2"
@@ -6142,6 +6158,16 @@ __metadata:
languageName: node
linkType: hard
"html2canvas@npm:^1.4.1":
version: 1.4.1
resolution: "html2canvas@npm:1.4.1"
dependencies:
css-line-break: "npm:^2.1.0"
text-segmentation: "npm:^1.0.3"
checksum: 10c0/6de86f75762b00948edf2ea559f16da0a1ec3facc4a8a7d3f35fcec59bb0c5970463478988ae3d9082152e0173690d46ebf4082e7ac803dd4817bae1d355c0db
languageName: node
linkType: hard
"http-assert@npm:^1.3.0":
version: 1.5.0
resolution: "http-assert@npm:1.5.0"
@@ -7976,6 +8002,7 @@ __metadata:
"@types/three": "npm:^0.183.0"
dotenv: "npm:^17.3.1"
fluent-ffmpeg: "npm:^2.1.3"
html2canvas: "npm:^1.4.1"
nodemailer: "npm:^8.0.1"
nuxt: "npm:^4.3.1"
nuxt-aos: "npm:^1.2.6"
@@ -10007,6 +10034,15 @@ __metadata:
languageName: node
linkType: hard
"text-segmentation@npm:^1.0.3":
version: 1.0.3
resolution: "text-segmentation@npm:1.0.3"
dependencies:
utrie: "npm:^1.0.2"
checksum: 10c0/8b9ae8524e3a332371060d0ca62f10ad49a13e954719ea689a6c3a8b8c15c8a56365ede2bb91c322fb0d44b6533785f0da603e066b7554d052999967fb72d600
languageName: node
linkType: hard
"thenify-all@npm:^1.0.0":
version: 1.6.0
resolution: "thenify-all@npm:1.6.0"
@@ -10598,6 +10634,15 @@ __metadata:
languageName: node
linkType: hard
"utrie@npm:^1.0.2":
version: 1.0.2
resolution: "utrie@npm:1.0.2"
dependencies:
base64-arraybuffer: "npm:^1.0.2"
checksum: 10c0/eaffe645bd81a39e4bc3abb23df5895e9961dbdd49748ef3b173529e8b06ce9dd1163e9705d5309a1c61ee41ffcb825e2043bc0fd1659845ffbdf4b1515dfdb4
languageName: node
linkType: hard
"v-lazy-show@npm:^0.2.4":
version: 0.2.4
resolution: "v-lazy-show@npm:0.2.4"