Finalize admin tools and transmission fault flow

This commit is contained in:
Remi
2025-09-18 13:56:48 +02:00
committed by itsrubberduck
parent 97e80292a3
commit 3efd64a5b2
12 changed files with 1928 additions and 21 deletions

View File

@@ -0,0 +1,113 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import type { FilterQuery } from 'mongoose'
import { requireAdmin } from '../../utils/auth'
import { InvitationCode, type InvitationCodeDocument } from '../../models/InvitationCode'
const CHANNELS = new Set(['user', 'manual', 'bootstrap', 'admin'])
type InvitationListItem = {
id: string
code: string
channel: string
label?: string
createdAt: string
expiresAt?: string
usedAt?: string
createdBy?: { id: string; email: string; name?: string; role: string }
usedBy?: { id: string; email: string; name?: string; role: string }
}
function mapInvitation(doc: any): InvitationListItem {
return {
id: String(doc._id),
code: doc.code,
channel: doc.channel,
label: doc.label || undefined,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined,
usedAt: doc.usedAt ? new Date(doc.usedAt).toISOString() : undefined,
createdBy: doc.createdBy
? {
id: String(doc.createdBy._id),
email: doc.createdBy.email,
name: doc.createdBy.name || undefined,
role: doc.createdBy.role,
}
: undefined,
usedBy: doc.usedBy
? {
id: String(doc.usedBy._id),
email: doc.usedBy.email,
name: doc.usedBy.name || undefined,
role: doc.usedBy.role,
}
: undefined,
}
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const query = getQuery(event)
const search = typeof query.search === 'string' ? query.search.trim() : ''
const channel = typeof query.channel === 'string' ? query.channel.trim() : ''
const status = typeof query.status === 'string' ? query.status.trim().toLowerCase() : ''
const page = Number.parseInt(String(query.page ?? '1'), 10) || 1
const pageSizeRaw = Number.parseInt(String(query.pageSize ?? query.limit ?? '25'), 10)
const pageSize = Math.min(Math.max(pageSizeRaw || 25, 1), 100)
const skip = (page - 1) * pageSize
const filter: FilterQuery<InvitationCodeDocument> = {}
const andConditions: FilterQuery<InvitationCodeDocument>[] = []
const now = new Date()
if (search) {
const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
andConditions.push({ $or: [{ code: regex }, { label: regex }] })
}
if (channel) {
if (!CHANNELS.has(channel)) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' })
}
filter.channel = channel
}
if (status) {
if (status === 'active') {
filter.usedBy = { $exists: false }
andConditions.push({ $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gte: now } }] })
} else if (status === 'used') {
filter.usedBy = { $exists: true }
} else if (status === 'expired') {
filter.usedBy = { $exists: false }
filter.expiresAt = { $lt: now }
} else {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Status' })
}
}
if (andConditions.length) {
filter.$and = andConditions
}
const [total, items] = await Promise.all([
InvitationCode.countDocuments(filter),
InvitationCode.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(pageSize)
.populate('createdBy', 'email name role')
.populate('usedBy', 'email name role'),
])
return {
items: items.map(mapInvitation),
pagination: {
total,
page,
pageSize,
pages: Math.ceil(total / pageSize) || 1,
},
}
})

View File

@@ -0,0 +1,52 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { randomBytes } from 'node:crypto'
import { requireAdmin } from '../../utils/auth'
import { InvitationCode } from '../../models/InvitationCode'
interface CreateInvitationBody {
label?: string
expiresInDays?: number
}
function generateCode() {
return randomBytes(4).toString('hex').toUpperCase()
}
export default defineEventHandler(async (event) => {
const admin = await requireAdmin(event)
const body = await readBody<CreateInvitationBody>(event).catch(() => ({} as CreateInvitationBody))
const label = body.label?.trim() || undefined
const expiresInDays = typeof body.expiresInDays === 'number' ? Math.max(1, Math.min(body.expiresInDays, 120)) : 30
const now = new Date()
const expiresAt = new Date(now.getTime() + expiresInDays * 24 * 60 * 60 * 1000)
const code = generateCode()
const existing = await InvitationCode.findOne({ code })
if (existing) {
throw createError({ statusCode: 500, statusMessage: 'Generierung fehlgeschlagen, bitte erneut versuchen.' })
}
const invitation = await InvitationCode.create({
code,
channel: 'admin',
label,
createdAt: now,
createdBy: admin._id,
expiresAt,
})
return {
success: true,
invitation: {
id: String(invitation._id),
code: invitation.code,
channel: invitation.channel,
label: invitation.label || undefined,
createdAt: invitation.createdAt.toISOString(),
expiresAt: invitation.expiresAt ? invitation.expiresAt.toISOString() : undefined,
},
}
})

View File

@@ -0,0 +1,192 @@
import { defineEventHandler } from 'h3'
import { requireAdmin } from '../../utils/auth'
import { User } from '../../models/User'
import { InvitationCode } from '../../models/InvitationCode'
import { TransmissionLog } from '../../models/TransmissionLog'
type RecentUser = {
id: string
email: string
name?: string
role: string
createdAt: string
lastLoginAt?: string
}
type RecentInvitation = {
id: string
code: string
channel: string
label?: string
createdAt: string
expiresAt?: string
usedAt?: string
createdBy?: { id: string; email: string; name?: string; role: string }
usedBy?: { id: string; email: string; name?: string; role: string }
}
type RecentTransmission = {
id: string
role: string
channel: string
direction: string
text: string
normalized?: string
createdAt: string
user?: { id: string; email: string; name?: string; role: string }
metadata?: Record<string, any>
}
function mapUser(doc: any): RecentUser {
return {
id: String(doc._id),
email: doc.email,
name: doc.name || undefined,
role: doc.role,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
lastLoginAt: doc.lastLoginAt ? new Date(doc.lastLoginAt).toISOString() : undefined,
}
}
function mapInvitation(doc: any): RecentInvitation {
return {
id: String(doc._id),
code: doc.code,
channel: doc.channel,
label: doc.label || undefined,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined,
usedAt: doc.usedAt ? new Date(doc.usedAt).toISOString() : undefined,
createdBy: doc.createdBy
? {
id: String(doc.createdBy._id),
email: doc.createdBy.email,
name: doc.createdBy.name || undefined,
role: doc.createdBy.role,
}
: undefined,
usedBy: doc.usedBy
? {
id: String(doc.usedBy._id),
email: doc.usedBy.email,
name: doc.usedBy.name || undefined,
role: doc.usedBy.role,
}
: undefined,
}
}
function mapTransmission(doc: any): RecentTransmission {
return {
id: String(doc._id),
role: doc.role,
channel: doc.channel,
direction: doc.direction,
text: doc.text,
normalized: doc.normalized || undefined,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
user: doc.user
? {
id: String(doc.user._id),
email: doc.user.email,
name: doc.user.name || undefined,
role: doc.user.role,
}
: undefined,
metadata: doc.metadata || undefined,
}
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const now = new Date()
const dayAgo = new Date(now.getTime() - 1000 * 60 * 60 * 24)
const weekAgo = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 7)
const weekAhead = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 7)
const [
totalUsers,
adminCount,
devCount,
newUsersLastWeek,
totalInvitations,
activeInvitations,
expiringInvitations,
transmissionsTotal,
transmissionsLast24h,
transmissionsByChannelRaw,
transmissionsByRoleRaw,
recentUsersDocs,
recentInvitationsDocs,
recentTransmissionsDocs,
] = await Promise.all([
User.countDocuments(),
User.countDocuments({ role: 'admin' }),
User.countDocuments({ role: 'dev' }),
User.countDocuments({ createdAt: { $gte: weekAgo } }),
InvitationCode.countDocuments(),
InvitationCode.countDocuments({
usedBy: { $exists: false },
$or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gte: now } }],
}),
InvitationCode.countDocuments({
usedBy: { $exists: false },
expiresAt: { $gte: now, $lte: weekAhead },
}),
TransmissionLog.countDocuments(),
TransmissionLog.countDocuments({ createdAt: { $gte: dayAgo } }),
TransmissionLog.aggregate([{ $group: { _id: '$channel', count: { $sum: 1 } } }]),
TransmissionLog.aggregate([{ $group: { _id: '$role', count: { $sum: 1 } } }]),
User.find().sort({ createdAt: -1 }).limit(5),
InvitationCode.find()
.sort({ createdAt: -1 })
.limit(5)
.populate('createdBy', 'email name role')
.populate('usedBy', 'email name role'),
TransmissionLog.find()
.sort({ createdAt: -1 })
.limit(5)
.populate('user', 'email name role'),
])
const transmissionsByChannel = transmissionsByChannelRaw.reduce(
(acc: Record<string, number>, item: any) => {
acc[item._id || 'unknown'] = item.count
return acc
},
{} as Record<string, number>,
)
const transmissionsByRole = transmissionsByRoleRaw.reduce(
(acc: Record<string, number>, item: any) => {
acc[item._id || 'unknown'] = item.count
return acc
},
{} as Record<string, number>,
)
return {
generatedAt: now.toISOString(),
users: {
total: totalUsers,
admins: adminCount,
devs: devCount,
newLast7Days: newUsersLastWeek,
recent: recentUsersDocs.map(mapUser),
},
invitations: {
total: totalInvitations,
active: activeInvitations,
expiringSoon: expiringInvitations,
recent: recentInvitationsDocs.map(mapInvitation),
},
transmissions: {
total: transmissionsTotal,
last24h: transmissionsLast24h,
byChannel: transmissionsByChannel,
byRole: transmissionsByRole,
recent: recentTransmissionsDocs.map(mapTransmission),
},
}
})

View File

@@ -0,0 +1,81 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import type { FilterQuery } from 'mongoose'
import { requireAdmin } from '../../utils/auth'
import { User, type UserDocument } from '../../models/User'
type UserListItem = {
id: string
email: string
name?: string
role: string
createdAt: string
lastLoginAt?: string
invitationCodesIssued: number
}
function mapUser(doc: any): UserListItem {
return {
id: String(doc._id),
email: doc.email,
name: doc.name || undefined,
role: doc.role,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
lastLoginAt: doc.lastLoginAt ? new Date(doc.lastLoginAt).toISOString() : undefined,
invitationCodesIssued: doc.invitationCodesIssued || 0,
}
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const query = getQuery(event)
const search = typeof query.search === 'string' ? query.search.trim() : ''
const role = typeof query.role === 'string' ? query.role.trim() : ''
const page = Number.parseInt(String(query.page ?? '1'), 10) || 1
const pageSizeRaw = Number.parseInt(String(query.pageSize ?? query.limit ?? '20'), 10)
const pageSize = Math.min(Math.max(pageSizeRaw || 20, 1), 100)
const skip = (page - 1) * pageSize
const filter: FilterQuery<UserDocument> = {}
if (search) {
const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
filter.$or = [{ email: regex }, { name: regex }]
}
if (role) {
if (!['user', 'admin', 'dev'].includes(role)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
}
filter.role = role
}
const [total, items] = await Promise.all([
User.countDocuments(filter),
User.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(pageSize),
])
const [userCount, adminCount, devCount] = await Promise.all([
User.countDocuments({ role: 'user' }),
User.countDocuments({ role: 'admin' }),
User.countDocuments({ role: 'dev' }),
])
return {
items: items.map(mapUser),
pagination: {
total,
page,
pageSize,
pages: Math.ceil(total / pageSize) || 1,
},
roles: {
user: userCount,
admin: adminCount,
dev: devCount,
},
}
})

View File

@@ -0,0 +1,52 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { User } from '../../../../models/User'
type UpdateRoleBody = {
role?: string
}
export default defineEventHandler(async (event) => {
const admin = await requireAdmin(event)
const params = event.context.params as { id?: string }
const userId = params?.id
if (!userId) {
throw createError({ statusCode: 400, statusMessage: 'User-ID fehlt' })
}
const body = await readBody<UpdateRoleBody>(event).catch(() => ({}))
const role = body.role?.trim()
if (!role || !['user', 'admin', 'dev'].includes(role)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
}
const target = await User.findById(userId)
if (!target) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
}
const previousRole = target.role
target.role = role as any
if (role !== previousRole) {
target.tokenVersion += 1
}
await target.save()
return {
success: true,
user: {
id: String(target._id),
email: target.email,
name: target.name || undefined,
role: target.role,
createdAt: target.createdAt ? target.createdAt.toISOString() : new Date().toISOString(),
lastLoginAt: target.lastLoginAt ? target.lastLoginAt.toISOString() : undefined,
invitationCodesIssued: target.invitationCodesIssued || 0,
updatedBy: String(admin._id),
},
}
})