Initial release v1.0.0

HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.
This commit is contained in:
Marc-André Bombeck committed 2026-03-10 20:14:33 +01:00
commit 38309932f3
301 files changed
+60041

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
filter: z.enum(["auth", "all"]).default("auth"),
});
export const dynamic = "force-dynamic";
/**
* Admin endpoint: list all auth-related audit log entries across all users.
* Supports filtering by action prefix and pagination.
*/
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.audit-log.list" } });
const { searchParams } = new URL(request.url);
const { limit, offset, filter } = paginationSchema.parse({
limit: searchParams.get("limit") ?? undefined,
offset: searchParams.get("offset") ?? undefined,
filter: searchParams.get("filter") ?? undefined,
});
const where =
filter === "auth" ? { action: { startsWith: "auth." } } : undefined;
const [entries, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
select: {
id: true,
action: true,
ipAddress: true,
location: true,
details: true,
createdAt: true,
user: {
select: { id: true, username: true },
},
},
}),
prisma.auditLog.count({ where }),
]);
return apiSuccess({ entries, meta: { total, limit, offset } });
});
+85
View File
@@ -0,0 +1,85 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
/**
* Admin-only global wipe of all health/integration data.
* Keeps users/passkeys so access to the app remains possible.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-data-delete:${ip}`, 5, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { user } = await requireAdmin();
annotate({ action: { name: "admin.data.delete" } });
let confirm = "";
try {
const body = await request.json();
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Ungültige Anfrage", 422);
}
if (confirm !== "DELETE ALL") {
return apiError("Bestätigung fehlt", 422);
}
const result = await prisma.$transaction(async (tx) => {
const measurements = await tx.measurement.deleteMany({});
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
const medications = await tx.medication.deleteMany({});
const apiTokens = await tx.apiToken.deleteMany({});
const withingsConnections = await tx.withingsConnection.deleteMany({});
const auditLogs = await tx.auditLog.deleteMany({});
const authChallenges = await tx.authChallenge.deleteMany({});
await tx.user.updateMany({
data: {
heightCm: null,
dateOfBirth: null,
gender: null,
openaiKeyEncrypted: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
onboardingCompletedAt: null,
},
});
return {
measurements: measurements.count,
intakeEvents: intakeEvents.count,
medications: medications.count,
apiTokens: apiTokens.count,
withingsConnections: withingsConnections.count,
auditLogs: auditLogs.count,
authChallenges: authChallenges.count,
};
});
await auditLog("admin.data.clear", {
userId: user.id,
ipAddress: getClientIp(request),
details: result,
});
return apiSuccess({ cleared: true, ...result });
});
@@ -0,0 +1,44 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.monitoring.glitchtip-test" } });
const settings = await getGlitchtipSettings();
if (!settings.glitchtipEnabled) {
return apiError("Glitchtip ist deaktiviert", 422);
}
if (!settings.glitchtipDsn) {
return apiError("Glitchtip DSN fehlt", 422);
}
const delivery = await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
input: {
environment: settings.glitchtipEnvironment || "production",
message: "HealthLog Monitoring Test",
level: "error",
type: "HealthLogMonitoringTest",
sourceTag: "healthlog-admin-test",
},
});
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
return apiError(
`Glitchtip-Testevent wurde abgelehnt (HTTP ${delivery.status ?? 502})`,
502,
);
}
return apiSuccess({
sent: true,
message: "Glitchtip-Testevent wurde gesendet",
});
});
@@ -0,0 +1,123 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
export const dynamic = "force-dynamic";
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates));
} catch {
return [];
}
}
function resolveAppUrl(request: NextRequest): URL {
const configured =
process.env.APP_URL ??
process.env.NEXT_PUBLIC_APP_URL ??
`https://${request.headers.get("host") ?? "localhost:3000"}`;
try {
return new URL(configured);
} catch {
return new URL(
`https://${request.headers.get("host") ?? "localhost:3000"}`,
);
}
}
export const POST = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.monitoring.umami-test" } });
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return apiError("Umami ist deaktiviert", 422);
}
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
return apiError(
"Umami Script-URL und Website-ID müssen konfiguriert sein",
422,
);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
if (targetUrls.length === 0) {
return apiError("Umami Script-URL ist ungültig", 422);
}
const appUrl = resolveAppUrl(request);
const payload = {
type: "event",
payload: {
website: settings.umamiWebsiteId,
hostname: appUrl.hostname,
screen: "1920x1080",
language: "de-DE",
title: "HealthLog Monitoring Test",
url: "/admin",
referrer: "",
name: "healthlog_monitoring_test",
data: {
source: "admin",
},
},
};
let lastStatus = 404;
let lastDetails = "";
for (const targetUrl of targetUrls) {
const upstream = await fetch(targetUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "healthlog-admin-monitoring-test",
},
body: JSON.stringify(payload),
cache: "no-store",
});
if (upstream.ok) {
return apiSuccess({
sent: true,
message: "Umami-Testevent wurde gesendet",
});
}
lastStatus = upstream.status;
lastDetails = await upstream.text().catch(() => "");
// Try next candidate only for path misses.
if (upstream.status === 404) {
continue;
}
break;
}
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
return apiError(
`Umami-Testevent wurde abgelehnt (HTTP ${lastStatus})`,
502,
);
});
@@ -0,0 +1,201 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
export const dynamic = "force-dynamic";
function parseTimeToMinutes(value: string): number {
const [h, m] = value.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
return h * 60 + m;
}
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
interface ScheduleStatus {
window: string;
days: string;
status: "open" | "late" | "threshold" | "missed" | "skipped";
label: string;
minutesPastEnd: number | null;
notificationSent: boolean;
}
interface MedicationResult {
name: string;
dose: string;
user: string;
timezone: string;
localTime: string;
dayOfWeek: string;
notificationsEnabled: boolean;
schedules: ScheduleStatus[];
eventsToday: number;
}
/**
* POST: Execute the reminder check — analyzes all medications AND sends
* notifications for overdue schedules (late + missed). Returns detailed
* results for display in the admin panel.
*/
export const POST = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.notifications.reminder-check" } });
const now = new Date();
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { reminderMissedMinutes: true },
});
const missedMinutes = appSettings?.reminderMissedMinutes ?? 240;
const medications = await prisma.medication.findMany({
where: { active: true },
include: {
schedules: true,
user: { select: { id: true, username: true, timezone: true } },
},
});
const results: MedicationResult[] = [];
let notificationsSent = 0;
for (const med of medications) {
const userTz = med.user.timezone || "Europe/Berlin";
const { start: todayStart, end: todayEnd } = getUserTodayBounds(
now,
userTz,
);
const currentTime = now.toLocaleTimeString("de-DE", {
timeZone: userTz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const todayDow = getDayOfWeekInTz(now, userTz);
const eventCount = await prisma.medicationIntakeEvent.count({
where: {
medicationId: med.id,
userId: med.user.id,
scheduledFor: { gte: todayStart, lte: todayEnd },
},
});
const scheduleStatuses: ScheduleStatus[] = [];
let schedulesProcessed = 0;
const sortedSchedules = [...med.schedules].sort((a, b) =>
a.windowStart.localeCompare(b.windowStart),
);
for (const schedule of sortedSchedules) {
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
const endMins = parseTimeToMinutes(schedule.windowEnd);
const currentMins = parseTimeToMinutes(currentTime);
const minutesPastEnd = currentMins - endMins;
const dayMatch =
recurrence.daysOfWeek.length === 0 ||
recurrence.daysOfWeek.includes(todayDow);
const daysInfo =
recurrence.daysOfWeek.length > 0
? recurrence.daysOfWeek.map((d) => dayLabels[d]).join(", ")
: "Täglich";
let status: ScheduleStatus["status"];
let label: string;
let notificationSent = false;
if (!dayMatch) {
status = "skipped";
label = "Heute kein geplanter Tag";
} else if (currentMins <= endMins) {
status = "open";
label = `Fenster noch offen (endet um ${schedule.windowEnd})`;
} else if (minutesPastEnd <= missedMinutes) {
status = "threshold";
label = `Fenster vorbei seit ${minutesPastEnd} Min (Threshold: ${missedMinutes} Min)`;
} else {
status = "missed";
label = `Missed-Threshold erreicht (${minutesPastEnd} Min > ${missedMinutes} Min)`;
}
// Send notification for overdue schedules that haven't been taken
const isOverdue = dayMatch && minutesPastEnd > 0;
const hasEvent = eventCount > schedulesProcessed;
if (isOverdue && !hasEvent && med.notificationsEnabled) {
const doseInfo = schedule.dose ?? med.dose;
const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`;
try {
if (status === "missed") {
await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: med.user.id,
title: `Verpasst: ${med.name}`,
message: `<b>${med.name}</b> (${doseInfo}, ${timeWindow}) wurde als verpasst markiert.`,
metadata: { medicationId: med.id },
});
} else {
await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: med.user.id,
title: `Erinnerung: ${med.name}`,
message: `Erinnerung: <b>${med.name}</b> (${doseInfo}, ${timeWindow}) wurde noch nicht eingenommen. Seit ${minutesPastEnd} Min überfällig.`,
metadata: { medicationId: med.id },
});
}
notificationSent = true;
notificationsSent++;
} catch (err) {
getEvent()?.addWarning("Notification failed for " + med.name + ": " + err);
}
}
if (dayMatch && minutesPastEnd > 0) {
schedulesProcessed++;
}
scheduleStatuses.push({
window: `${schedule.windowStart}–${schedule.windowEnd}`,
days: daysInfo,
status,
label,
minutesPastEnd:
dayMatch && currentMins > endMins ? minutesPastEnd : null,
notificationSent,
});
}
results.push({
name: med.name,
dose: med.dose,
user: med.user.username,
timezone: userTz,
localTime: currentTime,
dayOfWeek: dayLabels[todayDow],
notificationsEnabled: med.notificationsEnabled,
schedules: scheduleStatuses,
eventsToday: eventCount,
});
}
return apiSuccess({
timestamp: now.toISOString(),
missedThresholdMinutes: missedMinutes,
medications: results,
notificationsSent,
message:
results.length > 0
? `${results.length} Medikamente geprüft, ${notificationsSent} Erinnerungen gesendet`
: "Keine aktiven Medikamente gefunden",
});
});
@@ -0,0 +1,184 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt, encrypt } from "@/lib/crypto";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import type {
TelegramChannelConfig,
NtfyChannelConfig,
ChannelType,
NotificationPayload,
} from "@/lib/notifications/types";
export const dynamic = "force-dynamic";
/**
* Ensure legacy Telegram config (stored on User model) has a matching
* NotificationChannel record. Returns true if a channel was created.
*/
async function ensureTelegramChannel(userId: string): Promise<boolean> {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId, type: "TELEGRAM" } },
});
if (existing) return false;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
telegramBotToken: true,
telegramChatId: true,
telegramEnabled: true,
},
});
if (
!user?.telegramEnabled ||
!user.telegramBotToken ||
!user.telegramChatId
) {
return false;
}
const botToken = decrypt(user.telegramBotToken);
const config = encrypt(
JSON.stringify({ botToken, chatId: user.telegramChatId }),
);
await prisma.notificationChannel.create({
data: {
userId,
type: "TELEGRAM",
enabled: true,
config,
},
});
return true;
}
export const POST = apiHandler(async () => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.notifications.test" } });
// Auto-migrate legacy Telegram config before querying channels
const migrated = await ensureTelegramChannel(user.id);
if (migrated) {
getEvent()?.addMeta("telegram_migrated", true);
}
const channels = await prisma.notificationChannel.findMany({
where: { userId: user.id, enabled: true },
include: {
preferences: { where: { eventType: "SYSTEM_ALERT" } },
},
});
if (channels.length === 0) {
return apiSuccess({
sent: false,
message: "Keine aktivierten Benachrichtigungskanäle gefunden",
results: [],
});
}
const payload: NotificationPayload = {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: "Test-Notification",
message:
"<b>HealthLog Test:</b> Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!",
};
const results: Array<{
channel: string;
success: boolean;
error?: string;
}> = [];
for (const channel of channels) {
const pref = channel.preferences[0];
if (pref && !pref.enabled) {
results.push({
channel: channel.type,
success: false,
error: "SYSTEM_ALERT deaktiviert in Einstellungen",
});
continue;
}
try {
const type = channel.type as ChannelType;
let success = false;
switch (type) {
case "TELEGRAM": {
const config = JSON.parse(
decrypt(channel.config),
) as TelegramChannelConfig;
const telegramResult = await sendViaTelegram(config, payload);
success = telegramResult.ok;
if (!success) {
results.push({
channel: "TELEGRAM",
success: false,
error: `Telegram API hat Fehler gemeldet (chatId: ${config.chatId})`,
});
continue;
}
break;
}
case "NTFY": {
const config = JSON.parse(
decrypt(channel.config),
) as NtfyChannelConfig;
success = await sendViaNtfy(config, payload);
if (!success) {
results.push({
channel: "NTFY",
success: false,
error: `Senden fehlgeschlagen (topic: ${config.topic})`,
});
continue;
}
break;
}
case "WEB_PUSH": {
success = await sendViaWebPush(user.id, payload);
break;
}
default:
results.push({
channel: type,
success: false,
error: "Unbekannter Kanaltyp",
});
continue;
}
results.push({ channel: type, success });
} catch (err) {
results.push({
channel: channel.type,
success: false,
error: err instanceof Error ? err.message : "Unbekannter Fehler",
});
}
}
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
let message: string;
if (failCount === 0) {
message = `Alle ${successCount} Kanäle erfolgreich`;
} else if (successCount === 0) {
message = `Alle ${failCount} Kanäle fehlgeschlagen`;
} else {
message = `${successCount} erfolgreich, ${failCount} fehlgeschlagen`;
}
return apiSuccess({ sent: successCount > 0, message, results });
});
+227
View File
@@ -0,0 +1,227 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.settings.get" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
return apiSuccess({
registrationEnabled: settings?.registrationEnabled ?? true,
defaultLocale: settings?.defaultLocale ?? "de",
telegramGlobal: settings?.telegramGlobal ?? true,
ntfyGlobal: settings?.ntfyGlobal ?? true,
webPushGlobal: settings?.webPushGlobal ?? true,
webPushVapidPublicKey: settings?.webPushVapidPublicKey ?? null,
webPushVapidSubject: settings?.webPushVapidSubject ?? null,
webPushVapidConfigured: Boolean(
settings?.webPushVapidPublicKey &&
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
),
apiGlobal: settings?.apiGlobal ?? true,
umamiEnabled: settings?.umamiEnabled ?? false,
umamiScriptUrl: settings?.umamiScriptUrl ?? null,
umamiWebsiteId: settings?.umamiWebsiteId ?? null,
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
glitchtipDsn: settings?.glitchtipDsn ?? null,
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
bugReportRepo: settings?.githubIssueRepo ?? null,
bugReportConfigured: Boolean(
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
),
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
});
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = adminSettingsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const data = parsed.data;
const updates: Record<string, unknown> = {};
const auditDetails: Record<string, unknown> = {};
// Boolean fields — direct mapping
const booleanFields = [
"registrationEnabled",
"telegramGlobal",
"ntfyGlobal",
"webPushGlobal",
"apiGlobal",
"umamiEnabled",
"glitchtipEnabled",
"moodLogGlobal",
] as const;
for (const field of booleanFields) {
if (data[field] !== undefined) {
updates[field] = data[field];
auditDetails[field] = data[field];
}
}
if (data.defaultLocale !== undefined) {
updates.defaultLocale = data.defaultLocale;
auditDetails.defaultLocale = data.defaultLocale;
}
// String fields that map directly (with empty → null)
if (data.webPushVapidPublicKey !== undefined) {
const value = data.webPushVapidPublicKey.trim();
updates.webPushVapidPublicKey = value || null;
auditDetails.webPushVapidPublicKey = value ? "configured" : null;
}
if (data.webPushVapidSubject !== undefined) {
const value = data.webPushVapidSubject.trim();
updates.webPushVapidSubject = value || null;
auditDetails.webPushVapidSubject = value || null;
}
// Encrypted fields
if (data.webPushVapidPrivateKey !== undefined) {
const value = data.webPushVapidPrivateKey.trim();
if (value) {
updates.webPushVapidPrivateKeyEncrypted = encrypt(value);
auditDetails.webPushVapidPrivateKeyUpdated = true;
}
}
if (data.clearWebPushVapidPrivateKey === true) {
updates.webPushVapidPrivateKeyEncrypted = null;
auditDetails.webPushVapidPrivateKeyUpdated = false;
}
// URL fields with normalization
if (data.umamiScriptUrl !== undefined) {
const value = data.umamiScriptUrl.trim();
if (!value) {
updates.umamiScriptUrl = null;
auditDetails.umamiScriptUrl = null;
} else {
const parsed = new URL(value);
if (parsed.pathname === "/" || parsed.pathname === "") {
parsed.pathname = "/script.js";
}
updates.umamiScriptUrl = parsed.toString();
auditDetails.umamiScriptUrl = parsed.toString();
}
}
if (data.umamiWebsiteId !== undefined) {
const value = data.umamiWebsiteId.trim();
updates.umamiWebsiteId = value || null;
auditDetails.umamiWebsiteId = value || null;
}
if (data.glitchtipDsn !== undefined) {
const value = data.glitchtipDsn.trim();
if (!value) {
updates.glitchtipDsn = null;
auditDetails.glitchtipDsn = null;
} else {
updates.glitchtipDsn = new URL(value).toString();
auditDetails.glitchtipDsn = "configured";
}
}
if (data.glitchtipEnvironment !== undefined) {
const value = data.glitchtipEnvironment.trim();
updates.glitchtipEnvironment = value || null;
auditDetails.glitchtipEnvironment = value || null;
}
// Bug report
if (data.bugReportRepo !== undefined) {
const repo = data.bugReportRepo.trim();
updates.githubIssueRepo = repo || null;
auditDetails.bugReportRepo = repo || null;
}
if (data.bugReportToken !== undefined) {
const token = data.bugReportToken.trim();
if (token) {
updates.githubIssueTokenEncrypted = encrypt(token);
auditDetails.bugReportTokenUpdated = true;
}
}
if (data.clearBugReportToken === true) {
updates.githubIssueTokenEncrypted = null;
auditDetails.bugReportTokenUpdated = false;
}
// Numeric thresholds
if (data.reminderLateMinutes !== undefined) {
updates.reminderLateMinutes = data.reminderLateMinutes;
auditDetails.reminderLateMinutes = data.reminderLateMinutes;
}
if (data.reminderMissedMinutes !== undefined) {
updates.reminderMissedMinutes = data.reminderMissedMinutes;
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
}
if (Object.keys(updates).length === 0) {
return apiError("Keine gültigen Felder", 422);
}
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
update: updates,
create: { id: "singleton", ...updates },
});
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: auditDetails,
});
return apiSuccess({
registrationEnabled: settings.registrationEnabled,
defaultLocale: settings.defaultLocale,
telegramGlobal: settings.telegramGlobal,
ntfyGlobal: settings.ntfyGlobal,
webPushGlobal: settings.webPushGlobal,
webPushVapidPublicKey: settings.webPushVapidPublicKey,
webPushVapidSubject: settings.webPushVapidSubject,
webPushVapidConfigured: Boolean(
settings.webPushVapidPublicKey &&
settings.webPushVapidPrivateKeyEncrypted &&
settings.webPushVapidSubject,
),
apiGlobal: settings.apiGlobal,
umamiEnabled: settings.umamiEnabled,
umamiScriptUrl: settings.umamiScriptUrl,
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment:
settings.glitchtipEnvironment ?? "production",
bugReportRepo: settings.githubIssueRepo,
bugReportConfigured: Boolean(
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
),
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
});
});
+93
View File
@@ -0,0 +1,93 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
import { readFileSync } from "node:fs";
// Build timestamp (set at build time via next.config)
const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString();
const START_TIME = new Date().toISOString();
// Try to read git commit info
let gitCommit = process.env.GIT_COMMIT ?? "unknown";
try {
const head = readFileSync(".git/HEAD", "utf-8").trim();
if (head.startsWith("ref:")) {
const ref = head.replace("ref: ", "");
gitCommit = readFileSync(`.git/${ref}`, "utf-8").trim().slice(0, 8);
} else {
gitCommit = head.slice(0, 8);
}
} catch {
// In Docker or production, .git may not exist
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.status" } });
const [
userCount,
measurementCount,
medicationCount,
intakeEventCount,
tokenCount,
sessionCount,
appSettings,
] = await Promise.all([
prisma.user.count(),
prisma.measurement.count(),
prisma.medication.count(),
prisma.medicationIntakeEvent.count(),
prisma.apiToken.count({ where: { revoked: false } }),
prisma.session.count(),
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
]);
const umamiConfigured = Boolean(
appSettings?.umamiScriptUrl && appSettings?.umamiWebsiteId,
);
const glitchtipConfigured = Boolean(appSettings?.glitchtipDsn);
const webPushConfigured = Boolean(
appSettings?.webPushVapidPublicKey &&
appSettings?.webPushVapidPrivateKeyEncrypted &&
appSettings?.webPushVapidSubject,
);
const bugReportConfigured = Boolean(
appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted,
);
const workerStatus = getWorkerStatus();
return apiSuccess({
version: process.env.npm_package_version ?? "0.1.0",
nodeVersion: process.version,
gitCommit,
buildTime: BUILD_TIME,
startTime: START_TIME,
database: "connected",
worker: workerStatus,
counts: {
users: userCount,
measurements: measurementCount,
medications: medicationCount,
intakeEvents: intakeEventCount,
activeTokens: tokenCount,
activeSessions: sessionCount,
},
integrations: {
umami: umamiConfigured
? { configured: true, enabled: appSettings?.umamiEnabled ?? false }
: null,
glitchtip: glitchtipConfigured
? {
configured: true,
enabled: appSettings?.glitchtipEnabled ?? false,
}
: null,
webPush: webPushConfigured ? { configured: true } : null,
bugReport: bugReportConfigured ? { configured: true } : null,
},
});
});
+29
View File
@@ -0,0 +1,29 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.tokens.list" } });
const tokens = await prisma.apiToken.findMany({
select: {
id: true,
name: true,
permissions: true,
lastUsedAt: true,
expiresAt: true,
createdAt: true,
revoked: true,
user: {
select: { id: true, username: true },
},
},
orderBy: { createdAt: "desc" },
});
return apiSuccess(tokens);
});
@@ -0,0 +1,65 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest, NextResponse } from "next/server";
export const POST = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.reset-password", entity_type: "user", entity_id: id } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const { password } = body as { password?: string };
if (!password || typeof password !== "string") {
return apiError("Passwort erforderlich", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("Benutzer nicht gefunden", 404);
const strength = checkPasswordStrength(password, [target.username]);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
422,
);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
// Invalidate all sessions of the target user
await destroyAllSessions(id);
await auditLog("admin.user.reset-password", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id },
});
return apiSuccess({ success: true });
});
+68
View File
@@ -0,0 +1,68 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
const updateUserSchema = z.object({
username: z.string().min(3).max(30).optional(),
email: z.string().email().nullable().optional(),
role: z.enum(["ADMIN", "USER"]).optional(),
});
export const PUT = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.update", entity_type: "user", entity_id: id } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("Benutzer nicht gefunden", 404);
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("Der letzte Admin kann nicht degradiert werden", 400);
}
}
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(parsed.data.username !== undefined && {
username: parsed.data.username,
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
},
});
await auditLog("admin.user.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id, changes: parsed.data },
});
return apiSuccess(updatedUser);
});
+34
View File
@@ -0,0 +1,34 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.users.list" } });
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
});
return apiSuccess(
users.map((u) => ({
id: u.id,
username: u.username,
email: u.email,
role: u.role,
createdAt: u.createdAt,
passkeyCount: u._count.passkeys,
})),
);
});