From 92efc5a6c24428bb9d32dc6dbf7b4b58567d4a17 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:23:10 +0200 Subject: [PATCH] Wire feedback form to backend endpoint --- app/pages/api-docs.vue | 88 ++++++++- app/pages/feedback.vue | 217 +++++++++------------ server/api/service/feedback/index.post.ts | 107 ++++++++++ server/api/service/feedback/issues.post.ts | 70 +++++++ 4 files changed, 361 insertions(+), 121 deletions(-) create mode 100644 server/api/service/feedback/index.post.ts create mode 100644 server/api/service/feedback/issues.post.ts diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue index 6169677..3543ffa 100644 --- a/app/pages/api-docs.vue +++ b/app/pages/api-docs.vue @@ -406,13 +406,14 @@ interface NavigationSectionItem { const BASE_URL = 'https://opensquawk.de' const sectionCategoryOrder: Record = { - '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> = { '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', diff --git a/app/pages/feedback.vue b/app/pages/feedback.vue index 9b1c132..a1df35f 100644 --- a/app/pages/feedback.vue +++ b/app/pages/feedback.vue @@ -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.

-
-
-
-

Quick heads-up

-

All notes are sent by email. When you submit below we prepare an email to info@opensquawk.de with your answers, just like the rest of our forms.

-
- - - Request a call - +
+
+ OpenSquawk community charting the path forward
-
+
@@ -170,36 +160,61 @@ I am happy for you to email me back about this feedback or to invite me to jam on the roadmap. -

Submitting prepares an email draft to info@opensquawk.de. Please review it and hit send from your client.

+

Submissions are routed straight to info@opensquawk.de so the team can read and respond quickly.

-
-

Email preview

-

- Your email client should open automatically. If not, copy the text below and send it to info@opensquawk.de. -

-
-
{{ emailBody }}
+
+
+ +
+

Thank you for steering the mission!

+

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.

+
+
+
+ +
+
+ +
+

Something jammed on the way out

+

{{ submissionError || 'We could not send this feedback. Please try again in a moment or email info@opensquawk.de directly.' }}

+
+
+
+ +
+
+
+

Prefer to talk it through?

+

We are more than happy to jump on Discord or TeamSpeak for a quick debrief about your ideas.

+
+
- -

Please copy the text manually if your browser blocks clipboard access.

@@ -207,8 +222,8 @@ - - diff --git a/server/api/service/feedback/index.post.ts b/server/api/service/feedback/index.post.ts new file mode 100644 index 0000000..dcb3d4d --- /dev/null +++ b/server/api/service/feedback/index.post.ts @@ -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(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 } +}) diff --git a/server/api/service/feedback/issues.post.ts b/server/api/service/feedback/issues.post.ts new file mode 100644 index 0000000..2128a8c --- /dev/null +++ b/server/api/service/feedback/issues.post.ts @@ -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(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, + } +})