mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
commit
38309932f3
301 files changed
+60041
No files matched your search
@@ -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 } });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user