mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-10 03:16:10 +08:00
Add roadmap voting and waitlist stats updates
This commit is contained in:
35
server/api/service/invitations/bootstrap.post.ts
Normal file
35
server/api/service/invitations/bootstrap.post.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
54
server/api/service/roadmap.get.ts
Normal file
54
server/api/service/roadmap.get.ts
Normal 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(),
|
||||
}
|
||||
})
|
||||
67
server/api/service/roadmap.post.ts
Normal file
67
server/api/service/roadmap.post.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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(),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user