User feedback
This boilerplate includes a complete user feedback system with forms for bug reports, feature requests, and support messages, all stored in your database.
Overview
The feedback system provides:
- Bug report form - Users can report bugs with details and context
- Feature request form - Collect user suggestions for new features
- Support form - General contact/support messages
- Database storage - All feedback saved in PostgreSQL
- User tracking - Know which user submitted each item
- Easy access - Forms available in header and footer
Database schema
Feedback is stored in dedicated tables:
Bug reports
model BugReport {
id String @id @default(dbgenerated("gen_random_uuid()"))
type String // Type of bug
details String // Detailed description
userId String // Who reported it
url String // Page URL where bug occurred
createdAt DateTime @default(now())
}
Feature requests
model FeatureRequest {
id String @id @default(dbgenerated("gen_random_uuid()"))
type String // Type of feature
details String // Detailed description
userId String // Who requested it
url String // Page URL where requested
createdAt DateTime @default(now())
}
Bug report form
The bug report form is located in app/components/feedback/ReportBugForm.vue:
<script setup lang="ts">
import { z } from 'zod'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isOpen = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const schema = toTypedSchema(
z.object({
type: z.string().min(1, 'Please select a bug type'),
details: z.string().min(10, 'Please provide more details (at least 10 characters)'),
})
)
const { handleSubmit, errors, defineField } = useForm({
validationSchema: schema,
})
const [type] = defineField('type')
const [details] = defineField('details')
const isSubmitting = ref(false)
const onSubmit = handleSubmit(async values => {
isSubmitting.value = true
try {
await $fetch('/api/bug-report', {
method: 'POST',
body: {
type: values.type,
details: values.details,
},
})
toast.success('Bug report submitted successfully!')
isOpen.value = false
} catch (error) {
toast.error('Failed to submit bug report')
} finally {
isSubmitting.value = false
}
})
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>Report a bug</DialogTitle>
<DialogDescription> Help us improve by reporting bugs you encounter </DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4">
<div class="space-y-2">
<Label for="type">Bug type</Label>
<Select v-model="type">
<SelectTrigger>
<SelectValue placeholder="Select bug type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ui">UI/Display issue</SelectItem>
<SelectItem value="functionality">Functionality not working</SelectItem>
<SelectItem value="performance">Performance issue</SelectItem>
<SelectItem value="security">Security concern</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<p v-if="errors.type" class="text-sm text-destructive">
{{ errors.type }}
</p>
</div>
<div class="space-y-2">
<Label for="details">Details</Label>
<Textarea id="details" v-model="details" placeholder="Describe the bug in detail..." rows="5" />
<p v-if="errors.details" class="text-sm text-destructive">
{{ errors.details }}
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="isOpen = false"> Cancel </Button>
<Button type="submit" :disabled="isSubmitting">
<span v-if="isSubmitting">Submitting...</span>
<span v-else>Submit Report</span>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
Using the bug report form
In your header or other components:
<script setup>
const showReportBugForm = ref(false)
</script>
<template>
<Button @click="showReportBugForm = true"> Report a bug </Button>
<ReportBugForm v-if="showReportBugForm" v-model="showReportBugForm" />
</template>
Feature request form
The feature request form is similar, located in app/components/feedback/RequestFeatureForm.vue:
<script setup lang="ts">
const schema = toTypedSchema(
z.object({
type: z.string().min(1, 'Please select a feature type'),
details: z.string().min(10, 'Please provide more details'),
})
)
// ... similar to bug report form
const onSubmit = handleSubmit(async values => {
await $fetch('/api/feature-request', {
method: 'POST',
body: {
type: values.type,
details: values.details,
},
})
toast.success('Feature request submitted!')
isOpen.value = false
})
</script>
<template>
<Dialog v-model:open="isOpen">
<!-- Similar structure with different options -->
<SelectContent>
<SelectItem value="new-feature">New feature</SelectItem>
<SelectItem value="improvement">Improvement</SelectItem>
<SelectItem value="integration">Integration</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Dialog>
</template>
Support form
The support form allows users to send general inquiries. It's located in app/components/support/SupportForm.vue:
<script setup lang="ts">
const schema = toTypedSchema(
z.object({
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(10, 'Please provide more details'),
})
)
const onSubmit = handleSubmit(async values => {
await $fetch('/api/email/support', {
method: 'POST',
body: {
subject: values.subject,
message: values.message,
},
})
toast.success('Message sent successfully!')
})
</script>
<template>
<form @submit="onSubmit" class="space-y-4">
<div class="space-y-2">
<Label for="subject">Subject</Label>
<Input id="subject" v-model="subject" placeholder="Brief description of your issue" />
</div>
<div class="space-y-2">
<Label for="message">Message</Label>
<Textarea id="message" v-model="message" placeholder="Provide more details..." rows="6" />
</div>
<Button type="submit" :disabled="isSubmitting"> Send message </Button>
</form>
</template>
API endpoints
Bug report endpoint
import { requireAuth } from '~/server/utils/require-auth'
import { createBugReport } from '~/server/services/bug-reports-server-service'
export default defineEventHandler(async event => {
const { user } = await requireAuth(event)
const { type, details } = await readBody(event)
// Get the referring URL
const referer = getHeader(event, 'referer') || ''
const bugReport = await createBugReport({
type,
details,
userId: user.id,
url: referer,
})
return { success: true, id: bugReport.id }
})
Feature request endpoint
import { requireAuth } from '~/server/utils/require-auth'
import { createFeatureRequest } from '~/server/services/feature-requests-server-service'
export default defineEventHandler(async event => {
const { user } = await requireAuth(event)
const { type, details } = await readBody(event)
const referer = getHeader(event, 'referer') || ''
const featureRequest = await createFeatureRequest({
type,
details,
userId: user.id,
url: referer,
})
return { success: true, id: featureRequest.id }
})
Support email endpoint
import { requireAuth } from '~/server/utils/require-auth'
import { sendSupportEmail } from '~/server/services/email-server-service'
export default defineEventHandler(async event => {
const { user } = await requireAuth(event)
const { subject, message } = await readBody(event)
await sendSupportEmail({
subject: `Support: ${subject}`,
html: `
<h2>Support Request</h2>
<p><strong>From:</strong> ${user.name} (${user.email})</p>
<p><strong>Subject:</strong> ${subject}</p>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>
`,
})
return { success: true }
})
Service layer
The service layer handles database operations:
import { prisma } from '@@/lib/prisma'
export async function createBugReport(data: { type: string; details: string; userId: string; url: string }) {
return prisma.bugReport.create({
data: {
type: data.type,
details: data.details,
userId: data.userId,
url: data.url,
},
})
}
export async function getBugReports(options?: { userId?: string; limit?: number; offset?: number }) {
return prisma.bugReport.findMany({
where: options?.userId ? { userId: options.userId } : undefined,
take: options?.limit,
skip: options?.offset,
orderBy: { createdAt: 'desc' },
})
}
Viewing feedback
Create an admin page to view all feedback:
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
// Add admin role check if needed
})
const activeTab = ref<'bugs' | 'features'>('bugs')
const { data: bugReports } = await useFetch('/api/admin/bug-reports')
const { data: featureRequests } = await useFetch('/api/admin/feature-requests')
</script>
<template>
<div class="base-container py-8">
<h1 class="text-4xl font-bold mb-8">User feedback</h1>
<Tabs v-model="activeTab">
<TabsList>
<TabsTrigger value="bugs"> Bug Reports ({{ bugReports?.length || 0 }}) </TabsTrigger>
<TabsTrigger value="features"> Feature Requests ({{ featureRequests?.length || 0 }}) </TabsTrigger>
</TabsList>
<TabsContent value="bugs">
<div class="space-y-4">
<Card v-for="bug in bugReports" :key="bug.id">
<CardHeader>
<CardTitle>{{ bug.type }}</CardTitle>
<CardDescription>
Reported {{ new Date(bug.createdAt).toLocaleDateString() }} at {{ bug.url }}
</CardDescription>
</CardHeader>
<CardContent>
<p>{{ bug.details }}</p>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="features">
<div class="space-y-4">
<Card v-for="feature in featureRequests" :key="feature.id">
<CardHeader>
<CardTitle>{{ feature.type }}</CardTitle>
<CardDescription> Requested {{ new Date(feature.createdAt).toLocaleDateString() }} </CardDescription>
</CardHeader>
<CardContent>
<p>{{ feature.details }}</p>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
</template>
Customizing feedback forms
Add more fields
const schema = toTypedSchema(
z.object({
type: z.string(),
details: z.string(),
severity: z.enum(['low', 'medium', 'high']), // Add severity
screenshot: z.string().url().optional(), // Add screenshot URL
})
)
Add file uploads
Use the storage integration to allow screenshot uploads:
<script setup>
const file = (ref < File) | (null > null)
async function uploadScreenshot() {
if (!file.value) return null
const formData = new FormData()
formData.append('file', file.value)
const { url } = await $fetch('/api/storage/upload', {
method: 'POST',
body: formData,
})
return url
}
const onSubmit = handleSubmit(async values => {
const screenshotUrl = await uploadScreenshot()
await $fetch('/api/bug-report', {
method: 'POST',
body: {
...values,
screenshot: screenshotUrl,
},
})
})
</script>
<template>
<Input type="file" @change="file = $event.target.files[0]" accept="image/*" />
</template>
Notifications
Get notified when users submit feedback:
Email notifications
import { sendEmail } from '~/server/services/email-server-service'
// After creating bug report
await sendEmail({
to: 'admin@yourdomain.com',
subject: 'New Bug Report',
html: `
<h2>New Bug Report</h2>
<p><strong>Type:</strong> ${type}</p>
<p><strong>Details:</strong> ${details}</p>
<p><strong>User:</strong> ${user.email}</p>
<p><strong>URL:</strong> ${url}</p>
`,
})
Slack/Discord webhooks
async function notifySlack(bugReport: BugReport) {
await fetch('https://hooks.slack.com/services/YOUR/WEBHOOK/URL', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New bug report: ${bugReport.type}`,
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: bugReport.details },
},
],
}),
})
}
Best practices
Require authentication
Only allow authenticated users to submit feedback:
const { user } = await requireAuth(event)
Rate limiting
Prevent spam by implementing rate limits:
const limiter = createRateLimiter({
interval: 60000, // 1 minute
max: 3, // 3 reports per minute
})
await limiter.check(event)
Sanitize input
Clean user input before storing:
import DOMPurify from 'dompurify'
const sanitizedDetails = DOMPurify.sanitize(details)
Auto-close duplicate reports
Implement deduplication logic to prevent duplicate reports.