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)