Add roadmap voting and waitlist stats updates

This commit is contained in:
Remi
2025-09-16 18:37:13 +02:00
parent 147761311d
commit c46ac3765d
10 changed files with 637 additions and 63 deletions

View File

@@ -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,
}
})

View File

@@ -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(),
}
})

View File

@@ -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<string, number>()
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,
}
})

View File

@@ -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(),
}
})