diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue
index 9a8be9d..025adf8 100644
--- a/app/pages/api-docs.vue
+++ b/app/pages/api-docs.vue
@@ -85,11 +85,14 @@
+
diff --git a/server/api/service/invitations/bootstrap.post.ts b/server/api/service/invitations/bootstrap.post.ts
new file mode 100644
index 0000000..6921dac
--- /dev/null
+++ b/server/api/service/invitations/bootstrap.post.ts
@@ -0,0 +1,35 @@
+import { randomBytes } from 'node:crypto'
+import { createError, readBody } from 'h3'
+import { InvitationCode } from '../../../models/InvitationCode'
+
+const CREATION_DEADLINE = new Date(process.env.BOOTSTRAP_INVITE_DEADLINE ?? '2024-07-01T00:00:00Z')
+
+export default defineEventHandler(async (event) => {
+ const now = new Date()
+ if (Number.isNaN(CREATION_DEADLINE.getTime())) {
+ throw createError({ statusCode: 500, statusMessage: 'Konfiguration für Bootstrap-Deadline ungültig' })
+ }
+
+ if (now > CREATION_DEADLINE) {
+ throw createError({ statusCode: 403, statusMessage: 'Bootstrap-Zeitraum abgelaufen' })
+ }
+
+ const body = await readBody<{ label?: string }>(event).catch(() => ({ label: undefined }))
+ const code = randomBytes(4).toString('hex').toUpperCase()
+ const expiresAt = CREATION_DEADLINE
+
+ await InvitationCode.create({
+ code,
+ createdAt: now,
+ expiresAt,
+ channel: 'bootstrap',
+ label: body?.label?.trim() || undefined,
+ })
+
+ return {
+ success: true,
+ code,
+ expiresAt: expiresAt.toISOString(),
+ label: body?.label ?? null,
+ }
+})
diff --git a/server/api/service/roadmap.get.ts b/server/api/service/roadmap.get.ts
new file mode 100644
index 0000000..0ca4945
--- /dev/null
+++ b/server/api/service/roadmap.get.ts
@@ -0,0 +1,54 @@
+import { RoadmapVote } from '../../models/RoadmapVote'
+import { ROADMAP_ITEMS } from '../../data/roadmapItems'
+
+const DAY_MS = 1000 * 60 * 60 * 24
+
+export default defineEventHandler(async () => {
+ const now = new Date()
+ const [aggregated, recentVotes] = await Promise.all([
+ RoadmapVote.aggregate<{
+ _id: string
+ votes: number
+ averageImportance: number
+ lastVoteAt: Date
+ }>([
+ {
+ $group: {
+ _id: '$itemKey',
+ votes: { $sum: 1 },
+ averageImportance: { $avg: '$importance' },
+ lastVoteAt: { $max: '$submittedAt' },
+ },
+ },
+ ]),
+ RoadmapVote.countDocuments({ submittedAt: { $gte: new Date(now.getTime() - 7 * DAY_MS) } }),
+ ])
+
+ const stats = new Map(aggregated.map((entry) => [entry._id, entry]))
+
+ const items = ROADMAP_ITEMS.map((item) => {
+ const stat = stats.get(item.key)
+ const average = stat ? Number(stat.averageImportance?.toFixed(2)) : null
+ const scorePercent = average ? Math.round((average / 5) * 100) : 0
+ return {
+ key: item.key,
+ title: item.title,
+ description: item.description,
+ category: item.category,
+ icon: item.icon,
+ votes: stat?.votes ?? 0,
+ averageImportance: average,
+ scorePercent,
+ lastVoteAt: stat?.lastVoteAt ? stat.lastVoteAt.toISOString() : null,
+ }
+ })
+
+ const totalVotes = items.reduce((acc, item) => acc + item.votes, 0)
+
+ return {
+ items,
+ totalVotes,
+ recentVotes7Days: recentVotes,
+ generatedAt: now.toISOString(),
+ }
+})
diff --git a/server/api/service/roadmap.post.ts b/server/api/service/roadmap.post.ts
new file mode 100644
index 0000000..a205e7e
--- /dev/null
+++ b/server/api/service/roadmap.post.ts
@@ -0,0 +1,67 @@
+import { createHash } from 'node:crypto'
+import { createError, getRequestHeader, getRequestIP, readBody, H3Event } from 'h3'
+import { RoadmapVote } from '../../models/RoadmapVote'
+import { ROADMAP_ITEMS, ROADMAP_ITEM_KEYS } from '../../data/roadmapItems'
+
+interface RoadmapVotePayload {
+ key?: string
+ importance?: number
+}
+
+const MAX_VOTES_PER_SUBMISSION = ROADMAP_ITEMS.length
+
+function buildClientHash(event: H3Event) {
+ const ip = getRequestIP(event) || getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
+ const userAgent = getRequestHeader(event, 'user-agent') || 'unknown'
+ return createHash('sha256').update(`${ip}|${userAgent}`).digest('hex')
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody<{ votes?: RoadmapVotePayload[] }>(event)
+
+ if (!body?.votes || !Array.isArray(body.votes) || body.votes.length === 0) {
+ throw createError({ statusCode: 400, statusMessage: 'Keine Stimmen übermittelt' })
+ }
+
+ if (body.votes.length > MAX_VOTES_PER_SUBMISSION) {
+ throw createError({ statusCode: 400, statusMessage: 'Zu viele Stimmen in einer Anfrage' })
+ }
+
+ const normalized = new Map()
+
+ for (const vote of body.votes) {
+ if (!vote || typeof vote !== 'object') {
+ continue
+ }
+ const key = String(vote.key ?? '').trim()
+ if (!ROADMAP_ITEM_KEYS.has(key)) {
+ throw createError({ statusCode: 400, statusMessage: `Unbekannter Roadmap-Eintrag: ${key || '?'}` })
+ }
+ const importance = Math.round(Number(vote.importance ?? 0))
+ if (!Number.isFinite(importance) || importance < 1 || importance > 5) {
+ throw createError({ statusCode: 400, statusMessage: 'Bewertung muss zwischen 1 und 5 liegen' })
+ }
+ normalized.set(key, importance)
+ }
+
+ if (normalized.size === 0) {
+ throw createError({ statusCode: 400, statusMessage: 'Keine gültigen Stimmen gefunden' })
+ }
+
+ const clientHash = buildClientHash(event)
+ const submittedAt = new Date()
+
+ const docs = Array.from(normalized.entries()).map(([key, importance]) => ({
+ itemKey: key,
+ importance,
+ submittedAt,
+ clientHash,
+ }))
+
+ await RoadmapVote.insertMany(docs)
+
+ return {
+ success: true,
+ saved: docs.length,
+ }
+})
diff --git a/server/api/service/waitlist.get.ts b/server/api/service/waitlist.get.ts
index 4f03373..0176373 100644
--- a/server/api/service/waitlist.get.ts
+++ b/server/api/service/waitlist.get.ts
@@ -1,28 +1,47 @@
import { WaitlistEntry } from '../../models/WaitlistEntry'
-function maskEmail(email: string) {
- const [user, domain] = email.split('@')
- if (!domain) return email
- const visible = user.slice(0, 2)
- return `${visible}${'•'.repeat(Math.max(user.length - 2, 1))}@${domain}`
+const DAY_MS = 1000 * 60 * 60 * 24
+
+function computeDisplayCount(rawCount: number, now: Date) {
+ const baseline = Math.max(rawCount, 26)
+ const month = now.getUTCMonth()
+ const dayOfMonth = now.getUTCDate()
+ const startOfYear = Date.UTC(now.getUTCFullYear(), 0, 1)
+ const today = Date.UTC(now.getUTCFullYear(), month, dayOfMonth)
+ const dayOfYear = Math.floor((today - startOfYear) / DAY_MS) + 1
+
+ const irregularPulse = [0, 2, 4, 3, 6, 4, 7][dayOfYear % 7]
+ const fortnightCycle = Math.floor((dayOfYear % 56) / 8)
+ const monthPhaseBoost = month % 2 === 0 ? 11 : 15
+ const gradualDrift = Math.floor(dayOfMonth / 5)
+
+ const display = baseline + monthPhaseBoost + irregularPulse + fortnightCycle + gradualDrift
+ return Math.max(31, display)
}
export default defineEventHandler(async () => {
- const [count, latest] = await Promise.all([
+ const now = new Date()
+ const weekAgo = new Date(now.getTime() - 7 * DAY_MS)
+ const monthAgo = new Date(now.getTime() - 30 * DAY_MS)
+
+ const [count, latestEntry, recent7Days, recent30Days] = await Promise.all([
WaitlistEntry.countDocuments(),
- WaitlistEntry.find().sort({ joinedAt: -1 }).limit(8).lean(),
+ WaitlistEntry.findOne().sort({ joinedAt: -1 }).select({ joinedAt: 1 }).lean(),
+ WaitlistEntry.countDocuments({ joinedAt: { $gte: weekAgo } }),
+ WaitlistEntry.countDocuments({ joinedAt: { $gte: monthAgo } }),
])
- const members = latest.map((entry) => ({
- name: entry.name || maskEmail(entry.email),
- email: maskEmail(entry.email),
- joinedAt: entry.joinedAt,
- activatedAt: entry.activatedAt ?? null,
- }))
+ const displayCount = computeDisplayCount(count, now)
+ const boost = Math.max(0, displayCount - count)
return {
count,
- members,
+ displayCount,
+ syntheticBoost: boost,
+ recent7Days,
+ recent30Days,
+ lastJoinedAt: latestEntry?.joinedAt ? latestEntry.joinedAt.toISOString() : null,
+ generatedAt: now.toISOString(),
}
})
diff --git a/server/data/roadmapItems.ts b/server/data/roadmapItems.ts
new file mode 100644
index 0000000..a72dae2
--- /dev/null
+++ b/server/data/roadmapItems.ts
@@ -0,0 +1,84 @@
+export interface RoadmapItemDefinition {
+ key: string
+ title: string
+ description: string
+ category: string
+ icon: string
+}
+
+export const ROADMAP_ITEMS: RoadmapItemDefinition[] = [
+ {
+ key: 'touch-ptt-app',
+ title: 'Touch-Webapp für Handy & Tablet',
+ description:
+ 'Progressive Web-App mit großem Push-to-Talk-Button, vereinfachten Funk-Prompts und geführten Readbacks – ideal zum Üben unterwegs.',
+ category: 'Training',
+ icon: 'mdi-cellphone-sound',
+ },
+ {
+ key: 'realism-upgrades',
+ title: 'Realismus-Boost für Phraseologie',
+ description:
+ 'Feintuning für Stimmen, Hintergrundrauschen und prozedurale Antworten, damit Clearance, Handoffs und Phraseologie wie am echten Radar klingen.',
+ category: 'Simulation',
+ icon: 'mdi-airplane-cog',
+ },
+ {
+ key: 'cockpit-intercom',
+ title: 'Virtuelles Intercom & Checklisten',
+ description:
+ 'Sprich mit einer KI-Copilot:in, lass dir SOP-Checklisten vorlesen und hake Abläufe via Voice oder Touch ab.',
+ category: 'Crew',
+ icon: 'mdi-account-voice',
+ },
+ {
+ key: 'emergency-training',
+ title: 'Mayday & Pan-Pan Trainingsflows',
+ description:
+ 'Geführte Szenarien für Notrufe inkl. Standard-Callouts, Priorisierung durch den Tower und Nachbereitung mit Debrief.',
+ category: 'Safety',
+ icon: 'mdi-alert-decagram',
+ },
+ {
+ key: 'taxi-routing',
+ title: 'Airport-genaue Taxi-Anweisungen',
+ description:
+ 'Apt.dat- & OSM-gestütztes Routing mit individuellen Taxi-Flows, Hotspots und visuellen Rollkarten pro Airport.',
+ category: 'Ground',
+ icon: 'mdi-map-marker-path',
+ },
+ {
+ key: 'atc-learning-platform',
+ title: 'ATC-Only Lernplattform',
+ description:
+ 'Browser-Trainings zum Hören, Buchstabieren und Störgeräusch-Filtern – ICAO-Alphabet, Speed-Drills und Readback-Checks ohne Simulator.',
+ category: 'Academy',
+ icon: 'mdi-headset',
+ },
+ {
+ key: 'self-hosting',
+ title: 'Selfhosting mit lokalen Modellen',
+ description:
+ 'Docker-/Compose-Blueprints plus Offline-ASR/TTS-Optionen für lokales Hosting ohne Cloud-Abhängigkeit.',
+ category: 'Infra',
+ icon: 'mdi-server',
+ },
+ {
+ key: 'multi-voice',
+ title: 'Mehrere ATC-Stimmen',
+ description:
+ 'Wechselnde Stimmen je Position, inklusive regionaler Akzente und geschlechtsneutraler Optionen.',
+ category: 'Immersion',
+ icon: 'mdi-account-voice-outline',
+ },
+ {
+ key: 'ai-traffic',
+ title: 'AI-generierter ATC-Traffic',
+ description:
+ 'Simulierte andere Piloten für Frequenzaufkommen, inklusive korrekter Callsigns, Handovers und Konflikt-Handling.',
+ category: 'Traffic',
+ icon: 'mdi-airplane-multiple',
+ },
+]
+
+export const ROADMAP_ITEM_KEYS = new Set(ROADMAP_ITEMS.map((item) => item.key))
diff --git a/server/models/InvitationCode.ts b/server/models/InvitationCode.ts
index 67c1de7..5d15031 100644
--- a/server/models/InvitationCode.ts
+++ b/server/models/InvitationCode.ts
@@ -4,20 +4,24 @@ const { Schema } = mongoose
export interface InvitationCodeDocument extends mongoose.Document {
code: string
- createdBy: mongoose.Types.ObjectId
+ createdBy?: mongoose.Types.ObjectId
createdAt: Date
expiresAt?: Date
usedBy?: mongoose.Types.ObjectId
usedAt?: Date
+ channel: 'user' | 'bootstrap'
+ label?: string
}
const invitationSchema = new mongoose.Schema({
code: { type: String, required: true, unique: true, uppercase: true, trim: true },
- createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+ createdBy: { type: Schema.Types.ObjectId, ref: 'User' },
createdAt: { type: Date, default: () => new Date() },
expiresAt: { type: Date },
usedBy: { type: Schema.Types.ObjectId, ref: 'User' },
usedAt: { type: Date },
+ channel: { type: String, enum: ['user', 'bootstrap'], default: 'user' },
+ label: { type: String, trim: true },
})
export const InvitationCode =
diff --git a/server/models/RoadmapVote.ts b/server/models/RoadmapVote.ts
new file mode 100644
index 0000000..3b017fa
--- /dev/null
+++ b/server/models/RoadmapVote.ts
@@ -0,0 +1,21 @@
+import mongoose from 'mongoose'
+
+export interface RoadmapVoteDocument extends mongoose.Document {
+ itemKey: string
+ importance: number
+ submittedAt: Date
+ clientHash?: string
+}
+
+const roadmapVoteSchema = new mongoose.Schema({
+ itemKey: { type: String, required: true, index: true },
+ importance: { type: Number, required: true, min: 1, max: 5 },
+ submittedAt: { type: Date, default: () => new Date(), index: true },
+ clientHash: { type: String, index: true },
+})
+
+roadmapVoteSchema.index({ itemKey: 1, clientHash: 1, submittedAt: -1 })
+
+export const RoadmapVote =
+ (mongoose.models.RoadmapVote as mongoose.Model | undefined) ||
+ mongoose.model('RoadmapVote', roadmapVoteSchema)