mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Wire feedback form to backend endpoint
This commit is contained in:
@@ -406,13 +406,14 @@ interface NavigationSectionItem {
|
||||
const BASE_URL = 'https://opensquawk.de'
|
||||
|
||||
const sectionCategoryOrder: Record<string, string[]> = {
|
||||
'Public endpoints': ['Waitlist & marketing', 'Authentication & onboarding', 'Tools & diagnostics'],
|
||||
'Public endpoints': ['Waitlist & marketing', 'Feedback & insights', 'Authentication & onboarding', 'Tools & diagnostics'],
|
||||
'Protected endpoints': ['Session controls', 'Invitation management', 'ATC synthesis', 'Decision engine'],
|
||||
}
|
||||
|
||||
const sectionCategoryDescriptions: Record<string, Record<string, string>> = {
|
||||
'Public endpoints': {
|
||||
'Waitlist & marketing': 'Capture interest, collect consent, and gauge roadmap sentiment.',
|
||||
'Feedback & insights': 'Share structured bug reports and qualitative feedback with the team.',
|
||||
'Authentication & onboarding': 'Account creation, recovery, and invitation flows during the alpha programme.',
|
||||
'Tools & diagnostics': 'Helper utilities for simulator integrations and latency checks.',
|
||||
},
|
||||
@@ -578,6 +579,91 @@ const endpointSections: EndpointSection[] = [
|
||||
"suggestionId": "6625c9f0e1f8f5d4068a1234"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/service/feedback',
|
||||
summary: 'Send qualitative feedback directly to the OpenSquawk crew.',
|
||||
category: 'Feedback & insights',
|
||||
auth: 'public',
|
||||
body: [
|
||||
{ name: 'name', type: 'string', description: 'Optional display name.' },
|
||||
{ name: 'email', type: 'string', description: 'Optional contact email to receive a reply.' },
|
||||
{ name: 'excitement', type: 'number (1..5)', required: true, description: 'Overall excitement rating.' },
|
||||
{
|
||||
name: 'highlightSelections',
|
||||
type: 'string[]',
|
||||
description: 'Checkbox selections describing what already works well.',
|
||||
},
|
||||
{ name: 'highlightNotes', type: 'string', description: 'Long-form notes about the highlights.' },
|
||||
{
|
||||
name: 'frictionSelections',
|
||||
type: 'string[]',
|
||||
description: 'Checkbox selections describing friction points.',
|
||||
},
|
||||
{ name: 'frictionNotes', type: 'string', description: 'Detailed explanation of friction areas.' },
|
||||
{ name: 'classroomNotes', type: 'string', description: 'Stories about the classroom missions.' },
|
||||
{ name: 'hostingInterest', type: 'string', description: 'Thoughts on self-hosting or managed hosting.' },
|
||||
{ name: 'otherIdeas', type: 'string', description: 'Wildcard space for big ideas and requests.' },
|
||||
{
|
||||
name: 'contactConsent',
|
||||
type: 'boolean',
|
||||
description: 'Set true if it is okay for the team to follow up via email.',
|
||||
},
|
||||
],
|
||||
sampleRequest: `curl -X POST https://opensquawk.de/api/service/feedback \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "Jane Pilot",
|
||||
"email": "jane.pilot@example.com",
|
||||
"excitement": 5,
|
||||
"highlightSelections": ["Lesson console", "UI design"],
|
||||
"highlightNotes": "The console feels crisp and fast.",
|
||||
"frictionSelections": ["Latency or responsiveness"],
|
||||
"frictionNotes": "Audio hiccups during busy moments.",
|
||||
"classroomNotes": "Loved the clearance training mission.",
|
||||
"hostingInterest": "Curious about a managed EU cluster.",
|
||||
"otherIdeas": "It would be great to add printable kneeboards.",
|
||||
"contactConsent": true
|
||||
}'`,
|
||||
sampleResponse: `{
|
||||
"success": true
|
||||
}`,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/service/feedback/issues',
|
||||
summary: 'File a public feedback report with categories, title, and description.',
|
||||
category: 'Feedback & insights',
|
||||
auth: 'public',
|
||||
body: [
|
||||
{ name: 'title', type: 'string', required: true, description: 'At least 4 characters.' },
|
||||
{ name: 'description', type: 'string', required: true, description: 'At least 20 characters explaining the report.' },
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'string[]',
|
||||
required: true,
|
||||
description: 'One or more tags such as "audio", "ui", or "content".',
|
||||
},
|
||||
{
|
||||
name: 'contactEmail',
|
||||
type: 'string',
|
||||
description: 'Optional email address if you want a follow-up message.',
|
||||
},
|
||||
],
|
||||
sampleRequest: `curl -X POST https://opensquawk.de/api/service/feedback/issues \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"title": "Audio drops in busy towers",
|
||||
"description": "While working EDDF Approach the audio fell silent twice during high traffic. It recovers after ~5 seconds but the gap is scary.",
|
||||
"categories": ["audio", "stability"],
|
||||
"contactEmail": "pilot@example.com"
|
||||
}'`,
|
||||
sampleResponse: `{
|
||||
"success": true,
|
||||
"reportId": "5f0b4a16-7b52-4b63-8894-0f3a28e2d6a4"
|
||||
}`,
|
||||
notes: 'Reports are delivered to the operations team and may be triaged publicly in future releases.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/service/auth/login',
|
||||
|
||||
@@ -23,24 +23,14 @@
|
||||
We are currently a German development team, but all public docs and tools start in English so we can reach as many people as possible.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-400/30 bg-amber-400/10 p-5 text-amber-100 shadow-lg shadow-amber-500/10">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.25em] text-amber-50/80">Quick heads-up</p>
|
||||
<p class="text-sm text-amber-50/80">All notes are sent by email. When you submit below we prepare an email to <strong>info@opensquawk.de</strong> with your answers, just like the rest of our forms.</p>
|
||||
</div>
|
||||
<a
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-amber-200/40 bg-amber-200/10 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-200/20"
|
||||
href="mailto:info@opensquawk.de?subject=OpenSquawk%20feedback%20call"
|
||||
>
|
||||
<v-icon icon="mdi-phone" size="18" class="text-amber-100" />
|
||||
Request a call
|
||||
</a>
|
||||
<div class="overflow-hidden rounded-3xl border border-white/10 bg-black/40">
|
||||
<div class="relative aspect-[3/1]">
|
||||
<img src="/img/landing/path.jpeg" alt="OpenSquawk community charting the path forward" class="absolute inset-0 h-full w-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="space-y-10 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-xl shadow-cyan-500/10 backdrop-blur" @submit.prevent="handleSubmit">
|
||||
<form class="space-y-10 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-xl shadow-cyan-500/10 backdrop-blur" @submit.prevent="handleSubmit" novalidate>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label for="name" class="text-xs font-semibold uppercase tracking-[0.3em] text-white/50">Name (optional)</label>
|
||||
@@ -170,36 +160,61 @@
|
||||
<input type="checkbox" class="mt-1 accent-cyan-400" v-model="form.contactConsent" />
|
||||
<span>I am happy for you to email me back about this feedback or to invite me to jam on the roadmap.</span>
|
||||
</label>
|
||||
<p class="text-xs text-white/50">Submitting prepares an email draft to <strong>info@opensquawk.de</strong>. Please review it and hit send from your client.</p>
|
||||
<p class="text-xs text-white/50">Submissions are routed straight to <strong>info@opensquawk.de</strong> so the team can read and respond quickly.</p>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-2xl bg-cyan-400 px-6 py-3 text-base font-semibold text-slate-950 shadow-[0_16px_40px_rgba(56,189,248,0.35)] transition hover:bg-cyan-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
|
||||
:disabled="submissionState === 'submitting'"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-2xl bg-cyan-400 px-6 py-3 text-base font-semibold text-slate-950 shadow-[0_16px_40px_rgba(56,189,248,0.35)] transition hover:bg-cyan-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-cyan-400"
|
||||
>
|
||||
<v-icon icon="mdi-send" size="20" class="text-slate-950" />
|
||||
Prepare email
|
||||
<v-icon :icon="submissionState === 'submitting' ? 'mdi-dots-circle' : 'mdi-send'" size="20" class="text-slate-950" />
|
||||
<span>{{ submissionState === 'submitting' ? 'Sending…' : 'Send feedback' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-if="submissionState !== 'idle'" class="rounded-3xl border border-white/10 bg-white/5 p-6 text-sm text-white/80 shadow-xl shadow-cyan-500/10">
|
||||
<h2 class="text-lg font-semibold text-white">Email preview</h2>
|
||||
<p class="mt-2 text-white/70">
|
||||
Your email client should open automatically. If not, copy the text below and send it to <a class="text-cyan-300 underline" href="mailto:info@opensquawk.de">info@opensquawk.de</a>.
|
||||
</p>
|
||||
<div class="mt-4 rounded-2xl border border-white/10 bg-black/50 p-4">
|
||||
<pre class="whitespace-pre-wrap break-words text-sm text-white/80">{{ emailBody }}</pre>
|
||||
<section v-if="submissionState === 'success'" class="space-y-4 rounded-3xl border border-emerald-400/30 bg-emerald-400/10 p-6 text-sm text-emerald-100 shadow-xl shadow-emerald-500/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<v-icon icon="mdi-check-decagram" size="28" class="mt-0.5 text-emerald-200" />
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-semibold text-white">Thank you for steering the mission!</h2>
|
||||
<p>Your notes are already on their way to the cockpit at info@opensquawk.de. We will follow up if you checked that it’s okay.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="submissionState === 'error'" class="space-y-4 rounded-3xl border border-rose-400/40 bg-rose-400/10 p-6 text-sm text-rose-100 shadow-xl shadow-rose-500/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<v-icon icon="mdi-alert" size="26" class="mt-0.5 text-rose-200" />
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-semibold text-white">Something jammed on the way out</h2>
|
||||
<p>{{ submissionError || 'We could not send this feedback. Please try again in a moment or email info@opensquawk.de directly.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-white/10 bg-white/5 p-6 text-sm text-white/80 shadow-xl shadow-cyan-500/10">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-semibold text-white">Prefer to talk it through?</h2>
|
||||
<p>We are more than happy to jump on Discord or TeamSpeak for a quick debrief about your ideas.</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<a
|
||||
class="inline-flex items-center justify-center gap-2 rounded-2xl border border-cyan-200/40 bg-cyan-200/10 px-5 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-200/70 hover:bg-cyan-200/20"
|
||||
href="mailto:info@opensquawk.de?subject=OpenSquawk%20feedback%20call&body=I'd%20love%20to%20chat%20about%20feedback%20on%20Discord%20or%20TeamSpeak."
|
||||
>
|
||||
<v-icon icon="mdi-discord" size="20" />
|
||||
Request Discord call
|
||||
</a>
|
||||
<a
|
||||
class="inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-200/40 bg-indigo-200/10 px-5 py-3 text-sm font-semibold text-indigo-100 transition hover:border-indigo-200/70 hover:bg-indigo-200/20"
|
||||
href="mailto:info@opensquawk.de?subject=OpenSquawk%20feedback%20call&body=TeamSpeak%20works%20great%20for%20me%20%E2%80%93%20let's%20connect!"
|
||||
>
|
||||
<v-icon icon="mdi-headset" size="20" />
|
||||
Request TeamSpeak call
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition hover:border-cyan-400 hover:text-cyan-100"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<v-icon :icon="copyStatus === 'copied' ? 'mdi-check' : 'mdi-content-copy'" size="18" />
|
||||
<span v-if="copyStatus === 'copied'">Copied to clipboard</span>
|
||||
<span v-else-if="copyStatus === 'failed'">Copy not available</span>
|
||||
<span v-else>Copy email text</span>
|
||||
</button>
|
||||
<p v-if="copyStatus === 'failed'" class="mt-2 text-xs text-rose-200/80">Please copy the text manually if your browser blocks clipboard access.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,8 +222,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useHead, useNuxtApp } from '#imports'
|
||||
|
||||
useHead({ title: 'Feedback & ideas • OpenSquawk' })
|
||||
|
||||
@@ -246,93 +261,55 @@ const form = reactive({
|
||||
contactConsent: true,
|
||||
})
|
||||
|
||||
const submissionState = ref<'idle' | 'ready'>('idle')
|
||||
const emailBody = ref('')
|
||||
const copyStatus = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||
const submissionState = ref<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
||||
const submissionError = ref<string | null>(null)
|
||||
|
||||
const formattedHighlights = computed(() => (form.highlightSelections.length ? form.highlightSelections.join(', ') : '—'))
|
||||
const formattedFrictions = computed(() => (form.frictionSelections.length ? form.frictionSelections.join(', ') : '—'))
|
||||
|
||||
function clipboardAvailable() {
|
||||
return typeof navigator !== 'undefined' && !!navigator.clipboard && 'writeText' in navigator.clipboard
|
||||
}
|
||||
|
||||
function buildEmailBody() {
|
||||
const lines: string[] = []
|
||||
lines.push('Hey OpenSquawk team,', '')
|
||||
lines.push(`Name: ${form.name || '—'}`)
|
||||
lines.push(`Email: ${form.email || '—'}`)
|
||||
lines.push(`Okay to follow up: ${form.contactConsent ? 'Yes' : 'No'}`)
|
||||
lines.push('')
|
||||
lines.push(`Overall excitement (1-5): ${form.excitement}`)
|
||||
lines.push(`What feels great: ${formattedHighlights.value}`)
|
||||
if (form.highlightNotes) {
|
||||
lines.push(form.highlightNotes)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push(`What feels rough: ${formattedFrictions.value}`)
|
||||
if (form.frictionNotes) {
|
||||
lines.push(form.frictionNotes)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('Classroom stories:')
|
||||
lines.push(form.classroomNotes || '—')
|
||||
lines.push('')
|
||||
lines.push('Hosting & deployment thoughts:')
|
||||
lines.push(form.hostingInterest || '—')
|
||||
lines.push('')
|
||||
lines.push('Big ideas & requests:')
|
||||
lines.push(form.otherIdeas || '—')
|
||||
lines.push('')
|
||||
lines.push('Thank you for building something ambitious and keeping it accessible!')
|
||||
|
||||
while (lines[lines.length - 1] === '') {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
const { $fetch } = useNuxtApp()
|
||||
|
||||
async function handleSubmit() {
|
||||
const body = buildEmailBody()
|
||||
emailBody.value = body
|
||||
submissionState.value = 'ready'
|
||||
copyStatus.value = 'idle'
|
||||
|
||||
const subject = `OpenSquawk feedback – excitement ${form.excitement}`
|
||||
const mailto = `mailto:info@opensquawk.de?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
if (clipboardAvailable()) {
|
||||
await navigator.clipboard.writeText(body)
|
||||
copyStatus.value = 'copied'
|
||||
}
|
||||
} catch {
|
||||
copyStatus.value = 'failed'
|
||||
}
|
||||
|
||||
window.location.href = mailto
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!clipboardAvailable()) {
|
||||
copyStatus.value = 'failed'
|
||||
return
|
||||
}
|
||||
submissionError.value = null
|
||||
submissionState.value = 'submitting'
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(emailBody.value)
|
||||
copyStatus.value = 'copied'
|
||||
} catch {
|
||||
copyStatus.value = 'failed'
|
||||
await $fetch('/api/service/feedback', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...form,
|
||||
excitement: form.excitement,
|
||||
highlightSelections: [...form.highlightSelections],
|
||||
frictionSelections: [...form.frictionSelections],
|
||||
},
|
||||
})
|
||||
|
||||
submissionState.value = 'success'
|
||||
|
||||
form.highlightSelections = []
|
||||
form.frictionSelections = []
|
||||
form.highlightNotes = ''
|
||||
form.frictionNotes = ''
|
||||
form.classroomNotes = ''
|
||||
form.hostingInterest = ''
|
||||
form.otherIdeas = ''
|
||||
} catch (error: unknown) {
|
||||
submissionState.value = 'error'
|
||||
if (error && typeof error === 'object') {
|
||||
const statusMessage =
|
||||
(error as any).statusMessage ||
|
||||
((error as any).data && typeof (error as any).data?.statusMessage === 'string' ? (error as any).data.statusMessage : null)
|
||||
if (typeof statusMessage === 'string' && statusMessage.length > 0) {
|
||||
submissionError.value = statusMessage
|
||||
return
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
submissionError.value = error.message
|
||||
} else {
|
||||
submissionError.value = 'Unknown error occurred.'
|
||||
}
|
||||
} finally {
|
||||
if (submissionState.value === 'submitting') {
|
||||
submissionState.value = 'idle'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, SFMono, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
107
server/api/service/feedback/index.post.ts
Normal file
107
server/api/service/feedback/index.post.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { sendAdminNotification } from '../../../utils/notifications'
|
||||
|
||||
interface FeedbackRequestBody {
|
||||
name?: string
|
||||
email?: string
|
||||
excitement?: number
|
||||
highlightSelections?: string[]
|
||||
highlightNotes?: string
|
||||
frictionSelections?: string[]
|
||||
frictionNotes?: string
|
||||
classroomNotes?: string
|
||||
hostingInterest?: string
|
||||
otherIdeas?: string
|
||||
contactConsent?: boolean
|
||||
}
|
||||
|
||||
function ensureRating(value: unknown) {
|
||||
if (typeof value !== 'number') {
|
||||
return null
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
const rounded = Math.round(value)
|
||||
if (rounded < 1 || rounded > 5) {
|
||||
return null
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
function normaliseText(value?: string | null) {
|
||||
return value?.trim() || ''
|
||||
}
|
||||
|
||||
function normaliseArray(value?: string[] | null) {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<FeedbackRequestBody>(event)
|
||||
|
||||
const excitement = ensureRating(body.excitement)
|
||||
if (!excitement) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please rate your excitement from 1 to 5.' })
|
||||
}
|
||||
|
||||
const name = normaliseText(body.name)
|
||||
const email = normaliseText(body.email)
|
||||
const highlightNotes = normaliseText(body.highlightNotes)
|
||||
const frictionNotes = normaliseText(body.frictionNotes)
|
||||
const classroomNotes = normaliseText(body.classroomNotes)
|
||||
const hostingInterest = normaliseText(body.hostingInterest)
|
||||
const otherIdeas = normaliseText(body.otherIdeas)
|
||||
const highlightSelections = normaliseArray(body.highlightSelections)
|
||||
const frictionSelections = normaliseArray(body.frictionSelections)
|
||||
const allowContact = Boolean(body.contactConsent)
|
||||
|
||||
const details: string[] = []
|
||||
details.push(`Overall excitement: ${excitement}/5`)
|
||||
details.push(`Highlights: ${highlightSelections.length ? highlightSelections.join(', ') : '—'}`)
|
||||
if (highlightNotes) {
|
||||
details.push('')
|
||||
details.push('Highlight notes:')
|
||||
details.push(highlightNotes)
|
||||
}
|
||||
details.push('')
|
||||
details.push(`Friction: ${frictionSelections.length ? frictionSelections.join(', ') : '—'}`)
|
||||
if (frictionNotes) {
|
||||
details.push('')
|
||||
details.push('Friction notes:')
|
||||
details.push(frictionNotes)
|
||||
}
|
||||
details.push('')
|
||||
details.push('Classroom stories:')
|
||||
details.push(classroomNotes || '—')
|
||||
details.push('')
|
||||
details.push('Hosting & deployment:')
|
||||
details.push(hostingInterest || '—')
|
||||
details.push('')
|
||||
details.push('Big ideas & requests:')
|
||||
details.push(otherIdeas || '—')
|
||||
|
||||
const summaryParts = [`Excitement ${excitement}/5`]
|
||||
if (name) {
|
||||
summaryParts.push(`from ${name}`)
|
||||
}
|
||||
|
||||
await sendAdminNotification({
|
||||
event: 'Feedback submission',
|
||||
summary: `New feedback ${summaryParts.join(' ')}`,
|
||||
message: details.join('\n'),
|
||||
data: [
|
||||
['Name', name || '—'],
|
||||
['Email', email || '—'],
|
||||
['Okay to contact', allowContact ? 'Yes' : 'No'],
|
||||
],
|
||||
from: email || undefined,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
70
server/api/service/feedback/issues.post.ts
Normal file
70
server/api/service/feedback/issues.post.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { sendAdminNotification } from '../../../utils/notifications'
|
||||
|
||||
interface FeedbackIssueRequest {
|
||||
title?: string
|
||||
description?: string
|
||||
categories?: string[]
|
||||
contactEmail?: string
|
||||
}
|
||||
|
||||
function sanitize(value?: string | null) {
|
||||
return value?.trim() || ''
|
||||
}
|
||||
|
||||
function sanitizeCategories(value?: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0)
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<FeedbackIssueRequest>(event)
|
||||
|
||||
const title = sanitize(body.title)
|
||||
const description = sanitize(body.description)
|
||||
const categories = sanitizeCategories(body.categories)
|
||||
const contactEmail = sanitize(body.contactEmail)
|
||||
|
||||
if (title.length < 4) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Title must be at least 4 characters long.' })
|
||||
}
|
||||
|
||||
if (description.length < 20) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Description must be at least 20 characters long.' })
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Please select at least one category.' })
|
||||
}
|
||||
|
||||
const reportId = randomUUID()
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`Title: ${title}`)
|
||||
lines.push(`Categories: ${categories.join(', ')}`)
|
||||
lines.push('')
|
||||
lines.push(description)
|
||||
|
||||
await sendAdminNotification({
|
||||
event: 'Public feedback report',
|
||||
summary: `Feedback report: ${title}`,
|
||||
message: lines.join('\n'),
|
||||
data: [
|
||||
['Report ID', reportId],
|
||||
['Categories', categories.join(', ')],
|
||||
['Contact email', contactEmail || '—'],
|
||||
],
|
||||
from: contactEmail || undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reportId,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user