From 38309932f3b8808286108fd873c2b1beaa89e140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Mar 2026 20:14:33 +0100 Subject: [PATCH] Initial release v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HealthLog — personal health-tracking PWA with weight, blood pressure, pulse, mood tracking, medication compliance, Withings integration, doctor report PDF export, and Dracula-themed UI. --- .env.example | 37 + .github/dependabot.yml | 12 + .github/workflows/dependabot-auto-merge.yml | 33 + .github/workflows/security.yml | 96 + .gitignore | 41 + .prettierignore | 6 + .prettierrc | 8 + AGENTS.md | 217 + CONTRIBUTING.md | 94 + Dockerfile | 75 + LICENSE | 661 + README.md | 335 + SECURITY.md | 61 + components.json | 23 + docker-compose.yml | 39 + docker-entrypoint.sh | 33 + eslint.config.mjs | 18 + messages/de.json | 1212 ++ messages/en.json | 1212 ++ next.config.ts | 17 + package.json | 74 + pnpm-lock.yaml | 10485 ++++++++++++++++ pnpm-workspace.yaml | 3 + postcss.config.mjs | 7 + prisma.config.ts | 40 + prisma/migrations/0001_init/migration.sql | 252 + .../0002_roles_and_settings/migration.sql | 25 + .../0003_add_missing_columns/migration.sql | 14 + .../0004_add_audit_log_location/migration.sql | 5 + .../0005_notification_system/migration.sql | 55 + .../migration.sql | 4 + .../0007_medication_paused_at/migration.sql | 4 + .../migration.sql | 4 + .../migration.sql | 2 + .../migration.sql | 7 + .../migration.sql | 9 + .../migration.sql | 6 + .../migration.sql | 2 + .../migration.sql | 35 + .../migration.sql | 53 + .../migration.sql | 13 + .../0017_add_data_backups/migration.sql | 19 + .../0018_add_user_achievements/migration.sql | 19 + .../0019_add_rate_limits/migration.sql | 11 + prisma/schema.prisma | 486 + public/favicon.svg | 9 + public/logo-192.png | Bin 0 -> 8105 bytes public/logo-512.png | Bin 0 -> 23332 bytes public/logo-readme.png | Bin 0 -> 104815 bytes public/logo-readme.svg | 16 + public/manifest.json | 21 + public/sw.js | 175 + scripts/seed-demo.ts | 375 + scripts/test-notifications.ts | 209 + src/app/achievements/page.tsx | 258 + src/app/admin/page.tsx | 2151 ++++ src/app/api/admin/audit-log/route.ts | 56 + src/app/api/admin/data/route.ts | 85 + .../admin/monitoring/glitchtip-test/route.ts | 44 + .../api/admin/monitoring/umami-test/route.ts | 123 + .../notifications/reminder-check/route.ts | 201 + src/app/api/admin/notifications/test/route.ts | 184 + src/app/api/admin/settings/route.ts | 227 + src/app/api/admin/status/route.ts | 93 + src/app/api/admin/tokens/route.ts | 29 + .../admin/users/[id]/reset-password/route.ts | 65 + src/app/api/admin/users/[id]/route.ts | 68 + src/app/api/admin/users/route.ts | 34 + src/app/api/analytics/route.ts | 118 + src/app/api/audit-log/route.ts | 46 + src/app/api/auth/login/route.ts | 80 + src/app/api/auth/logout/route.ts | 12 + src/app/api/auth/me/route.ts | 25 + .../api/auth/passkey/login-options/route.ts | 30 + .../api/auth/passkey/login-verify/route.ts | 65 + .../auth/passkey/register-options/route.ts | 17 + .../api/auth/passkey/register-verify/route.ts | 50 + src/app/api/auth/passkeys/[id]/route.ts | 48 + src/app/api/auth/passkeys/route.ts | 26 + src/app/api/auth/password/route.ts | 92 + src/app/api/auth/profile/route.ts | 62 + src/app/api/auth/register/route.ts | 109 + src/app/api/auth/registration-status/route.ts | 24 + src/app/api/bugreport/route.ts | 157 + src/app/api/doctor-report/route.ts | 171 + src/app/api/export/route.ts | 138 + .../api/gamification/achievements/route.ts | 746 ++ src/app/api/health/route.ts | 50 + src/app/api/import/route.ts | 122 + src/app/api/ingest/medication/route.ts | 145 + .../insights/blood-pressure-status/route.ts | 31 + src/app/api/insights/bmi-status/route.ts | 28 + src/app/api/insights/comprehensive/route.ts | 378 + src/app/api/insights/general-status/route.ts | 28 + src/app/api/insights/generate/route.ts | 156 + .../medication-compliance-status/route.ts | 31 + src/app/api/insights/mood-status/route.ts | 28 + src/app/api/insights/pulse-status/route.ts | 28 + src/app/api/insights/settings/route.ts | 86 + src/app/api/insights/targets/route.ts | 589 + src/app/api/insights/weight-status/route.ts | 28 + .../api/integrations/moodlog/status/route.ts | 33 + .../api/integrations/moodlog/sync/route.ts | 35 + .../api/integrations/moodlog/webhook/route.ts | 128 + src/app/api/measurements/[id]/route.ts | 113 + src/app/api/measurements/route.ts | 157 + .../medications/[id]/api-endpoint/route.ts | 177 + .../api/medications/[id]/compliance/route.ts | 133 + .../[id]/intake/[eventId]/route.ts | 102 + .../medications/[id]/intake/import/route.ts | 112 + .../medications/[id]/intake/purge/route.ts | 39 + src/app/api/medications/[id]/intake/route.ts | 139 + .../medications/[id]/phase-config/route.ts | 114 + src/app/api/medications/[id]/route.ts | 206 + .../api/medications/intake-summary/route.ts | 102 + src/app/api/medications/route.ts | 148 + src/app/api/monitoring/csp-report/route.ts | 53 + src/app/api/monitoring/glitchtip/route.ts | 61 + src/app/api/monitoring/settings/route.ts | 17 + src/app/api/monitoring/umami-script/route.ts | 49 + src/app/api/mood-entries/[id]/route.ts | 117 + src/app/api/mood-entries/route.ts | 122 + src/app/api/mood/analytics/route.ts | 53 + .../api/notifications/preferences/route.ts | 127 + src/app/api/notifications/vapid/route.ts | 19 + src/app/api/notifications/web-push/route.ts | 109 + src/app/api/onboarding/complete/route.ts | 55 + src/app/api/send/route.ts | 112 + src/app/api/settings/account/route.ts | 66 + src/app/api/settings/data/route.ts | 117 + src/app/api/settings/global-services/route.ts | 16 + src/app/api/settings/moodlog/route.ts | 80 + src/app/api/settings/ntfy/route.ts | 92 + src/app/api/settings/ntfy/test/route.ts | 52 + .../api/settings/reminder-thresholds/route.ts | 16 + src/app/api/settings/telegram/route.ts | 198 + src/app/api/settings/telegram/test/route.ts | 49 + src/app/api/telegram/webhook/route.ts | 633 + src/app/api/tokens/[id]/route.ts | 33 + src/app/api/tokens/route.ts | 77 + src/app/api/withings/callback/route.ts | 114 + src/app/api/withings/connect/route.ts | 40 + src/app/api/withings/credentials/route.ts | 80 + src/app/api/withings/disconnect/route.ts | 43 + src/app/api/withings/status/route.ts | 89 + src/app/api/withings/sync/route.ts | 26 + src/app/api/withings/webhook/route.ts | 105 + src/app/auth/login/page.tsx | 230 + src/app/auth/register/page.tsx | 141 + src/app/bugreport/page.tsx | 202 + src/app/favicon.ico | Bin 0 -> 9662 bytes src/app/globals.css | 168 + src/app/insights/page.tsx | 1871 +++ src/app/layout.tsx | 82 + src/app/measurements/page.tsx | 73 + src/app/medications/[id]/history/page.tsx | 87 + src/app/medications/page.tsx | 803 ++ src/app/notifications/page.tsx | 328 + src/app/onboarding/page.tsx | 324 + src/app/page.tsx | 491 + src/app/robots.ts | 11 + src/app/settings/page.tsx | 2861 +++++ src/app/stimmung/page.tsx | 73 + src/app/zielwerte/page.tsx | 558 + src/components/charts/compliance-charts.tsx | 145 + src/components/charts/compliance-heatmap.tsx | 293 + .../charts/compliance-line-chart.tsx | 147 + src/components/charts/health-chart.tsx | 827 ++ src/components/charts/mood-chart.tsx | 439 + src/components/charts/trend-card.tsx | 124 + .../achievement-unlock-notifier.tsx | 130 + src/components/insights/insights-card.tsx | 164 + src/components/layout/auth-shell.tsx | 140 + src/components/layout/bottom-nav.tsx | 64 + src/components/layout/sidebar-nav.tsx | 438 + src/components/layout/top-bar.tsx | 137 + .../measurements/measurement-form.tsx | 378 + .../measurements/measurement-list.tsx | 748 ++ .../medications/intake-history-list.tsx | 832 ++ .../medications/intake-timeline.tsx | 163 + .../medications/medication-card.tsx | 568 + .../medications/medication-form.tsx | 902 ++ .../medications/phase-config-dialog.tsx | 239 + src/components/monitoring/bootstrap.tsx | 50 + .../monitoring/glitchtip-reporter.tsx | 72 + src/components/monitoring/umami-script.tsx | 41 + src/components/mood/mood-form.tsx | 214 + src/components/mood/mood-list.tsx | 697 + src/components/providers.tsx | 128 + src/components/ui/alert-dialog.tsx | 196 + src/components/ui/avatar.tsx | 109 + src/components/ui/badge.tsx | 48 + src/components/ui/button.tsx | 64 + src/components/ui/card.tsx | 92 + src/components/ui/dialog.tsx | 161 + src/components/ui/dropdown-menu.tsx | 257 + src/components/ui/input.tsx | 21 + src/components/ui/label.tsx | 26 + src/components/ui/logo.tsx | 43 + src/components/ui/password-strength.tsx | 113 + src/components/ui/progress.tsx | 31 + src/components/ui/select.tsx | 190 + src/components/ui/separator.tsx | 28 + src/components/ui/sheet.tsx | 143 + src/components/ui/sonner.tsx | 37 + src/components/ui/switch.tsx | 35 + src/components/ui/table.tsx | 116 + src/components/ui/tabs.tsx | 91 + src/components/ui/tooltip.tsx | 57 + src/hooks/use-auth.ts | 57 + src/instrumentation.ts | 122 + src/lib/__tests__/crypto.test.ts | 70 + src/lib/__tests__/export.test.ts | 71 + src/lib/__tests__/medication-schedule.test.ts | 54 + .../__tests__/classifications.test.ts | 167 + .../analytics/__tests__/compliance.test.ts | 292 + .../analytics/__tests__/correlations.test.ts | 145 + src/lib/analytics/__tests__/trends.test.ts | 109 + src/lib/analytics/bp-targets.ts | 40 + src/lib/analytics/classifications.ts | 424 + src/lib/analytics/compliance.ts | 175 + src/lib/analytics/correlations.ts | 137 + src/lib/analytics/pulse-targets.ts | 127 + src/lib/analytics/trends.ts | 253 + src/lib/analytics/value-bands.ts | 121 + src/lib/api-handler.ts | 177 + src/lib/api-response.ts | 34 + src/lib/app-settings.ts | 72 + src/lib/auth/__tests__/hmac.test.ts | 85 + src/lib/auth/audit.ts | 40 + src/lib/auth/hmac.ts | 39 + src/lib/auth/passkey.ts | 222 + src/lib/auth/password.ts | 54 + src/lib/auth/session.ts | 113 + src/lib/crypto.ts | 86 + src/lib/db-compat.ts | 200 + src/lib/db.ts | 19 + src/lib/doctor-report-pdf.ts | 371 + src/lib/export.ts | 129 + src/lib/format.ts | 63 + .../__tests__/achievements.test.ts | 58 + src/lib/gamification/achievements.ts | 380 + src/lib/geo.ts | 37 + src/lib/gravatar.ts | 12 + src/lib/i18n/config.ts | 9 + src/lib/i18n/context.tsx | 122 + src/lib/insights/blood-pressure-status.ts | 606 + src/lib/insights/bmi-status.ts | 380 + src/lib/insights/features.ts | 298 + src/lib/insights/general-status.ts | 495 + .../insights/medication-compliance-status.ts | 471 + src/lib/insights/mood-status.ts | 516 + src/lib/insights/no-key-fallbacks.ts | 67 + src/lib/insights/prompt.ts | 56 + src/lib/insights/pulse-status.ts | 430 + src/lib/insights/sanitize.ts | 21 + src/lib/insights/weight-status.ts | 500 + src/lib/jobs/boss-instance.ts | 17 + src/lib/jobs/reminder-phases.ts | 183 + src/lib/jobs/reminder-worker.ts | 1065 ++ src/lib/jobs/worker-status.ts | 80 + src/lib/logging/background.ts | 49 + src/lib/logging/config.ts | 66 + src/lib/logging/context.ts | 29 + src/lib/logging/event-builder.ts | 149 + src/lib/logging/index.ts | 13 + src/lib/logging/sampler.ts | 29 + src/lib/logging/transports.ts | 103 + src/lib/logging/types.ts | 118 + src/lib/medication-category.ts | 97 + src/lib/medication-schedule.ts | 57 + src/lib/monitoring-settings.ts | 67 + src/lib/monitoring/glitchtip.ts | 233 + src/lib/moodlog/sync.ts | 131 + src/lib/notifications/dispatcher.ts | 161 + src/lib/notifications/senders/ntfy.ts | 57 + src/lib/notifications/senders/telegram.ts | 147 + src/lib/notifications/senders/web-push.ts | 90 + src/lib/notifications/types.ts | 62 + src/lib/notifications/vapid-config.ts | 57 + src/lib/rate-limit.ts | 76 + src/lib/telegram.ts | 167 + src/lib/time-window-format.ts | 11 + src/lib/timezone.ts | 106 + src/lib/utils.ts | 6 + src/lib/validations/admin.ts | 78 + src/lib/validations/auth.ts | 46 + src/lib/validations/measurement.ts | 108 + src/lib/validations/medication.ts | 92 + src/lib/validations/moodlog.ts | 106 + src/lib/validations/notifications.ts | 58 + src/lib/validations/phase-config.ts | 16 + src/lib/validations/telegram.ts | 7 + src/lib/validations/withings.ts | 6 + src/lib/withings/client.ts | 305 + src/lib/withings/credentials.ts | 34 + src/lib/withings/sync.ts | 166 + src/lib/zxcvbn-de.ts | 64 + src/proxy.ts | 121 + tsconfig.json | 34 + vitest.config.mts | 18 + 301 files changed, 60041 insertions(+) create mode 100644 .env.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-auto-merge.yml create mode 100644 .github/workflows/security.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 components.json create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 eslint.config.mjs create mode 100644 messages/de.json create mode 100644 messages/en.json create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/0001_init/migration.sql create mode 100644 prisma/migrations/0002_roles_and_settings/migration.sql create mode 100644 prisma/migrations/0003_add_missing_columns/migration.sql create mode 100644 prisma/migrations/0004_add_audit_log_location/migration.sql create mode 100644 prisma/migrations/0005_notification_system/migration.sql create mode 100644 prisma/migrations/0006_medication_notifications_enabled/migration.sql create mode 100644 prisma/migrations/0007_medication_paused_at/migration.sql create mode 100644 prisma/migrations/0008_app_settings_bugreport_github/migration.sql create mode 100644 prisma/migrations/0009_app_settings_api_global/migration.sql create mode 100644 prisma/migrations/0010_app_settings_monitoring/migration.sql create mode 100644 prisma/migrations/0011_app_settings_web_push_vapid/migration.sql create mode 100644 prisma/migrations/0012_app_settings_reminder_thresholds/migration.sql create mode 100644 prisma/migrations/0013_medication_snoozed_until/migration.sql create mode 100644 prisma/migrations/0014_add_moodlog_integration/migration.sql create mode 100644 prisma/migrations/0015_add_reminder_phase_tracking/migration.sql create mode 100644 prisma/migrations/0016_telegram_scheduled_deletions/migration.sql create mode 100644 prisma/migrations/0017_add_data_backups/migration.sql create mode 100644 prisma/migrations/0018_add_user_achievements/migration.sql create mode 100644 prisma/migrations/0019_add_rate_limits/migration.sql create mode 100644 prisma/schema.prisma create mode 100644 public/favicon.svg create mode 100644 public/logo-192.png create mode 100644 public/logo-512.png create mode 100644 public/logo-readme.png create mode 100644 public/logo-readme.svg create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 scripts/seed-demo.ts create mode 100644 scripts/test-notifications.ts create mode 100644 src/app/achievements/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/admin/audit-log/route.ts create mode 100644 src/app/api/admin/data/route.ts create mode 100644 src/app/api/admin/monitoring/glitchtip-test/route.ts create mode 100644 src/app/api/admin/monitoring/umami-test/route.ts create mode 100644 src/app/api/admin/notifications/reminder-check/route.ts create mode 100644 src/app/api/admin/notifications/test/route.ts create mode 100644 src/app/api/admin/settings/route.ts create mode 100644 src/app/api/admin/status/route.ts create mode 100644 src/app/api/admin/tokens/route.ts create mode 100644 src/app/api/admin/users/[id]/reset-password/route.ts create mode 100644 src/app/api/admin/users/[id]/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/analytics/route.ts create mode 100644 src/app/api/audit-log/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/passkey/login-options/route.ts create mode 100644 src/app/api/auth/passkey/login-verify/route.ts create mode 100644 src/app/api/auth/passkey/register-options/route.ts create mode 100644 src/app/api/auth/passkey/register-verify/route.ts create mode 100644 src/app/api/auth/passkeys/[id]/route.ts create mode 100644 src/app/api/auth/passkeys/route.ts create mode 100644 src/app/api/auth/password/route.ts create mode 100644 src/app/api/auth/profile/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/auth/registration-status/route.ts create mode 100644 src/app/api/bugreport/route.ts create mode 100644 src/app/api/doctor-report/route.ts create mode 100644 src/app/api/export/route.ts create mode 100644 src/app/api/gamification/achievements/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/import/route.ts create mode 100644 src/app/api/ingest/medication/route.ts create mode 100644 src/app/api/insights/blood-pressure-status/route.ts create mode 100644 src/app/api/insights/bmi-status/route.ts create mode 100644 src/app/api/insights/comprehensive/route.ts create mode 100644 src/app/api/insights/general-status/route.ts create mode 100644 src/app/api/insights/generate/route.ts create mode 100644 src/app/api/insights/medication-compliance-status/route.ts create mode 100644 src/app/api/insights/mood-status/route.ts create mode 100644 src/app/api/insights/pulse-status/route.ts create mode 100644 src/app/api/insights/settings/route.ts create mode 100644 src/app/api/insights/targets/route.ts create mode 100644 src/app/api/insights/weight-status/route.ts create mode 100644 src/app/api/integrations/moodlog/status/route.ts create mode 100644 src/app/api/integrations/moodlog/sync/route.ts create mode 100644 src/app/api/integrations/moodlog/webhook/route.ts create mode 100644 src/app/api/measurements/[id]/route.ts create mode 100644 src/app/api/measurements/route.ts create mode 100644 src/app/api/medications/[id]/api-endpoint/route.ts create mode 100644 src/app/api/medications/[id]/compliance/route.ts create mode 100644 src/app/api/medications/[id]/intake/[eventId]/route.ts create mode 100644 src/app/api/medications/[id]/intake/import/route.ts create mode 100644 src/app/api/medications/[id]/intake/purge/route.ts create mode 100644 src/app/api/medications/[id]/intake/route.ts create mode 100644 src/app/api/medications/[id]/phase-config/route.ts create mode 100644 src/app/api/medications/[id]/route.ts create mode 100644 src/app/api/medications/intake-summary/route.ts create mode 100644 src/app/api/medications/route.ts create mode 100644 src/app/api/monitoring/csp-report/route.ts create mode 100644 src/app/api/monitoring/glitchtip/route.ts create mode 100644 src/app/api/monitoring/settings/route.ts create mode 100644 src/app/api/monitoring/umami-script/route.ts create mode 100644 src/app/api/mood-entries/[id]/route.ts create mode 100644 src/app/api/mood-entries/route.ts create mode 100644 src/app/api/mood/analytics/route.ts create mode 100644 src/app/api/notifications/preferences/route.ts create mode 100644 src/app/api/notifications/vapid/route.ts create mode 100644 src/app/api/notifications/web-push/route.ts create mode 100644 src/app/api/onboarding/complete/route.ts create mode 100644 src/app/api/send/route.ts create mode 100644 src/app/api/settings/account/route.ts create mode 100644 src/app/api/settings/data/route.ts create mode 100644 src/app/api/settings/global-services/route.ts create mode 100644 src/app/api/settings/moodlog/route.ts create mode 100644 src/app/api/settings/ntfy/route.ts create mode 100644 src/app/api/settings/ntfy/test/route.ts create mode 100644 src/app/api/settings/reminder-thresholds/route.ts create mode 100644 src/app/api/settings/telegram/route.ts create mode 100644 src/app/api/settings/telegram/test/route.ts create mode 100644 src/app/api/telegram/webhook/route.ts create mode 100644 src/app/api/tokens/[id]/route.ts create mode 100644 src/app/api/tokens/route.ts create mode 100644 src/app/api/withings/callback/route.ts create mode 100644 src/app/api/withings/connect/route.ts create mode 100644 src/app/api/withings/credentials/route.ts create mode 100644 src/app/api/withings/disconnect/route.ts create mode 100644 src/app/api/withings/status/route.ts create mode 100644 src/app/api/withings/sync/route.ts create mode 100644 src/app/api/withings/webhook/route.ts create mode 100644 src/app/auth/login/page.tsx create mode 100644 src/app/auth/register/page.tsx create mode 100644 src/app/bugreport/page.tsx create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/insights/page.tsx create mode 100644 src/app/layout.tsx create mode 100644 src/app/measurements/page.tsx create mode 100644 src/app/medications/[id]/history/page.tsx create mode 100644 src/app/medications/page.tsx create mode 100644 src/app/notifications/page.tsx create mode 100644 src/app/onboarding/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/robots.ts create mode 100644 src/app/settings/page.tsx create mode 100644 src/app/stimmung/page.tsx create mode 100644 src/app/zielwerte/page.tsx create mode 100644 src/components/charts/compliance-charts.tsx create mode 100644 src/components/charts/compliance-heatmap.tsx create mode 100644 src/components/charts/compliance-line-chart.tsx create mode 100644 src/components/charts/health-chart.tsx create mode 100644 src/components/charts/mood-chart.tsx create mode 100644 src/components/charts/trend-card.tsx create mode 100644 src/components/gamification/achievement-unlock-notifier.tsx create mode 100644 src/components/insights/insights-card.tsx create mode 100644 src/components/layout/auth-shell.tsx create mode 100644 src/components/layout/bottom-nav.tsx create mode 100644 src/components/layout/sidebar-nav.tsx create mode 100644 src/components/layout/top-bar.tsx create mode 100644 src/components/measurements/measurement-form.tsx create mode 100644 src/components/measurements/measurement-list.tsx create mode 100644 src/components/medications/intake-history-list.tsx create mode 100644 src/components/medications/intake-timeline.tsx create mode 100644 src/components/medications/medication-card.tsx create mode 100644 src/components/medications/medication-form.tsx create mode 100644 src/components/medications/phase-config-dialog.tsx create mode 100644 src/components/monitoring/bootstrap.tsx create mode 100644 src/components/monitoring/glitchtip-reporter.tsx create mode 100644 src/components/monitoring/umami-script.tsx create mode 100644 src/components/mood/mood-form.tsx create mode 100644 src/components/mood/mood-list.tsx create mode 100644 src/components/providers.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/logo.tsx create mode 100644 src/components/ui/password-strength.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/use-auth.ts create mode 100644 src/instrumentation.ts create mode 100644 src/lib/__tests__/crypto.test.ts create mode 100644 src/lib/__tests__/export.test.ts create mode 100644 src/lib/__tests__/medication-schedule.test.ts create mode 100644 src/lib/analytics/__tests__/classifications.test.ts create mode 100644 src/lib/analytics/__tests__/compliance.test.ts create mode 100644 src/lib/analytics/__tests__/correlations.test.ts create mode 100644 src/lib/analytics/__tests__/trends.test.ts create mode 100644 src/lib/analytics/bp-targets.ts create mode 100644 src/lib/analytics/classifications.ts create mode 100644 src/lib/analytics/compliance.ts create mode 100644 src/lib/analytics/correlations.ts create mode 100644 src/lib/analytics/pulse-targets.ts create mode 100644 src/lib/analytics/trends.ts create mode 100644 src/lib/analytics/value-bands.ts create mode 100644 src/lib/api-handler.ts create mode 100644 src/lib/api-response.ts create mode 100644 src/lib/app-settings.ts create mode 100644 src/lib/auth/__tests__/hmac.test.ts create mode 100644 src/lib/auth/audit.ts create mode 100644 src/lib/auth/hmac.ts create mode 100644 src/lib/auth/passkey.ts create mode 100644 src/lib/auth/password.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/db-compat.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/doctor-report-pdf.ts create mode 100644 src/lib/export.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/gamification/__tests__/achievements.test.ts create mode 100644 src/lib/gamification/achievements.ts create mode 100644 src/lib/geo.ts create mode 100644 src/lib/gravatar.ts create mode 100644 src/lib/i18n/config.ts create mode 100644 src/lib/i18n/context.tsx create mode 100644 src/lib/insights/blood-pressure-status.ts create mode 100644 src/lib/insights/bmi-status.ts create mode 100644 src/lib/insights/features.ts create mode 100644 src/lib/insights/general-status.ts create mode 100644 src/lib/insights/medication-compliance-status.ts create mode 100644 src/lib/insights/mood-status.ts create mode 100644 src/lib/insights/no-key-fallbacks.ts create mode 100644 src/lib/insights/prompt.ts create mode 100644 src/lib/insights/pulse-status.ts create mode 100644 src/lib/insights/sanitize.ts create mode 100644 src/lib/insights/weight-status.ts create mode 100644 src/lib/jobs/boss-instance.ts create mode 100644 src/lib/jobs/reminder-phases.ts create mode 100644 src/lib/jobs/reminder-worker.ts create mode 100644 src/lib/jobs/worker-status.ts create mode 100644 src/lib/logging/background.ts create mode 100644 src/lib/logging/config.ts create mode 100644 src/lib/logging/context.ts create mode 100644 src/lib/logging/event-builder.ts create mode 100644 src/lib/logging/index.ts create mode 100644 src/lib/logging/sampler.ts create mode 100644 src/lib/logging/transports.ts create mode 100644 src/lib/logging/types.ts create mode 100644 src/lib/medication-category.ts create mode 100644 src/lib/medication-schedule.ts create mode 100644 src/lib/monitoring-settings.ts create mode 100644 src/lib/monitoring/glitchtip.ts create mode 100644 src/lib/moodlog/sync.ts create mode 100644 src/lib/notifications/dispatcher.ts create mode 100644 src/lib/notifications/senders/ntfy.ts create mode 100644 src/lib/notifications/senders/telegram.ts create mode 100644 src/lib/notifications/senders/web-push.ts create mode 100644 src/lib/notifications/types.ts create mode 100644 src/lib/notifications/vapid-config.ts create mode 100644 src/lib/rate-limit.ts create mode 100644 src/lib/telegram.ts create mode 100644 src/lib/time-window-format.ts create mode 100644 src/lib/timezone.ts create mode 100644 src/lib/utils.ts create mode 100644 src/lib/validations/admin.ts create mode 100644 src/lib/validations/auth.ts create mode 100644 src/lib/validations/measurement.ts create mode 100644 src/lib/validations/medication.ts create mode 100644 src/lib/validations/moodlog.ts create mode 100644 src/lib/validations/notifications.ts create mode 100644 src/lib/validations/phase-config.ts create mode 100644 src/lib/validations/telegram.ts create mode 100644 src/lib/validations/withings.ts create mode 100644 src/lib/withings/client.ts create mode 100644 src/lib/withings/credentials.ts create mode 100644 src/lib/withings/sync.ts create mode 100644 src/lib/zxcvbn-de.ts create mode 100644 src/proxy.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.mts diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..0f7b1067b --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Database +DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public" + +# App +NEXT_PUBLIC_APP_URL="http://localhost:3000" +APP_URL="http://localhost:3000" +NODE_ENV="development" + +# Session +SESSION_SECRET="change-me-to-a-random-64-char-hex-string" + +# Encryption (for tokens, sensitive notes) +ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string" + +# HMAC key for API token hashing +API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string" + +# Withings OAuth (M5) +# WITHINGS_CLIENT_ID="" +# WITHINGS_CLIENT_SECRET="" +# WITHINGS_REDIRECT_URI="" +# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening + +# Telegram webhook hardening (optional but recommended) +# TELEGRAM_WEBHOOK_SECRET="" + +# OpenAI (optional, user-provided keys stored encrypted) +# No server-side key needed - users bring their own + +# Structured Logging (Wide Events) +# LOG_LEVEL="info" # debug | info | warn | error +# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log +# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged +# LOG_INCLUDE_STACK="true" # Include stack traces in error events +# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100) +# LOKI_USERNAME="" # Optional: Loki basic auth username +# LOKI_PASSWORD="" # Optional: Loki basic auth password diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..df857cd93 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + security-patches: + applies-to: security-updates + update-types: + - "patch" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 000000000..78aed86b2 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,33 @@ +name: Auto-merge Dependabot + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - run: pnpm test + + - run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 000000000..b6a2247d6 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,96 @@ +name: Security & Quality + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + name: Lint, Typecheck & Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - name: ESLint + run: pnpm lint + continue-on-error: true + + - name: TypeScript strict check + run: pnpm typecheck + continue-on-error: true + + - name: Unit tests + run: pnpm test + + dependency-audit: + name: Dependency Audit + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - name: Audit for known vulnerabilities + run: pnpm audit --prod --audit-level=high + continue-on-error: true + + secret-scan: + name: Secret Scanning + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Gitleaks secret scan + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + docker-security: + name: Container Security + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t healthlog:scan . + + - name: Install Trivy + run: | + sudo apt-get install -y wget apt-transport-https gnupg + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list + sudo apt-get update && sudo apt-get install -y trivy + + - name: Trivy container scan + run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan + continue-on-error: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..385cf5f4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env +.env.local +.env.*.local + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma generated +/src/generated/prisma diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..06202557b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +.next +out +build +src/generated +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..6c45155fe --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 80, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8a9af31c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,217 @@ +# AGENTS.md + +Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) working on this repository. + +## Project + +**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design. + +**Status**: Feature-complete (v1.0.0). See GitHub Issues for open tasks. + +## Tech Stack + +| Layer | Technology | Version | Notes | +| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- | +| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity | +| ORM | Prisma | 7.4 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below | +| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` | +| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` | +| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens | +| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) | +| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` | +| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) | +| Testing | Vitest | latest | Config in `vitest.config.ts` | +| Package manager | pnpm | latest | **Not** npm or yarn | +| Node | 20.x | via nvm | | +| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas | +| Auth | SimpleWebAuthn | 13 | Passkeys primary, password fallback | +| i18n | Custom context-based | — | `useTranslations()` hook, `messages/de.json` + `messages/en.json` | +| PDF | jsPDF + autotable | latest | Client-side doctor report generation | + +## Commands + +```bash +# Development +pnpm dev # Start dev server (http://localhost:3000) +pnpm build # Production build +pnpm lint # ESLint +pnpm format # Prettier format +pnpm format:check # Prettier check +pnpm typecheck # TypeScript strict check + +# Tests +pnpm test # Run all tests (vitest) +pnpm test:watch # Watch mode + +# Database +pnpm db:generate # Generate Prisma client +pnpm db:migrate # Create & apply migration (dev) +pnpm db:migrate:deploy # Apply migrations (production) +pnpm db:push # Push schema without migration (prototyping) +pnpm db:studio # Prisma Studio GUI + +# Docker +docker compose up -d # Start app + postgres +docker compose logs -f app # Tail app logs + +# Verification (run before completing tasks) +pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build +``` + +## File Layout + +``` +src/ +├── app/ # Pages and API routes (App Router) +│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA) +│ ├── page.tsx # Dashboard (/) with quick entry dropdown +│ ├── globals.css # Dracula theme CSS variables +│ ├── admin/page.tsx # Admin panel +│ ├── auth/login/page.tsx # Login +│ ├── auth/register/page.tsx # Registration +│ ├── achievements/page.tsx # Gamification achievements +│ ├── bugreport/page.tsx # Bug report → GitHub issue +│ ├── insights/page.tsx # Health insights + correlations + AI (7 sections with sticky nav) +│ ├── measurements/page.tsx # Measurements CRUD table +│ ├── medications/page.tsx # Medications management +│ ├── notifications/page.tsx # Notification preferences matrix +│ ├── onboarding/page.tsx # 4-step guided onboarding +│ ├── settings/page.tsx # All settings (13 sections, ~2100 lines) +│ ├── zielwerte/page.tsx # Target values dashboard +│ └── api/ # ~60 API route files (see docs/api.md) +│ ├── mood-entries/ # Mood CRUD +│ ├── import/ # JSON data import +│ ├── doctor-report/ # Doctor report data aggregation +│ ├── gamification/ # Achievements (persistent) +│ ├── integrations/moodlog/ # moodLog.app webhook + sync + status +│ └── ... +├── components/ +│ ├── ui/ # shadcn/ui primitives +│ ├── layout/ # Shell: sidebar-nav, topbar, mobile-nav, bottom-nav +│ ├── medications/ # Medication form, card, timeline +│ ├── measurements/ # Measurement form, list +│ ├── mood/ # Mood form, mood list +│ ├── charts/ # Recharts wrappers, compliance charts +│ ├── insights/ # AI insights card +│ └── monitoring/ # Umami, GlitchTip bootstrap +├── lib/ +│ ├── db.ts # Prisma client singleton +│ ├── crypto.ts # AES-256-GCM encryption +│ ├── gravatar.ts # Gravatar URL from email (SHA-256) +│ ├── api-response.ts # { data, error, meta } envelope helpers +│ ├── rate-limit.ts # In-memory sliding window rate limiter +│ ├── export.ts # CSV/JSON export formatting (incl. mood) +│ ├── doctor-report-pdf.ts # Client-side PDF generation (jsPDF) +│ ├── auth/session.ts # getSession() → { session, user } | null +│ ├── analytics/ # Trend calculations, compliance, correlations +│ ├── i18n/ # I18n context + config +│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push) +│ ├── jobs/ # pg-boss worker (reminders, insights, backups) +│ ├── validations/ # Zod schemas (shared between API + client) +│ └── withings/ # Withings OAuth client + sync service +├── hooks/ +│ └── use-auth.ts # useAuth(), useLogout() hooks +├── generated/prisma/client/ # Generated Prisma client (DO NOT EDIT) +messages/ +├── de.json # German translations (primary UI language) +└── en.json # English translations +prisma/ +├── schema.prisma # Database schema (22 models) +└── migrations/ # Migration files (0001–0018) +prisma.config.ts # Prisma config (DB URL lives here, NOT in schema) +public/ +├── sw.js # Service worker (Web Push + offline caching) +├── manifest.json # PWA manifest +└── ... # Static assets +docs.healthlog.dev # External documentation site +``` + +## Key Conventions + +- **UI language is German**. All user-facing text in German. Code, comments, docs in English. +- **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`. +- **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`. +- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Nicht angemeldet", 401);` +- **Timezone**: `Europe/Berlin` for display, UTC in database. +- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`. +- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements. +- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie on all non-public paths. Unauthenticated page requests → redirect `/auth/login`. API routes keep their own `getSession()` auth. Public paths defined in `PUBLIC_PATHS` array. +- **PWA safe area**: Root layout uses `viewportFit: "cover"`. Bottom-nav has `pb-[env(safe-area-inset-bottom)]`. Auth-shell uses `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]`. +- **PDF generation**: Client-side via `src/lib/doctor-report-pdf.ts` using jsPDF. Data fetched from `POST /api/doctor-report`. European medical format with German terminology. +- **Data backup**: pg-boss weekly job (`data-backup` queue, Sundays 03:00), stores compressed JSON in `DataBackup` model. +- **Achievements**: Persistent in `UserAchievement` table. API at `/api/gamification/achievements` computes current state, compares with DB, persists new unlocks. `unlockedAt` timestamp is stable once set. + +## Critical Gotchas + +These are hard-won lessons. Ignoring them will cause errors: + +### Prisma 7 + +- **No `url` in schema.prisma** — The database URL is configured in `prisma.config.ts`, not in the `datasource` block. +- **Import path**: `import { ... } from "@/generated/prisma/client"` (with `/client` suffix, not `@/generated/prisma`). +- **Adapter required**: PrismaClient needs `PrismaPg` adapter from `@prisma/adapter-pg`. See `src/lib/db.ts`. +- **No `earlyAccess`** in defineConfig — causes TypeScript errors. + +### Next.js 16 + +- **Proxy, not middleware**: Next.js 16 renamed middleware to proxy. The file is `src/proxy.ts` (not `middleware.ts`). Having both causes a build error: "Both middleware file and proxy file detected". + +### Libraries + +- **SimpleWebAuthn v13**: No `@simplewebauthn/server/script/deps` — define Transport type inline. +- **zxcvbn-typescript**: Default export only. `zxcvbnAsync` and `zxcvbnOptions` do not exist as named exports. +- **pg-boss v12**: `{ PgBoss }` named import (not default), use `localConcurrency` (not `teamSize`), handler receives `Job[]` array. +- **Zod v4**: Import from `"zod/v4"`, not `"zod"`. +- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin. + +### Settings Page + +- One large file (~2100 lines), 13 sections. Sidebar switches to "settings mode" showing section shortcuts. +- Sections scroll-to with highlight animation (`section-highlight` CSS class). +- Section IDs: `profil`, `sprache`, `sicherheit`, `telegram`, `ntfy`, `web-push`, `insights`, `withings`, `api`, `export`, `protokoll`, `daten`. + +### Insights Page + +- Large file (~1800+ lines), 7 content sections with AI summaries. +- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking. +- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `medications`, `bmi`. +- Each section has `scroll-mt-28` for header offset on scroll. + +### Sidebar + +- Collapsible (icons-only mode, `w-16`). State persisted in localStorage (`healthlog-sidebar-collapsed`). +- User section at bottom: Avatar (Gravatar if email, initials fallback) + username + three-dot MoreVertical menu (right side). +- Three-dot menu: Admin link, Notifications, Theme picker, Logout. +- ESLint enforces `react-hooks/set-state-in-effect` — use lazy `useState(() => ...)` for localStorage reads, NOT `useEffect` + `setState`. + +### Gravatar + +- `src/lib/gravatar.ts` generates Gravatar URL server-side (SHA-256 hash of email, `?d=404` fallback). +- Returned in `/api/auth/me` response as `gravatarUrl` field. Used by `AvatarImage` component with `AvatarFallback` for initials. + +### Notification System + +- **Channels**: `TELEGRAM`, `NTFY`, `WEB_PUSH` (DB: `NotificationChannel`). +- **Event types**: `MEDICATION_REMINDER`, `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, `SYSTEM_ALERT`. +- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists. +- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type. +- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h. + +### Onboarding + +- 4-step guided flow: Profile → Medications → Notifications teaser → Target values. +- Optional steps 2-4 have "Skip" buttons. +- Progress indicator bar at top. +- Located at `/onboarding`, linked from proxy as public path. + +## Database Models (Prisma) + +22 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`. + +## When Making Changes + +1. **Read before modifying** — understand the existing code patterns. +2. **Run verification** after changes: `pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build` +3. **German UI text** — all user-facing strings go through `t("key")` with translations in both `messages/de.json` and `messages/en.json`. +4. **Don't over-engineer** — keep changes focused. No speculative abstractions. +5. **Update `docs/STATUS.md`** when completing tasks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..942e7a34a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing to HealthLog + +Contributions are welcome. This guide covers how to set up the project and submit changes. + +## Prerequisites + +- Node.js 22+ +- pnpm +- PostgreSQL 16+ (or Docker) +- Git + +## Setup + +```bash +git clone https://github.com/MBombeck/HealthLog.git +cd HealthLog +cp .env.example .env +``` + +Edit `.env` and configure at minimum: + +``` +DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog" +SESSION_SECRET=<64-char hex> +ENCRYPTION_KEY=<64-char hex> +API_TOKEN_HMAC_KEY=<64-char hex> +``` + +Generate secrets with `openssl rand -hex 32`. + +### With Docker (database only) + +```bash +docker compose up -d db +pnpm install +pnpm db:generate +pnpm db:migrate +pnpm dev +``` + +### Full Docker + +```bash +docker compose up -d +``` + +## Development Workflow + +```bash +pnpm dev # Start dev server (http://localhost:3000) +pnpm lint # ESLint +pnpm format # Prettier +pnpm typecheck # TypeScript strict mode +pnpm test # Vitest +``` + +All four checks must pass before submitting a PR. + +## Code Conventions + +- **TypeScript strict mode** -- no `any` types, no type assertions unless absolutely necessary +- **UI text in German**, code/comments/docs in English +- **API responses** follow `{ data, error, meta }` envelope pattern +- **Zod v4** for input validation (API routes and forms) +- **shadcn/ui** components (new-york style) for UI primitives +- **Dracula theme** -- dark mode only, use `--dracula-*` CSS tokens + +See `CLAUDE.md` for detailed architecture and patterns. + +## Database Changes + +```bash +# After modifying prisma/schema.prisma: +pnpm db:migrate # Creates a new migration +pnpm db:generate # Regenerates Prisma client +``` + +Always include the migration file in your PR. + +## Pull Requests + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Make your changes +4. Ensure all checks pass (`pnpm lint && pnpm typecheck && pnpm test`) +5. Submit a PR with a clear description of the changes + +## Security + +If you find a security vulnerability, **do not open a public issue**. See [SECURITY.md](SECURITY.md) for responsible disclosure instructions. + +## License + +By contributing, you agree that your contributions will be licensed under the [AGPL-3.0](LICENSE) license. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f356f9bff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# ── Stage 1: Dependencies ────────────────────────────────── +FROM node:22-alpine AS deps +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --prod=false + +# ── Stage 2: Build ───────────────────────────────────────── +FROM node:22-alpine AS builder +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Generate Prisma client +RUN pnpm db:generate + +# Build Next.js +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm build + +# ── Stage 3: Production runner ───────────────────────────── +FROM node:22-alpine AS runner +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_PATH="/opt/pg-boss/node_modules" + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Copy Prisma for migrations (schema, migration SQL, config, engines) +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts +COPY --from=builder /app/src/generated ./src/generated + +# Install all worker runtime dependencies in a shared prefix. +# NODE_PATH makes them available to Node.js module resolution. +# These packages are listed in next.config.ts serverExternalPackages +# and are NOT bundled by Next.js — they must exist at runtime. +RUN mkdir -p /opt/pg-boss && \ + cd /opt/pg-boss && \ + npm init -y && \ + npm install --omit=dev pg-boss@12 @prisma/adapter-pg@7 pg@8 + +# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree) +RUN mkdir -p /opt/prisma-cli && \ + cd /opt/prisma-cli && \ + npm init -y && \ + npm install --omit=dev prisma@7.4.0 @prisma/engines@7.4.0 + +# Entrypoint script (runs migrations, then starts app) +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1 + +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 000000000..077ea537d --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +

+ HealthLog Logo +

+ +

HealthLog

+ +

+ Your health data belongs to you. Track it on your own terms. +

+ +

+ License: AGPL-3.0 + TypeScript + Next.js 16 + Self-Hosted + PRs Welcome +

+ +

+ A self-hosted, privacy-first health tracking PWA.
+ Weight, blood pressure, mood, medications, AI insights -- all under your control. +

+ +

+ Website · + Live Demo · + Documentation +

+ +--- + +## Why HealthLog? + +Most health apps lock your data behind proprietary clouds, push subscriptions, and sell your metrics to advertisers. HealthLog takes a different approach: your data stays on your server, encrypted at rest, accessible only to you. + +--- + +## Key Features + +**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, and steps with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 guidelines. + +**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook. + +**Medication Compliance** -- Flexible scheduling with time windows, recurrence patterns, intake logging (take/skip/snooze), and compliance heatmaps. External API for iOS Shortcuts integration. + +**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication. + +**AI-Powered Insights** -- Bring your own OpenAI API key. Personalized analysis across blood pressure, weight, pulse, mood, BMI, and medication adherence. Cached daily to minimize API usage. + +**Doctor Report PDF Export** -- Generate professional medical reports client-side. European format with vital sign summaries, BP/BMI classification, compliance rates, and optional AI analysis. + +**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access. + +**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), and Web Push. Medication reminders with late/missed escalation. + +**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks. + +**Internationalization** -- Full German and English UI with 1000+ translation keys. Browser-based locale detection with per-user override. + +--- + +## Quick Start + +```bash +git clone https://github.com/MBombeck/HealthLog.git +cd HealthLog +cp .env.example .env +``` + +Generate the required secrets: + +```bash +openssl rand -hex 32 # SESSION_SECRET +openssl rand -hex 32 # ENCRYPTION_KEY +openssl rand -hex 32 # API_TOKEN_HMAC_KEY +``` + +Add them to `.env`, then: + +```bash +docker compose up -d +``` + +Open **http://localhost:3000**. The first registered user becomes admin. + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Framework | Next.js 16 (App Router, React Server Components) | +| Language | TypeScript (strict mode) | +| Database | PostgreSQL 16 + Prisma 7 (22 models) | +| Job Queue | pg-boss 12 (reminders, insights, backups) | +| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons | +| Charts | Recharts 3 | +| Data Fetching | TanStack Query 5 | +| Forms | React Hook Form 7 + Zod 4 | +| Auth | SimpleWebAuthn 13, Argon2id | +| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) | +| PDF | jsPDF (client-side generation) | +| Testing | Vitest 4 | +| Deployment | Docker (multi-stage Alpine) | + +--- + +## Security and Privacy + +HealthLog is designed for people who take data ownership seriously. + +- **Self-hosted** -- Your data never leaves your server. No telemetry, no third-party tracking. +- **AES-256-GCM encryption** -- All stored secrets (OAuth tokens, API keys, VAPID keys) are encrypted at rest. +- **Passkey authentication** -- WebAuthn as primary auth with password fallback (Argon2id + zxcvbn strength validation). +- **Server-side sessions** -- PostgreSQL-backed with 30-day sliding expiry, HttpOnly/SameSite=Strict cookies. +- **Security headers** -- CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy, Referrer-Policy. +- **Rate limiting** -- Sliding window on auth and API endpoints. +- **HMAC-SHA256 API tokens** -- Bearer tokens are hashed before storage. +- **Audit logging** -- All sensitive operations tracked with IP addresses. + +--- + +## Environment Variables + +### Required + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | PostgreSQL connection string | +| `SESSION_SECRET` | 64-char hex string for session signing | +| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM | +| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing | + +### Optional + +| Variable | Description | +|----------|-------------| +| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) | +| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID | +| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret | +| `WITHINGS_REDIRECT_URI` | OAuth callback URL | +| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret | +| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret | + +Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database. + +--- + +## Architecture + +``` +src/ +├── app/ # Next.js App Router pages & API routes +│ ├── api/ # REST API endpoints (~60 route files) +│ ├── admin/ # Admin panel +│ ├── medications/ # Medication management +│ ├── measurements/ # Health metric entry +│ ├── insights/ # AI-powered analytics +│ ├── achievements/ # Gamification page +│ ├── onboarding/ # 4-step guided setup +│ └── settings/ # User preferences +├── components/ +│ ├── ui/ # shadcn/ui primitives +│ ├── layout/ # Shell (sidebar, topbar, bottom nav) +│ ├── medications/ # Medication cards, forms, timeline +│ ├── measurements/ # Measurement form, list +│ ├── mood/ # Mood form, mood list +│ └── charts/ # Recharts wrappers +├── lib/ +│ ├── auth/ # Session, audit, passkey logic +│ ├── notifications/ # Dispatcher + channel senders +│ ├── jobs/ # pg-boss worker +│ ├── analytics/ # Trend calculations, compliance +│ ├── withings/ # OAuth client, sync service +│ ├── i18n/ # Translations context & config +│ ├── validations/ # Shared Zod schemas +│ ├── crypto.ts # AES-256-GCM encrypt/decrypt +│ ├── db.ts # Prisma singleton +│ └── doctor-report-pdf.ts # Client-side PDF generation +├── hooks/ # React hooks +└── generated/prisma/ # Generated Prisma client +``` + +### Key Patterns + +- **RSC by default** -- `"use client"` only for interactive components +- **API envelope** -- All responses follow `{ data, error, meta }` shape +- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials +- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database +- **Route protection** -- `proxy.ts` checks session cookie, redirects unauthenticated requests +- **Client-side PDF** -- Doctor reports generated in browser via jsPDF + +--- + +## API Reference + +All mutations require authentication via session cookie. External ingest uses Bearer tokens. + +
+Health Data + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/measurements` | List measurements (paginated, filterable) | +| `POST` | `/api/measurements` | Create measurement | +| `DELETE` | `/api/measurements/:id` | Delete measurement | +| `GET` | `/api/analytics` | Trend summaries (7d/30d) | +| `GET` | `/api/export` | Export as CSV or JSON | +| `POST` | `/api/import` | Import from JSON | +| `POST` | `/api/doctor-report` | Aggregated data for PDF | + +
+ +
+Mood + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/mood-entries` | List mood entries | +| `POST` | `/api/mood-entries` | Create mood entry | +| `DELETE` | `/api/mood-entries/:id` | Delete mood entry | +| `GET` | `/api/mood/analytics` | Mood trend analytics | +| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook | + +
+ +
+Medications + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/medications` | List all medications | +| `POST` | `/api/medications` | Create medication | +| `PUT` | `/api/medications/:id` | Update medication | +| `DELETE` | `/api/medications/:id` | Delete medication | +| `POST` | `/api/medications/:id/intake` | Log intake event | +| `GET` | `/api/medications/:id/compliance` | Compliance stats | +| `POST` | `/api/ingest/medication` | External intake (Bearer) | + +
+ +
+Auth and Integrations + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/auth/register` | Create account | +| `POST` | `/api/auth/login` | Password login | +| `POST` | `/api/auth/logout` | Destroy session | +| `GET` | `/api/auth/me` | Current user profile | +| `POST` | `/api/auth/passkey/*` | WebAuthn flows | +| `GET` | `/api/withings/connect` | Initiate Withings OAuth | +| `POST` | `/api/insights/generate` | Regenerate AI insights | +| `GET` | `/api/gamification/achievements` | Achievement progress | +| `GET` | `/api/health` | Docker health check | + +
+ +--- + +## Integrations + +| Integration | Setup | Purpose | +|-------------|-------|---------| +| **Withings** | Env vars | Auto-sync weight, BP, and activity | +| **Telegram** | Admin Panel | Medication reminders with inline buttons | +| **ntfy** | User Settings | Self-hosted push notifications | +| **Web Push** | Admin Panel | Browser-native VAPID notifications | +| **OpenAI** | User Settings | AI health insights (BYOK) | +| **moodLog.app** | User Settings | Mood tracking sync | +| **Umami** | Admin Panel | Privacy-friendly analytics | +| **GlitchTip** | Admin Panel | Sentry-compatible error tracking | + +--- + +## Local Development + +```bash +# Prerequisites: Node.js 20+, pnpm, PostgreSQL + +cp .env.example .env +pnpm install +pnpm db:generate +pnpm db:migrate +pnpm dev +``` + +### Scripts + +```bash +pnpm dev # Development server +pnpm build # Production build +pnpm lint # ESLint +pnpm typecheck # TypeScript strict check +pnpm test # Vitest +pnpm format # Prettier + +pnpm db:generate # Generate Prisma client +pnpm db:migrate # Create & apply migration +pnpm db:migrate:deploy # Apply migrations (production) +pnpm db:studio # Prisma Studio GUI +``` + +--- + +## Deployment + +The included `docker-compose.yml` runs the app and PostgreSQL. The entrypoint automatically waits for the database, runs pending migrations, and starts the server. + +The app listens on port **3000**. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with [Coolify](https://coolify.io/). + +--- + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +- Code style: `pnpm format && pnpm lint` +- Type safety: `pnpm typecheck` must pass +- Tests: `pnpm test` +- UI language: German. Code, comments, and commits: English. + +--- + +## License + +HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE). + +--- + +

+ healthlog.dev · + Live Demo · + Docs +

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..8e24b8d2c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,61 @@ +# Security Policy + +## Supported Versions + +Only the latest release on the `main` branch is actively supported with security updates. + +| Version | Supported | +|---------|-----------| +| Latest | Yes | +| Older | No | + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it responsibly. + +**Do not open a public GitHub issue for security vulnerabilities.** + +Instead, send an email to **security@bombeck.io** with: + +- A description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +## Response Timeline + +- **Acknowledgement**: Within 72 hours +- **Initial assessment**: Within 1 week +- **Fix and disclosure**: Coordinated with reporter, typically within 30 days + +## Scope + +The following are in scope: + +- Authentication and session management +- Data encryption and secret handling +- API authorization and access control +- Cross-site scripting (XSS), CSRF, and injection vulnerabilities +- Information disclosure + +Out of scope: + +- Denial of service attacks +- Social engineering +- Vulnerabilities in third-party dependencies (report upstream) +- Issues requiring physical access to the server + +## Security Architecture + +HealthLog is designed with security as a core principle: + +- **Passkey-first authentication** (WebAuthn/FIDO2) with password fallback +- **AES-256-GCM encryption** for all stored secrets (OAuth tokens, API keys, VAPID keys) +- **Argon2id** password hashing with zxcvbn strength validation +- **HMAC-SHA256** hashed API tokens +- **Server-side sessions** in PostgreSQL (HttpOnly, SameSite=Strict, 30-day sliding expiry) +- **Rate limiting** on authentication and external-facing endpoints +- **Security headers**: CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy +- **Proxy-level route protection** via `proxy.ts` (session cookie validation on all non-public paths) + +For more details, see the [security documentation](https://docs.healthlog.dev/security/overview/). diff --git a/components.json b/components.json new file mode 100644 index 000000000..2dd3f723e --- /dev/null +++ b/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..b7f4355d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: healthlog-app + expose: + - "3000" + environment: + DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public" + NODE_ENV: production + SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}" + ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}" + API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}" + NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" + APP_URL: "${APP_URL:-http://localhost:3000}" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + container_name: healthlog-db + environment: + POSTGRES_USER: healthlog + POSTGRES_PASSWORD: healthlog + POSTGRES_DB: healthlog + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U healthlog"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + pgdata: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 000000000..22ef0bc5d --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -e + +# ── Wait for PostgreSQL ────────────────────────────────────── +MAX_RETRIES=30 +RETRY=0 + +echo "HealthLog: Waiting for database..." +until node -e " + const { createConnection } = require('net'); + const url = new URL(process.env.DATABASE_URL); + const sock = createConnection(Number(url.port) || 5432, url.hostname); + sock.on('connect', () => { sock.destroy(); process.exit(0); }); + sock.on('error', () => process.exit(1)); +" 2>/dev/null; do + RETRY=$((RETRY + 1)) + if [ "$RETRY" -ge "$MAX_RETRIES" ]; then + echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." + exit 1 + fi + sleep 1 +done +echo "HealthLog: Database is reachable." + +# ── Run Prisma migrations ──────────────────────────────────── +echo "HealthLog: Running database migrations..." +NODE_PATH=/opt/prisma-cli/node_modules \ + node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy +echo "HealthLog: Migrations complete." + +# ── Start application ──────────────────────────────────────── +echo "HealthLog: Starting application..." +exec "$@" diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/messages/de.json b/messages/de.json new file mode 100644 index 000000000..e626a6c50 --- /dev/null +++ b/messages/de.json @@ -0,0 +1,1212 @@ +{ + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "moreOptions": "Weitere Optionen", + "create": "Erstellen", + "close": "Schließen", + "loading": "Laden...", + "error": "Fehler", + "success": "Erfolgreich", + "saved": "Gespeichert", + "confirm": "Bestätigen", + "back": "Zurück", + "next": "Weiter", + "yes": "Ja", + "no": "Nein", + "search": "Suchen", + "filter": "Filtern", + "noData": "Keine Daten vorhanden", + "optional": "Optional", + "required": "Erforderlich", + "all": "Alle", + "active": "Aktiv", + "inactive": "Inaktiv", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "networkError": "Netzwerkfehler", + "unknownError": "Unbekannter Fehler", + "notLoggedIn": "Nicht angemeldet", + "noAccess": "Kein Zugriff", + "copied": "Kopiert!", + "or": "oder", + "unknown": "Unbekannt", + "replace": "Ersetzen", + "add": "Hinzufügen", + "import": "Importieren", + "test": "Test" + }, + "nav": { + "home": "Home", + "dashboard": "Dashboard", + "measurements": "Messungen", + "medications": "Medikamente", + "charts": "Verlauf", + "insights": "Insights", + "targets": "Zielwerte", + "achievements": "Erfolge", + "settings": "Einstellungen", + "bugreport": "Bugreport", + "admin": "Admin-Konsole", + "logout": "Abmelden", + "login": "Anmelden", + "notifications": "Benachrichtigungen", + "theme": "Darstellung", + "themeSystem": "System", + "themeDark": "Dunkel", + "themeLight": "Hell", + "collapseSidebar": "Einklappen", + "userMenu": "Benutzermenü", + "mood": "Stimmung", + "skipToContent": "Zum Inhalt springen", + "sidebar": "Sidebar", + "mainNavigation": "Hauptnavigation", + "mobileNavigation": "Mobile Navigation", + "loadingScreen": "Laden…" + }, + "auth": { + "login": "Anmelden", + "register": "Registrieren", + "loginTitle": "Anmeldung", + "registerTitle": "Registrierung", + "username": "Benutzername", + "password": "Passwort", + "email": "E-Mail-Adresse", + "emailPlaceholder": "name@beispiel.de", + "emailOrUsername": "E-Mail-Adresse oder Benutzername", + "emailOrUsernamePlaceholder": "name@beispiel.de oder benutzername", + "loginWithPasskey": "Mit Passkey anmelden", + "loginWithPassword": "Mit Passwort anmelden", + "noAccount": "Noch kein Konto?", + "hasAccount": "Bereits ein Konto?", + "registerDisabled": "Registrierung ist deaktiviert.", + "loginFailed": "Anmeldung fehlgeschlagen", + "passkeyFailed": "Passkey-Anmeldung abgebrochen oder fehlgeschlagen", + "registerFailed": "Registrierung fehlgeschlagen", + "createAccount": "Konto erstellen", + "newAccount": "Neues Konto erstellen", + "backToPasskey": "Zurück zu Passkey", + "passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.", + "passwordMinLength": "Min. 12 Zeichen", + "passwordStrength": "Passwortstärke", + "strengthWeak": "Sehr schwach", + "strengthFair": "Schwach", + "strengthGood": "Mittel", + "strengthStrong": "Stark", + "strengthVeryStrong": "Sehr stark" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Dein Gesundheitsüberblick", + "greeting": { + "morning": "Guten Morgen", + "day": "Hallo", + "evening": "Guten Abend" + }, + "welcomeBack": "{greeting}, willkommen zurück.", + "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", + "weight": "Gewicht", + "bloodPressure": "Blutdruck", + "bloodPressureSys": "Blutdruck (Sys)", + "bloodPressureDia": "Blutdruck (Dia)", + "pulse": "Puls", + "bodyFat": "Körperfett", + "bpInTarget": "BD im Zielbereich", + "days30Short": "30T", + "medications": "Medikamente", + "lastMeasurement": "Letzter Messwert", + "noMeasurements": "Noch keine Messwerte", + "trend": "Trend", + "trendUp": "steigend", + "trendDown": "fallend", + "trendStable": "stabil", + "quickEntry": "Schnelleingabe", + "addMeasurement": "Messwert hinzufügen", + "compliance7d": "Einnahmetreue (7 Tage)", + "complianceGood": "Gut", + "complianceLow": "Niedrig", + "mood": "Stimmung" + }, + "measurements": { + "title": "Messungen", + "subtitle": "Gewicht, Blutdruck, Puls & mehr", + "addMeasurement": "Hinzufügen", + "editMeasurement": "Messwert bearbeiten", + "type": "Typ", + "value": "Wert", + "unit": "Einheit", + "date": "Datum", + "source": "Quelle", + "notes": "Notizen", + "typeWeight": "Gewicht", + "typeBloodPressure": "Blutdruck", + "typeBpSys": "Sys", + "typeBpDia": "Dia", + "typePulse": "Puls", + "typeBodyFat": "Körperfett", + "typeSleep": "Schlaf", + "typeSteps": "Schritte", + "unitHours": "Stunden", + "unitSteps": "Schritte", + "sourceManual": "Manuell", + "sourceWithings": "Withings", + "sourceImport": "Import", + "systolicLabel": "Systolisch (mmHg)", + "diastolicLabel": "Diastolisch (mmHg)", + "pulseLabel": "Puls (bpm)", + "valueWithUnit": "Wert ({unit})", + "timestamp": "Zeitpunkt", + "notesPlaceholder": "z.B. nach dem Essen", + "formReset": "Formular zurücksetzen", + "saveError": "Fehler beim Speichern", + "deleteError": "Fehler beim Löschen", + "invalidValue": "Bitte einen gültigen Wert eingeben.", + "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", + "loginRequired": "Bitte anmelden, um Messwerte zu sehen.", + "allTypes": "Alle Typen", + "measurementCount": "{count} Messwerte", + "noMeasurements": "Keine Messwerte vorhanden", + "comment": "Kommentar", + "actions": "Aktionen", + "deleteConfirmTitle": "Messwert löschen?", + "deleteConfirmDescription": "Dieser Messwert wird unwiderruflich gelöscht.", + "pageInfo": "Seite {page} von {total}", + "showing": "Zeigt", + "of": "von", + "entries": "Einträgen" + }, + "mood": { + "title": "Stimmung", + "subtitle": "Stimmungsverlauf & Einträge", + "addEntry": "Hinzufügen", + "editEntry": "Eintrag bearbeiten", + "moodLevel": "Stimmung", + "tags": "Tags", + "tagsPlaceholder": "z.B. Sport, Arbeit, Familie", + "tagsHelp": "Kommagetrennt eingeben", + "timestamp": "Zeitpunkt", + "date": "Datum", + "source": "Quelle", + "levelSuperGut": "Super gut", + "levelGut": "Gut", + "levelOkay": "Okay", + "levelSchlecht": "Schlecht", + "levelLausig": "Lausig", + "sourceManual": "Manuell", + "sourceMoodlog": "moodLog", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "allMoods": "Alle Stimmungen", + "entryCount": "{count} Einträge", + "noEntries": "Keine Stimmungseinträge vorhanden", + "actions": "Aktionen", + "formReset": "Formular zurücksetzen", + "saveError": "Fehler beim Speichern", + "deleteError": "Fehler beim Löschen", + "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", + "deleteConfirmTitle": "Eintrag löschen?", + "deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.", + "pageInfo": "Seite {page} von {total}", + "loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen." + }, + "medications": { + "title": "Medikamente", + "subtitle": "Medikation, Zeitpläne & Einnahmen", + "loginRequired": "Bitte anmelden, um Medikamente zu verwalten.", + "addMedication": "Hinzufügen", + "editMedication": "Medikament bearbeiten", + "newMedication": "Neues Medikament", + "firstMedication": "Erstes Medikament anlegen", + "noMedicationsYet": "Noch keine Medikamente angelegt", + "loadFailed": "Medikamente konnten nicht geladen werden", + "retryLoad": "Erneut laden", + "name": "Name", + "dose": "Dosis", + "doseAmount": "Menge", + "doseUnit": "Einheit", + "category": "Kategorie", + "schedule": "Zeitfenster", + "scheduleFrom": "Von", + "scheduleTo": "Bis", + "scheduleLabel": "Bezeichnung", + "scheduleDays": "Tage", + "scheduleDaily": "Täglich", + "active": "Aktiv", + "inactive": "Pausiert", + "intakeHistory": "Einnahme-Verlauf", + "showIntakeHistory": "Einnahme-Verlauf anzeigen", + "hideIntakeHistory": "Einnahme-Verlauf ausblenden", + "markTaken": "Eingenommen", + "markSkipped": "Übersprungen", + "deleteConfirm": "Medikament löschen?", + "deleteDescription": "Das Medikament und alle Einnahmen werden unwiderruflich gelöscht.", + "noMedications": "Keine Medikamente vorhanden.", + "compliance": "Einnahmetreue", + "import": "Import", + "importIntakes": "Einnahmen importieren", + "importDescription": "JSON-Array mit Einnahme-Daten einfügen. Erwartetes Format:", + "importUploadFile": "JSON-Datei hochladen", + "importPaste": "JSON hier einfügen...", + "importSelected": "Ausgewählt: {name}", + "importFileLoaded": "Datei \"{name}\" geladen", + "importInvalidJson": "Datei enthält kein gültiges JSON", + "importNoArray": "Kein Array gefunden", + "importResult": "{imported} Einnahmen importiert", + "importDuplicatesSkipped": "{count} Duplikate übersprungen", + "importInvalidSkipped": "{count} ungültige Einträge übersprungen", + "importFailed": "Import fehlgeschlagen", + "importInvalidFormat": "Ungültiges JSON-Format", + "apiAccess": "API-Zugang", + "apiEndpointTitle": "API-Endpoint: {name}", + "apiEndpointDescription": "Nutze diesen Endpoint, um Einnahmen per API zu erfassen (z.B. via iPhone-Kurzbefehl oder cron-Job).", + "apiEndpointActive": "API-Endpoint aktiv", + "apiEndpointActivated": "API-Endpoint aktiviert", + "apiEndpointDeactivated": "API-Endpoint deaktiviert", + "apiTokenCount": "Aktiv ({count} Token)", + "apiToken": "API-Token", + "apiTokenActiveHint": "Endpoint ist aktiv. Vorhandene Tokens bleiben gültig, können aber nicht erneut im Klartext angezeigt werden.", + "apiTokenActivateHint": "Aktiviere den Endpoint, um einen neuen Token zu erstellen.", + "apiTokenOnceHint": "Der Token wird nur bei der Erstellung einmal im Klartext angezeigt.", + "requestExample": "Request-Beispiel", + "statusLoadFailed": "Status konnte nicht geladen werden", + "changeFailed": "Ändern fehlgeschlagen", + "categoryBloodPressure": "Blutdrucksenker", + "categoryCholesterol": "Cholesterin", + "categoryDiabetes": "Diabetes", + "categoryVitamin": "Vitamine", + "categoryThyroid": "Schilddrüse", + "categoryHormone": "Hormone", + "categorySupplement": "Nahrungsergänzung", + "categoryPainRelief": "Schmerzmittel", + "categoryAllergy": "Allergie", + "categoryDigestive": "Magen/Darm", + "categorySkin": "Hautpflege", + "categorySleepAid": "Schlafmittel", + "categoryOther": "Sonstiges", + "daysSun": "So", + "daysMon": "Mo", + "daysTue": "Di", + "daysWed": "Mi", + "daysThu": "Do", + "daysFri": "Fr", + "daysSat": "Sa", + "intakeHistoryTitle": "Einnahme-Verlauf", + "newIntake": "Hinzufügen", + "editIntake": "Einnahme bearbeiten", + "intakeScheduledFor": "Geplant", + "intakeTakenAt": "Eingenommen um", + "intakeStatus": "Status", + "intakeSource": "Quelle", + "intakeStatusTaken": "Eingenommen", + "intakeStatusSkipped": "Übersprungen", + "intakeStatusMissed": "Verpasst", + "intakeDeleteConfirm": "Einnahme-Eintrag löschen?", + "intakeDeleteDescription": "Dieser Einnahme-Eintrag wird unwiderruflich gelöscht.", + "intakeCount": "{count} Einnahmen", + "viewHistory": "Verlauf", + "today": "Heute", + "yesterday": "Gestern", + "tomorrow": "Morgen", + "withoutNotification": "Ohne Benachrichtigung", + "pausedSince": "Pausiert seit", + "takeNow": "Jetzt einnehmen", + "overdue": "Überfällig", + "veryOverdue": "Stark überfällig", + "compliance7d": "7-Tage-Compliance", + "compliance30d": "30-Tage-Compliance", + "lastIntake": "Letzte Einnahme:", + "nextIntake": "Nächste Einnahme:", + "dayStreak": "Tage Serie", + "taken": "Genommen", + "skipped": "Übersprungen", + "weekdaySunday": "Sonntag", + "weekdayMonday": "Montag", + "weekdayTuesday": "Dienstag", + "weekdayWednesday": "Mittwoch", + "weekdayThursday": "Donnerstag", + "weekdayFriday": "Freitag", + "weekdaySaturday": "Samstag", + "weekdaySundayPlural": "Sonntags", + "weekdayMondayPlural": "Montags", + "weekdayTuesdayPlural": "Dienstags", + "weekdayWednesdayPlural": "Mittwochs", + "weekdayThursdayPlural": "Donnerstags", + "weekdayFridayPlural": "Freitags", + "weekdaySaturdayPlural": "Samstags", + "formType": "Typ", + "formDose": "Dosis", + "formUnit": "Einheit", + "formSchedule": "Zeitfenster", + "newSchedule": "Neues Zeitfenster", + "removeSchedule": "Zeitfenster entfernen", + "advancedEditing": "Erweiterte Bearbeitung", + "advancedEditingHide": "Erweiterte Bearbeitung ausblenden", + "scheduleInterval": "Intervall", + "scheduleEveryWeek": "jede Woche", + "scheduleEveryNWeeks": "alle {n} Wochen", + "scheduleIntervalPrefix": "Intervall:", + "nextSchedulePrefix": "Nächstes Zeitfenster:", + "nextScheduleDaily": "täglich", + "importIntakesAction": "Einnahmen importieren", + "apiEndpointAction": "API-Endpunkt", + "pause": "Pausieren", + "activate": "Aktivieren", + "disableNotifications": "Benachrichtigungen deaktivieren", + "enableNotifications": "Benachrichtigungen aktivieren", + "purgeRecords": "Alle Aufzeichnungen löschen", + "formReset": "Formular zurücksetzen", + "createMedication": "Medikament anlegen", + "deleteConfirmDescription": "Das Medikament und die gesamte Einnahme-Historie werden unwiderruflich gelöscht.", + "purgeConfirmTitle": "Alle Aufzeichnungen löschen?", + "purgeConfirmDescription": "Alle Einnahme-Aufzeichnungen für dieses Medikament werden unwiderruflich gelöscht. Das Medikament selbst bleibt bestehen und startet ohne Verlaufsdaten.", + "saveError": "Fehler beim Speichern", + "deleteError": "Fehler beim Löschen", + "purgeError": "Fehler beim Löschen der Aufzeichnungen", + "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", + "noIntakesYet": "Noch keine Einnahmen vorhanden", + "noIntakesForMedication": "Noch keine Einnahmen für", + "deleteIntakeAriaLabel": "Einnahme löschen", + "actions": "Aktionen", + "pageInfo": "Seite {page} von {total}", + "loginRequiredIntakes": "Bitte anmelden, um Einnahmen zu sehen.", + "clear": "Löschen", + "phaseConfig": "Erinnerungsphasen", + "phaseConfigDescription": "Konfiguriere die Zeitpunkte für Erinnerungen.", + "phaseGreen": "Grün", + "phaseYellow": "Gelb", + "phaseOrange": "Orange", + "phaseRed": "Rot", + "phaseMinutes": "Min", + "phasePercent": "%", + "phaseBeforeEnd": "vor Ende", + "phaseAfterEnd": "nach Ende", + "phaseResetDefaults": "Auf Defaults zurücksetzen", + "phaseSaved": "Erinnerungsphasen gespeichert", + "phaseReset": "Auf Defaults zurückgesetzt" + }, + "charts": { + "title": "Verlauf", + "subtitle": "Visualisierung deiner Gesundheitsdaten", + "timeRange": "Zeitraum", + "days7": "7 Tage", + "days30": "30 Tage", + "days90": "90 Tage", + "days365": "1 Jahr", + "daysAll": "Alle", + "weight": "Gewicht", + "bloodPressure": "Blutdruck", + "pulse": "Puls", + "bodyFat": "Körperfett", + "sleep": "Schlaf", + "steps": "Schritte", + "compliance": "Einnahmetreue", + "noData": "Keine Daten im gewählten Zeitraum", + "movingAverage": "Gleitender Durchschnitt", + "target": "Zielbereich", + "points7Label": "7M", + "points7Title": "Die sieben letzten Messpunkte", + "points30Label": "30M", + "points30Title": "Die dreißig letzten Messpunkte", + "points90Label": "90M", + "points90Title": "Die neunzig letzten Messpunkte", + "pointsAllLabel": "Alle", + "pointsAllTitle": "Alle verfügbaren Messpunkte", + "systolic": "Systolisch", + "diastolic": "Diastolisch", + "movingAverage7d": "7T-Schnitt", + "trend": "Trend", + "targetRanges": "Zielbereiche", + "perWeek": "/Woche", + "selectMedication": "Medikament wählen", + "noComplianceData": "Keine Compliance-Daten verfügbar", + "history": "Verlauf", + "complianceDays90": "Einnahmetreue (90 Tage)", + "colorGreen": "Grün:", + "colorOrange": "Orange:", + "colorRed": "Rot:", + "mood": "Stimmung", + "moodScore": "Stimmungswert", + "moodMA": "7-Tage-Schnitt" + }, + "insights": { + "title": "Insights", + "subtitle": "KI-basierte Analyse deiner Gesundheitsdaten", + "overviewSubtitle": "Trends, Risiken, Fortschritt auf einen Blick", + "navGeneral": "Allgemein", + "navBloodPressure": "Blutdruck", + "navWeight": "Gewicht", + "navPulse": "Puls", + "navMood": "Stimmung", + "navMedication": "Medikamente", + "navBmi": "BMI", + "measurementsOverDays": "{count} Messungen über {days} Tage", + "healthStatus": "Gesundheitsstatus", + "noWeightData": "Keine Gewichtsdaten oder Größe nicht hinterlegt.", + "notEnoughBpData": "Nicht genügend Blutdruckdaten.", + "bpTargetRange": "Zielbereich Blutdruck", + "inTargetRange": "im Zielbereich", + "bpTarget": "Ziel: {range}", + "noDobSet": "Geburtsdatum nicht hinterlegt.", + "avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018", + "whoClassification": "WHO-Klassifikation", + "correlations": "Korrelationen", + "weightVsBp": "Gewicht vs. Blutdruck (Systolisch)", + "strengthStrong": "Starke Korrelation", + "strengthModerate": "Moderate Korrelation", + "strengthWeak": "Schwache Korrelation", + "strengthNone": "Keine Korrelation", + "weightBpStrong": "Es besteht ein starker Zusammenhang zwischen Gewicht und Blutdruck.", + "weightBpModerate": "Es besteht ein moderater Zusammenhang zwischen Gewicht und Blutdruck.", + "weightBpWeak": "Es besteht ein schwacher Zusammenhang zwischen Gewicht und Blutdruck.", + "weightBpNone": "Kein erkennbarer Zusammenhang zwischen Gewicht und Blutdruck.", + "notEnoughCorrelationData": "Zu wenig Daten für Korrelationsanalyse.", + "minCorrelationData": "Mindestens 5 zeitlich übereinstimmende Gewichts- und Blutdruckmessungen nötig.", + "bpMedContinuity": "Blutdrucksenker-Kontinuität vs. Blutdruck (Systolisch)", + "continuityLabel": "Kontinuität", + "continuityBpLabel": "Kontinuität Blutdrucksenker (%)", + "weightKgLabel": "Gewicht (kg)", + "bpMedNegative": "Negativer Zusammenhang: höhere Kontinuität geht eher mit niedrigerem systolischen Blutdruck einher.", + "bpMedPositive": "Positiver Zusammenhang: höhere Kontinuität geht in deinen Daten eher mit höherem systolischen Blutdruck einher.", + "bpMedNone": "Kein erkennbarer Zusammenhang in deinen Daten.", + "bpMedCount": "{count} Blutdrucksenker", + "notEnoughMedData": "Zu wenig Daten für diese Analyse.", + "notEnoughMedDataHint": "Markiere Medikamente als Blutdrucksenker und erfasse ausreichend Einnahmen + Blutdruckmessungen.", + "medicationCompliance": "Medikamenten-Compliance", + "dayStreak": "{count} Tage Serie", + "compliance7d": "7-Tage-Compliance", + "compliance30d": "30-Tage-Compliance", + "taken": "genommen", + "skipped": "übersprungen", + "missed": "verpasst", + "complianceTrend": "Compliance-Verlauf", + "generalStatusTitle": "Allgemeiner Gesundheitszustand", + "generalStatusLoading": "Allgemeiner Zustand wird erstellt...", + "generalStatusNoKey": "Der allgemeine Zustand kann nicht erstellt werden, da kein OpenAI-KI-Schlüssel hinterlegt ist.", + "generalStatusUnavailable": "Der allgemeine Zustand konnte aktuell nicht geladen werden.", + "generalStatusBadge": { + "good": "Gut", + "watch": "Beobachten", + "critical": "Kritisch" + }, + "assessmentTitle": "Einschätzung", + "bloodPressureSectionTitle": "Blutdruck", + "bloodPressureMedicationListTitle": "Blutdrucksenker & Compliance", + "bloodPressureMedicationListEmpty": "Keine aktiven Blutdrucksenker hinterlegt.", + "bloodPressureStatusLoading": "Blutdruck-Zusammenfassung wird erstellt...", + "bloodPressureStatusNoKey": "Die Blutdruck-Zusammenfassung kann nicht erstellt werden, da kein OpenAI-KI-Schlüssel hinterlegt ist.", + "bloodPressureStatusUnavailable": "Die Blutdruck-Zusammenfassung konnte aktuell nicht geladen werden.", + "weightSectionTitle": "Gewicht", + "weightStatusLoading": "Gewichts-Zusammenfassung wird erstellt...", + "weightStatusNoKey": "Die Gewichts-Zusammenfassung kann nicht erstellt werden, da kein OpenAI-KI-Schlüssel hinterlegt ist.", + "weightStatusUnavailable": "Die Gewichts-Zusammenfassung konnte aktuell nicht geladen werden.", + "pulseSectionTitle": "Puls", + "pulseStatusLoading": "Puls-Zusammenfassung wird erstellt...", + "pulseStatusNoKey": "Die Puls-Zusammenfassung kann nicht erstellt werden, da kein OpenAI-KI-Schlüssel hinterlegt ist.", + "pulseStatusUnavailable": "Die Puls-Zusammenfassung konnte aktuell nicht geladen werden.", + "bmiSectionTitle": "BMI", + "bmiStatusLoading": "BMI-Zusammenfassung wird erstellt...", + "bmiStatusNoKey": "Die BMI-Zusammenfassung kann nicht erstellt werden, da kein OpenAI-KI-Schlüssel hinterlegt ist.", + "bmiStatusUnavailable": "Die BMI-Zusammenfassung konnte aktuell nicht geladen werden.", + "medicationComplianceStatusLoading": "Text zur Medikamentencompliance wird erstellt...", + "medicationComplianceStatusNoKey": "Der Text zur Medikamentencompliance kann nicht erstellt werden, da kein OpenAI-KI-Schlüssel hinterlegt ist.", + "medicationComplianceStatusUnavailable": "Der Text zur Medikamentencompliance konnte aktuell nicht geladen werden.", + "complianceNoData": "Keine Compliance-Daten verfügbar", + "aiInsights": "KI-Insights", + "configureApiKey": "Konfiguriere einen OpenAI API-Key in den Einstellungen, um KI-basierte Insights zu erhalten.", + "alertsTitle": "Hinweise & Empfehlungen", + "showLess": "Weniger anzeigen", + "showMore": "+{count} weitere", + "generate": "Neue Analyse erstellen", + "generating": "Analyse wird erstellt...", + "lastGenerated": "Letzte Analyse", + "noInsights": "Noch keine Analyse vorhanden.", + "rateLimit": "Bitte warte, bevor du eine neue Analyse erstellst.", + "noApiKey": "Bitte hinterlege zuerst einen OpenAI API-Key in den Einstellungen.", + "sendRawData": "Rohdaten mitsenden", + "minMoodCorrelationData": "Mindestens 5 zeitlich übereinstimmende Stimmungs- und Messdaten nötig.", + "moodBpModerate": "Es besteht ein moderater Zusammenhang zwischen Stimmung und Blutdruck.", + "moodBpNone": "Kein erkennbarer Zusammenhang zwischen Stimmung und Blutdruck.", + "moodBpStrong": "Es besteht ein starker Zusammenhang zwischen Stimmung und Blutdruck.", + "moodBpWeak": "Es besteht ein schwacher Zusammenhang zwischen Stimmung und Blutdruck.", + "moodScoreLabel": "Stimmung (Ø)", + "moodSectionTitle": "Stimmung", + "moodStatusLoading": "Stimmungsanalyse wird erstellt...", + "moodStatusNoKey": "Die Stimmungs-Zusammenfassung kann nicht erstellt werden, da kein OpenAI-KI-Schlüssel hinterlegt ist.", + "moodStatusUnavailable": "Stimmungsanalyse nicht verfügbar", + "moodVsBp": "Stimmung vs. Blutdruck (Systolisch)", + "moodVsPulse": "Stimmung vs. Puls", + "moodVsWeight": "Stimmung vs. Gewicht", + "moodWeightModerate": "Es besteht ein moderater Zusammenhang zwischen Stimmung und Gewicht.", + "moodWeightNone": "Kein erkennbarer Zusammenhang zwischen Stimmung und Gewicht.", + "moodWeightStrong": "Es besteht ein starker Zusammenhang zwischen Stimmung und Gewicht.", + "moodWeightWeak": "Es besteht ein schwacher Zusammenhang zwischen Stimmung und Gewicht.", + "notEnoughMoodCorrelationData": "Zu wenig Stimmungsdaten für Korrelationsanalyse." + }, + "targets": { + "title": "Zielwerte", + "subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil", + "introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.", + "current": "Aktuell", + "average30d": "30-Tage-Durchschnitt:", + "targetRange": "Zielbereich", + "classification": "Bewertung", + "source": "Quelle", + "sourceLabel": "Quelle: {source}", + "trend": "Trend", + "weight": "Gewicht", + "bloodPressure": "Blutdruck", + "pulse": "Ruhepuls", + "sleep": "Schlafdauer", + "bmi": "BMI", + "bodyFat": "Körperfett", + "steps": "Schritte/Tag", + "compliance": "Einnahmetreue", + "compliancePerMedication": "Pro Medikament", + "diastolic": "Diastolisch", + "noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.", + "profileIncomplete": "Profil unvollständig", + "profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.", + "profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.", + "profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.", + "noMeasurementData": "Noch keine Messdaten vorhanden.", + "noMeasurementYet": "Noch keine Messung vorhanden", + "currentValue": "Aktuell: {value} {unit}", + "targetRangeValue": "Zielbereich: {min}–{max} {unit}", + "inTarget": "Im Zielbereich", + "belowTarget": "{delta} {unit} unter Ziel", + "aboveTarget": "{delta} {unit} über Ziel", + "loadError": "Fehler beim Laden", + "classificationOptimal": "Optimal", + "classificationNormal": "Normal", + "classificationElevated": "Erhöht", + "classificationHigh": "Hoch", + "classificationLow": "Niedrig" + }, + "onboarding": { + "welcome": "Willkommen bei HealthLog!", + "setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.", + "completeSetup": "Einrichtung abschließen", + "skip": "Überspringen", + "medicationsTitle": "Nimmst du Medikamente?", + "medicationsDescription": "Füge deine Medikamente hinzu, um Erinnerungen und Compliance-Tracking zu nutzen.", + "medNamePlaceholder": "Name", + "medDosePlaceholder": "Dosis", + "notificationsTitle": "Erinnerungen", + "notificationsDescription": "HealthLog kann dich an deine Medikamente erinnern. Konfiguriere dies später in den Einstellungen.", + "telegramHint": "Erinnerungen per Telegram-Bot mit Snooze-Buttons", + "webPushHint": "Browser-Benachrichtigungen direkt auf dein Gerät", + "notificationsLater": "Du kannst dies jederzeit in den Einstellungen einrichten.", + "targetsTitle": "Zielwerte", + "targetsDescription": "Setze optionale Zielwerte für Blutdruck und Gewicht.", + "targetSys": "Systolisch (mmHg)", + "targetDia": "Diastolisch (mmHg)", + "targetWeight": "Zielgewicht (kg)" + }, + "settings": { + "title": "Einstellungen", + "subtitle": "Profil, Sicherheit & Verbindungen", + "sectionsTitle": "Settings", + "categoryGeneral": "Allgemein", + "categoryNotifications": "Benachrichtigungen", + "categoryAi": "KI", + "categoryIntegration": "Integration", + "categoryApi": "API", + "profile": "Profil", + "security": "Sicherheit", + "language": "Sprache", + "languageDescription": "Wähle die Anzeigesprache der Anwendung.", + "languageSystem": "System (automatisch)", + "username": "Benutzername", + "height": "Körpergröße (cm)", + "dateOfBirth": "Geburtsdatum", + "dateOfBirthHint": "Wird für die automatische Berechnung der Blutdruck-Zielwerte benötigt.", + "gender": "Geschlecht", + "genderNone": "Keine Angabe", + "genderMale": "Männlich", + "genderFemale": "Weiblich", + "genderHint": "Wird für geschlechtsspezifische Zielwerte verwendet.", + "profileSaved": "Profil gespeichert", + "changePassword": "Passwort ändern", + "changePasswordDescription": "Ersetze dein aktuelles Passwort durch ein neues.", + "passwordReset": "Passwort-Reset", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "confirmNewPassword": "Neues Passwort bestätigen", + "passwordMismatch": "Neue Passwörter stimmen nicht überein", + "passwordUpdated": "Passwort erfolgreich geändert", + "passkeys": "Passkeys", + "registeredPasskeys": "Registrierte Passkeys", + "passkeysDescription": "Passkeys ermöglichen sicheres Anmelden ohne Passwort.", + "passkeyName": "Name", + "passkeyDevice": "Gerät", + "passkeyBackup": "Backup", + "passkeyCreated": "Erstellt", + "passkeyActions": "Aktionen", + "noPasskeys": "Keine Passkeys registriert.", + "addPasskey": "Passkey hinzufügen", + "passkeyAdded": "Passkey erfolgreich hinzugefügt!", + "passkeyOptionsError": "Konnte Passkey-Optionen nicht laden", + "passkeyRegistrationFailed": "Fehler bei Passkey-Registrierung", + "passkeyRegistrationCancelled": "Passkey-Registrierung abgebrochen", + "deletePasskey": "Passkey löschen?", + "deletePasskeyDescription": "Der Passkey wird unwiderruflich gelöscht.", + "singleDevice": "Einzelgerät", + "multiDevice": "Multi-Gerät", + "backedUp": "gesichert", + "telegram": "Telegram-Benachrichtigungen", + "telegramDescription": "Erhalte Erinnerungen bei vergessenen Medikamenten-Einnahmen per Telegram.", + "telegramSaved": "Telegram-Einstellungen gespeichert", + "botToken": "Bot-Token", + "chatId": "Chat-ID", + "enableNotifications": "Benachrichtigungen aktivieren", + "testMessage": "Test-Nachricht", + "testSent": "Test-Nachricht gesendet!", + "telegramStep1": "1. Erstelle einen Bot über @BotFather in Telegram und kopiere den Token.", + "telegramStep2": "2. Sende /start an deinen Bot, um den Chat zu aktivieren.", + "telegramStep3": "3. Finde deine Chat-ID über @userinfobot oder die Bot-API.", + "ntfy": "ntfy", + "ntfyDescription": "Benachrichtigungen über ntfy (self-hosted oder ntfy.sh).", + "ntfyEnable": "ntfy aktivieren", + "ntfyServer": "Server-URL", + "ntfyTopic": "Topic", + "ntfyAuthToken": "Auth-Token (optional)", + "ntfyAuthTokenHint": "Nur nötig bei privaten Topics mit Zugangsschutz.", + "webPush": "Browser-Push", + "webPushDescription": "Erhalte Benachrichtigungen direkt im Browser, auch wenn HealthLog nicht geöffnet ist.", + "webPushNotSupported": "Dein Browser unterstützt keine Push-Benachrichtigungen.", + "webPushDenied": "Push-Benachrichtigungen wurden blockiert. Erlaube sie in den Browser-Einstellungen.", + "webPushNotConfigured": "Web Push ist serverseitig nicht konfiguriert.", + "webPushSubscribe": "Push aktivieren", + "webPushUnsubscribe": "Push deaktivieren", + "webPushSubscribed": "Push-Benachrichtigungen aktiviert!", + "webPushUnsubscribed": "Push-Benachrichtigungen deaktiviert.", + "webPushSubscribeFailed": "Aktivierung fehlgeschlagen", + "webPushActive": "Aktiv", + "kiInsights": "KI-Insights (OpenAI)", + "kiInsightsDescription": "Optional: Hinterlege deinen OpenAI-Key für tägliche, automatische KI-Auswertungen in den Insights.", + "kiInsightsBenefitsTitle": "Vorteile mit OpenAI-Key", + "kiInsightsBenefit1": "Tägliche Zusammenfassungen für allgemeinen Zustand, Blutdruck, Gewicht, Puls, BMI und Medikamenten-Compliance.", + "kiInsightsBenefit2": "Bessere Einordnung von Trends und Korrelationen auf Basis deiner letzten 30 Tagesmesspunkte pro Bereich.", + "kiInsightsBenefit3": "Automatische Aktualisierung im Hintergrund, damit die Insights-Seite immer aktuelle Bewertungen zeigt.", + "createApiKeyHint": "API-Key erstellen unter:", + "createApiKeyLinkLabel": "platform.openai.com/api-keys", + "apiKey": "API-Key", + "apiKeyStored": "Key hinterlegt — neuen eingeben zum Ersetzen", + "apiKeySaved": "API-Key gespeichert", + "apiKeyRemoved": "API-Key entfernt", + "rawData": "Rohdaten mitsenden", + "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit die KI zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.", + "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, die KI kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.", + "rawDataWarning": "Rohdaten aktiv: Die KI erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.", + "regenerateInsights": "Reports neu generieren", + "regenerateSuccess": "Reports wurden neu generiert", + "regenerateRateLimit": "Bitte warte — maximal 2 Analysen pro Stunde.", + "lastGeneratedAt": "Zuletzt generiert", + "withings": "Withings", + "withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.", + "withingsCredentials": "API-Zugangsdaten", + "withingsClientId": "Client-ID", + "withingsClientSecret": "Client-Secret", + "withingsCredentialsSaved": "Zugangsdaten gespeichert", + "withingsCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen", + "withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen", + "withingsSaveCredentials": "Zugangsdaten speichern", + "withingsConnected": "Verbunden", + "withingsTokenExpired": "Token abgelaufen", + "withingsLastSync": "Letzte Sync", + "withingsSync": "Jetzt synchronisieren", + "withingsFullSync": "Alle Daten synchronisieren", + "withingsFullSyncTitle": "Vollsynchronisierung?", + "withingsFullSyncDescription": "Alle verfügbaren Withings-Daten werden vollständig synchronisiert. Das kann je nach Historie etwas dauern.", + "withingsSyncResult": "{count} Messwerte synchronisiert", + "withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert", + "withingsSyncFailed": "Sync fehlgeschlagen", + "withingsSynchronize": "Synchronisieren", + "withingsDisconnect": "Trennen", + "withingsDisconnectTitle": "Withings-Verbindung trennen?", + "withingsDisconnectDescription": "Die Verbindung zu Withings wird getrennt. Bereits synchronisierte Daten bleiben erhalten.", + "withingsConnect": "Mit Withings verbinden", + "withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.", + "apiTokens": "API-Tokens", + "apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).", + "tokenName": "Token-Name", + "tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)", + "tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.", + "tokenRevoke": "Token widerrufen?", + "tokenRevokeDescription": "Der Token wird unwiderruflich deaktiviert. Anwendungen, die ihn nutzen, verlieren den Zugriff.", + "tokenRevoked": "Widerrufen", + "tokenExpired": "Abgelaufen", + "tokenActive": "Aktiv", + "activeTokensTitle": "Aktive Tokens", + "revokedTokensTitle": "Widerrufene Tokens ({count})", + "tokenTableName": "Name", + "tokenTablePermissions": "Berechtigungen", + "tokenTableStatus": "Status", + "tokenTableCreated": "Erstellt", + "tokenTableLastUsed": "Zuletzt genutzt", + "tokenTableActions": "Aktionen", + "noActiveTokens": "Keine aktiven Tokens vorhanden.", + "tokenNeverUsed": "Nie", + "tokenLastUsed": "Zuletzt:", + "tokenCreatedAt": "Erstellt:", + "apiEndpoint": "API-Endpoint für Medikamenten-Einnahme:", + "apiEndpointsTitle": "API-Endpoints", + "apiEndpointsDescription": "Übersicht der derzeit verfügbaren externen Endpoint(s) für Token-basierte Eingaben.", + "apiEndpointMethod": "Methode", + "apiEndpointPath": "Pfad", + "apiEndpointAuth": "Authentifizierung", + "apiEndpointExample": "Body-Beispiel", + "export": "Daten-Export", + "exportDescription": "Exportiere alle deine Gesundheitsdaten.", + "exportJson": "JSON-Export", + "exportCsv": "CSV-Export", + "doctorReport": "Arztreport (PDF)", + "auditLog": "Aktivitätsprotokoll", + "auditLogHidden": "Protokoll ausgeblendet.", + "auditLogShowing": "Zeigt die letzten {count} von {total} Einträgen", + "auditNoActivities": "Keine Aktivitäten vorhanden.", + "auditAction": "Aktion", + "auditCategory": "Kategorie", + "auditIp": "IP", + "auditLocation": "Standort", + "auditTimestamp": "Zeitpunkt", + "auditOther": "Sonstig", + "auditCategoryAuth": "Auth", + "auditCategoryData": "Daten", + "auditCategoryIntegration": "Integration", + "auditCategoryAI": "KI", + "auditCategoryAdmin": "Admin", + "auditActionAuthRegister": "Registrierung", + "auditActionAuthLogin": "Anmeldung", + "auditActionAuthLoginPasskey": "Passkey-Anmeldung", + "auditActionAuthLoginPassword": "Passwort-Anmeldung", + "auditActionAuthLoginFailed": "Fehlgeschlagene Anmeldung", + "auditActionAuthLogout": "Abmeldung", + "auditActionAuthPasskeyRegister": "Passkey hinzugefügt", + "auditActionAuthPasskeyDelete": "Passkey gelöscht", + "auditActionAuthPasswordChange": "Passwort geändert", + "auditActionMeasurementCreate": "Messwert erstellt", + "auditActionMeasurementUpdate": "Messwert aktualisiert", + "auditActionMeasurementDelete": "Messwert gelöscht", + "auditActionMedicationCreate": "Medikament erstellt", + "auditActionMedicationUpdate": "Medikament aktualisiert", + "auditActionMedicationDelete": "Medikament gelöscht", + "auditActionMedicationIntake": "Einnahme erfasst", + "auditActionMedicationIngestExternal": "Externe Einnahme", + "auditActionWithingsConnect": "Withings verbunden", + "auditActionWithingsDisconnect": "Withings getrennt", + "auditActionExportDownload": "Export heruntergeladen", + "auditActionInsightsGenerate": "KI-Insight generiert", + "auditActionAdminUserUpdate": "Admin: Benutzer aktualisiert", + "auditActionAdminUserResetPassword": "Admin: Passwort zurückgesetzt", + "auditActionAdminSettingsUpdate": "Admin: Einstellungen geändert", + "collapse": "Einklappen", + "expand": "Ausklappen", + "dangerZoneTitle": "Danger Zone", + "dangerZone": "Alle Daten löschen", + "dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Benutzerkonto bleibt erhalten.", + "dangerZoneConfirm": "Alle Daten löschen?", + "dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Benutzerkonto bleibt erhalten.", + "dangerZoneSuccess": "Alle persönlichen Daten wurden gelöscht", + "dangerZoneDeleteFailed": "Löschen fehlgeschlagen", + "finalDelete": "Endgültig löschen", + "saved": "Gespeichert", + "savingError": "Fehler beim Speichern", + "adminAreaTitle": "Admin-Bereich", + "adminAreaDescription": "Systemweite Einstellungen und Benutzerverwaltung findest du in der Admin-Konsole.", + "openAdminConsole": "Admin-Konsole öffnen", + "moodLogTitle": "moodLog", + "moodLogDescription": "Stimmungsdaten aus moodLog importieren", + "moodLogUrl": "moodLog URL", + "moodLogUrlPlaceholder": "https://mood.example.com", + "moodLogApiKey": "API-Key", + "moodLogApiKeyPlaceholder": "ml_...", + "moodLogWebhookSecret": "Webhook-Secret", + "moodLogWebhookSecretHelp": "Dieses Secret in moodLog als Webhook-Secret eintragen", + "moodLogSync": "Sync starten", + "moodLogFullSync": "Voll-Sync", + "moodLogFullSyncTitle": "Vollsynchronisierung?", + "moodLogFullSyncDescription": "Alle verfügbaren Stimmungsdaten werden vollständig synchronisiert.", + "moodLogDisconnect": "Trennen", + "moodLogDisconnectTitle": "moodLog trennen?", + "moodLogDisconnectDescription": "Die Verbindung wird getrennt und alle importierten Stimmungsdaten werden gelöscht.", + "moodLogLastSync": "Letzter Sync", + "moodLogEntries": "Einträge", + "moodLogSyncResult": "{count} Einträge synchronisiert", + "moodLogSyncFailed": "Sync fehlgeschlagen", + "moodLogSaved": "moodLog-Verbindung gespeichert", + "moodLogDisconnected": "moodLog getrennt" + }, + "admin": { + "title": "Administration", + "subtitle": "Systemstatus, Benutzer und Einstellungen", + "sectionsTitle": "Admin", + "systemStatus": "Systemstatus", + "version": "Version", + "database": "Datenbank", + "databaseConnected": "Verbunden", + "databaseError": "Fehler", + "gitCommit": "Git Commit", + "startedAt": "Gestartet", + "users": "Benutzer", + "measurementsCount": "Messwerte", + "activeTokens": "Aktive API-Tokens", + "activeSessions": "Aktive Sessions", + "loadingStatus": "Lade Status...", + "workerStatus": "Job-Worker", + "workerRunning": "Aktiv", + "workerStopped": "Gestoppt", + "lastReminderCheck": "Letzter Reminder-Check", + "appSettings": "Anwendungseinstellungen", + "registrationEnabled": "Registrierung erlauben", + "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Benutzer registrieren.", + "defaultLanguage": "Standard-Sprache", + "defaultLanguageDescription": "Wird für neue Benutzer und als Fallback verwendet.", + "servicesGlobal": "Dienste (global)", + "servicesGlobalDescription": "Deaktivierte Dienste stehen keinem Benutzer zur Verfügung.", + "telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.", + "ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.", + "webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.", + "apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.", + "moodLogGlobal": "moodLog-Integration systemweit erlauben.", + "sectionGeneral": "Allgemein", + "sectionIntegrations": "Integrationen", + "sectionMedication": "Medikamente", + "sectionManagement": "Verwaltung", + "umamiTitle": "Umami", + "umamiDescription": "Web-Analyse mit Umami", + "glitchtipTitle": "GlitchTip", + "glitchtipDescription": "Fehler-Tracking mit GlitchTip", + "webPushVapidTitle": "Web Push VAPID", + "monitoring": "Monitoring (Umami/Glitchtip)", + "monitoringDescription": "Konfiguration für Website-Analytics und Fehler-Tracking.", + "umamiEnabled": "Umami-Tracking aktivieren.", + "umamiScriptUrl": "Umami Script-URL", + "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", + "umamiWebsiteId": "Umami Website-ID", + "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "glitchtipEnabled": "Glitchtip Fehler-Tracking aktivieren.", + "glitchtipDsn": "Glitchtip DSN", + "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", + "glitchtipEnvironment": "Glitchtip Environment", + "glitchtipEnvironmentPlaceholder": "production", + "webPushVapid": "Web Push (VAPID)", + "webPushVapidDescription": "Konfiguriere VAPID-Schlüssel und Subject direkt hier, damit Browser-Push ohne ENV-Variablen funktioniert.", + "webPushVapidPublicKey": "VAPID Public Key", + "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", + "webPushVapidPrivateKey": "VAPID Private Key", + "webPushVapidPrivateKeyPlaceholder": "Optional: neuen Private Key eintragen", + "webPushVapidSubject": "VAPID Subject", + "webPushVapidSubjectPlaceholder": "mailto:deine-adresse@example.com", + "configured": "Konfiguriert", + "notConfigured": "Nicht konfiguriert", + "monitoringTestUmami": "Umami-Test senden", + "monitoringTestGlitchtip": "Glitchtip-Test senden", + "monitoringTestSuccess": "Testevent wurde gesendet", + "monitoringTestFailed": "Monitoring-Test fehlgeschlagen", + "monitoringSave": "Monitoring speichern", + "bugReportGithub": "Bugreport (GitHub Issues)", + "bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.", + "bugReportRepo": "Repository", + "bugReportRepoPlaceholder": "owner/repo", + "bugReportToken": "Issue-Token", + "bugReportTokenPlaceholder": "ghp_...", + "bugReportConfigured": "Bugreport ist konfiguriert.", + "bugReportNotConfigured": "Bugreport ist noch nicht vollständig konfiguriert.", + "bugReportSave": "Bugreport speichern", + "userManagement": "Benutzerverwaltung", + "userEmail": "E-Mail", + "userRole": "Rolle", + "userPasskeys": "Passkeys", + "userCreated": "Erstellt", + "userActions": "Aktionen", + "editUser": "Benutzer bearbeiten", + "editUserTitle": "Benutzer bearbeiten: {name}", + "demoteToUser": "Zu User degradieren", + "promoteToAdmin": "Zum Admin befördern", + "toUser": "→ User", + "toAdmin": "→ Admin", + "ownRoleUnchangeable": "Eigene Rolle nicht änderbar", + "lastAdminError": "Der letzte Admin kann nicht degradiert werden", + "resetPassword": "Passwort zurücksetzen", + "resetPasswordTitle": "Passwort zurücksetzen: {name}", + "newPassword": "Neues Passwort", + "newPasswordPlaceholder": "Mindestens 12 Zeichen", + "passwordReset": "Passwort wurde zurückgesetzt", + "reset": "Zurücksetzen", + "loadingUsers": "Lade Benutzer...", + "apiTokens": "API-Tokens", + "tokenUser": "Benutzer", + "tokenName": "Name", + "tokenPermissions": "Berechtigungen", + "tokenStatus": "Status", + "tokenLastUsed": "Zuletzt benutzt", + "tokenCreated": "Erstellt", + "tokenNeverUsed": "Nie", + "noTokens": "Keine API-Tokens vorhanden.", + "loginOverview": "Anmeldeübersicht", + "allAuthEvents": "Alle Auth-Ereignisse", + "failedOnly": "Nur fehlgeschlagene", + "noEntries": "Keine Einträge vorhanden.", + "showingEntries": "Zeigt {count} von {total} Auth-Einträgen", + "status": "Status", + "action": "Aktion", + "ip": "IP", + "location": "Standort", + "timestamp": "Zeitpunkt", + "dangerZone": "Gefahrenzone", + "deleteAllData": "Alle Daten global löschen", + "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.", + "deleteAllConfirm": "Alle Daten wirklich löschen?", + "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen und Integrationsdaten aller Benutzer unwiderruflich. Benutzerkonten bleiben erhalten.", + "deleteButton": "Alle Daten löschen", + "finalDelete": "Endgültig löschen", + "deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen", + "authRegister": "Registrierung", + "authLogin": "Anmeldung", + "authLoginPasskey": "Passkey-Anmeldung", + "authLoginPassword": "Passwort-Anmeldung", + "authLoginFailed": "Fehlgeschlagen", + "authLogout": "Abmeldung", + "authPasskeyRegister": "Passkey hinzugefügt", + "authPasskeyDelete": "Passkey gelöscht", + "medicationReminders": "Medikamentenerinnerungen", + "medicationRemindersDescription": "Schwellenwerte für die Farbübergänge der Erinnerungs-Badges auf den Medikamentenkarten.", + "reminderLateMinutes": "Spät-Schwellenwert (Minuten)", + "reminderLateMinutesDescription": "Minuten nach Fenster-Ende bis der Badge von Gelb auf Orange wechselt.", + "reminderMissedMinutes": "Verpasst-Schwellenwert (Minuten)", + "reminderMissedMinutesDescription": "Minuten nach Fenster-Ende bis die Einnahme als verpasst gilt.", + "reminderThresholdsSave": "Schwellenwerte speichern", + "notificationTest": "Benachrichtigungen testen", + "notificationTestSend": "Test-Notification senden", + "notificationTestSuccess": "Test-Notification wurde gesendet", + "notificationTestFailed": "Test-Notification konnte nicht gesendet werden", + "reminderCheckRun": "Reminder-Check ausführen", + "reminderCheckSuccess": "Reminder-Check abgeschlossen", + "reminderCheckFailed": "Reminder-Check konnte nicht ausgeführt werden", + "reminderCheckResults": "Ergebnisse", + "reminderCheckNotifOn": "Benachrichtigungen an", + "reminderCheckNotifOff": "Benachrichtigungen aus", + "reminderCheckEventsToday": "Heutige Events", + "reminderCheckNotifSent": "Gesendet" + }, + "achievements": { + "title": "Erfolge", + "subtitle": "Sammle Erfolge ab Feature-Start durch konsequente Gesundheitstrends und Medikamentenroutinen.", + "loginRequired": "Bitte anmelden, um Erfolge zu sehen.", + "points": "Punkte", + "unlocked": "Freigeschaltet", + "nextGoal": "Nächstes Ziel", + "allCompleted": "Alle Erfolge freigeschaltet!", + "completed": "Abgeschlossen", + "inProgress": "In Arbeit", + "progress": "Fortschritt", + "goalReached": "Ziel erreicht", + "remaining": "Noch {count} bis zum Ziel", + "remainingUnlocks": "Noch {count} freischaltbar", + "noneUnlockedYet": "Noch keine Erfolge freigeschaltet.", + "pointsValue": "{points} Punkte", + "metricCount": "{count}", + "metricDays": "{count} Tage", + "metricPercent": "{count}%", + "badges": { + "intakeTotal1": { + "title": "Erste Einnahme", + "description": "Dokumentiere dein erstes eingenommenes Medikament." + }, + "intakeTotal10": { + "title": "Einnahme-Starter", + "description": "Dokumentiere 10 eingenommene Medikamente." + }, + "intakeTotal50": { + "title": "Einnahme-Routine", + "description": "Dokumentiere 50 eingenommene Medikamente." + }, + "intakeTotal150": { + "title": "Einnahme-Profi", + "description": "Dokumentiere 150 eingenommene Medikamente." + }, + "intakeTotal300": { + "title": "Einnahme-Legende", + "description": "Dokumentiere 300 eingenommene Medikamente." + }, + "overIntake1": { + "title": "Idiot", + "description": "Nimm mindestens einmal mehr ein als vorgesehen." + }, + "skippedIntake1": { + "title": "Lazy Boy", + "description": "Überspringe mindestens eine geplante Einnahme." + }, + "passkeyCreated1": { + "title": "Passkey gesetzt", + "description": "Erstelle deinen ersten Passkey." + }, + "passkeyLogin1": { + "title": "Passkey genutzt", + "description": "Melde dich mindestens einmal per Passkey an." + }, + "passwordLogin1": { + "title": "Oldschool", + "description": "Melde dich mindestens einmal mit Username und Passwort an." + }, + "bugReport1": { + "title": "Fehlerjäger", + "description": "Reiche einen Bugreport ein." + }, + "loginStreak7": { + "title": "Login-Serie 7T", + "description": "Logge dich an 7 Tagen in Folge ein." + }, + "loginStreak30": { + "title": "Login-Serie 30T", + "description": "Logge dich an 30 Tagen in Folge ein." + }, + "onTimePerfect1": { + "title": "Zeitfenster 1T", + "description": "Nimm 1 Tage in Folge alle Medikamente im Zeitfenster ein." + }, + "compliance801": { + "title": "Treue 80% · 1T", + "description": "Erreiche 1 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + }, + "bmiGreen1": { + "title": "BMI im Grün · 1T", + "description": "Halte deinen BMI 1 Tage in Folge im Normalbereich." + }, + "bpGreen1": { + "title": "Blutdruck im Grün · 1T", + "description": "Halte deinen Blutdruck 1 Tage in Folge im Normalbereich." + }, + "pulseGreen1": { + "title": "Puls im Grün · 1T", + "description": "Halte deinen Ruhepuls 1 Tage in Folge im Normalbereich." + }, + "onTimePerfect7": { + "title": "Zeitfenster 7T", + "description": "Nimm 7 Tage in Folge alle Medikamente im Zeitfenster ein." + }, + "compliance807": { + "title": "Treue 80% · 7T", + "description": "Erreiche 7 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + }, + "bmiGreen7": { + "title": "BMI im Grün · 7T", + "description": "Halte deinen BMI 7 Tage in Folge im Normalbereich." + }, + "bpGreen7": { + "title": "Blutdruck im Grün · 7T", + "description": "Halte deinen Blutdruck 7 Tage in Folge im Normalbereich." + }, + "pulseGreen7": { + "title": "Puls im Grün · 7T", + "description": "Halte deinen Ruhepuls 7 Tage in Folge im Normalbereich." + }, + "onTimePerfect30": { + "title": "Zeitfenster 30T", + "description": "Nimm 30 Tage in Folge alle Medikamente im Zeitfenster ein." + }, + "compliance8030": { + "title": "Treue 80% · 30T", + "description": "Erreiche 30 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + }, + "bmiGreen30": { + "title": "BMI im Grün · 30T", + "description": "Halte deinen BMI 30 Tage in Folge im Normalbereich." + }, + "bpGreen30": { + "title": "Blutdruck im Grün · 30T", + "description": "Halte deinen Blutdruck 30 Tage in Folge im Normalbereich." + }, + "pulseGreen30": { + "title": "Puls im Grün · 30T", + "description": "Halte deinen Ruhepuls 30 Tage in Folge im Normalbereich." + }, + "onTimePerfect180": { + "title": "Zeitfenster 180T", + "description": "Nimm 180 Tage in Folge alle Medikamente im Zeitfenster ein." + }, + "compliance80180": { + "title": "Treue 80% · 180T", + "description": "Erreiche 180 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + }, + "bmiGreen180": { + "title": "BMI im Grün · 180T", + "description": "Halte deinen BMI 180 Tage in Folge im Normalbereich." + }, + "bpGreen180": { + "title": "Blutdruck im Grün · 180T", + "description": "Halte deinen Blutdruck 180 Tage in Folge im Normalbereich." + }, + "pulseGreen180": { + "title": "Puls im Grün · 180T", + "description": "Halte deinen Ruhepuls 180 Tage in Folge im Normalbereich." + }, + "onTimePerfect360": { + "title": "Zeitfenster 360T", + "description": "Nimm 360 Tage in Folge alle Medikamente im Zeitfenster ein." + }, + "compliance80360": { + "title": "Treue 80% · 360T", + "description": "Erreiche 360 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + }, + "bmiGreen360": { + "title": "BMI im Grün · 360T", + "description": "Halte deinen BMI 360 Tage in Folge im Normalbereich." + }, + "bpGreen360": { + "title": "Blutdruck im Grün · 360T", + "description": "Halte deinen Blutdruck 360 Tage in Folge im Normalbereich." + }, + "pulseGreen360": { + "title": "Puls im Grün · 360T", + "description": "Halte deinen Ruhepuls 360 Tage in Folge im Normalbereich." + } + }, + "nextProgressLabel": "Fortschritt bis Freischaltung", + "completedOn": "Abgeschlossen am {date}" + }, + "bugreport": { + "title": "Bugreport", + "subtitle": "Melde einen Fehler oder ein Problem. Es wird automatisch ein GitHub Issue erstellt.", + "loginRequired": "Bitte anmelden, um einen Bug zu melden.", + "bugTitle": "Titel", + "bugTitlePlaceholder": "Kurze Beschreibung des Problems", + "description": "Beschreibung", + "descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?", + "screenshot": "Screenshot (optional)", + "screenshotPreview": "Screenshot-Vorschau", + "attachScreenshot": "Screenshot anhängen", + "imageOnly": "Nur Bilddateien erlaubt", + "tooLarge": "Screenshot ist zu groß (max. 5 MB)", + "submit": "Senden", + "success": "Vielen Dank! Dein Bug Report wurde erfolgreich übermittelt.", + "viewOnGithub": "Auf GitHub ansehen", + "errorCreating": "Fehler beim Erstellen" + }, + "notifications": { + "title": "Benachrichtigungen", + "subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.", + "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.", + "goToSettings": "Zu den Einstellungen", + "channelDisabled": "Deaktiviert", + "eventMedicationReminder": "Medikamenten-Erinnerung", + "eventMedicationReminderDesc": "Erinnerung, wenn eine Einnahme aussteht.", + "eventMeasurementAnomaly": "Anomale Messwerte", + "eventMeasurementAnomalyDesc": "Warnung bei ungewöhnlichen Messungen (z.B. hoher Blutdruck).", + "eventComplianceLow": "Niedrige Einnahmetreue", + "eventComplianceLowDesc": "Benachrichtigung, wenn die Einnahmetreue unter einen Schwellwert fällt.", + "eventWithingsSyncFailed": "Withings-Sync-Fehler", + "eventWithingsSyncFailedDesc": "Meldung bei Fehlern beim automatischen Withings-Datenabgleich.", + "eventSystemAlert": "System-Hinweise", + "eventSystemAlertDesc": "Wichtige Systemnachrichten und administrative Hinweise." + }, + "format": { + "dateShort": "{day}.{month}.{year}", + "timeShort": "{hours}:{minutes}", + "dateTime": "{day}.{month}.{year}, {hours}:{minutes}" + } +} diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 000000000..8bc173d6c --- /dev/null +++ b/messages/en.json @@ -0,0 +1,1212 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "moreOptions": "More options", + "create": "Create", + "close": "Close", + "loading": "Loading...", + "error": "Error", + "success": "Success", + "saved": "Saved", + "confirm": "Confirm", + "back": "Back", + "next": "Next", + "yes": "Yes", + "no": "No", + "search": "Search", + "filter": "Filter", + "noData": "No data available", + "optional": "Optional", + "required": "Required", + "all": "All", + "active": "Active", + "inactive": "Inactive", + "enabled": "Enabled", + "disabled": "Disabled", + "networkError": "Network error", + "unknownError": "Unknown error", + "notLoggedIn": "Not logged in", + "noAccess": "Access denied", + "copied": "Copied!", + "or": "or", + "unknown": "Unknown", + "replace": "Replace", + "add": "Add", + "import": "Import", + "test": "Test" + }, + "nav": { + "home": "Home", + "dashboard": "Dashboard", + "measurements": "Measurements", + "medications": "Medications", + "charts": "History", + "insights": "Insights", + "targets": "Targets", + "achievements": "Achievements", + "settings": "Settings", + "bugreport": "Bug Report", + "admin": "Admin Console", + "logout": "Sign out", + "login": "Sign in", + "notifications": "Notifications", + "theme": "Appearance", + "themeSystem": "System", + "themeDark": "Dark", + "themeLight": "Light", + "collapseSidebar": "Collapse", + "userMenu": "User menu", + "mood": "Mood", + "skipToContent": "Skip to content", + "sidebar": "Sidebar", + "mainNavigation": "Main navigation", + "mobileNavigation": "Mobile navigation", + "loadingScreen": "Loading…" + }, + "auth": { + "login": "Sign in", + "register": "Sign up", + "loginTitle": "Sign In", + "registerTitle": "Sign Up", + "username": "Username", + "password": "Password", + "email": "Email address", + "emailPlaceholder": "name@example.com", + "emailOrUsername": "Email address or username", + "emailOrUsernamePlaceholder": "name@example.com or username", + "loginWithPasskey": "Sign in with Passkey", + "loginWithPassword": "Sign in with password", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "registerDisabled": "Registration is disabled.", + "loginFailed": "Login failed", + "passkeyFailed": "Passkey authentication cancelled or failed", + "registerFailed": "Registration failed", + "createAccount": "Create account", + "newAccount": "Create new account", + "backToPasskey": "Back to Passkey", + "passkeySetupLater": "You can set up passkeys at any time in Settings.", + "passwordMinLength": "Min. 12 characters", + "passwordStrength": "Password strength", + "strengthWeak": "Very weak", + "strengthFair": "Weak", + "strengthGood": "Fair", + "strengthStrong": "Strong", + "strengthVeryStrong": "Very strong" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Your health overview", + "greeting": { + "morning": "Good morning", + "day": "Hello", + "evening": "Good evening" + }, + "welcomeBack": "{greeting}, welcome back.", + "welcomeBackWithName": "{greeting} {name}, welcome back.", + "weight": "Weight", + "bloodPressure": "Blood Pressure", + "bloodPressureSys": "Blood Pressure (Sys)", + "bloodPressureDia": "Blood Pressure (Dia)", + "pulse": "Pulse", + "bodyFat": "Body Fat", + "bpInTarget": "BP in Target", + "days30Short": "30d", + "medications": "Medications", + "lastMeasurement": "Last measurement", + "noMeasurements": "No measurements yet", + "trend": "Trend", + "trendUp": "rising", + "trendDown": "falling", + "trendStable": "stable", + "quickEntry": "Quick entry", + "addMeasurement": "Add measurement", + "compliance7d": "Compliance (7 days)", + "complianceGood": "Good", + "complianceLow": "Low", + "mood": "Mood" + }, + "measurements": { + "title": "Measurements", + "subtitle": "Weight, blood pressure, pulse & more", + "addMeasurement": "Add", + "editMeasurement": "Edit measurement", + "type": "Type", + "value": "Value", + "unit": "Unit", + "date": "Date", + "source": "Source", + "notes": "Notes", + "typeWeight": "Weight", + "typeBloodPressure": "Blood Pressure", + "typeBpSys": "Sys", + "typeBpDia": "Dia", + "typePulse": "Pulse", + "typeBodyFat": "Body Fat", + "typeSleep": "Sleep", + "typeSteps": "Steps", + "unitHours": "hours", + "unitSteps": "steps", + "sourceManual": "Manual", + "sourceWithings": "Withings", + "sourceImport": "Import", + "systolicLabel": "Systolic (mmHg)", + "diastolicLabel": "Diastolic (mmHg)", + "pulseLabel": "Pulse (bpm)", + "valueWithUnit": "Value ({unit})", + "timestamp": "Time", + "notesPlaceholder": "e.g. after a meal", + "formReset": "Reset form", + "saveError": "Error saving", + "deleteError": "Error deleting", + "invalidValue": "Please enter a valid value.", + "invalidTimestamp": "Please select a valid time.", + "loginRequired": "Please sign in to view measurements.", + "allTypes": "All types", + "measurementCount": "{count} measurements", + "noMeasurements": "No measurements found", + "comment": "Comment", + "actions": "Actions", + "deleteConfirmTitle": "Delete measurement?", + "deleteConfirmDescription": "This measurement will be permanently deleted.", + "pageInfo": "Page {page} of {total}", + "showing": "Showing", + "of": "of", + "entries": "entries" + }, + "mood": { + "title": "Mood", + "subtitle": "Mood tracker & entries", + "addEntry": "Add", + "editEntry": "Edit entry", + "moodLevel": "Mood", + "tags": "Tags", + "tagsPlaceholder": "e.g. Exercise, Work, Family", + "tagsHelp": "Comma-separated", + "timestamp": "Time", + "date": "Date", + "source": "Source", + "levelSuperGut": "Amazing", + "levelGut": "Good", + "levelOkay": "Okay", + "levelSchlecht": "Bad", + "levelLausig": "Terrible", + "sourceManual": "Manual", + "sourceMoodlog": "moodLog", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "allMoods": "All moods", + "entryCount": "{count} entries", + "noEntries": "No mood entries found", + "actions": "Actions", + "formReset": "Reset form", + "saveError": "Error saving", + "deleteError": "Error deleting", + "invalidTimestamp": "Please select a valid time.", + "deleteConfirmTitle": "Delete entry?", + "deleteConfirmDescription": "This mood entry will be permanently deleted.", + "pageInfo": "Page {page} of {total}", + "loginRequired": "Please sign in to view mood entries." + }, + "medications": { + "title": "Medications", + "subtitle": "Medications, schedules & intake", + "loginRequired": "Please sign in to manage medications.", + "addMedication": "Add", + "editMedication": "Edit medication", + "newMedication": "New medication", + "firstMedication": "Add first medication", + "noMedicationsYet": "No medications added yet", + "loadFailed": "Could not load medications", + "retryLoad": "Retry", + "name": "Name", + "dose": "Dose", + "doseAmount": "Amount", + "doseUnit": "Unit", + "category": "Category", + "schedule": "Schedule", + "scheduleFrom": "From", + "scheduleTo": "To", + "scheduleLabel": "Label", + "scheduleDays": "Days", + "scheduleDaily": "Daily", + "active": "Active", + "inactive": "Paused", + "intakeHistory": "Intake history", + "showIntakeHistory": "Show intake history", + "hideIntakeHistory": "Hide intake history", + "markTaken": "Taken", + "markSkipped": "Skipped", + "deleteConfirm": "Delete medication?", + "deleteDescription": "The medication and all intake records will be permanently deleted.", + "noMedications": "No medications found.", + "compliance": "Compliance", + "import": "Import", + "importIntakes": "Import intakes", + "importDescription": "Paste a JSON array with intake data. Expected format:", + "importUploadFile": "Upload JSON file", + "importPaste": "Paste JSON here...", + "importSelected": "Selected: {name}", + "importFileLoaded": "File \"{name}\" loaded", + "importInvalidJson": "File does not contain valid JSON", + "importNoArray": "No array found", + "importResult": "{imported} intakes imported", + "importDuplicatesSkipped": "{count} duplicates skipped", + "importInvalidSkipped": "{count} invalid entries skipped", + "importFailed": "Import failed", + "importInvalidFormat": "Invalid JSON format", + "apiAccess": "API access", + "apiEndpointTitle": "API Endpoint: {name}", + "apiEndpointDescription": "Use this endpoint to record intakes via API (e.g., iPhone Shortcut or cron job).", + "apiEndpointActive": "API endpoint active", + "apiEndpointActivated": "API endpoint activated", + "apiEndpointDeactivated": "API endpoint deactivated", + "apiTokenCount": "Active ({count} tokens)", + "apiToken": "API Token", + "apiTokenActiveHint": "Endpoint is active. Existing tokens remain valid but cannot be shown in plain text again.", + "apiTokenActivateHint": "Activate the endpoint to create a new token.", + "apiTokenOnceHint": "The token is only shown in plain text once at creation.", + "requestExample": "Request example", + "statusLoadFailed": "Could not load status", + "changeFailed": "Change failed", + "categoryBloodPressure": "Blood Pressure", + "categoryCholesterol": "Cholesterol", + "categoryDiabetes": "Diabetes", + "categoryVitamin": "Vitamins", + "categoryThyroid": "Thyroid", + "categoryHormone": "Hormones", + "categorySupplement": "Supplement", + "categoryPainRelief": "Pain Relief", + "categoryAllergy": "Allergy", + "categoryDigestive": "Gastrointestinal", + "categorySkin": "Skincare", + "categorySleepAid": "Sleep Aid", + "categoryOther": "Other", + "daysSun": "Sun", + "daysMon": "Mon", + "daysTue": "Tue", + "daysWed": "Wed", + "daysThu": "Thu", + "daysFri": "Fri", + "daysSat": "Sat", + "intakeHistoryTitle": "Intake History", + "newIntake": "Add", + "editIntake": "Edit Intake", + "intakeScheduledFor": "Scheduled", + "intakeTakenAt": "Taken at", + "intakeStatus": "Status", + "intakeSource": "Source", + "intakeStatusTaken": "Taken", + "intakeStatusSkipped": "Skipped", + "intakeStatusMissed": "Missed", + "intakeDeleteConfirm": "Delete intake record?", + "intakeDeleteDescription": "This intake record will be permanently deleted.", + "intakeCount": "{count} intakes", + "viewHistory": "History", + "today": "Today", + "yesterday": "Yesterday", + "tomorrow": "Tomorrow", + "withoutNotification": "Without notification", + "pausedSince": "Paused since", + "takeNow": "Take now", + "overdue": "Overdue", + "veryOverdue": "Very overdue", + "compliance7d": "7-day compliance", + "compliance30d": "30-day compliance", + "lastIntake": "Last intake:", + "nextIntake": "Next intake:", + "dayStreak": "day streak", + "taken": "Taken", + "skipped": "Skipped", + "weekdaySunday": "Sunday", + "weekdayMonday": "Monday", + "weekdayTuesday": "Tuesday", + "weekdayWednesday": "Wednesday", + "weekdayThursday": "Thursday", + "weekdayFriday": "Friday", + "weekdaySaturday": "Saturday", + "weekdaySundayPlural": "Sundays", + "weekdayMondayPlural": "Mondays", + "weekdayTuesdayPlural": "Tuesdays", + "weekdayWednesdayPlural": "Wednesdays", + "weekdayThursdayPlural": "Thursdays", + "weekdayFridayPlural": "Fridays", + "weekdaySaturdayPlural": "Saturdays", + "formType": "Type", + "formDose": "Dose", + "formUnit": "Unit", + "formSchedule": "Schedule", + "newSchedule": "New schedule", + "removeSchedule": "Remove schedule", + "advancedEditing": "Advanced editing", + "advancedEditingHide": "Hide advanced editing", + "scheduleInterval": "Interval", + "scheduleEveryWeek": "every week", + "scheduleEveryNWeeks": "every {n} weeks", + "scheduleIntervalPrefix": "Interval:", + "nextSchedulePrefix": "Next schedule:", + "nextScheduleDaily": "daily", + "importIntakesAction": "Import intakes", + "apiEndpointAction": "API endpoint", + "pause": "Pause", + "activate": "Activate", + "disableNotifications": "Disable notifications", + "enableNotifications": "Enable notifications", + "purgeRecords": "Delete all records", + "formReset": "Reset form", + "createMedication": "Create medication", + "deleteConfirmDescription": "The medication and all intake history will be permanently deleted.", + "purgeConfirmTitle": "Delete all records?", + "purgeConfirmDescription": "All intake records for this medication will be permanently deleted. The medication itself will remain and start without history data.", + "saveError": "Error saving", + "deleteError": "Error deleting", + "purgeError": "Error deleting records", + "invalidTimestamp": "Please select a valid time.", + "noIntakesYet": "No intakes recorded yet", + "noIntakesForMedication": "No intakes yet for", + "deleteIntakeAriaLabel": "Delete intake", + "actions": "Actions", + "pageInfo": "Page {page} of {total}", + "loginRequiredIntakes": "Please sign in to view intakes.", + "clear": "Clear", + "phaseConfig": "Reminder phases", + "phaseConfigDescription": "Configure timing for reminders.", + "phaseGreen": "Green", + "phaseYellow": "Yellow", + "phaseOrange": "Orange", + "phaseRed": "Red", + "phaseMinutes": "Min", + "phasePercent": "%", + "phaseBeforeEnd": "before end", + "phaseAfterEnd": "after end", + "phaseResetDefaults": "Reset to defaults", + "phaseSaved": "Reminder phases saved", + "phaseReset": "Reset to defaults" + }, + "charts": { + "title": "History", + "subtitle": "Visualization of your health data", + "timeRange": "Time range", + "days7": "7 days", + "days30": "30 days", + "days90": "90 days", + "days365": "1 year", + "daysAll": "All", + "weight": "Weight", + "bloodPressure": "Blood Pressure", + "pulse": "Pulse", + "bodyFat": "Body Fat", + "sleep": "Sleep", + "steps": "Steps", + "compliance": "Compliance", + "noData": "No data in selected time range", + "movingAverage": "Moving average", + "target": "Target range", + "points7Label": "7P", + "points7Title": "Last seven data points", + "points30Label": "30P", + "points30Title": "Last thirty data points", + "points90Label": "90P", + "points90Title": "Last ninety data points", + "pointsAllLabel": "All", + "pointsAllTitle": "All available data points", + "systolic": "Systolic", + "diastolic": "Diastolic", + "movingAverage7d": "7d avg", + "trend": "Trend", + "targetRanges": "Target ranges", + "perWeek": "/week", + "selectMedication": "Select medication", + "noComplianceData": "No compliance data available", + "history": "History", + "complianceDays90": "Compliance (90 days)", + "colorGreen": "Green:", + "colorOrange": "Orange:", + "colorRed": "Red:", + "mood": "Mood", + "moodScore": "Mood Score", + "moodMA": "7-Day Average" + }, + "insights": { + "title": "Insights", + "subtitle": "AI-powered analysis of your health data", + "overviewSubtitle": "Trends, risks, progress at a glance", + "navGeneral": "General", + "navBloodPressure": "Blood Pressure", + "navWeight": "Weight", + "navPulse": "Pulse", + "navMood": "Mood", + "navMedication": "Medication", + "navBmi": "BMI", + "measurementsOverDays": "{count} measurements over {days} days", + "healthStatus": "Health Status", + "noWeightData": "No weight data or height not set.", + "notEnoughBpData": "Not enough blood pressure data.", + "bpTargetRange": "BP Target Range", + "inTargetRange": "in target range", + "bpTarget": "Target: {range}", + "noDobSet": "Date of birth not set.", + "avg30dEsc": "30d average · ESC/ESH 2018", + "whoClassification": "WHO Classification", + "correlations": "Correlations", + "weightVsBp": "Weight vs. Blood Pressure (Systolic)", + "strengthStrong": "Strong correlation", + "strengthModerate": "Moderate correlation", + "strengthWeak": "Weak correlation", + "strengthNone": "No correlation", + "weightBpStrong": "There is a strong correlation between weight and blood pressure.", + "weightBpModerate": "There is a moderate correlation between weight and blood pressure.", + "weightBpWeak": "There is a weak correlation between weight and blood pressure.", + "weightBpNone": "No discernible correlation between weight and blood pressure.", + "notEnoughCorrelationData": "Not enough data for correlation analysis.", + "minCorrelationData": "At least 5 time-matched weight and blood pressure measurements needed.", + "bpMedContinuity": "BP Medication Continuity vs. Blood Pressure (Systolic)", + "continuityLabel": "Continuity", + "continuityBpLabel": "BP Medication Continuity (%)", + "weightKgLabel": "Weight (kg)", + "bpMedNegative": "Negative correlation: higher continuity tends to be associated with lower systolic blood pressure.", + "bpMedPositive": "Positive correlation: higher continuity tends to be associated with higher systolic blood pressure in your data.", + "bpMedNone": "No discernible correlation in your data.", + "bpMedCount": "{count} BP medications", + "notEnoughMedData": "Not enough data for this analysis.", + "notEnoughMedDataHint": "Tag medications as blood pressure medications and record sufficient intakes + blood pressure measurements.", + "medicationCompliance": "Medication Compliance", + "dayStreak": "{count} day streak", + "compliance7d": "7-day compliance", + "compliance30d": "30-day compliance", + "taken": "taken", + "skipped": "skipped", + "missed": "missed", + "complianceTrend": "Compliance Trend", + "generalStatusTitle": "General Health Status", + "generalStatusLoading": "Generating general status...", + "generalStatusNoKey": "The general status cannot be generated because no OpenAI key is configured.", + "generalStatusUnavailable": "The general status is currently unavailable.", + "generalStatusBadge": { + "good": "Good", + "watch": "Watch", + "critical": "Critical" + }, + "assessmentTitle": "Assessment", + "bloodPressureSectionTitle": "Blood Pressure", + "bloodPressureMedicationListTitle": "Blood Pressure Medication & Compliance", + "bloodPressureMedicationListEmpty": "No active blood pressure medication configured.", + "bloodPressureStatusLoading": "Generating blood pressure summary...", + "bloodPressureStatusNoKey": "The blood pressure summary cannot be generated because no OpenAI key is configured.", + "bloodPressureStatusUnavailable": "The blood pressure summary is currently unavailable.", + "weightSectionTitle": "Weight", + "weightStatusLoading": "Generating weight summary...", + "weightStatusNoKey": "The weight summary cannot be generated because no OpenAI key is configured.", + "weightStatusUnavailable": "The weight summary is currently unavailable.", + "pulseSectionTitle": "Pulse", + "pulseStatusLoading": "Generating pulse summary...", + "pulseStatusNoKey": "The pulse summary cannot be generated because no OpenAI key is configured.", + "pulseStatusUnavailable": "The pulse summary is currently unavailable.", + "bmiSectionTitle": "BMI", + "bmiStatusLoading": "Generating BMI summary...", + "bmiStatusNoKey": "The BMI summary cannot be generated because no OpenAI key is configured.", + "bmiStatusUnavailable": "The BMI summary is currently unavailable.", + "medicationComplianceStatusLoading": "Generating medication compliance text...", + "medicationComplianceStatusNoKey": "The medication compliance text cannot be generated because no OpenAI key is configured.", + "medicationComplianceStatusUnavailable": "The medication compliance text is currently unavailable.", + "complianceNoData": "No compliance data available.", + "aiInsights": "AI Insights", + "configureApiKey": "Configure an OpenAI API key in Settings to get AI-powered insights.", + "alertsTitle": "Notes & Recommendations", + "showLess": "Show less", + "showMore": "+{count} more", + "generate": "Generate new analysis", + "generating": "Generating analysis...", + "lastGenerated": "Last analysis", + "noInsights": "No analysis available yet.", + "rateLimit": "Please wait before generating a new analysis.", + "noApiKey": "Please add an OpenAI API key in Settings first.", + "sendRawData": "Send raw data", + "minMoodCorrelationData": "At least 5 temporally matched mood and measurement data points required.", + "moodBpModerate": "There is a moderate correlation between mood and blood pressure.", + "moodBpNone": "No detectable correlation between mood and blood pressure.", + "moodBpStrong": "There is a strong correlation between mood and blood pressure.", + "moodBpWeak": "There is a weak correlation between mood and blood pressure.", + "moodScoreLabel": "Mood (avg)", + "moodSectionTitle": "Mood", + "moodStatusLoading": "Generating mood analysis...", + "moodStatusNoKey": "Mood summary cannot be generated because no OpenAI API key is configured.", + "moodStatusUnavailable": "Mood analysis unavailable", + "moodVsBp": "Mood vs. Blood Pressure (Systolic)", + "moodVsPulse": "Mood vs. Pulse", + "moodVsWeight": "Mood vs. Weight", + "moodWeightModerate": "There is a moderate correlation between mood and weight.", + "moodWeightNone": "No detectable correlation between mood and weight.", + "moodWeightStrong": "There is a strong correlation between mood and weight.", + "moodWeightWeak": "There is a weak correlation between mood and weight.", + "notEnoughMoodCorrelationData": "Not enough mood data for correlation analysis." + }, + "targets": { + "title": "Targets", + "subtitle": "Personal reference ranges based on your profile", + "introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.", + "current": "Current", + "average30d": "30-day average:", + "targetRange": "Target range", + "classification": "Rating", + "source": "Source", + "sourceLabel": "Source: {source}", + "trend": "Trend", + "weight": "Weight", + "bloodPressure": "Blood Pressure", + "pulse": "Resting Pulse", + "sleep": "Sleep Duration", + "bmi": "BMI", + "bodyFat": "Body Fat", + "steps": "Steps/Day", + "compliance": "Compliance", + "compliancePerMedication": "Per medication", + "diastolic": "Diastolic", + "noProfile": "Please complete your profile to get personalized targets.", + "profileIncomplete": "Profile incomplete", + "profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.", + "profileIncompleteHeight": "Add your height in your profile to calculate weight target range.", + "profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.", + "noMeasurementData": "No measurement data available yet.", + "noMeasurementYet": "No measurement available yet", + "currentValue": "Current: {value} {unit}", + "targetRangeValue": "Target range: {min}–{max} {unit}", + "inTarget": "In target range", + "belowTarget": "{delta} {unit} below target", + "aboveTarget": "{delta} {unit} above target", + "loadError": "Error loading data", + "classificationOptimal": "Optimal", + "classificationNormal": "Normal", + "classificationElevated": "Elevated", + "classificationHigh": "High", + "classificationLow": "Low" + }, + "onboarding": { + "welcome": "Welcome to HealthLog!", + "setupDescription": "Set up your profile to get started. You can change this later in Settings.", + "completeSetup": "Complete setup", + "skip": "Skip", + "medicationsTitle": "Do you take medications?", + "medicationsDescription": "Add your medications to enable reminders and compliance tracking.", + "medNamePlaceholder": "Name", + "medDosePlaceholder": "Dose", + "notificationsTitle": "Reminders", + "notificationsDescription": "HealthLog can remind you about your medications. Configure this later in Settings.", + "telegramHint": "Reminders via Telegram bot with snooze buttons", + "webPushHint": "Browser notifications directly to your device", + "notificationsLater": "You can set this up anytime in Settings.", + "targetsTitle": "Target Values", + "targetsDescription": "Set optional target values for blood pressure and weight.", + "targetSys": "Systolic (mmHg)", + "targetDia": "Diastolic (mmHg)", + "targetWeight": "Target weight (kg)" + }, + "settings": { + "title": "Settings", + "subtitle": "Profile, Security & Connections", + "sectionsTitle": "Settings", + "categoryGeneral": "General", + "categoryNotifications": "Notifications", + "categoryAi": "AI", + "categoryIntegration": "Integration", + "categoryApi": "API", + "profile": "Profile", + "security": "Security", + "language": "Language", + "languageDescription": "Choose the display language of the application.", + "languageSystem": "System (automatic)", + "username": "Username", + "height": "Height (cm)", + "dateOfBirth": "Date of birth", + "dateOfBirthHint": "Used for automatic blood pressure target calculations.", + "gender": "Gender", + "genderNone": "Not specified", + "genderMale": "Male", + "genderFemale": "Female", + "genderHint": "Used for gender-specific target values.", + "profileSaved": "Profile saved", + "changePassword": "Change password", + "changePasswordDescription": "Replace your current password with a new one.", + "passwordReset": "Password reset", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmNewPassword": "Confirm new password", + "passwordMismatch": "New passwords do not match", + "passwordUpdated": "Password updated successfully", + "passkeys": "Passkeys", + "registeredPasskeys": "Registered Passkeys", + "passkeysDescription": "Passkeys allow secure login without a password.", + "passkeyName": "Name", + "passkeyDevice": "Device", + "passkeyBackup": "Backup", + "passkeyCreated": "Created", + "passkeyActions": "Actions", + "noPasskeys": "No passkeys registered.", + "addPasskey": "Add passkey", + "passkeyAdded": "Passkey added successfully!", + "passkeyOptionsError": "Could not load passkey options", + "passkeyRegistrationFailed": "Passkey registration failed", + "passkeyRegistrationCancelled": "Passkey registration cancelled", + "deletePasskey": "Delete passkey?", + "deletePasskeyDescription": "The passkey will be permanently deleted.", + "singleDevice": "Single device", + "multiDevice": "Multi-device", + "backedUp": "backed up", + "telegram": "Telegram Notifications", + "telegramDescription": "Get reminders for missed medication intake via Telegram.", + "telegramSaved": "Telegram settings saved", + "botToken": "Bot Token", + "chatId": "Chat ID", + "enableNotifications": "Enable notifications", + "testMessage": "Test message", + "testSent": "Test message sent!", + "telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.", + "telegramStep2": "2. Send /start to your bot to activate the chat.", + "telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.", + "ntfy": "ntfy", + "ntfyDescription": "Notifications via ntfy (self-hosted or ntfy.sh).", + "ntfyEnable": "Enable ntfy", + "ntfyServer": "Server URL", + "ntfyTopic": "Topic", + "ntfyAuthToken": "Auth Token (optional)", + "ntfyAuthTokenHint": "Only needed for private topics with access control.", + "webPush": "Browser Push", + "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", + "webPushNotSupported": "Your browser does not support push notifications.", + "webPushDenied": "Push notifications are blocked. Allow them in your browser settings.", + "webPushNotConfigured": "Web Push is not configured on the server.", + "webPushSubscribe": "Enable push", + "webPushUnsubscribe": "Disable push", + "webPushSubscribed": "Push notifications enabled!", + "webPushUnsubscribed": "Push notifications disabled.", + "webPushSubscribeFailed": "Activation failed", + "webPushActive": "Active", + "kiInsights": "AI Insights (OpenAI)", + "kiInsightsDescription": "Optional: Save your OpenAI key for daily, automatic AI evaluations in Insights.", + "kiInsightsBenefitsTitle": "Benefits with OpenAI key", + "kiInsightsBenefit1": "Daily summaries for general status, blood pressure, weight, pulse, BMI, and medication compliance.", + "kiInsightsBenefit2": "Better trend and correlation interpretation based on your latest 30 daily measurement points per area.", + "kiInsightsBenefit3": "Automatic background refresh so the Insights page always shows up-to-date assessments.", + "createApiKeyHint": "Create your API key at:", + "createApiKeyLinkLabel": "platform.openai.com/api-keys", + "apiKey": "API Key", + "apiKeyStored": "Key stored — enter new one to replace", + "apiKeySaved": "API key saved", + "apiKeyRemoved": "API key removed", + "rawData": "Send raw data", + "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so AI can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.", + "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but AI can be less precise for short-term patterns and fluctuations.", + "rawDataWarning": "Raw mode enabled: AI receives additional anonymized points from the last 30 days for higher analysis accuracy.", + "regenerateInsights": "Regenerate reports", + "regenerateSuccess": "Reports regenerated successfully", + "regenerateRateLimit": "Please wait — max 2 analyses per hour.", + "lastGeneratedAt": "Last generated", + "withings": "Withings", + "withingsDescription": "Connect your Withings scale and blood pressure monitors.", + "withingsCredentials": "API Credentials", + "withingsClientId": "Client ID", + "withingsClientSecret": "Client Secret", + "withingsCredentialsSaved": "Credentials saved", + "withingsCredentialsSavedPlaceholder": "Saved — enter new to replace", + "withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "withingsSaveCredentials": "Save credentials", + "withingsConnected": "Connected", + "withingsTokenExpired": "Token expired", + "withingsLastSync": "Last sync", + "withingsSync": "Sync now", + "withingsFullSync": "Sync all data", + "withingsFullSyncTitle": "Full synchronization?", + "withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.", + "withingsSyncResult": "{count} measurements synchronized", + "withingsFullSyncResult": "{count} measurements fully synchronized", + "withingsSyncFailed": "Sync failed", + "withingsSynchronize": "Synchronize", + "withingsDisconnect": "Disconnect", + "withingsDisconnectTitle": "Disconnect Withings?", + "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", + "withingsConnect": "Connect with Withings", + "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "apiTokens": "API Tokens", + "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", + "tokenName": "Token name", + "tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)", + "tokenCreated": "Token created — copy it now! It won't be shown again.", + "tokenRevoke": "Revoke token?", + "tokenRevokeDescription": "The token will be permanently deactivated. Applications using it will lose access.", + "tokenRevoked": "Revoked", + "tokenExpired": "Expired", + "tokenActive": "Active", + "activeTokensTitle": "Active tokens", + "revokedTokensTitle": "Revoked tokens ({count})", + "tokenTableName": "Name", + "tokenTablePermissions": "Permissions", + "tokenTableStatus": "Status", + "tokenTableCreated": "Created", + "tokenTableLastUsed": "Last used", + "tokenTableActions": "Actions", + "noActiveTokens": "No active tokens available.", + "tokenNeverUsed": "Never", + "tokenLastUsed": "Last used:", + "tokenCreatedAt": "Created:", + "apiEndpoint": "API endpoint for medication intake:", + "apiEndpointsTitle": "API endpoints", + "apiEndpointsDescription": "Overview of currently available external endpoint(s) for token-based ingestion.", + "apiEndpointMethod": "Method", + "apiEndpointPath": "Path", + "apiEndpointAuth": "Authentication", + "apiEndpointExample": "Body example", + "export": "Data Export", + "exportDescription": "Export all your health data.", + "exportJson": "JSON Export", + "exportCsv": "CSV Export", + "doctorReport": "Doctor Report (PDF)", + "auditLog": "Activity Log", + "auditLogHidden": "Log hidden.", + "auditLogShowing": "Showing last {count} of {total} entries", + "auditNoActivities": "No activities found.", + "auditAction": "Action", + "auditCategory": "Category", + "auditIp": "IP", + "auditLocation": "Location", + "auditTimestamp": "Time", + "auditOther": "Other", + "auditCategoryAuth": "Auth", + "auditCategoryData": "Data", + "auditCategoryIntegration": "Integration", + "auditCategoryAI": "AI", + "auditCategoryAdmin": "Admin", + "auditActionAuthRegister": "Registration", + "auditActionAuthLogin": "Login", + "auditActionAuthLoginPasskey": "Passkey login", + "auditActionAuthLoginPassword": "Password login", + "auditActionAuthLoginFailed": "Failed login", + "auditActionAuthLogout": "Logout", + "auditActionAuthPasskeyRegister": "Passkey added", + "auditActionAuthPasskeyDelete": "Passkey deleted", + "auditActionAuthPasswordChange": "Password changed", + "auditActionMeasurementCreate": "Measurement created", + "auditActionMeasurementUpdate": "Measurement updated", + "auditActionMeasurementDelete": "Measurement deleted", + "auditActionMedicationCreate": "Medication created", + "auditActionMedicationUpdate": "Medication updated", + "auditActionMedicationDelete": "Medication deleted", + "auditActionMedicationIntake": "Intake recorded", + "auditActionMedicationIngestExternal": "External intake", + "auditActionWithingsConnect": "Withings connected", + "auditActionWithingsDisconnect": "Withings disconnected", + "auditActionExportDownload": "Export downloaded", + "auditActionInsightsGenerate": "AI insight generated", + "auditActionAdminUserUpdate": "Admin: User updated", + "auditActionAdminUserResetPassword": "Admin: Password reset", + "auditActionAdminSettingsUpdate": "Admin: Settings changed", + "collapse": "Collapse", + "expand": "Expand", + "dangerZoneTitle": "Danger Zone", + "dangerZone": "Delete All Data", + "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", + "dangerZoneConfirm": "Delete all data?", + "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", + "dangerZoneSuccess": "All personal data has been deleted", + "dangerZoneDeleteFailed": "Deletion failed", + "finalDelete": "Delete permanently", + "saved": "Saved", + "savingError": "Error saving", + "adminAreaTitle": "Admin area", + "adminAreaDescription": "System-wide settings and user management are available in the admin console.", + "openAdminConsole": "Open admin console", + "moodLogTitle": "moodLog", + "moodLogDescription": "Import mood data from moodLog", + "moodLogUrl": "moodLog URL", + "moodLogUrlPlaceholder": "https://mood.example.com", + "moodLogApiKey": "API Key", + "moodLogApiKeyPlaceholder": "ml_...", + "moodLogWebhookSecret": "Webhook Secret", + "moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret", + "moodLogSync": "Start sync", + "moodLogFullSync": "Full sync", + "moodLogFullSyncTitle": "Full sync?", + "moodLogFullSyncDescription": "All available mood data will be fully synchronized.", + "moodLogDisconnect": "Disconnect", + "moodLogDisconnectTitle": "Disconnect moodLog?", + "moodLogDisconnectDescription": "The connection will be removed and all imported mood data will be deleted.", + "moodLogLastSync": "Last sync", + "moodLogEntries": "Entries", + "moodLogSyncResult": "{count} entries synchronized", + "moodLogSyncFailed": "Sync failed", + "moodLogSaved": "moodLog connection saved", + "moodLogDisconnected": "moodLog disconnected" + }, + "admin": { + "title": "Administration", + "subtitle": "System status, users, and settings", + "sectionsTitle": "Admin", + "systemStatus": "System Status", + "version": "Version", + "database": "Database", + "databaseConnected": "Connected", + "databaseError": "Error", + "gitCommit": "Git Commit", + "startedAt": "Started", + "users": "Users", + "measurementsCount": "Measurements", + "activeTokens": "Active API Tokens", + "activeSessions": "Active Sessions", + "loadingStatus": "Loading status...", + "workerStatus": "Job Worker", + "workerRunning": "Running", + "workerStopped": "Stopped", + "lastReminderCheck": "Last Reminder Check", + "appSettings": "Application Settings", + "registrationEnabled": "Allow registration", + "registrationEnabledDescription": "When disabled, new users cannot register.", + "defaultLanguage": "Default language", + "defaultLanguageDescription": "Used for new users and as fallback.", + "servicesGlobal": "Services (global)", + "servicesGlobalDescription": "Disabled services are not available to any user.", + "telegramGlobal": "Allow Telegram notifications system-wide.", + "ntfyGlobal": "Allow ntfy notifications system-wide.", + "webPushGlobal": "Allow browser push notifications system-wide.", + "apiGlobal": "Allow API endpoints and API tokens system-wide.", + "moodLogGlobal": "Allow moodLog integration system-wide.", + "sectionGeneral": "General", + "sectionIntegrations": "Integrations", + "sectionMedication": "Medications", + "sectionManagement": "Management", + "umamiTitle": "Umami", + "umamiDescription": "Web analytics with Umami", + "glitchtipTitle": "GlitchTip", + "glitchtipDescription": "Error tracking with GlitchTip", + "webPushVapidTitle": "Web Push VAPID", + "monitoring": "Monitoring (Umami/Glitchtip)", + "monitoringDescription": "Configuration for website analytics and error tracking.", + "umamiEnabled": "Enable Umami tracking.", + "umamiScriptUrl": "Umami script URL", + "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", + "umamiWebsiteId": "Umami website ID", + "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "glitchtipEnabled": "Enable Glitchtip error tracking.", + "glitchtipDsn": "Glitchtip DSN", + "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", + "glitchtipEnvironment": "Glitchtip environment", + "glitchtipEnvironmentPlaceholder": "production", + "webPushVapid": "Web Push (VAPID)", + "webPushVapidDescription": "Configure VAPID keys and subject here so browser push works without environment variables.", + "webPushVapidPublicKey": "VAPID public key", + "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", + "webPushVapidPrivateKey": "VAPID private key", + "webPushVapidPrivateKeyPlaceholder": "Optional: enter a new private key", + "webPushVapidSubject": "VAPID subject", + "webPushVapidSubjectPlaceholder": "mailto:you@example.com", + "configured": "Configured", + "notConfigured": "Not configured", + "monitoringTestUmami": "Send Umami test", + "monitoringTestGlitchtip": "Send Glitchtip test", + "monitoringTestSuccess": "Test event sent", + "monitoringTestFailed": "Monitoring test failed", + "monitoringSave": "Save monitoring", + "bugReportGithub": "Bug report (GitHub Issues)", + "bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.", + "bugReportRepo": "Repository", + "bugReportRepoPlaceholder": "owner/repo", + "bugReportToken": "Issue token", + "bugReportTokenPlaceholder": "ghp_...", + "bugReportConfigured": "Bug report is configured.", + "bugReportNotConfigured": "Bug report is not fully configured yet.", + "bugReportSave": "Save bug report settings", + "userManagement": "User Management", + "userEmail": "Email", + "userRole": "Role", + "userPasskeys": "Passkeys", + "userCreated": "Created", + "userActions": "Actions", + "editUser": "Edit user", + "editUserTitle": "Edit user: {name}", + "demoteToUser": "Demote to user", + "promoteToAdmin": "Promote to admin", + "toUser": "→ User", + "toAdmin": "→ Admin", + "ownRoleUnchangeable": "Cannot change own role", + "lastAdminError": "Cannot demote the last admin", + "resetPassword": "Reset password", + "resetPasswordTitle": "Reset password: {name}", + "newPassword": "New password", + "newPasswordPlaceholder": "At least 12 characters", + "passwordReset": "Password has been reset", + "reset": "Reset", + "loadingUsers": "Loading users...", + "apiTokens": "API Tokens", + "tokenUser": "User", + "tokenName": "Name", + "tokenPermissions": "Permissions", + "tokenStatus": "Status", + "tokenLastUsed": "Last used", + "tokenCreated": "Created", + "tokenNeverUsed": "Never", + "noTokens": "No API tokens found.", + "loginOverview": "Login Overview", + "allAuthEvents": "All auth events", + "failedOnly": "Failed only", + "noEntries": "No entries found.", + "showingEntries": "Showing {count} of {total} auth entries", + "status": "Status", + "action": "Action", + "ip": "IP", + "location": "Location", + "timestamp": "Time", + "dangerZone": "Danger Zone", + "deleteAllData": "Delete all data globally", + "deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.", + "deleteAllConfirm": "Really delete all data?", + "deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake records, and integration data for all users. User accounts will be preserved.", + "deleteButton": "Delete all data", + "finalDelete": "Delete permanently", + "deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes", + "authRegister": "Registration", + "authLogin": "Login", + "authLoginPasskey": "Passkey login", + "authLoginPassword": "Password login", + "authLoginFailed": "Failed", + "authLogout": "Logout", + "authPasskeyRegister": "Passkey added", + "authPasskeyDelete": "Passkey deleted", + "medicationReminders": "Medication Reminders", + "medicationRemindersDescription": "Thresholds for reminder badge color transitions on medication cards.", + "reminderLateMinutes": "Late threshold (minutes)", + "reminderLateMinutesDescription": "Minutes after window end until the badge changes from yellow to orange.", + "reminderMissedMinutes": "Missed threshold (minutes)", + "reminderMissedMinutesDescription": "Minutes after window end until the intake is considered missed.", + "reminderThresholdsSave": "Save thresholds", + "notificationTest": "Test Notifications", + "notificationTestSend": "Send test notification", + "notificationTestSuccess": "Test notification sent", + "notificationTestFailed": "Failed to send test notification", + "reminderCheckRun": "Run reminder check", + "reminderCheckSuccess": "Reminder check completed", + "reminderCheckFailed": "Failed to run reminder check", + "reminderCheckResults": "Results", + "reminderCheckNotifOn": "Notifications on", + "reminderCheckNotifOff": "Notifications off", + "reminderCheckEventsToday": "Events today", + "reminderCheckNotifSent": "Sent" + }, + "achievements": { + "title": "Achievements", + "subtitle": "Unlock achievements from feature rollout onward through consistent health trends and medication habits.", + "loginRequired": "Please sign in to view achievements.", + "points": "Points", + "unlocked": "Unlocked", + "nextGoal": "Next goal", + "allCompleted": "All achievements unlocked!", + "completed": "Completed", + "inProgress": "In progress", + "progress": "Progress", + "goalReached": "Goal reached", + "remaining": "{count} remaining", + "remainingUnlocks": "{count} still unlockable", + "noneUnlockedYet": "No achievements unlocked yet.", + "pointsValue": "{points} points", + "metricCount": "{count}", + "metricDays": "{count} days", + "metricPercent": "{count}%", + "badges": { + "intakeTotal1": { + "title": "First intake", + "description": "Record your first taken medication event." + }, + "intakeTotal10": { + "title": "Intake starter", + "description": "Record 10 taken medication events." + }, + "intakeTotal50": { + "title": "Intake routine", + "description": "Record 50 taken medication events." + }, + "intakeTotal150": { + "title": "Intake expert", + "description": "Record 150 taken medication events." + }, + "intakeTotal300": { + "title": "Intake legend", + "description": "Record 300 taken medication events." + }, + "overIntake1": { + "title": "Idiot", + "description": "Take medication at least once more than planned." + }, + "skippedIntake1": { + "title": "Lazy Boy", + "description": "Skip at least one planned intake." + }, + "passkeyCreated1": { + "title": "Passkey set up", + "description": "Create your first passkey." + }, + "passkeyLogin1": { + "title": "Passkey login", + "description": "Sign in at least once with a passkey." + }, + "passwordLogin1": { + "title": "Old school", + "description": "Sign in at least once with username and password." + }, + "bugReport1": { + "title": "Bug hunter", + "description": "Submit a bug report." + }, + "loginStreak7": { + "title": "Login streak 7d", + "description": "Sign in on 7 consecutive days." + }, + "loginStreak30": { + "title": "Login streak 30d", + "description": "Sign in on 30 consecutive days." + }, + "onTimePerfect1": { + "title": "On-time 1d", + "description": "Take all medications on time for 1 consecutive days." + }, + "compliance801": { + "title": "80% adherence · 1d", + "description": "Reach at least 80% 30-day adherence for 1 consecutive days." + }, + "bmiGreen1": { + "title": "BMI green · 1d", + "description": "Keep your BMI in the normal range for 1 consecutive days." + }, + "bpGreen1": { + "title": "BP green · 1d", + "description": "Keep your blood pressure in the normal range for 1 consecutive days." + }, + "pulseGreen1": { + "title": "Pulse green · 1d", + "description": "Keep your resting pulse in the normal range for 1 consecutive days." + }, + "onTimePerfect7": { + "title": "On-time 7d", + "description": "Take all medications on time for 7 consecutive days." + }, + "compliance807": { + "title": "80% adherence · 7d", + "description": "Reach at least 80% 30-day adherence for 7 consecutive days." + }, + "bmiGreen7": { + "title": "BMI green · 7d", + "description": "Keep your BMI in the normal range for 7 consecutive days." + }, + "bpGreen7": { + "title": "BP green · 7d", + "description": "Keep your blood pressure in the normal range for 7 consecutive days." + }, + "pulseGreen7": { + "title": "Pulse green · 7d", + "description": "Keep your resting pulse in the normal range for 7 consecutive days." + }, + "onTimePerfect30": { + "title": "On-time 30d", + "description": "Take all medications on time for 30 consecutive days." + }, + "compliance8030": { + "title": "80% adherence · 30d", + "description": "Reach at least 80% 30-day adherence for 30 consecutive days." + }, + "bmiGreen30": { + "title": "BMI green · 30d", + "description": "Keep your BMI in the normal range for 30 consecutive days." + }, + "bpGreen30": { + "title": "BP green · 30d", + "description": "Keep your blood pressure in the normal range for 30 consecutive days." + }, + "pulseGreen30": { + "title": "Pulse green · 30d", + "description": "Keep your resting pulse in the normal range for 30 consecutive days." + }, + "onTimePerfect180": { + "title": "On-time 180d", + "description": "Take all medications on time for 180 consecutive days." + }, + "compliance80180": { + "title": "80% adherence · 180d", + "description": "Reach at least 80% 30-day adherence for 180 consecutive days." + }, + "bmiGreen180": { + "title": "BMI green · 180d", + "description": "Keep your BMI in the normal range for 180 consecutive days." + }, + "bpGreen180": { + "title": "BP green · 180d", + "description": "Keep your blood pressure in the normal range for 180 consecutive days." + }, + "pulseGreen180": { + "title": "Pulse green · 180d", + "description": "Keep your resting pulse in the normal range for 180 consecutive days." + }, + "onTimePerfect360": { + "title": "On-time 360d", + "description": "Take all medications on time for 360 consecutive days." + }, + "compliance80360": { + "title": "80% adherence · 360d", + "description": "Reach at least 80% 30-day adherence for 360 consecutive days." + }, + "bmiGreen360": { + "title": "BMI green · 360d", + "description": "Keep your BMI in the normal range for 360 consecutive days." + }, + "bpGreen360": { + "title": "BP green · 360d", + "description": "Keep your blood pressure in the normal range for 360 consecutive days." + }, + "pulseGreen360": { + "title": "Pulse green · 360d", + "description": "Keep your resting pulse in the normal range for 360 consecutive days." + } + }, + "nextProgressLabel": "Progress to unlock", + "completedOn": "Completed on {date}" + }, + "bugreport": { + "title": "Bug Report", + "subtitle": "Report a bug or issue. A GitHub issue will be created automatically.", + "loginRequired": "Please sign in to report a bug.", + "bugTitle": "Title", + "bugTitlePlaceholder": "Brief description of the problem", + "description": "Description", + "descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?", + "screenshot": "Screenshot (optional)", + "screenshotPreview": "Screenshot preview", + "attachScreenshot": "Attach screenshot", + "imageOnly": "Only image files allowed", + "tooLarge": "Screenshot is too large (max 5 MB)", + "submit": "Send", + "success": "Thank you! Your bug report has been submitted successfully.", + "viewOnGithub": "View on GitHub", + "errorCreating": "Error creating report" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Choose which notifications you want to receive through which channel.", + "noChannels": "Configure a notification channel in Settings first.", + "goToSettings": "Go to Settings", + "channelDisabled": "Disabled", + "eventMedicationReminder": "Medication Reminder", + "eventMedicationReminderDesc": "Reminder when an intake is due.", + "eventMeasurementAnomaly": "Anomalous Measurement", + "eventMeasurementAnomalyDesc": "Warning for unusual measurements (e.g., high blood pressure).", + "eventComplianceLow": "Low Compliance", + "eventComplianceLowDesc": "Notification when medication compliance drops below a threshold.", + "eventWithingsSyncFailed": "Withings Sync Failed", + "eventWithingsSyncFailedDesc": "Alert when the automatic Withings data sync fails.", + "eventSystemAlert": "System Alert", + "eventSystemAlertDesc": "Important system messages and administrative notices." + }, + "format": { + "dateShort": "{month}/{day}/{year}", + "timeShort": "{hours}:{minutes}", + "dateTime": "{month}/{day}/{year}, {hours}:{minutes}" + } +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 000000000..b84ce33b8 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,17 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + poweredByHeader: false, + serverExternalPackages: [ + "@prisma/client", + "pg-boss", + "@prisma/adapter-pg", + "pg", + ], + experimental: { + optimizePackageImports: ["recharts", "lucide-react"], + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 000000000..8347c206b --- /dev/null +++ b/package.json @@ -0,0 +1,74 @@ +{ + "name": "healthlog", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.31.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:migrate:deploy": "prisma migrate deploy", + "db:studio": "prisma studio", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@node-rs/argon2": "^2.0.2", + "@prisma/adapter-pg": "^7.4.2", + "@prisma/client": "^7.4.2", + "@simplewebauthn/browser": "^13.2.2", + "@simplewebauthn/server": "^13.2.3", + "@tanstack/react-query": "^5.90.21", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dotenv": "^17.3.1", + "jspdf": "^4.2.0", + "jspdf-autotable": "^5.0.7", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "next-themes": "^0.4.6", + "pg-boss": "^12.13.0", + "prisma": "^7.4.2", + "radix-ui": "^1.4.3", + "react": "19.2.4", + "react-dom": "19.2.3", + "react-hook-form": "^7.71.1", + "recharts": "^3.7.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "web-push": "^3.6.7", + "zod": "^4.3.6", + "zxcvbn-typescript": "^5.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", + "@vitejs/plugin-react": "^5.1.4", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "prettier": "^3.5.0", + "prettier-plugin-tailwindcss": "^0.6.0", + "shadcn": "^3.8.5", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "typescript": "^5", + "vitest": "^4.0.18" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "prisma", + "@prisma/engines" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 000000000..2a58f9756 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,10485 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.71.1(react@19.2.4)) + '@node-rs/argon2': + specifier: ^2.0.2 + version: 2.0.2 + '@prisma/adapter-pg': + specifier: ^7.4.2 + version: 7.4.2 + '@prisma/client': + specifier: ^7.4.2 + version: 7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + '@simplewebauthn/browser': + specifier: ^13.2.2 + version: 13.2.2 + '@simplewebauthn/server': + specifier: ^13.2.3 + version: 13.2.3 + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 + jspdf: + specifier: ^4.2.0 + version: 4.2.0 + jspdf-autotable: + specifier: ^5.0.7 + version: 5.0.7(jspdf@4.2.0) + lucide-react: + specifier: ^0.575.0 + version: 0.575.0(react@19.2.4) + next: + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + pg-boss: + specifier: ^12.13.0 + version: 12.13.0 + prisma: + specifier: ^7.4.2 + version: 7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.4) + react-hook-form: + specifier: ^7.71.1 + version: 7.71.1(react@19.2.4) + recharts: + specifier: ^3.7.0 + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + web-push: + specifier: ^3.6.7 + version: 3.6.7 + zod: + specifier: ^4.3.6 + version: 4.3.6 + zxcvbn-typescript: + specifier: ^5.0.1 + version: 5.0.1 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.0 + '@types/node': + specifier: ^20 + version: 20.19.33 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 + '@vitejs/plugin-react': + specifier: ^5.1.4 + version: 5.1.4(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)) + eslint: + specifier: ^9 + version: 9.39.3(jiti@2.6.1) + eslint-config-next: + specifier: 16.1.6 + version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + prettier: + specifier: ^3.5.0 + version: 3.8.1 + prettier-plugin-tailwindcss: + specifier: ^0.6.0 + version: 0.6.14(prettier@3.8.1) + shadcn: + specifier: ^3.8.5 + version: 3.8.5(@types/node@20.19.33)(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.0 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3)) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@antfu/ni@25.0.0': + resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} + hasBin: true + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + + '@dotenvx/dotenvx@1.52.0': + resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} + hasBin: true + + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@electric-sql/pglite-socket@0.0.20': + resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite-tools@0.2.20': + resolution: {integrity: sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==} + peerDependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite@0.3.15': + resolution: {integrity: sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==} + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.3': + resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + + '@modelcontextprotocol/sdk@1.26.0': + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mrleebo/prisma-ast@0.13.1': + resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} + engines: {node: '>=16'} + + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + + '@next/eslint-plugin-next@16.1.6': + resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} + + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@node-rs/argon2-android-arm-eabi@2.0.2': + resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@2.0.2': + resolution: {integrity: sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@2.0.2': + resolution: {integrity: sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/argon2-darwin-x64@2.0.2': + resolution: {integrity: sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/argon2-freebsd-x64@2.0.2': + resolution: {integrity: sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + resolution: {integrity: sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + resolution: {integrity: sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + resolution: {integrity: sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + resolution: {integrity: sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-rs/argon2-linux-x64-musl@2.0.2': + resolution: {integrity: sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-rs/argon2-wasm32-wasi@2.0.2': + resolution: {integrity: sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + resolution: {integrity: sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + resolution: {integrity: sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + resolution: {integrity: sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/argon2@2.0.2': + resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} + engines: {node: '>= 10'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + + '@prisma/adapter-pg@7.4.2': + resolution: {integrity: sha512-oUo2Zhe9Tf6YwVL8kLPuOLTK1Z2pwi/Ua77t2PuGyBan2w7shRKqHvYK+3XXmRH9RWhPJ4SMtHZKpNo6Ax/4bQ==} + + '@prisma/client-runtime-utils@7.4.2': + resolution: {integrity: sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q==} + + '@prisma/client@7.4.2': + resolution: {integrity: sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.4.2': + resolution: {integrity: sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.4.2': + resolution: {integrity: sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==} + + '@prisma/dev@0.20.0': + resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==} + + '@prisma/driver-adapter-utils@7.4.2': + resolution: {integrity: sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw==} + + '@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': + resolution: {integrity: sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==} + + '@prisma/engines@7.4.2': + resolution: {integrity: sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA==} + + '@prisma/fetch-engine@7.4.2': + resolution: {integrity: sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.4.2': + resolution: {integrity: sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/studio-core@0.13.1': + resolution: {integrity: sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@simplewebauthn/browser@13.2.2': + resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==} + + '@simplewebauthn/server@13.2.3': + resolution: {integrity: sha512-ZhcVBOw63birYx9jVfbhK6rTehckVes8PeWV324zpmdxr0BUfylospwMzcrxrdMcOi48MHWj2LCA+S528LnGvg==} + engines: {node: '>=20.0.0'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.0': + resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} + + '@tailwindcss/oxide-android-arm64@4.2.0': + resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.0': + resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.0': + resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.0': + resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.0': + resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.0': + resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.0': + resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.0': + resolution: {integrity: sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==} + + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.33': + resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@5.1.4': + resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@5.0.3: + resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001770: + resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} + + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + eciesjs@0.4.17: + resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.1.6: + resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.3: + resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + + graphql@16.12.0: + resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hono@4.11.4: + resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} + engines: {node: '>=16.9.0'} + + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jspdf-autotable@5.0.7: + resolution: {integrity: sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==} + peerDependencies: + jspdf: ^2 || ^3 || ^4 + + jspdf@4.2.0: + resolution: {integrity: sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + lucide-react@0.575.0: + resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.3: + resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==} + + minimatch@9.0.6: + resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + non-error@0.1.0: + resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} + engines: {node: '>=20'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + pg-boss@12.13.0: + resolution: {integrity: sha512-xaMIXAEyYR4+OjCid7y4574b7fYeY59j//TWD1x6yMFsn9EV7TiRUtIKYRqXV68rJ12Guduvz3eTjBMRje+iUw==} + engines: {node: '>=22.12.0'} + hasBin: true + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.6.14: + resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prisma@7.4.2: + resolution: {integrity: sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-hook-form@7.71.1: + resolution: {integrity: sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + recharts@3.7.0: + resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + serialize-error@13.0.1: + resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} + engines: {node: '>=20'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@3.8.5: + resolution: {integrity: sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.0: + resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.4.4: + resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} + engines: {node: '>=20'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.56.0: + resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zxcvbn-typescript@5.0.1: + resolution: {integrity: sha512-If2a6jdm278dfWmn6NL7oC1lNYRHOthBw0C78LsKvtAHHFNsMudvFhDn2fF/b74txWtSosJJiuJ7jBPn/xP6HQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@antfu/ni@25.0.0': + dependencies: + ansis: 4.2.0 + fzf: 0.5.2 + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + + '@dotenvx/dotenvx@1.52.0': + dependencies: + commander: 11.1.0 + dotenv: 17.3.1 + eciesjs: 0.4.17 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.3) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.3 + which: 4.0.0 + + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': + dependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite-tools@0.2.20(@electric-sql/pglite@0.3.15)': + dependencies: + '@electric-sql/pglite': 0.3.15 + + '@electric-sql/pglite@0.3.15': {} + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))': + dependencies: + eslint: 9.39.3(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.3 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.3 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.3': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + + '@floating-ui/utils@0.2.10': {} + + '@hexagon/base64@1.1.28': {} + + '@hono/node-server@1.19.9(hono@4.11.4)': + dependencies: + hono: 4.11.4 + + '@hookform/resolvers@5.2.2(react-hook-form@7.71.1(react@19.2.4))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.71.1(react@19.2.4) + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/confirm@5.1.21(@types/node@20.19.33)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.33) + '@inquirer/type': 3.0.10(@types/node@20.19.33) + optionalDependencies: + '@types/node': 20.19.33 + + '@inquirer/core@10.3.2(@types/node@20.19.33)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.33) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.33 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/type@3.0.10(@types/node@20.19.33)': + optionalDependencies: + '@types/node': 20.19.33 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@levischuck/tiny-cbor@0.2.11': {} + + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.4) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.11.4 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mrleebo/prisma-ast@0.13.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.1.6': {} + + '@next/eslint-plugin-next@16.1.6': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.1.6': + optional: true + + '@next/swc-darwin-x64@16.1.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.6': + optional: true + + '@next/swc-linux-arm64-musl@16.1.6': + optional: true + + '@next/swc-linux-x64-gnu@16.1.6': + optional: true + + '@next/swc-linux-x64-musl@16.1.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.6': + optional: true + + '@next/swc-win32-x64-msvc@16.1.6': + optional: true + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@node-rs/argon2-android-arm-eabi@2.0.2': + optional: true + + '@node-rs/argon2-android-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-x64@2.0.2': + optional: true + + '@node-rs/argon2-freebsd-x64@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-musl@2.0.2': + optional: true + + '@node-rs/argon2-wasm32-wasi@2.0.2': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + optional: true + + '@node-rs/argon2@2.0.2': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 2.0.2 + '@node-rs/argon2-android-arm64': 2.0.2 + '@node-rs/argon2-darwin-arm64': 2.0.2 + '@node-rs/argon2-darwin-x64': 2.0.2 + '@node-rs/argon2-freebsd-x64': 2.0.2 + '@node-rs/argon2-linux-arm-gnueabihf': 2.0.2 + '@node-rs/argon2-linux-arm64-gnu': 2.0.2 + '@node-rs/argon2-linux-arm64-musl': 2.0.2 + '@node-rs/argon2-linux-x64-gnu': 2.0.2 + '@node-rs/argon2-linux-x64-musl': 2.0.2 + '@node-rs/argon2-wasm32-wasi': 2.0.2 + '@node-rs/argon2-win32-arm64-msvc': 2.0.2 + '@node-rs/argon2-win32-ia32-msvc': 2.0.2 + '@node-rs/argon2-win32-x64-msvc': 2.0.2 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@peculiar/asn1-android@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + + '@prisma/adapter-pg@7.4.2': + dependencies: + '@prisma/driver-adapter-utils': 7.4.2 + pg: 8.18.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.4.2': {} + + '@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.4.2 + optionalDependencies: + prisma: 7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.4.2': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.4.2': {} + + '@prisma/dev@0.20.0(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.3.15 + '@electric-sql/pglite-socket': 0.0.20(@electric-sql/pglite@0.3.15) + '@electric-sql/pglite-tools': 0.2.20(@electric-sql/pglite@0.3.15) + '@hono/node-server': 1.19.9(hono@4.11.4) + '@mrleebo/prisma-ast': 0.13.1 + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.11.4 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.4.2': + dependencies: + '@prisma/debug': 7.4.2 + + '@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': {} + + '@prisma/engines@7.4.2': + dependencies: + '@prisma/debug': 7.4.2 + '@prisma/engines-version': 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + '@prisma/fetch-engine': 7.4.2 + '@prisma/get-platform': 7.4.2 + + '@prisma/fetch-engine@7.4.2': + dependencies: + '@prisma/debug': 7.4.2 + '@prisma/engines-version': 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + '@prisma/get-platform': 7.4.2 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.4.2': + dependencies: + '@prisma/debug': 7.4.2 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/studio-core@0.13.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@types/react': 19.2.14 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@rtsao/scc@1.1.0': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@simplewebauthn/browser@13.2.2': {} + + '@simplewebauthn/server@13.2.3': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/x509': 1.14.3 + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.0 + + '@tailwindcss/oxide-android-arm64@4.2.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + optional: true + + '@tailwindcss/oxide@4.2.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-x64': 4.2.0 + '@tailwindcss/oxide-freebsd-x64': 4.2.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-x64-musl': 4.2.0 + '@tailwindcss/oxide-wasm32-wasi': 4.2.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 + + '@tailwindcss/postcss@4.2.0': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.0 + '@tailwindcss/oxide': 4.2.0 + postcss: 8.5.6 + tailwindcss: 4.2.0 + + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.2 + path-browserify: 1.0.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.33': + dependencies: + undici-types: 6.21.0 + + '@types/pako@2.0.4': {} + + '@types/raf@3.4.3': + optional: true + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/statuses@2.0.6': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/use-sync-external-store@0.0.6': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@types/web-push@3.6.4': + dependencies: + '@types/node': 20.19.33 + + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 9.39.3(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.0': {} + + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 9.0.6 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3) + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@4.2.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + ast-types-flow@0.0.8: {} + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + aws-ssl-profiles@1.1.2: {} + + axe-core@4.11.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-arraybuffer@1.0.2: + optional: true + + baseline-browser-mapping@2.10.0: {} + + bn.js@4.12.3: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.3: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001770 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-equal-constant-time@1.0.1: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001770: {} + + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.28.6 + '@types/raf': 3.4.3 + core-js: 3.48.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + concat-map@0.0.1: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + core-js@3.48.0: + optional: true + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + damerau-levenshtein@1.0.8: {} + + data-uri-to-buffer@4.0.1: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + dedent@1.7.1: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@8.0.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + + dotenv@16.6.1: {} + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + eciesjs@0.4.17: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + ee-first@1.1.1: {} + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + electron-to-chromium@1.5.286: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.2: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.44.0: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.1.6 + eslint: 9.39.3(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + get-tsconfig: 4.13.6 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.3(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.3 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.3(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.3(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.3 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.3(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.3(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.3(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.3 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.3(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.3 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.3 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + expect-type@1.3.0: {} + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fflate@0.8.2: {} + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + fuzzysort@3.1.0: {} + + fzf@0.5.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-own-enumerable-keys@1.0.0: {} + + get-port-please@3.2.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + + graphql@16.12.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@4.0.3: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hono@4.11.4: {} + + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-status-codes@2.3.0: {} + + http_ece@1.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immer@10.2.0: {} + + immer@11.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + internmap@2.0.3: {} + + iobuffer@5.4.0: {} + + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-property@1.0.2: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-regexp@3.1.0: {} + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + jose@6.1.3: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jspdf-autotable@5.0.7(jspdf@4.2.0): + dependencies: + jspdf: 4.2.0 + + jspdf@4.2.0: + dependencies: + '@babel/runtime': 7.28.6 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.48.0 + dompurify: 3.3.1 + html2canvas: 1.4.1 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + lilconfig@2.1.0: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru.min@1.1.4: {} + + lucide-react@0.575.0(react@19.2.4): + dependencies: + react: 19.2.4 + + luxon@3.7.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimalistic-assert@1.0.1: {} + + minimatch@10.2.2: + dependencies: + brace-expansion: 5.0.3 + + minimatch@3.1.3: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.6: + dependencies: + brace-expansion: 5.0.3 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@20.19.33) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@2.0.0: {} + + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + next-themes@0.4.6(react-dom@19.2.3(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + + next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001770 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-domexception@1.0.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch-native@1.6.7: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.27: {} + + non-error@0.1.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.0.2 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object-treeify@1.1.33: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + obug@2.1.1: {} + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.2 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-manager-detector@1.6.0: {} + + pako@2.1.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + performance-now@2.1.0: + optional: true + + pg-boss@12.13.0: + dependencies: + cron-parser: 5.5.0 + pg: 8.18.0 + serialize-error: 13.0.1 + transitivePeerDependencies: + - pg-native + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pkce-challenge@5.0.1: {} + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + possible-typed-array-names@1.1.0: {} + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.6.14(prettier@3.8.1): + dependencies: + prettier: 3.8.1 + + prettier@3.8.1: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.4.2 + '@prisma/dev': 0.20.0(typescript@5.9.3) + '@prisma/engines': 7.4.2 + '@prisma/studio-core': 0.13.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - magicast + - react + - react-dom + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + react-dom@19.2.3(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-hook-form@7.71.1(react@19.2.4): + dependencies: + react: 19.2.4 + + react-is@16.13.1: {} + + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + + react-refresh@0.18.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.4: {} + + readdirp@4.1.2: {} + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.44.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reflect-metadata@0.2.2: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerator-runtime@0.13.11: + optional: true + + regexp-to-ast@0.5.0: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + remeda@2.33.4: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + reselect@5.1.1: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rgbcolor@1.0.1: + optional: true + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + seq-queue@0.0.5: {} + + serialize-error@13.0.1: + dependencies: + non-error: 0.1.0 + type-fest: 5.4.4 + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setprototypeof@1.2.0: {} + + shadcn@3.8.5(@types/node@20.19.33)(typescript@5.9.3): + dependencies: + '@antfu/ni': 25.0.0 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@dotenvx/dotenvx': 1.52.0 + '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.1 + commander: 14.0.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + dedent: 1.7.1 + deepmerge: 4.3.1 + diff: 8.0.3 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.3 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3) + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.5.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@types/node' + - babel-plugin-macros + - supports-color + - typescript + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@19.2.3(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.3(react@19.2.4) + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + split2@4.2.0: {} + + sqlstring@2.3.3: {} + + stable-hash@0.0.5: {} + + stackback@0.0.2: {} + + stackblur-canvas@2.7.0: + optional: true + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + stdin-discarder@0.2.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-pathdata@6.0.3: + optional: true + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.0: {} + + tapable@2.3.0: {} + + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.4.4: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + until-async@3.0.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + util-deprecate@1.0.2: {} + + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.33 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + vitest@4.0.18(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3)): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.33 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + + web-streams-polyfill@3.3.3: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@3.25.76: {} + + zod@4.3.6: {} + + zxcvbn-typescript@5.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..581a9d5b5 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 000000000..c1e7e5613 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import { defineConfig } from "prisma/config"; + +dotenv.config({ path: path.join(__dirname, ".env") }); +dotenv.config({ path: path.join(__dirname, ".env.local") }); + +const inContainer = fs.existsSync("/.dockerenv"); +const defaultDatabaseUrl = inContainer + ? "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public" + : "postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"; + +function resolveDatabaseUrl(): string { + const configured = process.env.DATABASE_URL?.trim() || defaultDatabaseUrl; + + // Host-local Prisma CLI calls cannot resolve Docker-internal hostname "db". + // Keep container behavior unchanged (Coolify / Docker runtime). + if (inContainer) return configured; + + try { + const parsed = new URL(configured); + if (parsed.hostname === "db") { + parsed.hostname = "localhost"; + return parsed.toString(); + } + return configured; + } catch { + return configured; + } +} + +const databaseUrl = resolveDatabaseUrl(); + +export default defineConfig({ + schema: path.join(__dirname, "prisma", "schema.prisma"), + datasource: { + url: databaseUrl, + }, +}); diff --git a/prisma/migrations/0001_init/migration.sql b/prisma/migrations/0001_init/migration.sql new file mode 100644 index 000000000..550385e17 --- /dev/null +++ b/prisma/migrations/0001_init/migration.sql @@ -0,0 +1,252 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "measurement_type" AS ENUM ('WEIGHT', 'BLOOD_PRESSURE_SYS', 'BLOOD_PRESSURE_DIA', 'PULSE', 'BODY_FAT', 'SLEEP_DURATION', 'ACTIVITY_STEPS'); + +-- CreateEnum +CREATE TYPE "measurement_source" AS ENUM ('MANUAL', 'WITHINGS', 'IMPORT'); + +-- CreateEnum +CREATE TYPE "intake_source" AS ENUM ('WEB', 'API', 'REMINDER'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT, + "password_hash" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "height_cm" DOUBLE PRECISION, + "timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin', + "bp_sys_target_low" INTEGER, + "bp_sys_target_high" INTEGER, + "bp_dia_target_low" INTEGER, + "bp_dia_target_high" INTEGER, + "openai_key_encrypted" TEXT, + "insights_privacy_mode" TEXT NOT NULL DEFAULT 'aggregated', + "insights_cached_at" TIMESTAMP(3), + "insights_cached_text" TEXT, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "passkeys" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL DEFAULT 'Passkey', + "credential_id" TEXT NOT NULL, + "credential_public_key" BYTEA NOT NULL, + "counter" BIGINT NOT NULL DEFAULT 0, + "credential_device_type" TEXT NOT NULL, + "credential_backed_up" BOOLEAN NOT NULL DEFAULT false, + "transports" TEXT[], + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "passkeys_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ip_address" TEXT, + "user_agent" TEXT, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "auth_challenges" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "challenge" TEXT NOT NULL, + "type" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "auth_challenges_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "measurements" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" "measurement_type" NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "unit" TEXT NOT NULL, + "source" "measurement_source" NOT NULL DEFAULT 'MANUAL', + "measured_at" TIMESTAMP(3) NOT NULL, + "notes" TEXT, + "external_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "measurements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medications" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dose" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "medications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medication_schedules" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "windowStart" TEXT NOT NULL, + "windowEnd" TEXT NOT NULL, + "label" TEXT, + + CONSTRAINT "medication_schedules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medication_intake_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "scheduled_for" TIMESTAMP(3) NOT NULL, + "taken_at" TIMESTAMP(3), + "skipped" BOOLEAN NOT NULL DEFAULT false, + "source" "intake_source" NOT NULL DEFAULT 'WEB', + "idempotency_key" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_intake_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "api_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "permissions" TEXT[] DEFAULT ARRAY['medication:ingest']::TEXT[], + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "withings_connections" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "withings_user_id" TEXT NOT NULL, + "access_token" TEXT NOT NULL, + "refresh_token" TEXT NOT NULL, + "token_expires_at" TIMESTAMP(3) NOT NULL, + "last_synced_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "withings_connections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "action" TEXT NOT NULL, + "details" TEXT, + "ip_address" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "passkeys_credential_id_key" ON "passkeys"("credential_id"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "auth_challenges_user_id_idx" ON "auth_challenges"("user_id"); + +-- CreateIndex +CREATE INDEX "measurements_user_id_type_measured_at_idx" ON "measurements"("user_id", "type", "measured_at"); + +-- CreateIndex +CREATE INDEX "measurements_external_id_idx" ON "measurements"("external_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "measurements_user_id_type_measured_at_source_key" ON "measurements"("user_id", "type", "measured_at", "source"); + +-- CreateIndex +CREATE INDEX "medications_user_id_idx" ON "medications"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "medication_intake_events_idempotency_key_key" ON "medication_intake_events"("idempotency_key"); + +-- CreateIndex +CREATE INDEX "medication_intake_events_user_id_medication_id_scheduled_fo_idx" ON "medication_intake_events"("user_id", "medication_id", "scheduled_for"); + +-- CreateIndex +CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "withings_connections_user_id_key" ON "withings_connections"("user_id"); + +-- CreateIndex +CREATE INDEX "audit_logs_user_id_created_at_idx" ON "audit_logs"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "audit_logs_action_created_at_idx" ON "audit_logs"("action", "created_at"); + +-- AddForeignKey +ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "auth_challenges" ADD CONSTRAINT "auth_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "measurements" ADD CONSTRAINT "measurements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medications" ADD CONSTRAINT "medications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_schedules" ADD CONSTRAINT "medication_schedules_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_intake_events" ADD CONSTRAINT "medication_intake_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_intake_events" ADD CONSTRAINT "medication_intake_events_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "withings_connections" ADD CONSTRAINT "withings_connections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/0002_roles_and_settings/migration.sql b/prisma/migrations/0002_roles_and_settings/migration.sql new file mode 100644 index 000000000..65c9930e2 --- /dev/null +++ b/prisma/migrations/0002_roles_and_settings/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable: Add role and date_of_birth to users +ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'USER'; +ALTER TABLE "users" ADD COLUMN "date_of_birth" TIMESTAMP(3); + +-- AlterTable: Remove BP target columns from users +ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_sys_target_low"; +ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_sys_target_high"; +ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_dia_target_low"; +ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_dia_target_high"; + +-- CreateTable: app_settings (singleton) +CREATE TABLE "app_settings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "registration_enabled" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "app_settings_pkey" PRIMARY KEY ("id") +); + +-- Insert default settings row +INSERT INTO "app_settings" ("id", "registration_enabled") VALUES ('singleton', true) +ON CONFLICT ("id") DO NOTHING; + +-- Set existing users as ADMIN (first user gets admin role) +UPDATE "users" SET "role" = 'ADMIN' +WHERE "id" = (SELECT "id" FROM "users" ORDER BY "created_at" ASC LIMIT 1); diff --git a/prisma/migrations/0003_add_missing_columns/migration.sql b/prisma/migrations/0003_add_missing_columns/migration.sql new file mode 100644 index 000000000..6854f32f2 --- /dev/null +++ b/prisma/migrations/0003_add_missing_columns/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable: Add missing columns to users +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "gender" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "telegram_bot_token" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "telegram_chat_id" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "telegram_enabled" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "withings_client_id_encrypted" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "withings_client_secret_encrypted" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "onboarding_completed_at" TIMESTAMP(3); + +-- AlterTable: Add missing dose column to medication_schedules +ALTER TABLE "medication_schedules" ADD COLUMN IF NOT EXISTS "dose" TEXT; + +-- AlterEnum: Add IMPORT to intake_source +ALTER TYPE "intake_source" ADD VALUE IF NOT EXISTS 'IMPORT'; diff --git a/prisma/migrations/0004_add_audit_log_location/migration.sql b/prisma/migrations/0004_add_audit_log_location/migration.sql new file mode 100644 index 000000000..95dd412fb --- /dev/null +++ b/prisma/migrations/0004_add_audit_log_location/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "audit_logs" ADD COLUMN "location" TEXT; + +-- DropTable (orphaned, not in schema) +DROP TABLE IF EXISTS "medication_categories"; diff --git a/prisma/migrations/0005_notification_system/migration.sql b/prisma/migrations/0005_notification_system/migration.sql new file mode 100644 index 000000000..04ffb8a0d --- /dev/null +++ b/prisma/migrations/0005_notification_system/migration.sql @@ -0,0 +1,55 @@ +-- CreateTable +CREATE TABLE "notification_channels" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "config" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notification_channels_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification_preferences" ( + "id" TEXT NOT NULL, + "channel_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "push_subscriptions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "p256dh" TEXT NOT NULL, + "auth" TEXT NOT NULL, + "user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notification_channels_user_id_idx" ON "notification_channels"("user_id"); +CREATE UNIQUE INDEX "notification_channels_user_id_type_key" ON "notification_channels"("user_id", "type"); + +-- CreateIndex +CREATE UNIQUE INDEX "notification_preferences_channel_id_event_type_key" ON "notification_preferences"("channel_id", "event_type"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_user_id_idx" ON "push_subscriptions"("user_id"); +CREATE UNIQUE INDEX "push_subscriptions_user_id_endpoint_key" ON "push_subscriptions"("user_id", "endpoint"); + +-- AddForeignKey +ALTER TABLE "notification_channels" ADD CONSTRAINT "notification_channels_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "notification_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0006_medication_notifications_enabled/migration.sql b/prisma/migrations/0006_medication_notifications_enabled/migration.sql new file mode 100644 index 000000000..76231b600 --- /dev/null +++ b/prisma/migrations/0006_medication_notifications_enabled/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable: per-medication notification toggle +ALTER TABLE "medications" +ADD COLUMN IF NOT EXISTS "notifications_enabled" BOOLEAN NOT NULL DEFAULT true; + diff --git a/prisma/migrations/0007_medication_paused_at/migration.sql b/prisma/migrations/0007_medication_paused_at/migration.sql new file mode 100644 index 000000000..3f32f1f73 --- /dev/null +++ b/prisma/migrations/0007_medication_paused_at/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable: track when medication was paused +ALTER TABLE "medications" +ADD COLUMN IF NOT EXISTS "paused_at" TIMESTAMP(3); + diff --git a/prisma/migrations/0008_app_settings_bugreport_github/migration.sql b/prisma/migrations/0008_app_settings_bugreport_github/migration.sql new file mode 100644 index 000000000..f897f78a0 --- /dev/null +++ b/prisma/migrations/0008_app_settings_bugreport_github/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable: add bug report GitHub configuration +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "github_issue_token_encrypted" TEXT, +ADD COLUMN IF NOT EXISTS "github_issue_repo" TEXT; diff --git a/prisma/migrations/0009_app_settings_api_global/migration.sql b/prisma/migrations/0009_app_settings_api_global/migration.sql new file mode 100644 index 000000000..a6113aff1 --- /dev/null +++ b/prisma/migrations/0009_app_settings_api_global/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "api_global" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/migrations/0010_app_settings_monitoring/migration.sql b/prisma/migrations/0010_app_settings_monitoring/migration.sql new file mode 100644 index 000000000..84a139f85 --- /dev/null +++ b/prisma/migrations/0010_app_settings_monitoring/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "umami_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN IF NOT EXISTS "umami_script_url" TEXT, +ADD COLUMN IF NOT EXISTS "umami_website_id" TEXT, +ADD COLUMN IF NOT EXISTS "glitchtip_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN IF NOT EXISTS "glitchtip_dsn" TEXT, +ADD COLUMN IF NOT EXISTS "glitchtip_environment" TEXT; diff --git a/prisma/migrations/0011_app_settings_web_push_vapid/migration.sql b/prisma/migrations/0011_app_settings_web_push_vapid/migration.sql new file mode 100644 index 000000000..f1e737f49 --- /dev/null +++ b/prisma/migrations/0011_app_settings_web_push_vapid/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable: add Web Push VAPID settings to app_settings +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "web_push_vapid_public_key" TEXT; + +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "web_push_vapid_private_key_encrypted" TEXT; + +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "web_push_vapid_subject" TEXT; diff --git a/prisma/migrations/0012_app_settings_reminder_thresholds/migration.sql b/prisma/migrations/0012_app_settings_reminder_thresholds/migration.sql new file mode 100644 index 000000000..89d3f64b7 --- /dev/null +++ b/prisma/migrations/0012_app_settings_reminder_thresholds/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable: add medication reminder thresholds to app_settings +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "reminder_late_minutes" INTEGER NOT NULL DEFAULT 120; + +ALTER TABLE "app_settings" +ADD COLUMN IF NOT EXISTS "reminder_missed_minutes" INTEGER NOT NULL DEFAULT 240; diff --git a/prisma/migrations/0013_medication_snoozed_until/migration.sql b/prisma/migrations/0013_medication_snoozed_until/migration.sql new file mode 100644 index 000000000..ba9a24480 --- /dev/null +++ b/prisma/migrations/0013_medication_snoozed_until/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "medications" ADD COLUMN "snoozed_until" TIMESTAMP(3); diff --git a/prisma/migrations/0014_add_moodlog_integration/migration.sql b/prisma/migrations/0014_add_moodlog_integration/migration.sql new file mode 100644 index 000000000..b8a2b635e --- /dev/null +++ b/prisma/migrations/0014_add_moodlog_integration/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "mood_entries" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "mood" TEXT NOT NULL, + "score" INTEGER NOT NULL, + "tags" TEXT, + "source" TEXT NOT NULL DEFAULT 'MOODLOG', + "mood_logged_at" TIMESTAMP(3) NOT NULL, + "synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "mood_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "mood_entries_user_id_date_idx" ON "mood_entries"("user_id", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "mood_entries_user_id_date_mood_logged_at_key" ON "mood_entries"("user_id", "date", "mood_logged_at"); + +-- AddForeignKey +ALTER TABLE "mood_entries" ADD CONSTRAINT "mood_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable: Add moodLog fields to users +ALTER TABLE "users" ADD COLUMN "mood_log_url_encrypted" TEXT; +ALTER TABLE "users" ADD COLUMN "mood_log_api_key_encrypted" TEXT; +ALTER TABLE "users" ADD COLUMN "mood_log_enabled" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "users" ADD COLUMN "mood_log_last_synced_at" TIMESTAMP(3); +ALTER TABLE "users" ADD COLUMN "mood_log_webhook_secret" TEXT; + +-- AlterTable: Add moodLogGlobal to app_settings +ALTER TABLE "app_settings" ADD COLUMN "mood_log_global" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/migrations/0015_add_reminder_phase_tracking/migration.sql b/prisma/migrations/0015_add_reminder_phase_tracking/migration.sql new file mode 100644 index 000000000..44158f4c9 --- /dev/null +++ b/prisma/migrations/0015_add_reminder_phase_tracking/migration.sql @@ -0,0 +1,53 @@ +-- CreateEnum +CREATE TYPE "reminder_phase" AS ENUM ('GREEN', 'YELLOW', 'ORANGE', 'RED'); + +-- CreateEnum +CREATE TYPE "phase_mode" AS ENUM ('MINUTES', 'PERCENT'); + +-- CreateTable +CREATE TABLE "reminder_phase_configs" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "green_value" INTEGER NOT NULL DEFAULT 60, + "green_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES', + "yellow_value" INTEGER NOT NULL DEFAULT 30, + "yellow_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES', + "orange_value" INTEGER NOT NULL DEFAULT 0, + "orange_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES', + "red_value" INTEGER NOT NULL DEFAULT 240, + "red_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES', + + CONSTRAINT "reminder_phase_configs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "telegram_reminder_messages" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "schedule_id" TEXT NOT NULL, + "chat_id" TEXT NOT NULL, + "message_id" INTEGER NOT NULL, + "phase" "reminder_phase" NOT NULL, + "sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "date" TEXT NOT NULL, + + CONSTRAINT "telegram_reminder_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "reminder_phase_configs_medication_id_key" ON "reminder_phase_configs"("medication_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_reminder_messages_medication_id_schedule_id_date_phase_key" ON "telegram_reminder_messages"("medication_id", "schedule_id", "date", "phase"); + +-- CreateIndex +CREATE INDEX "telegram_reminder_messages_medication_id_date_idx" ON "telegram_reminder_messages"("medication_id", "date"); + +-- AddForeignKey +ALTER TABLE "reminder_phase_configs" ADD CONSTRAINT "reminder_phase_configs_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "telegram_reminder_messages" ADD CONSTRAINT "telegram_reminder_messages_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "telegram_reminder_messages" ADD CONSTRAINT "telegram_reminder_messages_schedule_id_fkey" FOREIGN KEY ("schedule_id") REFERENCES "medication_schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0016_telegram_scheduled_deletions/migration.sql b/prisma/migrations/0016_telegram_scheduled_deletions/migration.sql new file mode 100644 index 000000000..29aa0103c --- /dev/null +++ b/prisma/migrations/0016_telegram_scheduled_deletions/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "telegram_scheduled_deletions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "chat_id" TEXT NOT NULL, + "message_id" INTEGER NOT NULL, + "delete_after" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "telegram_scheduled_deletions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "telegram_scheduled_deletions_delete_after_idx" ON "telegram_scheduled_deletions"("delete_after"); diff --git a/prisma/migrations/0017_add_data_backups/migration.sql b/prisma/migrations/0017_add_data_backups/migration.sql new file mode 100644 index 000000000..08e5f1e6f --- /dev/null +++ b/prisma/migrations/0017_add_data_backups/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "data_backups" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT 'WEEKLY_AUTO', + "data" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "data_backups_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "data_backups_user_id_idx" ON "data_backups"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "data_backups_user_id_type_key" ON "data_backups"("user_id", "type"); + +-- AddForeignKey +ALTER TABLE "data_backups" ADD CONSTRAINT "data_backups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0018_add_user_achievements/migration.sql b/prisma/migrations/0018_add_user_achievements/migration.sql new file mode 100644 index 000000000..23b1f27a5 --- /dev/null +++ b/prisma/migrations/0018_add_user_achievements/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "user_achievements" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "achievement_id" TEXT NOT NULL, + "unlocked_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_achievements_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_achievements_user_id_idx" ON "user_achievements"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_achievements_user_id_achievement_id_key" ON "user_achievements"("user_id", "achievement_id"); + +-- AddForeignKey +ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0019_add_rate_limits/migration.sql b/prisma/migrations/0019_add_rate_limits/migration.sql new file mode 100644 index 000000000..313f19b5e --- /dev/null +++ b/prisma/migrations/0019_add_rate_limits/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "rate_limits" ( + "key" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 1, + "reset_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE INDEX "rate_limits_reset_at_idx" ON "rate_limits"("reset_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 000000000..bef2fa7d9 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,486 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +// ─── User & Auth ─────────────────────────────────────────── + +model User { + id String @id @default(cuid()) + username String @unique + email String? @unique + passwordHash String? @map("password_hash") // argon2id, optional fallback + role String @default("USER") // "ADMIN" | "USER" + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Profile + heightCm Float? @map("height_cm") + dateOfBirth DateTime? @map("date_of_birth") + gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe + timezone String @default("Europe/Berlin") + + // OpenAI Insights (optional) + openaiKeyEncrypted String? @map("openai_key_encrypted") // AES-256-GCM encrypted + insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" + insightsCachedAt DateTime? @map("insights_cached_at") + insightsCachedText String? @map("insights_cached_text") // cached JSON output + + // Telegram notifications (per-user) + telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted + telegramChatId String? @map("telegram_chat_id") + telegramEnabled Boolean @default(false) @map("telegram_enabled") + + // Withings OAuth credentials (per-user, encrypted) + withingsClientIdEncrypted String? @map("withings_client_id_encrypted") + withingsClientSecretEncrypted String? @map("withings_client_secret_encrypted") + + // moodLog integration (per-user, encrypted) + moodLogUrlEncrypted String? @map("mood_log_url_encrypted") + moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted") + moodLogEnabled Boolean @default(false) @map("mood_log_enabled") + moodLogLastSyncedAt DateTime? @map("mood_log_last_synced_at") + moodLogWebhookSecret String? @map("mood_log_webhook_secret") + + // Locale (null = use system/admin default) + locale String? @map("locale") // "de" | "en" | null + + // Onboarding + onboardingCompletedAt DateTime? @map("onboarding_completed_at") + + // Relations + passkeys Passkey[] + sessions Session[] + challenges AuthChallenge[] + measurements Measurement[] + medications Medication[] + intakeEvents MedicationIntakeEvent[] + apiTokens ApiToken[] + auditLogs AuditLog[] + withingsConnection WithingsConnection? + moodEntries MoodEntry[] + notificationChannels NotificationChannel[] + pushSubscriptions PushSubscription[] + dataBackups DataBackup[] + achievements UserAchievement[] + + @@map("users") +} + +model Passkey { + id String @id @default(cuid()) + userId String @map("user_id") + name String @default("Passkey") + credentialId String @unique @map("credential_id") + credentialPublicKey Bytes @map("credential_public_key") + counter BigInt @default(0) + credentialDeviceType String @map("credential_device_type") + credentialBackedUp Boolean @default(false) @map("credential_backed_up") + transports String[] // e.g. ["usb", "ble", "nfc", "internal"] + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("passkeys") +} + +model Session { + id String @id @default(cuid()) + userId String @map("user_id") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("sessions") +} + +model AuthChallenge { + id String @id @default(cuid()) + userId String? @map("user_id") // null for registration + challenge String + type String // "registration" | "authentication" + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("auth_challenges") +} + +// ─── Measurements ────────────────────────────────────────── + +enum MeasurementType { + WEIGHT + BLOOD_PRESSURE_SYS + BLOOD_PRESSURE_DIA + PULSE + BODY_FAT + SLEEP_DURATION + ACTIVITY_STEPS + + @@map("measurement_type") +} + +enum MeasurementSource { + MANUAL + WITHINGS + IMPORT + + @@map("measurement_source") +} + +model Measurement { + id String @id @default(cuid()) + userId String @map("user_id") + type MeasurementType + value Float + unit String // kg, mmHg, bpm, %, hours, steps + source MeasurementSource @default(MANUAL) + measuredAt DateTime @map("measured_at") + notes String? // encrypted at app level if sensitive + externalId String? @map("external_id") // Withings grpid for dedup + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, type, measuredAt, source]) + @@index([userId, type, measuredAt]) + @@index([externalId]) + @@map("measurements") +} + +// ─── Medications ─────────────────────────────────────────── + +model Medication { + id String @id @default(cuid()) + userId String @map("user_id") + name String + dose String // e.g. "10mg" + active Boolean @default(true) + notificationsEnabled Boolean @default(true) @map("notifications_enabled") + pausedAt DateTime? @map("paused_at") + snoozedUntil DateTime? @map("snoozed_until") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + schedules MedicationSchedule[] + intakeEvents MedicationIntakeEvent[] + phaseConfig ReminderPhaseConfig? + telegramMessages TelegramReminderMessage[] + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("medications") +} + +model MedicationSchedule { + id String @id @default(cuid()) + medicationId String @map("medication_id") + windowStart String // HH:mm format, e.g. "08:00" + windowEnd String // HH:mm format, e.g. "10:00" + label String? // e.g. "Morning", "Evening" + dose String? // Per-schedule dose override; null = use Medication.dose + daysOfWeek String? @map("days_of_week") // Comma-separated day numbers (0=Sun..6=Sat), null = daily + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + telegramMessages TelegramReminderMessage[] + + @@map("medication_schedules") +} + +enum IntakeSource { + WEB + API + REMINDER + IMPORT + + @@map("intake_source") +} + +enum ReminderPhase { + GREEN + YELLOW + ORANGE + RED + + @@map("reminder_phase") +} + +enum PhaseMode { + MINUTES + PERCENT + + @@map("phase_mode") +} + +model MedicationIntakeEvent { + id String @id @default(cuid()) + userId String @map("user_id") + medicationId String @map("medication_id") + scheduledFor DateTime @map("scheduled_for") + takenAt DateTime? @map("taken_at") + skipped Boolean @default(false) + source IntakeSource @default(WEB) + idempotencyKey String? @unique @map("idempotency_key") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + + @@index([userId, medicationId, scheduledFor]) + @@map("medication_intake_events") +} + +model ReminderPhaseConfig { + id String @id @default(cuid()) + medicationId String @unique @map("medication_id") + greenValue Int @default(60) @map("green_value") + greenMode PhaseMode @default(MINUTES) @map("green_mode") + yellowValue Int @default(30) @map("yellow_value") + yellowMode PhaseMode @default(MINUTES) @map("yellow_mode") + orangeValue Int @default(0) @map("orange_value") + orangeMode PhaseMode @default(MINUTES) @map("orange_mode") + redValue Int @default(240) @map("red_value") + redMode PhaseMode @default(MINUTES) @map("red_mode") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + + @@map("reminder_phase_configs") +} + +model TelegramReminderMessage { + id String @id @default(cuid()) + medicationId String @map("medication_id") + scheduleId String @map("schedule_id") + chatId String @map("chat_id") + messageId Int @map("message_id") + phase ReminderPhase + sentAt DateTime @default(now()) @map("sent_at") + date String // YYYY-MM-DD for day grouping + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + + @@unique([medicationId, scheduleId, date, phase]) + @@index([medicationId, date]) + @@map("telegram_reminder_messages") +} + +model TelegramScheduledDeletion { + id String @id @default(cuid()) + userId String @map("user_id") + chatId String @map("chat_id") + messageId Int @map("message_id") + deleteAfter DateTime @map("delete_after") + + @@index([deleteAfter]) + @@map("telegram_scheduled_deletions") +} + +// ─── API Tokens (for external ingest) ────────────────────── + +model ApiToken { + id String @id @default(cuid()) + userId String @map("user_id") + name String + tokenHash String @unique @map("token_hash") // SHA-256 of the token + permissions String[] @default(["medication:ingest"]) + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revoked Boolean @default(false) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("api_tokens") +} + +// ─── Withings ────────────────────────────────────────────── + +model WithingsConnection { + id String @id @default(cuid()) + userId String @unique @map("user_id") + withingsUserId String @map("withings_user_id") + accessToken String @map("access_token") // encrypted at app level + refreshToken String @map("refresh_token") // encrypted at app level + tokenExpiresAt DateTime @map("token_expires_at") + lastSyncedAt DateTime? @map("last_synced_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("withings_connections") +} + +// ─── Mood Entries (moodLog) ─────────────────────────────────── + +model MoodEntry { + id String @id @default(cuid()) + userId String @map("user_id") + date String // YYYY-MM-DD + mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG + score Int // 1-5 + tags String? // JSON array of tag keys + source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO + moodLoggedAt DateTime @map("mood_logged_at") + syncedAt DateTime @default(now()) @map("synced_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, date, moodLoggedAt]) + @@index([userId, date]) + @@map("mood_entries") +} + +// ─── App Settings ───────────────────────────────────────── + +model AppSettings { + id String @id @default("singleton") + registrationEnabled Boolean @default(true) @map("registration_enabled") + defaultLocale String @default("de") @map("default_locale") // "de" | "en" + telegramGlobal Boolean @default(true) @map("telegram_global") // global toggle + ntfyGlobal Boolean @default(true) @map("ntfy_global") // global toggle + webPushGlobal Boolean @default(true) @map("web_push_global") // global toggle + webPushVapidPublicKey String? @map("web_push_vapid_public_key") + webPushVapidPrivateKeyEncrypted String? @map("web_push_vapid_private_key_encrypted") + webPushVapidSubject String? @map("web_push_vapid_subject") + apiGlobal Boolean @default(true) @map("api_global") // global toggle + moodLogGlobal Boolean @default(true) @map("mood_log_global") + umamiEnabled Boolean @default(false) @map("umami_enabled") + umamiScriptUrl String? @map("umami_script_url") + umamiWebsiteId String? @map("umami_website_id") + glitchtipEnabled Boolean @default(false) @map("glitchtip_enabled") + glitchtipDsn String? @map("glitchtip_dsn") + glitchtipEnvironment String? @map("glitchtip_environment") + githubIssueTokenEncrypted String? @map("github_issue_token_encrypted") + githubIssueRepo String? @map("github_issue_repo") + + // Medication reminder thresholds (minutes after windowEnd) + reminderLateMinutes Int @default(120) @map("reminder_late_minutes") + reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes") + + @@map("app_settings") +} + +// ─── Audit Log ───────────────────────────────────────────── + +model AuditLog { + id String @id @default(cuid()) + userId String? @map("user_id") + action String // e.g. "auth.login", "measurement.create", "export.csv" + details String? // JSON string, no sensitive data + ipAddress String? @map("ip_address") + location String? // e.g. "Berlin, DE" — resolved from IP at log time + createdAt DateTime @default(now()) @map("created_at") + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([userId, createdAt]) + @@index([action, createdAt]) + @@map("audit_logs") +} + +// ─── Notification System ────────────────────────────────────── + +model NotificationChannel { + id String @id @default(cuid()) + userId String @map("user_id") + type String // "TELEGRAM" | "NTFY" | "WEB_PUSH" + enabled Boolean @default(true) + config String // AES-256-GCM encrypted JSON (channel-specific config) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + preferences NotificationPreference[] + + @@unique([userId, type]) + @@index([userId]) + @@map("notification_channels") +} + +model NotificationPreference { + id String @id @default(cuid()) + channelId String @map("channel_id") + eventType String @map("event_type") // "MEDICATION_REMINDER" | "MEASUREMENT_ANOMALY" | etc. + enabled Boolean @default(true) + + channel NotificationChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + + @@unique([channelId, eventType]) + @@map("notification_preferences") +} + +model PushSubscription { + id String @id @default(cuid()) + userId String @map("user_id") + endpoint String + p256dh String // encrypted + auth String // encrypted + userAgent String? @map("user_agent") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, endpoint]) + @@index([userId]) + @@map("push_subscriptions") +} + +// ─── Data Backups ──────────────────────────────────────────── + +model DataBackup { + id String @id @default(cuid()) + userId String @map("user_id") + type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL + data String // JSON export blob + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, type], map: "data_backups_user_id_type_key") + @@index([userId]) + @@map("data_backups") +} + +// ─── Rate Limiting ────────────────────────────────────────── + +model RateLimit { + key String @id + count Int @default(1) + resetAt DateTime @map("reset_at") + + @@index([resetAt]) + @@map("rate_limits") +} + +// ─── User Achievements ─────────────────────────────────────── + +model UserAchievement { + id String @id @default(cuid()) + userId String @map("user_id") + achievementId String @map("achievement_id") + unlockedAt DateTime @map("unlocked_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, achievementId]) + @@index([userId]) + @@map("user_achievements") +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 000000000..8b52d9462 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/logo-192.png b/public/logo-192.png new file mode 100644 index 0000000000000000000000000000000000000000..80356a3ad885b959d2f81a3315d3379165d32986 GIT binary patch literal 8105 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 P-T@^!b=fLuvw;5x|7S>G literal 0 HcmV?d00001 diff --git a/public/logo-512.png b/public/logo-512.png new file mode 100644 index 0000000000000000000000000000000000000000..665eab40557e35f400eb3e9b80dd5703544d18ff GIT binary patch literal 23332 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 + + + + + + + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..47232e84e --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "HealthLog", + "short_name": "HealthLog", + "description": "Personal health tracking — weight, blood pressure, medications", + "start_url": "/", + "display": "standalone", + "theme_color": "#282a36", + "background_color": "#282a36", + "icons": [ + { + "src": "/logo-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/logo-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..102cb651a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,175 @@ +/** + * Service Worker for HealthLog PWA. + * Handles Web Push notifications and offline caching. + */ + +const CACHE_VERSION = "v1"; +const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; +const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; +const MAX_STATIC_ENTRIES = 150; +const MAX_PAGE_ENTRIES = 30; + +// App shell files to precache on install +const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"]; + +// ── Install: precache app shell ────────────────────────────────────────────── +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(STATIC_CACHE) + .then((cache) => cache.addAll(PRECACHE_URLS)) + .then(() => self.skipWaiting()), + ); +}); + +// ── Activate: clean up old caches ──────────────────────────────────────────── +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) + .map((k) => caches.delete(k)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +// ── Fetch: strategy per resource type ──────────────────────────────────────── +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + // Only handle same-origin GET requests + if (request.method !== "GET" || url.origin !== self.location.origin) return; + + // API calls — network only (never serve stale data) + if (url.pathname.startsWith("/api/")) return; + + // Next.js static assets — cache-first (immutable hashed filenames) + if (url.pathname.startsWith("/_next/static/")) { + event.respondWith(cacheFirst(request, STATIC_CACHE)); + return; + } + + // Static files (fonts, icons, images) — cache-first with long TTL + if ( + /\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname) + ) { + event.respondWith(cacheFirst(request, STATIC_CACHE)); + return; + } + + // HTML pages — network-first with cache fallback + if (request.headers.get("accept")?.includes("text/html")) { + event.respondWith(networkFirst(request, PAGE_CACHE)); + return; + } +}); + +/** + * Cache-first: serve from cache, fall back to network and cache the response. + */ +async function cacheFirst(request, cacheName) { + const cached = await caches.match(request); + if (cached) return cached; + + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + trimCache(cacheName, MAX_STATIC_ENTRIES); + } + return response; +} + +/** + * Network-first: try network, fall back to cache. + */ +async function networkFirst(request, cacheName) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + trimCache(cacheName, MAX_PAGE_ENTRIES); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + + // Offline fallback + return new Response( + 'HealthLog — Offline

Offline

Keine Internetverbindung. Bitte versuche es später erneut.

', + { + status: 503, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }, + ); + } +} + +/** + * Trim cache to maxEntries by removing oldest entries. + */ +async function trimCache(cacheName, maxEntries) { + const cache = await caches.open(cacheName); + let keys = await cache.keys(); + while (keys.length > maxEntries) { + await cache.delete(keys[0]); + keys = await cache.keys(); + } +} + +// ── Push Notifications ─────────────────────────────────────────────────────── +self.addEventListener("push", (event) => { + if (!event.data) return; + + let payload; + try { + payload = event.data.json(); + } catch { + payload = { title: "HealthLog", body: event.data.text() }; + } + + const { + title = "HealthLog", + body = "", + tag = "default", + url = "/", + } = payload; + + event.waitUntil( + self.registration.showNotification(title, { + body, + tag, + icon: "/logo-192.png", + badge: "/logo-192.png", + data: { url }, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + const url = event.notification.data?.url || "/"; + + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if (client.url.includes(self.location.origin) && "focus" in client) { + client.navigate(url); + return client.focus(); + } + } + return self.clients.openWindow(url); + }), + ); +}); diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts new file mode 100644 index 000000000..ba96346f5 --- /dev/null +++ b/scripts/seed-demo.ts @@ -0,0 +1,375 @@ +/** + * Demo Data Seed Script for HealthLog + * + * Creates realistic demo data: + * - 1 admin user (demo/demo123demo123) + * - 90 days of measurements (weight, BP, pulse, body fat, sleep, steps) + * - 3 medications with schedules and intake history + * - 90 days of mood entries + * - App settings (registration disabled, English locale) + * + * Usage: npx tsx scripts/seed-demo.ts + * Requires DATABASE_URL env var. + */ + +import pg from "pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error("DATABASE_URL is required"); + process.exit(1); +} + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +// ── Helpers ────────────────────────────────────── + +function cuid(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = "c"; + for (let i = 0; i < 24; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +function daysAgo(n: number): Date { + const d = new Date(); + d.setDate(d.getDate() - n); + d.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60), 0, 0); + return d; +} + +function formatDate(d: Date): string { + return d.toISOString().split("T")[0]; +} + +// Smooth random walk with mean reversion +function randomWalk(start: number, target: number, days: number, volatility: number): number[] { + const values: number[] = [start]; + for (let i = 1; i < days; i++) { + const prev = values[i - 1]; + const drift = (target - prev) * 0.03; // mean reversion + const noise = (Math.random() - 0.5) * volatility; + values.push(Math.round((prev + drift + noise) * 10) / 10); + } + return values; +} + +// ── Main ───────────────────────────────────────── + +async function seed() { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + console.log("Cleaning existing data..."); + await client.query("DELETE FROM mood_entries"); + await client.query("DELETE FROM medication_intake_events"); + await client.query("DELETE FROM medication_schedules"); + await client.query("DELETE FROM reminder_phase_configs"); + await client.query("DELETE FROM medications"); + await client.query("DELETE FROM measurements"); + await client.query("DELETE FROM user_achievements"); + await client.query("DELETE FROM audit_logs"); + await client.query("DELETE FROM sessions"); + await client.query("DELETE FROM passkeys"); + await client.query("DELETE FROM auth_challenges"); + await client.query("DELETE FROM notification_channels"); + await client.query("DELETE FROM push_subscriptions"); + await client.query("DELETE FROM api_tokens"); + await client.query("DELETE FROM data_backups"); + await client.query("DELETE FROM users"); + + // ── User ────────────────────────────────── + console.log("Creating demo user..."); + const userId = cuid(); + // argon2id hash of "demo123demo123" — pre-computed + // We'll use a bcrypt-compatible approach: register via API later + // For now, insert with a placeholder and we'll set it via the app + const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI"; + + await client.query( + `INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`, + [userId, "demo", "demo@healthlog.app", passwordHash, "ADMIN", 182.0, "1990-05-15", "MALE", "Europe/Berlin", "en"] + ); + + // ── Measurements (90 days) ──────────────── + console.log("Creating 90 days of measurements..."); + const days = 90; + + // Weight: 86.5 → trending down to ~82.5 + const weights = randomWalk(86.5, 82.0, days, 0.6); + // Systolic BP: 132 → improving to ~124 + const sysBP = randomWalk(132, 124, days, 4); + // Diastolic BP: 85 → improving to ~78 + const diaBP = randomWalk(85, 78, days, 3); + // Pulse: 72 → slight improvement to ~68 + const pulse = randomWalk(72, 68, days, 3); + // Body fat: 24.5% → trending to ~22% + const bodyFat = randomWalk(24.5, 22.0, days, 0.4); + // Sleep: 6.5h → improving to ~7.5h + const sleep = randomWalk(6.5, 7.5, days, 0.8); + // Steps: 5000 → improving to ~8000 + const steps = randomWalk(5000, 8500, days, 1500); + + for (let i = 0; i < days; i++) { + const date = daysAgo(days - i); + + // Weight (daily) + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'WEIGHT', $3, 'kg', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, weights[i], date] + ); + + // Blood pressure (daily) + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'BLOOD_PRESSURE_SYS', $3, 'mmHg', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, Math.round(sysBP[i]), date] + ); + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'BLOOD_PRESSURE_DIA', $3, 'mmHg', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, Math.round(diaBP[i]), date] + ); + + // Pulse (daily) + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, Math.round(pulse[i]), date] + ); + + // Body fat (every 2-3 days) + if (i % 2 === 0 || Math.random() > 0.5) { + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'BODY_FAT', $3, '%', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, bodyFat[i], date] + ); + } + + // Sleep (daily) + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, Math.round(sleep[i] * 10) / 10, date] + ); + + // Steps (daily) + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, Math.round(steps[i]), date] + ); + } + + // ── Medications ─────────────────────────── + console.log("Creating medications..."); + + // Medication 1: Ramipril (blood pressure) + const med1Id = cuid(); + await client.query( + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) + VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`, + [med1Id, userId, daysAgo(120)] + ); + const sched1Id = cuid(); + await client.query( + `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) + VALUES ($1, $2, '08:00', '10:00', 'Morning')`, + [sched1Id, med1Id] + ); + + // Medication 2: Vitamin D3 + const med2Id = cuid(); + await client.query( + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) + VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`, + [med2Id, userId, daysAgo(90)] + ); + const sched2Id = cuid(); + await client.query( + `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) + VALUES ($1, $2, '08:00', '10:00', 'Morning')`, + [sched2Id, med2Id] + ); + + // Medication 3: Magnesium (evening) + const med3Id = cuid(); + await client.query( + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) + VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`, + [med3Id, userId, daysAgo(60)] + ); + const sched3Id = cuid(); + await client.query( + `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) + VALUES ($1, $2, '20:00', '22:00', 'Evening')`, + [sched3Id, med3Id] + ); + + // ── Medication Intake Events ────────────── + console.log("Creating intake events (90 days)..."); + + for (let i = 0; i < days; i++) { + const date = daysAgo(days - i); + + // Ramipril: 96% compliance (miss ~1 every 25 days) + const takeMed1 = Math.random() > 0.04; + if (takeMed1) { + const takenTime = new Date(date); + takenTime.setHours(8, Math.floor(Math.random() * 45), 0); + await client.query( + `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) + VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`, + [cuid(), userId, med1Id, date, takenTime] + ); + } else { + await client.query( + `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) + VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`, + [cuid(), userId, med1Id, date] + ); + } + + // Vitamin D3: 92% compliance + const takeMed2 = Math.random() > 0.08; + if (takeMed2) { + const takenTime = new Date(date); + takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0); + await client.query( + `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) + VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`, + [cuid(), userId, med2Id, date, takenTime] + ); + } else { + await client.query( + `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) + VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`, + [cuid(), userId, med2Id, date] + ); + } + + // Magnesium: only last 60 days, 88% compliance + if (i >= 30) { + const takeMed3 = Math.random() > 0.12; + if (takeMed3) { + const takenTime = new Date(date); + takenTime.setHours(20, Math.floor(Math.random() * 60), 0); + await client.query( + `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) + VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`, + [cuid(), userId, med3Id, date, takenTime] + ); + } else { + await client.query( + `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) + VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`, + [cuid(), userId, med3Id, date] + ); + } + } + } + + // ── Mood Entries ───────────────────────── + console.log("Creating mood entries (90 days)..."); + + const moodOptions = ["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]; + const tagOptions = [ + ["productive", "exercise", "well-rested"], + ["focused", "social", "creative"], + ["tired", "stressed"], + ["anxious", "low-energy"], + ["exhausted", "pain"], + ]; + + // Mood trend: starts around 3, improves to ~4 over time + const moodTrend = randomWalk(3.0, 4.2, days, 0.8); + + for (let i = 0; i < days; i++) { + // Skip ~5% of days (forgot to log) + if (Math.random() < 0.05) continue; + + const date = daysAgo(days - i); + const rawScore = Math.round(Math.min(5, Math.max(1, moodTrend[i]))); + const mood = moodOptions[5 - rawScore]; // invert: score 5 = SUPER_GUT + const tags = tagOptions[5 - rawScore]; + const loggedAt = new Date(date); + loggedAt.setHours(21, Math.floor(Math.random() * 59), 0); + + await client.query( + `INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, 'WEB', $7, $7, $7, $7)`, + [cuid(), userId, formatDate(date), mood, rawScore, JSON.stringify(tags), loggedAt] + ); + } + + // ── Achievements ───────────────────────── + console.log("Creating achievements..."); + + const achievements = [ + { id: "first-measurement", daysAgo: 89 }, + { id: "first-medication", daysAgo: 89 }, + { id: "first-mood", daysAgo: 89 }, + { id: "week-streak-measurements", daysAgo: 82 }, + { id: "week-streak-mood", daysAgo: 82 }, + { id: "month-streak-measurements", daysAgo: 59 }, + { id: "compliance-90", daysAgo: 45 }, + { id: "weight-loss-5", daysAgo: 30 }, + { id: "bp-normal", daysAgo: 20 }, + { id: "steps-10k", daysAgo: 15 }, + { id: "month-streak-mood", daysAgo: 10 }, + { id: "three-month-streak", daysAgo: 2 }, + ]; + + for (const ach of achievements) { + const unlocked = daysAgo(ach.daysAgo); + await client.query( + `INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, created_at) + VALUES ($1, $2, $3, $4, $4)`, + [cuid(), userId, ach.id, unlocked] + ); + } + + // ── App Settings ───────────────────────── + console.log("Creating app settings..."); + await client.query( + `INSERT INTO app_settings (id, registration_enabled, default_locale) + VALUES ('singleton', false, 'en') + ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'` + ); + + // ── Audit Log (some login entries) ────── + console.log("Creating audit log entries..."); + for (let i = 0; i < 15; i++) { + const loginDate = daysAgo(Math.floor(Math.random() * 30)); + await client.query( + `INSERT INTO audit_logs (id, user_id, action, details, ip_address, created_at) + VALUES ($1, $2, 'auth.login', '{"method":"password"}', '203.0.113.42', $3)`, + [cuid(), userId, loginDate] + ); + } + + await client.query("COMMIT"); + console.log("\nDemo data seeded successfully!"); + console.log(` User: demo / demo123demo123`); + console.log(` Measurements: ${days * 6} entries (7 types, 90 days)`); + console.log(` Medications: 3 (with ${days * 2 + 60} intake events)`); + console.log(` Mood: ~${Math.round(days * 0.95)} entries`); + console.log(` Achievements: ${achievements.length}`); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Seed failed:", err); + throw err; + } finally { + client.release(); + await pool.end(); + } +} + +seed(); diff --git a/scripts/test-notifications.ts b/scripts/test-notifications.ts new file mode 100644 index 000000000..110ce3cef --- /dev/null +++ b/scripts/test-notifications.ts @@ -0,0 +1,209 @@ +/** + * Test script for notification delivery and reminder check. + * + * Usage (inside Docker container): + * npx tsx scripts/test-notifications.ts + * + * Or from host: + * docker compose exec app npx tsx scripts/test-notifications.ts + * + * What it does: + * 1. Lists all configured notification channels + * 2. Sends a test notification through each enabled channel + * 3. Runs the reminder check logic once with verbose logging + */ + +import { PrismaClient } from "@/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { decrypt } from "@/lib/crypto"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone"; + +const DATABASE_URL = process.env.DATABASE_URL!; + +function createPrisma() { + const adapter = new PrismaPg({ connectionString: DATABASE_URL }); + return new PrismaClient({ adapter }); +} + +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; +} + +// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/timezone + +const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; + +async function main() { + const prisma = createPrisma(); + + try { + console.log("=== HealthLog Notification Test ===\n"); + + // ── Step 1: List channels ── + console.log("── 1. Notification Channels ──\n"); + const channels = await prisma.notificationChannel.findMany({ + include: { user: { select: { id: true, username: true } } }, + }); + + if (channels.length === 0) { + console.log("❌ Keine Notification-Channels konfiguriert!"); + console.log( + " → Gehe zu Einstellungen > Benachrichtigungen und richte einen Kanal ein.\n", + ); + return; + } + + for (const ch of channels) { + const status = ch.enabled ? "✅ aktiv" : "⏸️ deaktiviert"; + let configInfo = ""; + try { + const config = JSON.parse(decrypt(ch.config)); + if (ch.type === "TELEGRAM") { + configInfo = `Chat-ID: ${config.chatId}`; + } else if (ch.type === "NTFY") { + configInfo = `Topic: ${config.topic}`; + } else { + configInfo = "Web Push"; + } + } catch { + configInfo = "(Config nicht lesbar)"; + } + console.log( + ` ${ch.type} [${status}] — User: ${ch.user.username} — ${configInfo}`, + ); + } + + // ── Step 2: Send test notification ── + console.log("\n── 2. Test-Notification senden ──\n"); + const enabledChannels = channels.filter((ch) => ch.enabled); + + if (enabledChannels.length === 0) { + console.log("❌ Keine aktivierten Channels — überspringe Test.\n"); + } else { + const userIds = [...new Set(enabledChannels.map((ch) => ch.userId))]; + for (const userId of userIds) { + console.log(` Sende Test-Notification an User ${userId}...`); + try { + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId, + title: "🔔 Test-Notification", + message: + "HealthLog Test: Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!", + }); + console.log(" ✅ Dispatched (best-effort, check deine Kanäle)\n"); + } catch (err) { + console.error(" ❌ Fehler:", err); + } + } + } + + // ── Step 3: Reminder check dry-run ── + console.log("── 3. Reminder-Check (Dry-Run) ──\n"); + + const now = new Date(); + console.log(` Aktuelle Zeit (UTC): ${now.toISOString()}`); + + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { reminderMissedMinutes: true }, + }); + const missedMinutes = appSettings?.reminderMissedMinutes ?? 240; + console.log(` Verpasst-Schwellenwert: ${missedMinutes} Minuten\n`); + + const medications = await prisma.medication.findMany({ + where: { active: true }, + include: { + schedules: true, + user: { select: { id: true, username: true, timezone: true } }, + }, + }); + + if (medications.length === 0) { + console.log(" Keine aktiven Medikamente gefunden.\n"); + return; + } + + 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); + + console.log( + ` 📋 ${med.name} (${med.dose}) — User: ${med.user.username}`, + ); + console.log( + ` Timezone: ${userTz}, Lokale Zeit: ${currentTime}, Tag: ${dayLabels[todayDow]}`, + ); + console.log( + ` Notifications: ${med.notificationsEnabled ? "✅ an" : "❌ aus"}`, + ); + console.log(` Schedules: ${med.schedules.length}`); + + for (const schedule of med.schedules) { + 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 windowPassed = currentMins > endMins; + const thresholdPassed = minutesPastEnd > missedMinutes; + + let status: string; + if (!dayMatch) { + status = "⏭️ Heute kein geplanter Tag"; + } else if (!windowPassed) { + status = `🟢 Fenster noch offen (endet um ${schedule.windowEnd})`; + } else if (!thresholdPassed) { + status = `🟡 Fenster vorbei seit ${minutesPastEnd} Min (Threshold: ${missedMinutes} Min)`; + } else { + status = `🔴 Missed-Threshold erreicht (${minutesPastEnd} Min > ${missedMinutes} Min)`; + } + + const daysInfo = + recurrence.daysOfWeek.length > 0 + ? ` [${recurrence.daysOfWeek.map((d) => dayLabels[d]).join(", ")}]` + : " [Täglich]"; + + console.log( + ` ${schedule.windowStart}–${schedule.windowEnd}${daysInfo}: ${status}`, + ); + } + + // Count existing events today + const eventCount = await prisma.medicationIntakeEvent.count({ + where: { + medicationId: med.id, + userId: med.user.id, + scheduledFor: { gte: todayStart, lte: todayEnd }, + }, + }); + console.log(` Heutige Events: ${eventCount}\n`); + } + + console.log("=== Test abgeschlossen ==="); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((err) => { + console.error("Test failed:", err); + process.exit(1); +}); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx new file mode 100644 index 000000000..dcb6926f5 --- /dev/null +++ b/src/app/achievements/page.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + AlertTriangle, + Bug, + CalendarCheck, + Fingerprint, + Flame, + Heart, + KeyRound, + Loader2, + LogIn, + Pill, + ShieldCheck, + SkipForward, + Star, + Target, + Trophy, + type LucideIcon, +} from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { formatDate } from "@/lib/format"; +import type { + AchievementMetrics, + AchievementProgress, + AchievementSummary, +} from "@/lib/gamification/achievements"; + +interface AchievementsData { + summary: AchievementSummary; + achievements: AchievementProgress[]; + metrics: AchievementMetrics; +} + +const iconMap: Record = { + Activity, + Flame, + CalendarCheck, + Pill, + ShieldCheck, + Target, + Trophy, + Heart, + KeyRound, + Fingerprint, + LogIn, + Bug, + AlertTriangle, + SkipForward, +}; + +function formatMetric( + format: AchievementProgress["format"], + value: number, + t: (key: string, params?: Record) => string, +) { + if (format === "percent") + return t("achievements.metricPercent", { count: value }); + if (format === "days") return t("achievements.metricDays", { count: value }); + return t("achievements.metricCount", { count: value }); +} + +export default function AchievementsPage() { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: ["gamification", "achievements"], + queryFn: async () => { + const res = await fetch("/api/gamification/achievements"); + if (!res.ok) throw new Error("Failed to load achievements"); + const json = await res.json(); + return json.data as AchievementsData; + }, + enabled: isAuthenticated, + }); + + if (authLoading || isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ( +
+

+ {t("achievements.title")} +

+

+ {t("achievements.loginRequired")} +

+
+ ); + } + + const summary = data?.summary; + const achievements = data?.achievements ?? []; + const unlockedAchievements = achievements.filter((item) => item.unlocked); + const remainingUnlocks = Math.max( + 0, + (summary?.totalCount ?? 0) - (summary?.unlockedCount ?? 0), + ); + + return ( +
+
+

+ {t("achievements.title")} +

+

+ {t("achievements.subtitle")} +

+
+ +
+
+

+ {t("achievements.points")} +

+

+ {summary?.earnedPoints ?? 0} + + / {summary?.totalPoints ?? 0} + +

+ +
+
+

+ {t("achievements.unlocked")} +

+

+ {summary?.unlockedCount ?? 0} + + / {summary?.totalCount ?? 0} + +

+

+ {t("achievements.remainingUnlocks", { count: remainingUnlocks })} +

+
+
+

+ {t("achievements.nextGoal")} +

+ {summary?.nextAchievement ? ( +
+

+ {t(summary.nextAchievement.titleKey)} +

+

+ {t(summary.nextAchievement.descriptionKey)} +

+

+ {t("achievements.nextProgressLabel")}:{" "} + {formatMetric( + summary.nextAchievement.format, + summary.nextAchievement.current, + t, + )}{" "} + /{" "} + {formatMetric( + summary.nextAchievement.format, + summary.nextAchievement.target, + t, + )}{" "} + ({summary.nextAchievement.progressPercent}%) +

+

+ {t("achievements.pointsValue", { + points: summary.nextAchievement.points, + })} +

+
+ ) : ( +

+ {t("achievements.allCompleted")} +

+ )} + +
+
+ +
+ {unlockedAchievements.map((achievement) => { + const Icon = iconMap[achievement.icon] ?? Star; + + return ( +
+
+
+
+ +
+
+

+ {t(achievement.titleKey)} +

+

+ {t(achievement.descriptionKey)} +

+
+
+ + {t("achievements.completed")} + +
+ +
+ + {t("achievements.pointsValue", { + points: achievement.points, + })} + + + {achievement.completedAt + ? t("achievements.completedOn", { + date: formatDate(achievement.completedAt), + }) + : t("achievements.goalReached")} + +
+
+ ); + })} +
+ + {unlockedAchievements.length === 0 && ( +
+

+ {t("achievements.noneUnlockedYet")} +

+
+ )} +
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 000000000..db0930a29 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,2151 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + Shield, + Users, + Settings, + Loader2, + KeyRound, + Pencil, + AlertTriangle, + Trash2, + Activity, + Database, + Server, + ScrollText, + ChevronDown, + XCircle, + CheckCircle2, + Eye, + EyeOff, + Clock, + Globe, + MessageCircle, + Bell, + BellRing, + Key, + Bug, + Cog, +} from "lucide-react"; +import { PasswordStrength } from "@/components/ui/password-strength"; +import { formatDate, formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { toast } from "sonner"; + +function PasswordInput(props: React.ComponentProps) { + const [visible, setVisible] = useState(false); + return ( +
+ + +
+ ); +} + +interface AdminUser { + id: string; + username: string; + email: string | null; + role: string; + createdAt: string; + passkeyCount: number; +} + +interface WorkerStatus { + running: boolean; + startedAt: string | null; + lastHeartbeat: string | null; + lastReminderCheck: string | null; + lastWithingsSync: string | null; + lastInsightsRun: string | null; + jobsProcessed: number; + errors: number; +} + +interface SystemStatus { + version: string; + nodeVersion: string; + gitCommit: string; + buildTime: string; + startTime: string; + database: string; + worker: WorkerStatus; + counts: { + users: number; + measurements: number; + medications: number; + intakeEvents: number; + activeTokens: number; + activeSessions: number; + }; + integrations: { + umami: { configured: boolean; enabled: boolean } | null; + glitchtip: { configured: boolean; enabled: boolean } | null; + webPush: { configured: boolean } | null; + bugReport: { configured: boolean } | null; + }; +} + +interface AdminSettings { + registrationEnabled: boolean; + defaultLocale: string; + telegramGlobal: boolean; + ntfyGlobal: boolean; + webPushGlobal: boolean; + webPushVapidPublicKey: string | null; + webPushVapidSubject: string | null; + webPushVapidConfigured: boolean; + apiGlobal: boolean; + umamiEnabled: boolean; + umamiScriptUrl: string | null; + umamiWebsiteId: string | null; + glitchtipEnabled: boolean; + glitchtipDsn: string | null; + glitchtipEnvironment: string | null; + bugReportRepo: string | null; + bugReportConfigured: boolean; + reminderLateMinutes: number; + reminderMissedMinutes: number; +} + +export default function AdminPage() { + const { user } = useAuth(); + const queryClient = useQueryClient(); + const { t } = useTranslations(); + + if (!user || user.role !== "ADMIN") return null; + + return ( +
+
+

+ {t("admin.title")} +

+

{t("admin.subtitle")}

+
+ +
+ + +

+ {t("admin.sectionGeneral")} +

+ + + +

+ {t("admin.sectionIntegrations")} +

+ + + + + +

+ {t("admin.sectionMedication")} +

+ + +

+ {t("admin.sectionManagement")} +

+ + + + +
+
+ ); +} + +/* ─────────────────────── Shared helpers ─────────────────────── */ + +function useAdminSettings() { + return useQuery({ + queryKey: ["admin", "settings"], + queryFn: async () => { + const res = await fetch("/api/admin/settings"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as AdminSettings; + }, + }); +} + +function useUpdateSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: Record) => { + const res = await fetch("/api/admin/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin", "settings"] }); + }, + }); +} + +async function getApiErrorMessage(response: Response): Promise { + const fallback = `HTTP ${response.status}`; + try { + const json = (await response.json()) as { error?: string }; + if (typeof json?.error === "string" && json.error.trim().length > 0) { + return json.error; + } + } catch { + return fallback; + } + return fallback; +} + +/* ─────────────────────── System Status ─────────────────────── */ + +function SystemStatusSection({ id }: { id: string }) { + const { t } = useTranslations(); + + const { data: status } = useQuery({ + queryKey: ["admin", "status"], + queryFn: async () => { + const res = await fetch("/api/admin/status"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as SystemStatus; + }, + }); + + return ( +
+
+ +

{t("admin.systemStatus")}

+
+ {status ? ( +
+ + + + + + + + {status.worker.lastReminderCheck && ( + + )} + {status.integrations.umami && ( + + )} + {status.integrations.glitchtip && ( + + )} + {status.integrations.webPush && ( + + )} + {status.integrations.bugReport && ( + + )} +
+ ) : ( +
+ + + {t("admin.loadingStatus")} + +
+ )} +
+ ); +} + +function StatusItem({ + icon: Icon, + label, + value, + className, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: string; + className?: string; +}) { + return ( +
+
+ + {label} +
+

{value}

+
+ ); +} + +/* ─────────────────────── General Settings ─────────────────────── */ + +function GeneralSettingsSection({ id }: { id: string }) { + const { t } = useTranslations(); + const { data: settings } = useAdminSettings(); + const updateSettings = useUpdateSettings(); + + return ( +
+
+ +

{t("admin.appSettings")}

+
+
+ + updateSettings.mutate({ registrationEnabled: checked }) + } + disabled={updateSettings.isPending} + /> + +
+
+

{t("admin.defaultLanguage")}

+

+ {t("admin.defaultLanguageDescription")} +

+
+ +
+
+
+ ); +} + +/* ─────────────────────── Services ─────────────────────── */ + +function ServicesSection({ id }: { id: string }) { + const { t } = useTranslations(); + const { data: settings } = useAdminSettings(); + const updateSettings = useUpdateSettings(); + + return ( +
+
+ +

{t("admin.servicesGlobal")}

+
+

+ {t("admin.servicesGlobalDescription")} +

+
+ + updateSettings.mutate({ telegramGlobal: checked }) + } + disabled={updateSettings.isPending} + /> + + updateSettings.mutate({ ntfyGlobal: checked }) + } + disabled={updateSettings.isPending} + /> + + updateSettings.mutate({ webPushGlobal: checked }) + } + disabled={updateSettings.isPending} + /> + + updateSettings.mutate({ apiGlobal: checked }) + } + disabled={updateSettings.isPending} + /> +
+
+ ); +} + +/* ─────────────────────── Umami ─────────────────────── */ + +function UmamiSection({ id }: { id: string }) { + const { t } = useTranslations(); + const { data: settings } = useAdminSettings(); + const updateSettings = useUpdateSettings(); + const [umamiScriptUrlDraft, setUmamiScriptUrlDraft] = useState( + null, + ); + const [umamiWebsiteIdDraft, setUmamiWebsiteIdDraft] = useState( + null, + ); + + const umamiScriptUrlValue = + umamiScriptUrlDraft ?? settings?.umamiScriptUrl ?? ""; + const umamiWebsiteIdValue = + umamiWebsiteIdDraft ?? settings?.umamiWebsiteId ?? ""; + + const configured = Boolean( + settings?.umamiScriptUrl && settings?.umamiWebsiteId, + ); + + const testUmami = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/admin/monitoring/umami-test", { + method: "POST", + }); + if (!res.ok) { + throw new Error(await getApiErrorMessage(res)); + } + const json = (await res.json()) as { data?: { message?: string } }; + return json.data?.message ?? t("admin.monitoringTestSuccess"); + }, + onSuccess: (message) => { + toast.success(message); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t("admin.monitoringTestFailed"), + ); + }, + }); + + function saveUmamiSettings() { + updateSettings.mutate( + { + umamiScriptUrl: umamiScriptUrlValue, + umamiWebsiteId: umamiWebsiteIdValue, + }, + { + onSuccess: () => { + setUmamiScriptUrlDraft(null); + setUmamiWebsiteIdDraft(null); + }, + }, + ); + } + + return ( +
+
+
+ +

{t("admin.umamiTitle")}

+
+
+ {configured && ( + + {t("admin.configured")} + + )} +
+
+

+ {t("admin.umamiDescription")} +

+ +
+ + updateSettings.mutate({ umamiEnabled: checked }) + } + disabled={updateSettings.isPending} + /> +
+
+ + setUmamiScriptUrlDraft(event.target.value)} + placeholder={t("admin.umamiScriptUrlPlaceholder")} + autoComplete="new-password" + spellCheck={false} + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+ + setUmamiWebsiteIdDraft(event.target.value)} + placeholder={t("admin.umamiWebsiteIdPlaceholder")} + autoComplete="new-password" + spellCheck={false} + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+
+ +
+ + +
+
+ ); +} + +/* ─────────────────────── GlitchTip ─────────────────────── */ + +function GlitchtipSection({ id }: { id: string }) { + const { t } = useTranslations(); + const { data: settings } = useAdminSettings(); + const updateSettings = useUpdateSettings(); + const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState( + null, + ); + const [glitchtipEnvironmentDraft, setGlitchtipEnvironmentDraft] = useState< + string | null + >(null); + + const glitchtipDsnValue = glitchtipDsnDraft ?? settings?.glitchtipDsn ?? ""; + const glitchtipEnvironmentValue = + glitchtipEnvironmentDraft ?? settings?.glitchtipEnvironment ?? "production"; + + const configured = Boolean(settings?.glitchtipDsn); + + const testGlitchtip = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/admin/monitoring/glitchtip-test", { + method: "POST", + }); + if (!res.ok) { + throw new Error(await getApiErrorMessage(res)); + } + const json = (await res.json()) as { data?: { message?: string } }; + return json.data?.message ?? t("admin.monitoringTestSuccess"); + }, + onSuccess: (message) => { + toast.success(message); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t("admin.monitoringTestFailed"), + ); + }, + }); + + function saveGlitchtipSettings() { + updateSettings.mutate( + { + glitchtipDsn: glitchtipDsnValue, + glitchtipEnvironment: glitchtipEnvironmentValue, + }, + { + onSuccess: () => { + setGlitchtipDsnDraft(null); + setGlitchtipEnvironmentDraft(null); + }, + }, + ); + } + + return ( +
+
+
+ +

{t("admin.glitchtipTitle")}

+
+
+ {configured && ( + + {t("admin.configured")} + + )} +
+
+

+ {t("admin.glitchtipDescription")} +

+ +
+ + updateSettings.mutate({ glitchtipEnabled: checked }) + } + disabled={updateSettings.isPending} + /> +
+
+ + setGlitchtipDsnDraft(event.target.value)} + placeholder={t("admin.glitchtipDsnPlaceholder")} + autoComplete="new-password" + spellCheck={false} + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+ + + setGlitchtipEnvironmentDraft(event.target.value) + } + placeholder={t("admin.glitchtipEnvironmentPlaceholder")} + autoComplete="new-password" + spellCheck={false} + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+
+ +
+ + +
+
+ ); +} + +/* ─────────────────────── Web Push VAPID ─────────────────────── */ + +function WebPushVapidSection({ id }: { id: string }) { + const { t } = useTranslations(); + const { data: settings } = useAdminSettings(); + const updateSettings = useUpdateSettings(); + const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState< + string | null + >(null); + const [webPushVapidPrivateKeyDraft, setWebPushVapidPrivateKeyDraft] = + useState(""); + const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState< + string | null + >(null); + + const webPushVapidPublicKeyValue = + webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? ""; + const webPushVapidSubjectValue = + webPushVapidSubjectDraft ?? settings?.webPushVapidSubject ?? ""; + + const configured = settings?.webPushVapidConfigured ?? false; + + function saveWebPushVapidSettings() { + const payload: Record = { + webPushVapidPublicKey: webPushVapidPublicKeyValue, + webPushVapidSubject: webPushVapidSubjectValue, + }; + if (webPushVapidPrivateKeyDraft.trim().length > 0) { + payload.webPushVapidPrivateKey = webPushVapidPrivateKeyDraft.trim(); + } + + updateSettings.mutate(payload, { + onSuccess: () => { + setWebPushVapidPublicKeyDraft(null); + setWebPushVapidPrivateKeyDraft(""); + setWebPushVapidSubjectDraft(null); + }, + }); + } + + return ( +
+
+
+ +

+ {t("admin.webPushVapidTitle")} +

+
+
+ {configured && ( + + {t("admin.configured")} + + )} +
+
+

+ {t("admin.webPushVapidDescription")} +

+ +
+
+ + + setWebPushVapidPublicKeyDraft(event.target.value) + } + placeholder={t("admin.webPushVapidPublicKeyPlaceholder")} + autoComplete="new-password" + spellCheck={false} + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+ + + setWebPushVapidPrivateKeyDraft(event.target.value) + } + placeholder={t("admin.webPushVapidPrivateKeyPlaceholder")} + autoComplete="new-password" + spellCheck={false} + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+ + + setWebPushVapidSubjectDraft(event.target.value) + } + placeholder={t("admin.webPushVapidSubjectPlaceholder")} + autoComplete="new-password" + spellCheck={false} + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+ +
+ +
+
+ ); +} + +/* ─────────────────────── Bug Report ─────────────────────── */ + +function BugReportSection({ id }: { id: string }) { + const { t } = useTranslations(); + const { data: settings } = useAdminSettings(); + const updateSettings = useUpdateSettings(); + const [bugReportRepoDraft, setBugReportRepoDraft] = useState( + null, + ); + const [bugReportTokenDraft, setBugReportTokenDraft] = useState(""); + + const bugReportRepoValue = + bugReportRepoDraft ?? settings?.bugReportRepo ?? ""; + const configured = settings?.bugReportConfigured ?? false; + + function saveBugReportSettings() { + const payload: Record = { + bugReportRepo: bugReportRepoValue, + }; + if (bugReportTokenDraft.trim().length > 0) { + payload.bugReportToken = bugReportTokenDraft.trim(); + } + + updateSettings.mutate(payload, { + onSuccess: () => { + setBugReportRepoDraft(null); + setBugReportTokenDraft(""); + }, + }); + } + + return ( +
+
+
+ +

+ {t("admin.bugReportGithub")} +

+
+
+ {configured && ( + + {t("admin.configured")} + + )} +
+
+

+ {t("admin.bugReportGithubDescription")} +

+ +
+
+ + setBugReportRepoDraft(event.target.value)} + placeholder={t("admin.bugReportRepoPlaceholder")} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + /> +
+
+ + setBugReportTokenDraft(event.target.value)} + placeholder={t("admin.bugReportTokenPlaceholder")} + disabled={updateSettings.isPending} + /> +
+
+ +
+ +
+
+ ); +} + +/* ─────────────────────── Reminders ─────────────────────── */ + +function RemindersSection({ id }: { id: string }) { + const { t } = useTranslations(); + const { data: settings } = useAdminSettings(); + const updateSettings = useUpdateSettings(); + const [reminderLateDraft, setReminderLateDraft] = useState( + null, + ); + const [reminderMissedDraft, setReminderMissedDraft] = useState( + null, + ); + + const testNotification = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/admin/notifications/test", { + method: "POST", + }); + if (!res.ok) { + throw new Error(await getApiErrorMessage(res)); + } + const json = (await res.json()) as { + data?: { + message?: string; + results?: Array<{ + channel: string; + success: boolean; + error?: string; + }>; + }; + }; + return json.data; + }, + onSuccess: (data) => { + const hasFailures = data?.results?.some((r) => !r.success); + if (hasFailures) { + toast.error(data?.message ?? t("admin.notificationTestFailed")); + } else { + toast.success(data?.message ?? t("admin.notificationTestSuccess")); + } + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t("admin.notificationTestFailed"), + ); + }, + }); + + const reminderCheck = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/admin/notifications/reminder-check", { + method: "POST", + }); + if (!res.ok) { + throw new Error(await getApiErrorMessage(res)); + } + const json = (await res.json()) as { + data?: { + message?: string; + medications?: Array<{ + name: string; + dose: string; + user: string; + localTime: string; + dayOfWeek: string; + notificationsEnabled: boolean; + schedules: Array<{ + window: string; + days: string; + status: string; + label: string; + notificationSent?: boolean; + }>; + eventsToday: number; + }>; + notificationsSent?: number; + }; + }; + return json.data; + }, + onSuccess: (data) => { + toast.success(data?.message ?? t("admin.reminderCheckSuccess")); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : t("admin.reminderCheckFailed"), + ); + }, + }); + + return ( +
+
+ +

+ {t("admin.medicationReminders")} +

+
+

+ {t("admin.medicationRemindersDescription")} +

+ +
+
+
+ +

+ {t("admin.reminderLateMinutesDescription")} +

+ setReminderLateDraft(Number(e.target.value))} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + className="w-32" + /> +
+
+ +

+ {t("admin.reminderMissedMinutesDescription")} +

+ setReminderMissedDraft(Number(e.target.value))} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + className="w-32" + /> +
+
+
+ +
+ + + +
+ + {testNotification.data?.results && + testNotification.data.results.length > 0 && ( +
+ {testNotification.data.results.map((r, i) => ( +
+ {r.success ? ( + + ) : ( + + )} + {r.channel} + {r.error && ( + — {r.error} + )} +
+ ))} +
+ )} + + {reminderCheck.data?.medications && + reminderCheck.data.medications.length > 0 && ( +
+

+ {t("admin.reminderCheckResults")} +

+
+ {reminderCheck.data.medications.map((med, i) => ( +
+
+ + {med.name} ({med.dose}) + + + {med.notificationsEnabled + ? t("admin.reminderCheckNotifOn") + : t("admin.reminderCheckNotifOff")} + +
+

+ {med.user} — {med.dayOfWeek} {med.localTime} —{" "} + {t("admin.reminderCheckEventsToday")}: {med.eventsToday} +

+ {med.schedules.map((sched, j) => { + const statusColor = + sched.status === "open" + ? "text-green-400" + : sched.status === "threshold" + ? "text-yellow-400" + : sched.status === "missed" + ? "text-red-400" + : sched.status === "skipped" + ? "text-muted-foreground" + : ""; + return ( +
+ + {sched.window} + + + [{sched.days}] + + {sched.label} + {sched.notificationSent && ( + + + {t("admin.reminderCheckNotifSent")} + + )} +
+ ); + })} +
+ ))} +
+
+ )} +
+ ); +} + +/* ─────────────────────── Shared UI Components ─────────────────────── */ + +function SettingsToggle({ + label, + description, + icon: Icon, + checked, + onCheckedChange, + disabled, +}: { + label: string; + description: string; + icon?: React.ComponentType<{ className?: string }>; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled: boolean; +}) { + return ( +
+
+ {Icon && } +
+

{label}

+

{description}

+
+
+ +
+ ); +} + +/* ─────────────────────── Login Overview ─────────────────────── */ + +interface AdminAuditEntry { + id: string; + action: string; + ipAddress: string | null; + location: string | null; + details: string | null; + createdAt: string; + user: { id: string; username: string } | null; +} + +function LoginOverviewSection({ id }: { id: string }) { + const { t } = useTranslations(); + const [expanded, setExpanded] = useState(false); + const [filter, setFilter] = useState<"all" | "failed">("all"); + + const AUTH_ACTION_LABELS: Record = { + "auth.register": t("admin.authRegister"), + "auth.login": t("admin.authLogin"), + "auth.login.passkey": t("admin.authLoginPasskey"), + "auth.login.password": t("admin.authLoginPassword"), + "auth.login.failed": t("admin.authLoginFailed"), + "auth.logout": t("admin.authLogout"), + "auth.passkey.register": t("admin.authPasskeyRegister"), + "auth.passkey.delete": t("admin.authPasskeyDelete"), + }; + + const { data, isLoading } = useQuery({ + queryKey: ["admin", "audit-log", filter], + queryFn: async () => { + const res = await fetch(`/api/admin/audit-log?limit=100&filter=auth`); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as { + entries: AdminAuditEntry[]; + meta: { total: number }; + }; + }, + enabled: expanded, + }); + + const entries = + filter === "failed" + ? data?.entries.filter((e) => e.action === "auth.login.failed") + : data?.entries; + + return ( +
+
+
+ +

{t("admin.loginOverview")}

+
+ +
+ + {expanded && ( +
+
+ + +
+ + {isLoading ? ( +
+ +
+ ) : !entries?.length ? ( +

+ {t("admin.noEntries")} +

+ ) : ( +
+ + + + + + + + + + + + + {entries.map((entry, i) => { + const isFailed = entry.action === "auth.login.failed"; + return ( + + + + + + + + + ); + })} + +
+ {t("admin.status")} + + {t("admin.users")} + + {t("admin.action")} + + {t("admin.ip")} + + {t("admin.location")} + + {t("admin.timestamp")} +
+ {isFailed ? ( + + ) : ( + + )} + + {entry.user?.username ?? t("common.unknown")} + + {AUTH_ACTION_LABELS[entry.action] ?? entry.action} + + {entry.ipAddress ?? "—"} + + {entry.location ?? "—"} + + {formatDateTime(entry.createdAt)} +
+ {data && data.meta.total > entries.length && ( +

+ {t("admin.showingEntries", { + count: entries.length, + total: data.meta.total, + })} +

+ )} +
+ )} +
+ )} +
+ ); +} + +/* ─────────────────────── API Token Overview ─────────────────────── */ + +interface ApiTokenInfo { + id: string; + name: string; + permissions: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; + revoked: boolean; + user: { id: string; username: string }; +} + +function ApiTokenOverviewSection({ id }: { id: string }) { + const { t } = useTranslations(); + const [expanded, setExpanded] = useState(false); + + const { data: tokens, isLoading } = useQuery({ + queryKey: ["admin", "tokens"], + queryFn: async () => { + const res = await fetch("/api/admin/tokens"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as ApiTokenInfo[]; + }, + enabled: expanded, + }); + + return ( +
+
+
+ +

{t("admin.apiTokens")}

+
+ +
+ + {expanded && ( +
+ {isLoading ? ( +
+ +
+ ) : !tokens?.length ? ( +

+ {t("admin.noTokens")} +

+ ) : ( +
+ + + + + + + + + + + + + {tokens.map((token, i) => { + const isExpired = + token.expiresAt && new Date(token.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
+ {t("admin.tokenUser")} + + {t("admin.tokenName")} + + {t("admin.tokenPermissions")} + + {t("admin.tokenStatus")} + + {t("admin.tokenLastUsed")} + + {t("admin.tokenCreated")} +
+ {token.user.username} + {token.name} +
+ {token.permissions.map((p) => ( + + {p} + + ))} +
+
+ {token.revoked ? ( + + {t("settings.tokenRevoked")} + + ) : isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("common.active")} + + )} + + {token.lastUsedAt + ? formatDateTime(token.lastUsedAt) + : t("admin.tokenNeverUsed")} + + {formatDate(token.createdAt)} +
+
+ )} +
+ )} +
+ ); +} + +/* ─────────────────────── Danger Zone ─────────────────────── */ + +function DangerZoneSection({ id }: { id: string }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [wipeMsg, setWipeMsg] = useState(null); + + const wipeAllData = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/admin/data", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE ALL" }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || t("common.error")); + return json.data as { + measurements: number; + intakeEvents: number; + medications: number; + }; + }, + onSuccess: (data) => { + queryClient.invalidateQueries(); + setWipeMsg( + t("admin.deletedResult", { + measurements: data.measurements, + medications: data.medications, + intakeEvents: data.intakeEvents, + }), + ); + }, + onError: (err: Error) => { + setWipeMsg(err.message); + }, + }); + + return ( +
+
+ +

+ {t("admin.dangerZone")} +

+
+
+

{t("admin.deleteAllData")}

+

+ {t("admin.deleteAllDescription")} +

+
+ + + + + + + + {t("admin.deleteAllConfirm")} + + + {t("admin.deleteAllConfirmDescription")} + + + + {t("common.cancel")} + wipeAllData.mutate()} + > + {t("admin.finalDelete")} + + + + +
+ {wipeMsg && ( +

+ {wipeMsg} +

+ )} +
+
+ ); +} + +/* ─────────────────────── User Management ─────────────────────── */ + +function UserManagementSection({ + id, + queryClient, + currentUserId, +}: { + id: string; + queryClient: ReturnType; + currentUserId: string; +}) { + const { t } = useTranslations(); + const [editingUser, setEditingUser] = useState(null); + const [editUsername, setEditUsername] = useState(""); + const [editEmail, setEditEmail] = useState(""); + const [resetUser, setResetUser] = useState(null); + const [resetPassword, setResetPassword] = useState(""); + const [resetMsg, setResetMsg] = useState(null); + + const { data: users } = useQuery({ + queryKey: ["admin", "users"], + queryFn: async () => { + const res = await fetch("/api/admin/users"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as AdminUser[]; + }, + }); + + const updateUser = useMutation({ + mutationFn: async ({ + id, + data, + }: { + id: string; + data: Record; + }) => { + const res = await fetch(`/api/admin/users/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || t("common.error")); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + setEditingUser(null); + }, + }); + + const resetPw = useMutation({ + mutationFn: async ({ id, password }: { id: string; password: string }) => { + const res = await fetch(`/api/admin/users/${id}/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || t("common.error")); + }, + onSuccess: () => { + setResetMsg(t("admin.passwordReset")); + setResetPassword(""); + }, + onError: (err: Error) => { + setResetMsg(err.message); + }, + }); + + function startEdit(u: AdminUser) { + setEditingUser(u); + setEditUsername(u.username); + setEditEmail(u.email ?? ""); + } + + function startReset(u: AdminUser) { + setResetUser(u); + setResetPassword(""); + setResetMsg(null); + } + + return ( +
+
+ +

{t("admin.userManagement")}

+ {users && ( + + {users.length} + + )} +
+ + {users ? ( +
+ + + + + + + + + + + + + {users.map((u, i) => ( + + + + + + + + + ))} + +
+ {t("admin.users")} + + {t("admin.userEmail")} + + {t("admin.userRole")} + + {t("admin.userPasskeys")} + + {t("admin.userCreated")} + + {t("admin.userActions")} +
{u.username} + {u.email || "—"} + + + {u.role} + + {u.passkeyCount} + {formatDate(u.createdAt)} + +
+ + + +
+
+
+ ) : ( +
+ + + {t("admin.loadingUsers")} + +
+ )} + + {/* Edit Dialog */} + {editingUser && ( +
+

+ {t("admin.editUserTitle", { name: editingUser.username })} +

+
+
+ + setEditUsername(e.target.value)} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + /> +
+
+ + setEditEmail(e.target.value)} + placeholder={t("common.optional")} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + /> +
+
+ + + {updateUser.isError && ( + + {(updateUser.error as Error).message} + + )} +
+
+
+ )} + + {/* Password Reset Dialog */} + {resetUser && ( +
+

+ {t("admin.resetPasswordTitle", { name: resetUser.username })} +

+
+
+ + setResetPassword(e.target.value)} + placeholder={t("admin.newPasswordPlaceholder")} + /> + +
+
+ + +
+ {resetMsg && ( +

+ {resetMsg} +

+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts new file mode 100644 index 000000000..e6a5be815 --- /dev/null +++ b/src/app/api/admin/audit-log/route.ts @@ -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 } }); +}); diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts new file mode 100644 index 000000000..241ccaf07 --- /dev/null +++ b/src/app/api/admin/data/route.ts @@ -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 }); +}); diff --git a/src/app/api/admin/monitoring/glitchtip-test/route.ts b/src/app/api/admin/monitoring/glitchtip-test/route.ts new file mode 100644 index 000000000..6f5e0218c --- /dev/null +++ b/src/app/api/admin/monitoring/glitchtip-test/route.ts @@ -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", + }); +}); diff --git a/src/app/api/admin/monitoring/umami-test/route.ts b/src/app/api/admin/monitoring/umami-test/route.ts new file mode 100644 index 000000000..578b9660a --- /dev/null +++ b/src/app/api/admin/monitoring/umami-test/route.ts @@ -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, + ); +}); diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts new file mode 100644 index 000000000..62fac174c --- /dev/null +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -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: `${med.name} (${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: ${med.name} (${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", + }); +}); diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts new file mode 100644 index 000000000..a57105286 --- /dev/null +++ b/src/app/api/admin/notifications/test/route.ts @@ -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 { + 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: + "HealthLog Test: 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 }); +}); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts new file mode 100644 index 000000000..88af83fd8 --- /dev/null +++ b/src/app/api/admin/settings/route.ts @@ -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 = {}; + const auditDetails: Record = {}; + + // 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, + }); +}); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts new file mode 100644 index 000000000..769005739 --- /dev/null +++ b/src/app/api/admin/status/route.ts @@ -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, + }, + }); +}); diff --git a/src/app/api/admin/tokens/route.ts b/src/app/api/admin/tokens/route.ts new file mode 100644 index 000000000..44c88646f --- /dev/null +++ b/src/app/api/admin/tokens/route.ts @@ -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); +}); diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts new file mode 100644 index 000000000..44dd0c7a9 --- /dev/null +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -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 }); +}); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 000000000..9499c4694 --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -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); +}); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 000000000..573891c7f --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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, + })), + ); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts new file mode 100644 index 000000000..8153d85a8 --- /dev/null +++ b/src/app/api/analytics/route.ts @@ -0,0 +1,118 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "analytics.get" } }); + + const types: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + ]; + + const measurementsByType = await Promise.all( + types.map((type) => + prisma.measurement + .findMany({ + where: { userId: user.id, type }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }) + .then((measurements) => ({ + type, + summary: summarize( + measurements.map( + (m): DataPoint => ({ + date: m.measuredAt, + value: m.value, + }), + ), + ), + })), + ), + ); + + const results: Record> = {}; + for (const { type, summary } of measurementsByType) { + results[type] = summary; + } + + // BMI calculation + let bmi: number | null = null; + if (user.heightCm && results.WEIGHT?.latest) { + const heightM = user.heightCm / 100; + bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10; + } + + // BP in-target percentage (auto-calculated from date of birth) + let bpInTargetPct: number | null = null; + const bpTargets = getBpTargets(user.dateOfBirth); + if (bpTargets) { + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const [sysData, diaData] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + measuredAt: { gte: thirtyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }), + prisma.measurement.findMany({ + where: { + userId: user.id, + type: "BLOOD_PRESSURE_DIA", + measuredAt: { gte: thirtyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }), + ]); + + if (sysData.length > 0 && diaData.length > 0) { + let inTarget = 0; + for (const sys of sysData) { + const closestDia = diaData.reduce((closest, dia) => + Math.abs(dia.measuredAt.getTime() - sys.measuredAt.getTime()) < + Math.abs(closest.measuredAt.getTime() - sys.measuredAt.getTime()) + ? dia + : closest, + ); + const timeDiff = Math.abs( + closestDia.measuredAt.getTime() - sys.measuredAt.getTime(), + ); + if (timeDiff < 5 * 60 * 1000) { + if ( + sys.value >= bpTargets.sysLow && + sys.value <= bpTargets.sysHigh && + closestDia.value >= bpTargets.diaLow && + closestDia.value <= bpTargets.diaHigh + ) { + inTarget++; + } + } + } + bpInTargetPct = + sysData.length > 0 + ? Math.round((inTarget / sysData.length) * 100) + : null; + } + } + + return apiSuccess({ + summaries: results, + bmi, + bpInTargetPct, + }); +}); diff --git a/src/app/api/audit-log/route.ts b/src/app/api/audit-log/route.ts new file mode 100644 index 000000000..4f834c6b2 --- /dev/null +++ b/src/app/api/audit-log/route.ts @@ -0,0 +1,46 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { NextRequest } from "next/server"; + +/** + * Get audit log entries for the current user. + * Query params: limit (default 50, max 200), offset (default 0) + */ +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "audit-log.list" } }); + + const { searchParams } = new URL(request.url); + const limit = Math.min( + Math.max(1, parseInt(searchParams.get("limit") ?? "50", 10) || 50), + 200, + ); + const offset = Math.max( + 0, + parseInt(searchParams.get("offset") ?? "0", 10) || 0, + ); + + const [entries, total] = await Promise.all([ + prisma.auditLog.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, + select: { + id: true, + action: true, + ipAddress: true, + location: true, + details: true, + createdAt: true, + }, + }), + prisma.auditLog.count({ + where: { userId: user.id }, + }), + ]); + + return apiSuccess({ entries, meta: { total, limit, offset } }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 000000000..04acb1ac5 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,80 @@ +import { prisma } from "@/lib/db"; +import { loginPasswordSchema } from "@/lib/validations/auth"; +import { verifyPassword } from "@/lib/auth/password"; +import { createSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const POST = apiHandler(async (request: NextRequest) => { + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit(`auth:login:${ip}`, 5, 15 * 60 * 1000); + if (!rl.allowed) { + return NextResponse.json( + { + data: null, + error: "Zu viele Anmeldeversuche. Bitte später erneut versuchen.", + }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + await ensureDbCompatibility(); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = loginPasswordSchema.safeParse(body); + + if (!parsed.success) { + return apiError("Ungültige Anmeldedaten", 422); + } + + const { email, password } = parsed.data; + const identifier = email.trim(); + + const user = await prisma.user.findFirst({ + where: { + OR: [ + { email: { equals: identifier, mode: "insensitive" } }, + { username: { equals: identifier, mode: "insensitive" } }, + ], + }, + }); + + if (!user || !user.passwordHash) { + await auditLog("auth.login.failed", { + ipAddress: ip, + details: { identifier, reason: "user_not_found_or_no_password" }, + }); + return apiError("Ungültige Anmeldedaten", 401); + } + + const valid = await verifyPassword(user.passwordHash, password); + if (!valid) { + await auditLog("auth.login.failed", { + userId: user.id, + ipAddress: ip, + details: { reason: "invalid_password" }, + }); + return apiError("Ungültige Anmeldedaten", 401); + } + + const ua = request.headers.get("user-agent"); + await createSession(user.id, ip, ua); + + await auditLog("auth.login.password", { + userId: user.id, + ipAddress: ip, + }); + + annotate({ action: { name: "auth.login.password" } }); + + return apiSuccess({ + user: { id: user.id, username: user.username }, + }); +}); diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 000000000..144ac34ec --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import { destroySession } from "@/lib/auth/session"; +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const POST = apiHandler(async () => { + await destroySession(); + + annotate({ action: { name: "auth.logout" } }); + + return apiSuccess({ loggedOut: true }); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 000000000..ecd2c210e --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,25 @@ +import { apiSuccess } from "@/lib/api-response"; +import { getGravatarUrl } from "@/lib/gravatar"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + annotate({ action: { name: "auth.me" } }); + + return apiSuccess({ + id: user.id, + username: user.username, + email: user.email, + role: user.role ?? "USER", + heightCm: user.heightCm, + dateOfBirth: user.dateOfBirth, + gender: user.gender, + timezone: user.timezone, + onboardingCompletedAt: user.onboardingCompletedAt, + gravatarUrl: user.email ? getGravatarUrl(user.email) : null, + }); +}); diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts new file mode 100644 index 000000000..273cb5314 --- /dev/null +++ b/src/app/api/auth/passkey/login-options/route.ts @@ -0,0 +1,30 @@ +import { createAuthenticationOptions } from "@/lib/auth/passkey"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const POST = apiHandler(async (request: Request) => { + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit( + `auth:passkey-login-options:${ip}`, + 10, + 15 * 60 * 1000, + ); + if (!rl.allowed) { + return NextResponse.json( + { + data: null, + error: "Zu viele Passkey-Anfragen. Bitte später erneut versuchen.", + }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + const { options, challengeId } = await createAuthenticationOptions(); + + annotate({ action: { name: "auth.passkey.login-options" } }); + + return apiSuccess({ options, challengeId }); +}); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts new file mode 100644 index 000000000..7cf22db0e --- /dev/null +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -0,0 +1,65 @@ +import { verifyAuthentication } from "@/lib/auth/passkey"; +import { createSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const POST = apiHandler(async (request: NextRequest) => { + await ensureDbCompatibility(); + + const ip = getClientIp(request); + const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000); + if (!rl.allowed) { + return apiError("Zu viele Versuche. Bitte 15 Minuten warten.", 429); + } + + const { data: body, error: jsonError } = await safeJson>(request); + + if (jsonError) return jsonError; + const challengeId = body.challengeId as string | undefined; + const credential = body.credential; + + if (!challengeId || !credential) { + return apiError("challengeId und credential erforderlich", 422); + } + + const { verification, passkey } = await verifyAuthentication( + challengeId, + credential, + ); + + if (!verification.verified) { + await auditLog("auth.login.failed", { + ipAddress: ip, + details: { reason: "passkey_verification_failed" }, + }); + return apiError("Passkey-Verifizierung fehlgeschlagen", 401); + } + + const user = await prisma.user.findUnique({ + where: { id: passkey.userId }, + }); + + if (!user) { + return apiError("Benutzer nicht gefunden", 404); + } + + const ua = request.headers.get("user-agent"); + await createSession(user.id, ip, ua); + + await auditLog("auth.login.passkey", { + userId: user.id, + ipAddress: ip, + }); + + annotate({ action: { name: "auth.login.passkey" } }); + + return apiSuccess({ + user: { id: user.id, username: user.username }, + }); +}); diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts new file mode 100644 index 000000000..23bb29e64 --- /dev/null +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -0,0 +1,17 @@ +import { createRegistrationOptions } from "@/lib/auth/passkey"; +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + const { options, challengeId } = await createRegistrationOptions( + user.id, + user.email ?? user.username, + ); + + annotate({ action: { name: "auth.passkey.register-options" } }); + + return apiSuccess({ options, challengeId }); +}); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts new file mode 100644 index 000000000..89202fb68 --- /dev/null +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -0,0 +1,50 @@ +import { prisma } from "@/lib/db"; +import { verifyRegistration } from "@/lib/auth/passkey"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson>(request); + + if (jsonError) return jsonError; + const challengeId = body.challengeId as string | undefined; + const credential = body.credential as Record | undefined; + + if (!challengeId || !credential) { + return apiError("challengeId und credential erforderlich", 422); + } + + const verification = await verifyRegistration(challengeId, credential); + + if (!verification.verified || !verification.registrationInfo) { + return apiError("Passkey-Verifizierung fehlgeschlagen", 400); + } + + const { registrationInfo } = verification; + + await prisma.passkey.create({ + data: { + userId: user.id, + credentialId: registrationInfo.credential.id, + credentialPublicKey: Buffer.from(registrationInfo.credential.publicKey), + counter: BigInt(registrationInfo.credential.counter), + credentialDeviceType: registrationInfo.credentialDeviceType, + credentialBackedUp: registrationInfo.credentialBackedUp, + transports: ((credential.response as Record | undefined)?.transports as string[]) ?? [], + }, + }); + + await auditLog("auth.passkey.register", { + userId: user.id, + ipAddress: getClientIp(request), + }); + + annotate({ action: { name: "auth.passkey.register" } }); + + return apiSuccess({ verified: true }); +}); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts new file mode 100644 index 000000000..444a2756d --- /dev/null +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -0,0 +1,48 @@ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const DELETE = apiHandler(async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + const { user } = await requireAuth(); + + const { id } = await params; + + const passkey = await prisma.passkey.findUnique({ + where: { id }, + }); + + if (!passkey || passkey.userId !== user.id) { + return apiError("Passkey nicht gefunden", 404); + } + + // Check: at least 1 auth method must remain + const passkeyCount = await prisma.passkey.count({ + where: { userId: user.id }, + }); + const hasPassword = !!user.passwordHash; + + if (passkeyCount <= 1 && !hasPassword) { + return apiError( + "Kann nicht gelöscht werden — es muss mindestens eine Anmeldemethode bestehen bleiben", + 400, + ); + } + + await prisma.passkey.delete({ where: { id } }); + + await auditLog("auth.passkey.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { passkeyId: id, passkeyName: passkey.name }, + }); + + annotate({ action: { name: "auth.passkey.delete" } }); + + return apiSuccess({ success: true }); +}); diff --git a/src/app/api/auth/passkeys/route.ts b/src/app/api/auth/passkeys/route.ts new file mode 100644 index 000000000..6c2a7d497 --- /dev/null +++ b/src/app/api/auth/passkeys/route.ts @@ -0,0 +1,26 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const passkeys = await prisma.passkey.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + credentialDeviceType: true, + credentialBackedUp: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + + annotate({ action: { name: "auth.passkeys.list" } }); + + return apiSuccess(passkeys); +}); diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts new file mode 100644 index 000000000..0a3c6db94 --- /dev/null +++ b/src/app/api/auth/password/route.ts @@ -0,0 +1,92 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { + verifyPassword, + hashPassword, + checkPasswordStrength, +} from "@/lib/auth/password"; +import { changePasswordSchema } from "@/lib/validations/auth"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { destroyAllSessions, createSession } from "@/lib/auth/session"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `auth:password:${user.id}`, + 5, + 15 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError( + "Zu viele Versuche. Bitte 15 Minuten warten.", + 429, + ); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = changePasswordSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { currentPassword, newPassword } = parsed.data; + if (!user.passwordHash) { + return apiError("Für dieses Konto ist kein Passwort gesetzt", 400); + } + + const currentValid = await verifyPassword( + user.passwordHash, + currentPassword, + ); + if (!currentValid) { + return apiError("Aktuelles Passwort ist falsch", 401); + } + + if (currentPassword === newPassword) { + return apiError( + "Neues Passwort muss sich vom aktuellen unterscheiden", + 422, + ); + } + + const strength = checkPasswordStrength(newPassword, [ + user.username, + user.email ?? "", + ]); + if (!strength.isAcceptable) { + return apiError( + strength.feedback[0] || "Passwort zu schwach (Score < 3)", + 422, + ); + } + + const newHash = await hashPassword(newPassword); + await prisma.user.update({ + where: { id: user.id }, + data: { passwordHash: newHash }, + }); + + // Invalidate all existing sessions and create a fresh one + await destroyAllSessions(user.id); + await createSession( + user.id, + getClientIp(request), + request.headers.get("user-agent"), + ); + + await auditLog("auth.password.change", { + userId: user.id, + ipAddress: getClientIp(request), + }); + + annotate({ action: { name: "auth.password.change" } }); + + return apiSuccess({ changed: true }); +}); diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts new file mode 100644 index 000000000..ba095efac --- /dev/null +++ b/src/app/api/auth/profile/route.ts @@ -0,0 +1,62 @@ +import { prisma } from "@/lib/db"; +import { profileSchema } from "@/lib/validations/auth"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = profileSchema.safeParse(body); + + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const data = parsed.data; + const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null; + + if (data.email !== undefined && normalizedEmail) { + const existingUser = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true }, + }); + + if (existingUser && existingUser.id !== user.id) { + return apiError("E-Mail-Adresse wird bereits verwendet", 409); + } + } + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + ...(data.email !== undefined ? { email: normalizedEmail } : {}), + heightCm: data.heightCm ?? null, + dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null, + ...(data.gender !== undefined ? { gender: data.gender } : {}), + }, + }); + + await auditLog("profile.update", { + userId: updatedUser.id, + ipAddress: getClientIp(request), + }); + + annotate({ action: { name: "auth.profile.update" } }); + + return apiSuccess({ + id: updatedUser.id, + username: updatedUser.username, + email: updatedUser.email, + role: updatedUser.role, + heightCm: updatedUser.heightCm, + dateOfBirth: updatedUser.dateOfBirth, + gender: updatedUser.gender, + timezone: updatedUser.timezone, + }); +}); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 000000000..67e7a2259 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,109 @@ +import { prisma } from "@/lib/db"; +import { registerSchema } from "@/lib/validations/auth"; +import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; +import { createSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const POST = apiHandler(async (request: NextRequest) => { + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit(`auth:register:${ip}`, 5, 15 * 60 * 1000); + if (!rl.allowed) { + return NextResponse.json( + { + data: null, + error: + "Zu viele Registrierungsversuche. Bitte später erneut versuchen.", + }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + await ensureDbCompatibility(); + + const userCount = await prisma.user.count(); + + // Check if registration is enabled. + // Safety valve: when there are zero users, always allow bootstrap registration. + let registrationEnabled = true; + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + if (settings && !settings.registrationEnabled && userCount > 0) { + registrationEnabled = false; + } + } catch { + // Table may not exist yet; allow registration + } + if (!registrationEnabled) { + return apiError("Registrierung ist deaktiviert", 403); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = registerSchema.safeParse(body); + + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { email, username, password } = parsed.data; + + // Check if email or username already taken (unified message to prevent enumeration) + const [existingEmail, existingUsername] = await Promise.all([ + prisma.user.findUnique({ where: { email } }), + prisma.user.findUnique({ where: { username } }), + ]); + if (existingEmail || existingUsername) { + return apiError("Benutzername oder E-Mail-Adresse bereits vergeben", 409); + } + + // Validate password strength + const strength = checkPasswordStrength(password, [username, email]); + if (!strength.isAcceptable) { + return apiError( + strength.feedback[0] || "Passwort zu schwach (Score < 3)", + 422, + ); + } + const passwordHash = await hashPassword(password); + + // First user becomes admin + const role = userCount === 0 ? "ADMIN" : "USER"; + + // Create user + const user = await prisma.user.create({ + data: { + email, + username, + passwordHash, + role, + }, + }); + + // Create session immediately + const ua = request.headers.get("user-agent"); + await createSession(user.id, ip, ua); + + await auditLog("auth.register", { + userId: user.id, + ipAddress: ip, + details: { method: "password" }, + }); + + annotate({ action: { name: "auth.register" } }); + + return apiSuccess( + { + user: { id: user.id, username: user.username, email: user.email }, + }, + 201, + ); +}); diff --git a/src/app/api/auth/registration-status/route.ts b/src/app/api/auth/registration-status/route.ts new file mode 100644 index 000000000..41f5dd26b --- /dev/null +++ b/src/app/api/auth/registration-status/route.ts @@ -0,0 +1,24 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + + annotate({ action: { name: "auth.registration-status" } }); + + return apiSuccess({ + registrationEnabled: settings?.registrationEnabled ?? true, + }); + } catch { + // Fail closed on backend errors. + annotate({ action: { name: "auth.registration-status" } }); + return apiSuccess({ registrationEnabled: false }); + } +}); diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts new file mode 100644 index 000000000..b6946191f --- /dev/null +++ b/src/app/api/bugreport/route.ts @@ -0,0 +1,157 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +const bugReportSchema = z.object({ + description: z.string().min(10, "Beschreibung zu kurz").max(5000), + screenshot: z + .string() + .max(7_000_000, "Screenshot zu groß (max. 5 MB)") + .optional(), +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximal 3 Bugreports pro Stunde", 429); + } + + annotate({ action: { name: "bugreport.submit" } }); + + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + githubIssueTokenEncrypted: true, + githubIssueRepo: true, + }, + }); + + let configuredToken: string | null = null; + if (appSettings?.githubIssueTokenEncrypted) { + try { + configuredToken = decrypt(appSettings.githubIssueTokenEncrypted); + } catch (err) { + getEvent()?.addWarning("Failed to decrypt GitHub issue token"); + } + } + + const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN; + const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo" + + if (!ghToken || !ghRepo) { + return apiError( + "Bugreport ist nicht konfiguriert (GitHub-Issue-Token/Repository in Admin-Einstellungen fehlt)", + 500, + ); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = bugReportSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { description, screenshot } = parsed.data; + + const dateStr = new Date().toLocaleString("de-DE", { + timeZone: "Europe/Berlin", + }); + const title = `Bug Report – ${dateStr}`; + + // Sanitize user-provided content for GitHub markdown + const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, ""); + const safeDescription = description + .replace(/```/g, "\\`\\`\\`") + .slice(0, 5000); + + // Build issue body + let issueBody = `**Gemeldet von:** ${safeUsername}\n`; + issueBody += `**Datum:** ${dateStr}\n\n`; + issueBody += `## Beschreibung\n\n${safeDescription}\n`; + issueBody += `\n---\n*Erstellt über die HealthLog Bugreport-Funktion*`; + + // If screenshot provided, upload to GitHub as a gist or include as base64 + // For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64 + if (screenshot) { + issueBody += `\n\n**Screenshot:** Ein Screenshot wurde angehängt (siehe Kommentar).`; + } + + // Create the issue + const issueRes = await fetch( + `https://api.github.com/repos/${ghRepo}/issues`, + { + method: "POST", + headers: { + Authorization: `Bearer ${ghToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github.v3+json", + }, + body: JSON.stringify({ + title: `[Bug] ${title}`, + body: issueBody, + labels: ["bug", "user-reported"], + }), + }, + ); + + if (!issueRes.ok) { + const errBody = await issueRes.text(); + getEvent()?.addWarning("GitHub issue creation failed: " + errBody); + return apiError("Issue konnte nicht erstellt werden", 500); + } + + const issue = (await issueRes.json()) as { + number: number; + html_url: string; + }; + + // If screenshot, upload as a comment + if (screenshot) { + // Extract just the base64 part + const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, ""); + const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg"; + + // Upload screenshot as issue comment with embedded image + // GitHub doesn't support base64 in comments, so we note the limitation + const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Hinweis: Der vollständige Screenshot wurde aus Größengründen gekürzt. Bitte den Nutzer direkt kontaktieren.*`; + + await fetch( + `https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`, + { + method: "POST", + headers: { + Authorization: `Bearer ${ghToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github.v3+json", + }, + body: JSON.stringify({ body: commentBody }), + }, + ); + } + + await auditLog("bugreport.submit", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + issueNumber: issue.number, + hasScreenshot: Boolean(screenshot), + }, + }); + + annotate({ meta: { issue_number: issue.number } }); + + return apiSuccess({ + issueNumber: issue.number, + issueUrl: issue.html_url, + }); +}); diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts new file mode 100644 index 000000000..94fe7bff2 --- /dev/null +++ b/src/app/api/doctor-report/route.ts @@ -0,0 +1,171 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { NextRequest } from "next/server"; + +/** + * Collect data for doctor report PDF generation (client-side). + * Returns aggregated health data for the specified time range. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "doctor-report.generate" } }); + + const rl = await checkRateLimit(`doctor-report:${user.id}`, 10, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximal 10 Reports pro Stunde", 429); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const rawDays = (body as Record)?.days; + const days = + typeof rawDays === "number" && Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365 + ? rawDays + : 90; + const since = new Date(); + since.setDate(since.getDate() - days); + + const userId = user.id; + + const [measurements, medications, intakeEvents, moodEntries, userProfile] = + await Promise.all([ + prisma.measurement.findMany({ + where: { userId, measuredAt: { gte: since } }, + orderBy: { measuredAt: "asc" }, + }), + prisma.medication.findMany({ + where: { userId, active: true }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId, scheduledFor: { gte: since } }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "asc" }, + }), + prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { + username: true, + dateOfBirth: true, + gender: true, + heightCm: true, + }, + }), + ]); + + // Group measurements by type + const byType: Record< + string, + Array<{ value: number; measuredAt: string }> + > = {}; + for (const m of measurements) { + if (!byType[m.type]) byType[m.type] = []; + byType[m.type].push({ + value: m.value, + measuredAt: m.measuredAt.toISOString(), + }); + } + + // Calculate stats per type + const stats: Record< + string, + { avg: number; min: number; max: number; count: number; latest: number } + > = {}; + for (const [type, entries] of Object.entries(byType)) { + const values = entries.map((e) => e.value); + stats[type] = { + avg: values.reduce((a, b) => a + b, 0) / values.length, + min: Math.min(...values), + max: Math.max(...values), + count: values.length, + latest: values[values.length - 1], + }; + } + + // Medication compliance + const complianceByMed: Record< + string, + { total: number; taken: number; skipped: number; missed: number } + > = {}; + for (const event of intakeEvents) { + const name = event.medication.name; + if (!complianceByMed[name]) { + complianceByMed[name] = { total: 0, taken: 0, skipped: 0, missed: 0 }; + } + complianceByMed[name].total++; + if (event.takenAt) { + complianceByMed[name].taken++; + } else if (event.skipped) { + complianceByMed[name].skipped++; + } else { + complianceByMed[name].missed++; + } + } + + // Mood summary + const moodScores = moodEntries.map((e) => e.score); + const moodSummary = + moodScores.length > 0 + ? { + avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length, + min: Math.min(...moodScores), + max: Math.max(...moodScores), + count: moodScores.length, + distribution: { + 1: moodScores.filter((s) => s === 1).length, + 2: moodScores.filter((s) => s === 2).length, + 3: moodScores.filter((s) => s === 3).length, + 4: moodScores.filter((s) => s === 4).length, + 5: moodScores.filter((s) => s === 5).length, + }, + } + : null; + + // BMI + const weightStats = stats.WEIGHT; + const bmi = + weightStats && userProfile?.heightCm + ? weightStats.latest / (userProfile.heightCm / 100) ** 2 + : null; + + await auditLog("doctor-report.generate", { + userId, + ipAddress: getClientIp(request), + details: { days }, + }); + + annotate({ meta: { report_days: days } }); + + return apiSuccess({ + period: { days, since: since.toISOString() }, + patient: { + username: userProfile?.username ?? null, + dateOfBirth: userProfile?.dateOfBirth ?? null, + gender: userProfile?.gender ?? null, + heightCm: userProfile?.heightCm ?? null, + }, + measurements: byType, + stats, + bmi: bmi ? Math.round(bmi * 10) / 10 : null, + compliance: complianceByMed, + medications: medications.map((m) => ({ + name: m.name, + dose: m.dose, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + })), + })), + mood: moodSummary, + }); +}); diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts new file mode 100644 index 000000000..bc586c2a3 --- /dev/null +++ b/src/app/api/export/route.ts @@ -0,0 +1,138 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + toCSV, + formatMeasurementsForExport, + formatMedicationsForExport, + formatIntakeEventsForExport, + formatMoodEntriesForExport, +} from "@/lib/export"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * Export user data. + * Query params: + * format: "csv" | "json" (default: json) + * type: "measurements" | "medications" | "intake" | "all" (default: all) + * + * Re-authentication is required via session (user must be logged in). + */ +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximal 10 Exports pro Stunde", 429); + } + + annotate({ action: { name: "export.download" } }); + + const { searchParams } = new URL(request.url); + const format = searchParams.get("format") ?? "json"; + const type = searchParams.get("type") ?? "all"; + + if (!["csv", "json"].includes(format)) { + return apiError("Format muss csv oder json sein", 422); + } + if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) { + return apiError("Typ muss measurements, medications, intake, mood oder all sein", 422); + } + + const userId = user.id; + + const data: Record = {}; + + if (type === "measurements" || type === "all") { + const measurements = await prisma.measurement.findMany({ + where: { userId }, + orderBy: { measuredAt: "desc" }, + }); + data.measurements = + format === "csv" + ? toCSV(formatMeasurementsForExport(measurements)) + : formatMeasurementsForExport(measurements); + } + + if (type === "medications" || type === "all") { + const medications = await prisma.medication.findMany({ + where: { userId }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }); + data.medications = + format === "csv" + ? toCSV(formatMedicationsForExport(medications)) + : formatMedicationsForExport(medications); + } + + if (type === "intake" || type === "all") { + const events = await prisma.medicationIntakeEvent.findMany({ + where: { userId }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }); + data.intakeEvents = + format === "csv" + ? toCSV(formatIntakeEventsForExport(events)) + : formatIntakeEventsForExport(events); + } + + if (type === "mood" || type === "all") { + const moodEntries = await prisma.moodEntry.findMany({ + where: { userId }, + orderBy: { moodLoggedAt: "desc" }, + }); + data.moodEntries = + format === "csv" + ? toCSV(formatMoodEntriesForExport(moodEntries)) + : formatMoodEntriesForExport(moodEntries); + } + + await auditLog("export.download", { + userId, + ipAddress: getClientIp(request), + details: { format, type }, + }); + + annotate({ meta: { export_format: format, export_type: type } }); + + if (format === "csv") { + // For CSV with all types, combine into separate sections + const csvParts: string[] = []; + if (data.measurements) { + csvParts.push("# Measurements\n" + data.measurements); + } + if (data.medications) { + csvParts.push("# Medications\n" + data.medications); + } + if (data.intakeEvents) { + csvParts.push("# Intake Events\n" + data.intakeEvents); + } + if (data.moodEntries) { + csvParts.push("# Mood Entries\n" + data.moodEntries); + } + const csvContent = csvParts.join("\n\n"); + + return new NextResponse(csvContent, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.csv"`, + }, + }); + } + + return NextResponse.json( + { data }, + { + status: 200, + headers: { + "Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.json"`, + }, + }, + ); +}); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts new file mode 100644 index 000000000..03603ffa9 --- /dev/null +++ b/src/app/api/gamification/achievements/route.ts @@ -0,0 +1,746 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { + ACHIEVEMENT_DEFINITIONS, + GAMIFICATION_ROLLOUT_AT, + calculateLongestStreak, + evaluateAchievementsWithCompletionDates, + toBerlinDayKey, +} from "@/lib/gamification/achievements"; +import { + classifyBMI, + classifyBP, + classifyPulse, +} from "@/lib/analytics/classifications"; +import { classifyIntakeTiming } from "@/lib/analytics/compliance"; + +export const dynamic = "force-dynamic"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const ROLLING_WINDOW_DAYS = 30; + +type IntakeEventRecord = { + medicationId: string; + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; +}; + +type MedicationScheduleRecord = { + windowStart: string; + windowEnd: string; + daysOfWeek: string | null; +}; + +type MeasurementRecord = { + type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE"; + value: number; + measuredAt: Date; +}; + +function maxDate(a: Date, b: Date): Date { + return a > b ? a : b; +} + +function dayKeyToSerial(dayKey: string): number { + const [year, month, day] = dayKey.split("-").map(Number); + return Date.UTC(year, month - 1, day) / DAY_MS; +} + +function serialToDayKey(serial: number): string { + const date = new Date(serial * DAY_MS); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function dayKeyToDate(dayKey: string): Date { + const [year, month, day] = dayKey.split("-").map(Number); + return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)); +} + +function mean(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((total, value) => total + value, 0) / values.length; +} + +function findCountCompletionDate(dates: Date[], target: number): Date | null { + if (dates.length < target) return null; + const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime()); + return sorted[target - 1] ?? null; +} + +function findStreakCompletionDate( + dayKeys: string[], + target: number, + firstDateByDay: Map, +): Date | null { + if (dayKeys.length === 0) return null; + + let streak = 1; + if (target === 1) { + return firstDateByDay.get(dayKeys[0]) ?? null; + } + + for (let i = 1; i < dayKeys.length; i++) { + const previous = dayKeyToSerial(dayKeys[i - 1]); + const current = dayKeyToSerial(dayKeys[i]); + streak = current - previous === 1 ? streak + 1 : 1; + + if (streak >= target) { + return firstDateByDay.get(dayKeys[i]) ?? null; + } + } + + return null; +} + +function toDaySeries(dayKeys: string[]): { + dayKeys: string[]; + firstDateByDay: Map; +} { + const sortedKeys = [...new Set(dayKeys)].sort(); + const firstDateByDay = new Map( + sortedKeys.map((key) => [key, dayKeyToDate(key)]), + ); + + return { + dayKeys: sortedKeys, + firstDateByDay, + }; +} + +function getEventDaySeries(dates: Date[]): { + dayKeys: string[]; + firstDateByDay: Map; +} { + const firstDateByDay = new Map(); + + for (const date of dates) { + const dayKey = toBerlinDayKey(date); + const existing = firstDateByDay.get(dayKey); + + if (!existing || date < existing) { + firstDateByDay.set(dayKey, date); + } + } + + return { + dayKeys: Array.from(firstDateByDay.keys()).sort(), + firstDateByDay, + }; +} + +function getHealthGreenDaySeries( + measurements: MeasurementRecord[], + heightCm: number | null, +) { + const dayStats = new Map< + string, + { + weight: number[]; + bpSys: number[]; + bpDia: number[]; + pulse: number[]; + } + >(); + + for (const measurement of measurements) { + const dayKey = toBerlinDayKey(measurement.measuredAt); + const bucket = dayStats.get(dayKey) ?? { + weight: [], + bpSys: [], + bpDia: [], + pulse: [], + }; + + if (measurement.type === "WEIGHT") bucket.weight.push(measurement.value); + if (measurement.type === "BLOOD_PRESSURE_SYS") + bucket.bpSys.push(measurement.value); + if (measurement.type === "BLOOD_PRESSURE_DIA") + bucket.bpDia.push(measurement.value); + if (measurement.type === "PULSE") bucket.pulse.push(measurement.value); + + dayStats.set(dayKey, bucket); + } + + const bmiGreenDays: string[] = []; + const bpGreenDays: string[] = []; + const pulseGreenDays: string[] = []; + + for (const [dayKey, bucket] of dayStats.entries()) { + if (heightCm && bucket.weight.length > 0) { + const bmi = mean(bucket.weight) / (heightCm / 100) ** 2; + if (classifyBMI(bmi).severity === "normal") { + bmiGreenDays.push(dayKey); + } + } + + if (bucket.bpSys.length > 0 && bucket.bpDia.length > 0) { + const bpClass = classifyBP(mean(bucket.bpSys), mean(bucket.bpDia)); + if (bpClass.severity === "normal") { + bpGreenDays.push(dayKey); + } + } + + if (bucket.pulse.length > 0) { + const pulseClass = classifyPulse(mean(bucket.pulse)); + if (pulseClass.severity === "normal") { + pulseGreenDays.push(dayKey); + } + } + } + + return { + bmi: toDaySeries(bmiGreenDays), + bp: toDaySeries(bpGreenDays), + pulse: toDaySeries(pulseGreenDays), + }; +} + +function getOnTimePerfectDaySeries( + intakeEvents: IntakeEventRecord[], + schedulesByMedicationId: Map, +) { + const eventsByDay = new Map(); + + for (const event of intakeEvents) { + const dayKey = toBerlinDayKey(event.scheduledFor); + const list = eventsByDay.get(dayKey) ?? []; + list.push(event); + eventsByDay.set(dayKey, list); + } + + const perfectDays: string[] = []; + + for (const [dayKey, events] of eventsByDay.entries()) { + if (events.length === 0) continue; + + const scheduledDate = dayKeyToDate(dayKey); + let isPerfectDay = true; + + for (const event of events) { + if (event.skipped || event.takenAt === null) { + isPerfectDay = false; + break; + } + + const schedules = schedulesByMedicationId.get(event.medicationId) ?? []; + if (schedules.length === 0) { + continue; + } + + const eventMinutes = + event.scheduledFor.getUTCHours() * 60 + + event.scheduledFor.getUTCMinutes(); + + let bestSchedule = schedules[0]; + let bestDistance = Infinity; + + for (const schedule of schedules) { + const [h, m] = schedule.windowStart.split(":").map(Number); + const scheduleMinutes = h * 60 + m; + const distance = Math.abs(eventMinutes - scheduleMinutes); + if (distance < bestDistance) { + bestDistance = distance; + bestSchedule = schedule; + } + } + + const timing = classifyIntakeTiming( + event.takenAt, + bestSchedule.windowStart, + bestSchedule.windowEnd, + scheduledDate, + ); + + if (timing !== "on_time") { + isPerfectDay = false; + break; + } + } + + if (isPerfectDay) { + perfectDays.push(dayKey); + } + } + + return toDaySeries(perfectDays); +} + +function parseDaysOfWeek(daysOfWeek: string | null): Set | null { + if (!daysOfWeek) return null; + + const values = daysOfWeek + .split(",") + .map((value) => Number(value.trim())) + .filter((value) => Number.isInteger(value) && value >= 0 && value <= 6); + + return values.length > 0 ? new Set(values) : null; +} + +function getExpectedIntakesForDay( + schedules: MedicationScheduleRecord[], + dayKey: string, +): number { + if (schedules.length === 0) return 0; + if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return 0; + + const day = dayKeyToDate(dayKey).getUTCDay(); + let expected = 0; + + for (const schedule of schedules) { + const allowedDays = parseDaysOfWeek(schedule.daysOfWeek); + if (!allowedDays || allowedDays.has(day)) { + expected += 1; + } + } + + return expected; +} + +function getIntakeIssueMetrics( + intakeEvents: IntakeEventRecord[], + schedulesByMedicationId: Map, +) { + const skippedIntakeDates = intakeEvents + .filter((event) => event.skipped) + .map((event) => event.scheduledFor) + .sort((a, b) => a.getTime() - b.getTime()); + + const eventsByMedicationDay = new Map< + string, + Map + >(); + + for (const event of intakeEvents) { + if (!event.medicationId || Number.isNaN(event.scheduledFor.getTime())) { + continue; + } + + const dayKey = toBerlinDayKey(event.scheduledFor); + const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map(); + const list = byDay.get(dayKey) ?? []; + list.push(event); + byDay.set(dayKey, list); + eventsByMedicationDay.set(event.medicationId, byDay); + } + + let overIntakeCount = 0; + const overIntakeDates: Date[] = []; + + for (const [medicationId, dayMap] of eventsByMedicationDay.entries()) { + const schedules = schedulesByMedicationId.get(medicationId) ?? []; + + for (const [dayKey, events] of dayMap.entries()) { + const expectedCount = getExpectedIntakesForDay(schedules, dayKey); + + if (expectedCount <= 0) { + continue; + } + + const takenEvents = events + .filter((event) => !event.skipped && event.takenAt !== null) + .sort((a, b) => { + const aTime = (a.takenAt ?? a.scheduledFor).getTime(); + const bTime = (b.takenAt ?? b.scheduledFor).getTime(); + return aTime - bTime; + }); + + const excessCount = Math.max(0, takenEvents.length - expectedCount); + if (excessCount === 0) { + continue; + } + + overIntakeCount += excessCount; + + for ( + let index = takenEvents.length - excessCount; + index < takenEvents.length; + index++ + ) { + const event = takenEvents[index]; + if (!event) continue; + overIntakeDates.push(event.takenAt ?? event.scheduledFor); + } + } + } + + overIntakeDates.sort((a, b) => a.getTime() - b.getTime()); + + return { + skippedIntakeCount: skippedIntakeDates.length, + skippedIntakeDates, + overIntakeCount, + overIntakeDates, + }; +} + +function getCompliance80DaySeries( + intakeEvents: IntakeEventRecord[], + startDate: Date, + endDate: Date, +) { + const perDayCounts = new Map(); + + for (const event of intakeEvents) { + const serial = dayKeyToSerial(toBerlinDayKey(event.scheduledFor)); + const current = perDayCounts.get(serial) ?? { logged: 0, taken: 0 }; + + const logged = event.skipped || event.takenAt !== null; + const taken = !event.skipped && event.takenAt !== null; + + if (logged) current.logged += 1; + if (taken) current.taken += 1; + + perDayCounts.set(serial, current); + } + + const startSerial = dayKeyToSerial(toBerlinDayKey(startDate)); + const endSerial = dayKeyToSerial(toBerlinDayKey(endDate)); + + let rollingLogged = 0; + let rollingTaken = 0; + const greenDays: string[] = []; + + for (let serial = startSerial; serial <= endSerial; serial++) { + const todayCounts = perDayCounts.get(serial); + if (todayCounts) { + rollingLogged += todayCounts.logged; + rollingTaken += todayCounts.taken; + } + + const outOfWindowCounts = perDayCounts.get(serial - ROLLING_WINDOW_DAYS); + if (outOfWindowCounts) { + rollingLogged -= outOfWindowCounts.logged; + rollingTaken -= outOfWindowCounts.taken; + } + + const hasFullWindow = serial >= startSerial + (ROLLING_WINDOW_DAYS - 1); + if (!hasFullWindow || rollingLogged <= 0) continue; + + const compliance = Math.round((rollingTaken / rollingLogged) * 100); + if (compliance >= 80) { + greenDays.push(serialToDayKey(serial)); + } + } + + return toDaySeries(greenDays); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "gamification.achievements" } }); + + const now = new Date(); + const userId = user.id; + const startDate = maxDate( + user.createdAt, + GAMIFICATION_ROLLOUT_AT, + ); + + const [measurements, intakeEvents, medications, passkeys, auditEvents] = + await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + measuredAt: { gte: startDate, lte: now }, + source: { not: "IMPORT" }, + type: { + in: [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + ], + }, + }, + select: { + type: true, + value: true, + measuredAt: true, + }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + source: { not: "IMPORT" }, + scheduledFor: { gte: startDate, lte: now }, + }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + }, + }), + prisma.medication.findMany({ + where: { userId }, + select: { + id: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + daysOfWeek: true, + }, + }, + }, + }), + prisma.passkey.findMany({ + where: { + userId, + createdAt: { gte: startDate, lte: now }, + }, + select: { + createdAt: true, + }, + }), + prisma.auditLog.findMany({ + where: { + userId, + createdAt: { gte: startDate, lte: now }, + action: { + in: [ + "auth.login.passkey", + "auth.login.password", + "bugreport.submit", + ], + }, + }, + select: { + action: true, + createdAt: true, + }, + }), + ]); + + const schedulesByMedicationId = new Map( + medications.map((med) => [med.id, med.schedules]), + ); + const intakeIssueMetrics = getIntakeIssueMetrics( + intakeEvents, + schedulesByMedicationId, + ); + + const takenIntakeDates = intakeEvents + .filter((event) => !event.skipped && event.takenAt !== null) + .map((event) => event.takenAt as Date); + const passkeyCreatedDates = passkeys.map((passkey) => passkey.createdAt); + const passkeyLoginDates = auditEvents + .filter((event) => event.action === "auth.login.passkey") + .map((event) => event.createdAt); + const passwordLoginDates = auditEvents + .filter((event) => event.action === "auth.login.password") + .map((event) => event.createdAt); + const bugReportDates = auditEvents + .filter((event) => event.action === "bugreport.submit") + .map((event) => event.createdAt); + const loginDaySeries = getEventDaySeries([ + ...passkeyLoginDates, + ...passwordLoginDates, + ]); + + const healthSeries = getHealthGreenDaySeries( + measurements as MeasurementRecord[], + user.heightCm, + ); + const onTimeSeries = getOnTimePerfectDaySeries( + intakeEvents, + schedulesByMedicationId, + ); + const complianceSeries = getCompliance80DaySeries( + intakeEvents, + startDate, + now, + ); + + const metrics = { + totalTakenIntakes: takenIntakeDates.length, + overIntakeCount: intakeIssueMetrics.overIntakeCount, + skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount, + bmiGreenStreak: calculateLongestStreak(healthSeries.bmi.dayKeys), + bpGreenStreak: calculateLongestStreak(healthSeries.bp.dayKeys), + pulseGreenStreak: calculateLongestStreak(healthSeries.pulse.dayKeys), + onTimePerfectDayStreak: calculateLongestStreak(onTimeSeries.dayKeys), + compliance80DayStreak: calculateLongestStreak(complianceSeries.dayKeys), + passkeyCreatedCount: passkeyCreatedDates.length, + passkeyLoginCount: passkeyLoginDates.length, + passwordLoginCount: passwordLoginDates.length, + loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys), + bugReportCount: bugReportDates.length, + }; + + const completionDates: Partial> = {}; + + for (const definition of ACHIEVEMENT_DEFINITIONS) { + if (definition.metric === "totalTakenIntakes") { + const completedAt = findCountCompletionDate( + takenIntakeDates, + definition.target, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "passkeyCreatedCount") { + const completedAt = findCountCompletionDate( + passkeyCreatedDates, + definition.target, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "passkeyLoginCount") { + const completedAt = findCountCompletionDate( + passkeyLoginDates, + definition.target, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "passwordLoginCount") { + const completedAt = findCountCompletionDate( + passwordLoginDates, + definition.target, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "bugReportCount") { + const completedAt = findCountCompletionDate( + bugReportDates, + definition.target, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "overIntakeCount") { + const completedAt = findCountCompletionDate( + intakeIssueMetrics.overIntakeDates, + definition.target, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "skippedIntakeCount") { + const completedAt = findCountCompletionDate( + intakeIssueMetrics.skippedIntakeDates, + definition.target, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "loginDayStreak") { + const completedAt = findStreakCompletionDate( + loginDaySeries.dayKeys, + definition.target, + loginDaySeries.firstDateByDay, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "bmiGreenStreak") { + const completedAt = findStreakCompletionDate( + healthSeries.bmi.dayKeys, + definition.target, + healthSeries.bmi.firstDateByDay, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "bpGreenStreak") { + const completedAt = findStreakCompletionDate( + healthSeries.bp.dayKeys, + definition.target, + healthSeries.bp.firstDateByDay, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "pulseGreenStreak") { + const completedAt = findStreakCompletionDate( + healthSeries.pulse.dayKeys, + definition.target, + healthSeries.pulse.firstDateByDay, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "onTimePerfectDayStreak") { + const completedAt = findStreakCompletionDate( + onTimeSeries.dayKeys, + definition.target, + onTimeSeries.firstDateByDay, + ); + if (completedAt) completionDates[definition.id] = completedAt; + continue; + } + + if (definition.metric === "compliance80DayStreak") { + const completedAt = findStreakCompletionDate( + complianceSeries.dayKeys, + definition.target, + complianceSeries.firstDateByDay, + ); + if (completedAt) completionDates[definition.id] = completedAt; + } + } + + // Load persisted achievements + const persisted = await prisma.userAchievement.findMany({ + where: { userId }, + select: { achievementId: true, unlockedAt: true }, + }); + const persistedMap = new Map( + persisted.map((p) => [p.achievementId, p.unlockedAt]), + ); + + // Merge: persisted dates take priority, then calculated dates + for (const [id, date] of Object.entries(completionDates)) { + if (!persistedMap.has(id) && date) { + persistedMap.set(id, date); + } + } + + // Use persisted dates as the source of truth + const mergedDates: Partial> = {}; + for (const [id, date] of persistedMap) { + mergedDates[id] = date; + } + + const result = evaluateAchievementsWithCompletionDates( + metrics, + mergedDates, + ); + + // Persist newly unlocked achievements + const newUnlocks = result.achievements.filter( + (a) => a.unlocked && a.completedAt && !persisted.some((p) => p.achievementId === a.id), + ); + if (newUnlocks.length > 0) { + await prisma.userAchievement.createMany({ + data: newUnlocks.map((a) => ({ + userId, + achievementId: a.id, + unlockedAt: new Date(a.completedAt!), + })), + skipDuplicates: true, + }); + } + + return apiSuccess(result); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 000000000..47cb20c7a --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,50 @@ +import { prisma } from "@/lib/db"; +import { getWorkerStatus } from "@/lib/jobs/worker-status"; +import { getSession } from "@/lib/auth/session"; +import { NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + annotate({ action: { name: "health.check" } }); + + let dbOk = true; + + try { + await prisma.$queryRaw`SELECT 1`; + } catch { + dbOk = false; + } + + const worker = getWorkerStatus(); + const status = dbOk && worker.running ? "ok" : "degraded"; + const statusCode = status === "ok" ? 200 : 503; + + const cacheHeaders = { + "Cache-Control": "no-store, no-cache, must-revalidate", + }; + + // Only expose detailed info to authenticated admins + const session = await getSession().catch(() => null); + if (session?.user.role === "ADMIN") { + return NextResponse.json( + { + status, + timestamp: new Date().toISOString(), + database: dbOk ? "connected" : "disconnected", + worker: worker.running ? "running" : "stopped", + ...(worker.lastHeartbeat + ? { workerLastHeartbeat: worker.lastHeartbeat } + : {}), + }, + { status: statusCode, headers: cacheHeaders }, + ); + } + + return NextResponse.json( + { status }, + { status: statusCode, headers: cacheHeaders }, + ); +}); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts new file mode 100644 index 000000000..b8fcdc2dc --- /dev/null +++ b/src/app/api/import/route.ts @@ -0,0 +1,122 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { validateMeasurementRange } from "@/lib/validations/measurement"; + +const measurementSchema = z + .object({ + type: z.enum([ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + ]), + value: z.number(), + unit: z.string(), + measuredAt: z.string().datetime(), + source: z.string().optional(), + notes: z.string().optional(), + }) + .refine( + (data) => validateMeasurementRange(data.type, data.value) === null, + "Wert ausserhalb des plausiblen Bereichs", + ); + +const moodEntrySchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]), + score: z.number().int().min(1).max(5), + tags: z.string().optional(), + loggedAt: z.string().datetime().optional(), +}); + +const importSchema = z.object({ + measurements: z.array(measurementSchema).max(10000).optional(), + moodEntries: z.array(moodEntrySchema).max(10000).optional(), +}); + +/** + * Import user data (JSON format matching export structure). + * Deduplicates via unique constraints — existing records are skipped. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "import.upload" } }); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = importSchema.safeParse(body); + if (!parsed.success) { + return apiSuccess({ error: parsed.error.issues[0].message }, 422); + } + + const userId = user.id; + const data = parsed.data; + const stats = { measurements: 0, moodEntries: 0, skipped: 0 }; + + // Import measurements + if (data.measurements?.length) { + for (const m of data.measurements) { + try { + await prisma.measurement.create({ + data: { + userId, + type: m.type, + value: m.value, + unit: m.unit, + source: "IMPORT", + measuredAt: new Date(m.measuredAt), + notes: m.notes || null, + }, + }); + stats.measurements++; + } catch { + // Unique constraint violation — skip duplicate + stats.skipped++; + } + } + } + + // Import mood entries + if (data.moodEntries?.length) { + for (const e of data.moodEntries) { + try { + const loggedAt = e.loggedAt ? new Date(e.loggedAt) : new Date(); + await prisma.moodEntry.create({ + data: { + userId, + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, + source: "IMPORT", + moodLoggedAt: loggedAt, + }, + }); + stats.moodEntries++; + } catch { + // Unique constraint violation — skip duplicate + stats.skipped++; + } + } + } + + await auditLog("import.upload", { + userId, + ipAddress: getClientIp(request), + details: stats, + }); + + annotate({ meta: { import_stats: stats } }); + + return apiSuccess(stats); +}); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts new file mode 100644 index 000000000..620ab44e7 --- /dev/null +++ b/src/app/api/ingest/medication/route.ts @@ -0,0 +1,145 @@ +import { prisma } from "@/lib/db"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { hashToken } from "@/lib/auth/hmac"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { externalIntakeSchema } from "@/lib/validations/medication"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * External medication ingest endpoint. + * Auth: Bearer token (hashed and looked up in api_tokens table). + * Idempotent via idempotencyKey. + * Rate limit: 60 requests per minute per IP. + */ +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "ingest.medication" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API ist global deaktiviert", 403); + } + + const ip = getClientIp(request); + + // Rate limiting: 60 requests per minute per IP + const rl = await checkRateLimit(`ingest:${ip}`, 60, 60 * 1000); + if (!rl.allowed) { + return NextResponse.json( + { data: null, error: "Rate limit exceeded" }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + // Extract bearer token + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return apiError("Authorization header required", 401); + } + + const token = authHeader.slice(7); + const tokenHashValue = hashToken(token); + + const apiToken = await prisma.apiToken.findUnique({ + where: { tokenHash: tokenHashValue }, + select: { + id: true, + userId: true, + permissions: true, + revoked: true, + expiresAt: true, + lastUsedAt: true, + }, + }); + + if (!apiToken || apiToken.revoked) { + return apiError("Invalid or revoked token", 401); + } + + if (apiToken.expiresAt && apiToken.expiresAt < new Date()) { + return apiError("Token expired", 401); + } + + if (!apiToken.permissions.includes("medication:ingest")) { + return apiError("Insufficient permissions", 403); + } + + // Annotate auth after verification + getEvent()?.setAuth({ + user_id: apiToken.userId, + auth_method: "api_key", + }); + + // Update lastUsedAt + await prisma.apiToken.update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = externalIntakeSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { medicationName, takenAt, idempotencyKey } = parsed.data; + + // Idempotency check (scoped to token owner to prevent cross-user lookups) + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { + idempotencyKey, + userId: apiToken.userId, + }, + }); + if (existing) { + return apiSuccess(existing); + } + + // Find medication by name for this user + const medication = await prisma.medication.findFirst({ + where: { + userId: apiToken.userId, + name: { equals: medicationName, mode: "insensitive" }, + active: true, + }, + }); + + if (!medication) { + return apiError("Medikament nicht gefunden", 404); + } + + const medicationScope = `medication:${medication.id}:ingest`; + if (!apiToken.permissions.includes(medicationScope)) { + return apiError("API-Endpoint fuer dieses Medikament ist deaktiviert", 403); + } + + const event = await prisma.medicationIntakeEvent.create({ + data: { + userId: apiToken.userId, + medicationId: medication.id, + scheduledFor: takenAt ?? new Date(), + takenAt: takenAt ?? new Date(), + skipped: false, + source: "API", + idempotencyKey, + }, + }); + + await auditLog("medication.ingest.external", { + userId: apiToken.userId, + ipAddress: ip, + details: { + medicationId: medication.id, + eventId: event.id, + tokenId: apiToken.id, + }, + }); + + annotate({ meta: { medication_id: medication.id, event_id: event.id } }); + + return apiSuccess(event, 201); +}); diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts new file mode 100644 index 000000000..7ee2f63b5 --- /dev/null +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { + generateBloodPressureStatusForUser, + resolveBloodPressureStatusLocale, +} from "@/lib/insights/blood-pressure-status"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const localeParam = request.nextUrl.searchParams.get("locale"); + const locale = resolveBloodPressureStatusLocale( + localeParam ?? user.locale ?? "de", + ); + + const result = await generateBloodPressureStatusForUser( + user.id, + { + locale, + force: false, + }, + ); + + annotate({ action: { name: "insights.blood-pressure-status" } }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts new file mode 100644 index 000000000..493bd3151 --- /dev/null +++ b/src/app/api/insights/bmi-status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { + generateBmiStatusForUser, + resolveBmiStatusLocale, +} from "@/lib/insights/bmi-status"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const localeParam = request.nextUrl.searchParams.get("locale"); + const locale = resolveBmiStatusLocale( + localeParam ?? user.locale ?? "de", + ); + + const result = await generateBmiStatusForUser(user.id, { + locale, + force: false, + }); + + annotate({ action: { name: "insights.bmi-status" } }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts new file mode 100644 index 000000000..ff4b7be62 --- /dev/null +++ b/src/app/api/insights/comprehensive/route.ts @@ -0,0 +1,378 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess } from "@/lib/api-response"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { + classifyBMI, + classifyBP, + generateAlerts, +} from "@/lib/analytics/classifications"; +import { + pairByTimestamp, + pearsonCorrelation, + type PairedPoint, +} from "@/lib/analytics/correlations"; +import { calculateCompliance } from "@/lib/analytics/compliance"; +import { getMedicationCategories } from "@/lib/medication-category"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const userId = user.id; + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + + // Fetch user profile + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + heightCm: true, + dateOfBirth: true, + openaiKeyEncrypted: true, + }, + }); + + // Fetch all measurements (90 days) + const types: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + ]; + + const allMeasurements = await prisma.measurement.findMany({ + where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } }, + orderBy: { measuredAt: "asc" }, + select: { type: true, value: true, measuredAt: true }, + }); + + // Fetch mood entries (90 days) + const moodEntries = await prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } }, + orderBy: { moodLoggedAt: "asc" }, + select: { date: true, score: true, moodLoggedAt: true }, + }); + + // Aggregate mood to daily averages + const moodByDay = new Map(); + for (const entry of moodEntries) { + const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; + current.sum += entry.score; + current.count += 1; + moodByDay.set(entry.date, current); + } + const dailyMoodEntries = Array.from(moodByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + day, + value: Math.round((stats.sum / stats.count) * 100) / 100, + })); + + // Build DataPoint array for mood summary + const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({ + date: e.moodLoggedAt, + value: e.score, + })); + const moodSummary = moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; + + const byType = (t: MeasurementType): DataPoint[] => + allMeasurements + .filter((m) => m.type === t) + .map((m) => ({ date: m.measuredAt, value: m.value })); + + // Summaries + const summaries: Record> = {}; + for (const t of types) { + const data = byType(t); + if (data.length > 0) { + summaries[t] = summarize(data); + } + } + + // BMI + let bmi: number | null = null; + let bmiClassification = null; + if (dbUser?.heightCm && summaries.WEIGHT?.latest) { + const heightM = dbUser.heightCm / 100; + bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10; + bmiClassification = classifyBMI(bmi); + } + + // BP classification (30-day average) + let bpClassification = null; + const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); + if ( + summaries.BLOOD_PRESSURE_SYS?.avg30 && + summaries.BLOOD_PRESSURE_DIA?.avg30 + ) { + bpClassification = classifyBP( + summaries.BLOOD_PRESSURE_SYS.avg30, + summaries.BLOOD_PRESSURE_DIA.avg30, + ); + } + + // BP target adherence + let bpPctInTarget: number | null = null; + if (bpTargets) { + const sysData = byType("BLOOD_PRESSURE_SYS"); + const diaData = byType("BLOOD_PRESSURE_DIA"); + const sysPairs = pairByTimestamp(sysData, diaData, 5 * 60 * 1000); + + if (sysPairs.length > 0) { + const inTarget = sysPairs.filter( + (p) => + p.a >= bpTargets.sysLow && + p.a <= bpTargets.sysHigh && + p.b >= bpTargets.diaLow && + p.b <= bpTargets.diaHigh, + ).length; + bpPctInTarget = Math.round((inTarget / sysPairs.length) * 100); + } + } + + // Correlations: Weight vs Sys BP + const weightData = byType("WEIGHT"); + const sysData = byType("BLOOD_PRESSURE_SYS"); + const weightBpPairs = pairByTimestamp(weightData, sysData); + const weightBpCorrelation = pearsonCorrelation(weightBpPairs); + const scatterData = weightBpPairs.map((p) => ({ + weight: p.a, + sysBP: p.b, + })); + + // Correlations: Mood vs metrics (using daily averages matched by date) + function buildMoodMetricPairs( + dailyMood: Array<{ day: string; value: number }>, + measurements: Array<{ measuredAt: Date; value: number }>, + ): PairedPoint[] { + // Group measurements by day + const metricByDay = new Map(); + for (const m of measurements) { + const dayKey = m.measuredAt.toISOString().slice(0, 10); + const current = metricByDay.get(dayKey) ?? { sum: 0, count: 0 }; + current.sum += m.value; + current.count += 1; + metricByDay.set(dayKey, current); + } + + const pairs: PairedPoint[] = []; + for (const mood of dailyMood) { + const metric = metricByDay.get(mood.day); + if (metric) { + pairs.push({ + a: mood.value, + b: Math.round((metric.sum / metric.count) * 100) / 100, + date: new Date(`${mood.day}T12:00:00.000Z`), + }); + } + } + return pairs; + } + + // Mood vs Systolic BP + const moodBpPairs = buildMoodMetricPairs( + dailyMoodEntries, + allMeasurements.filter((m) => m.type === "BLOOD_PRESSURE_SYS").map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + ); + const moodBpCorrelation = pearsonCorrelation(moodBpPairs); + const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b })); + + // Mood vs Weight + const moodWeightPairs = buildMoodMetricPairs( + dailyMoodEntries, + allMeasurements.filter((m) => m.type === "WEIGHT").map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + ); + const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs); + const moodWeightScatterData = moodWeightPairs.map((p) => ({ mood: p.a, weight: p.b })); + + // Mood vs Pulse + const moodPulsePairs = buildMoodMetricPairs( + dailyMoodEntries, + allMeasurements.filter((m) => m.type === "PULSE").map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + ); + const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs); + const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, pulse: p.b })); + + // Medication compliance + const medications = await prisma.medication.findMany({ + where: { userId, active: true }, + include: { schedules: true }, + }); + const categoryMap = await getMedicationCategories( + medications.map((m) => m.id), + ); + + const medCompliance = []; + const bpMedicationEvents: Array<{ + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; + }> = []; + const bpMedications = medications.filter( + (med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE", + ); + for (const med of medications) { + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + medicationId: med.id, + userId, + scheduledFor: { gte: ninetyDaysAgo }, + }, + orderBy: { scheduledFor: "desc" }, + }); + const mapped = events.map((e) => ({ + takenAt: e.takenAt, + skipped: e.skipped, + scheduledFor: e.scheduledFor, + })); + const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt); + const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt); + + medCompliance.push({ + id: med.id, + name: med.name, + dose: med.dose, + category: categoryMap[med.id] ?? "OTHER", + compliance7: c7.rate, + compliance30: c30.rate, + streak: c7.streak, + taken7: c7.taken, + skipped7: c7.skipped, + missed7: c7.missed, + }); + + if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") { + bpMedicationEvents.push( + ...events.map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + })), + ); + } + } + + // Correlation: continuity of BP medications vs systolic BP + let bpMedicationCorrelation: { + r: number; + strength: string; + n: number; + medicationCount: number; + } | null = null; + const bpMedicationScatterData: Array<{ + continuityPct: number; + sysBP: number; + }> = []; + + const expectedBpIntakesPerDay = bpMedications.reduce( + (sum, med) => sum + med.schedules.length, + 0, + ); + + if (expectedBpIntakesPerDay > 0) { + const sysByDay = new Map(); + for (const m of allMeasurements) { + if (m.type !== "BLOOD_PRESSURE_SYS") continue; + const dayKey = m.measuredAt.toISOString().slice(0, 10); + const list = sysByDay.get(dayKey) ?? []; + list.push(m.value); + sysByDay.set(dayKey, list); + } + + const takenByDay = new Map(); + for (const event of bpMedicationEvents) { + if (event.skipped || !event.takenAt) continue; + const dayKey = event.scheduledFor.toISOString().slice(0, 10); + takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); + } + + const pairs: Array<{ a: number; b: number; date: Date }> = []; + for (const [dayKey, sysValues] of sysByDay.entries()) { + const taken = takenByDay.get(dayKey) ?? 0; + const continuity = Math.min(1, taken / expectedBpIntakesPerDay); + const avgSys = + sysValues.reduce((sum, value) => sum + value, 0) / sysValues.length; + pairs.push({ + a: continuity, + b: avgSys, + date: new Date(`${dayKey}T00:00:00.000Z`), + }); + bpMedicationScatterData.push({ + continuityPct: Math.round(continuity * 100), + sysBP: Math.round(avgSys * 10) / 10, + }); + } + + const corr = pearsonCorrelation(pairs); + if (corr) { + bpMedicationCorrelation = { + ...corr, + medicationCount: bpMedications.length, + }; + } + } + + // Generate alerts + const alerts = generateAlerts({ + bmi, + bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null, + bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null, + bpPctInTarget, + weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, + pulseAvg30: summaries.PULSE?.avg30 ?? null, + pulseAnomalyCount: summaries.PULSE?.anomalyCount, + medications: medCompliance.map((m) => ({ + name: m.name, + compliance7: m.compliance7, + compliance30: m.compliance30, + })), + }); + + // Data span + const firstMeasurement = + allMeasurements.length > 0 ? allMeasurements[0].measuredAt : null; + const dataSpanDays = firstMeasurement + ? Math.ceil( + (Date.now() - firstMeasurement.getTime()) / (24 * 60 * 60 * 1000), + ) + : 0; + + annotate({ + action: { name: "insights.comprehensive" }, + meta: { + totalMeasurements: allMeasurements.length, + moodEntries: moodEntries.length, + medications: medications.length, + }, + }); + + return apiSuccess({ + summaries, + bmi, + bmiClassification, + bpClassification, + bpPctInTarget, + bpTargets, + weightBpCorrelation, + scatterData, + bpMedicationCorrelation, + bpMedicationScatterData, + moodSummary, + moodBpCorrelation, + moodBpScatterData, + moodWeightCorrelation, + moodWeightScatterData, + moodPulseCorrelation, + moodPulseScatterData, + medications: medCompliance, + alerts, + hasOpenAiKey: !!dbUser?.openaiKeyEncrypted, + dataSpanDays, + totalMeasurements: allMeasurements.length, + }); +}); diff --git a/src/app/api/insights/general-status/route.ts b/src/app/api/insights/general-status/route.ts new file mode 100644 index 000000000..a951f1cb8 --- /dev/null +++ b/src/app/api/insights/general-status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { + generateGeneralStatusForUser, + resolveGeneralStatusLocale, +} from "@/lib/insights/general-status"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const localeParam = request.nextUrl.searchParams.get("locale"); + const locale = resolveGeneralStatusLocale( + localeParam ?? user.locale ?? "de", + ); + + const result = await generateGeneralStatusForUser(user.id, { + locale, + force: false, + }); + + annotate({ action: { name: "insights.general-status" } }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts new file mode 100644 index 000000000..71000c80e --- /dev/null +++ b/src/app/api/insights/generate/route.ts @@ -0,0 +1,156 @@ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { extractFeatures } from "@/lib/insights/features"; +import { + INSIGHTS_SYSTEM_PROMPT, + buildUserPrompt, + type InsightsOutput, +} from "@/lib/insights/prompt"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * Generate AI-powered health insights. + * Rate limit: 2 per hour per user. + * Caches result daily (or until new data arrives). + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const userId = user.id; + + // Rate limit: 2 per hour per user + const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError( + "Maximal 2 Insights-Generierungen pro Stunde. Bitte spaeter erneut versuchen.", + 429, + ); + } + + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + openaiKeyEncrypted: true, + insightsPrivacyMode: true, + insightsCachedAt: true, + insightsCachedText: true, + }, + }); + + if (!dbUser?.openaiKeyEncrypted) { + return apiError( + "Kein OpenAI API-Key hinterlegt. Bitte in den Einstellungen konfigurieren.", + 422, + ); + } + + // Check if force refresh is requested + const body = await request.json().catch(() => ({})); + const forceRefresh = body.force === true; + + // Return cached result if available and less than 24h old + if ( + !forceRefresh && + dbUser.insightsCachedAt && + dbUser.insightsCachedText && + Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 + ) { + try { + const cached = JSON.parse(dbUser.insightsCachedText) as InsightsOutput; + annotate({ action: { name: "insights.generate" }, meta: { cached: true } }); + return apiSuccess({ + insights: cached, + cached: true, + cachedAt: dbUser.insightsCachedAt, + }); + } catch { + // Invalid cache, regenerate + } + } + + const apiKey = decrypt(dbUser.openaiKeyEncrypted); + const includeRaw = dbUser.insightsPrivacyMode === "raw"; + const features = await extractFeatures(userId, includeRaw); + const featuresJson = JSON.stringify(features, null, 2); + const userPrompt = buildUserPrompt(featuresJson, dbUser.insightsPrivacyMode); + + // Call OpenAI + const openaiRes = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: INSIGHTS_SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + temperature: 0.3, + max_tokens: 1000, + response_format: { type: "json_object" }, + }), + }, + ); + + if (!openaiRes.ok) { + const errBody = await openaiRes.text(); + annotate({ meta: { openai_status: openaiRes.status, openai_error: errBody } }); + if (openaiRes.status === 401) { + return apiError("Ungueltiger OpenAI API-Key", 422); + } + return apiError("OpenAI-Anfrage fehlgeschlagen", 502); + } + + const openaiJson = await openaiRes.json(); + const content = openaiJson.choices?.[0]?.message?.content; + + if (!content) { + return apiError("Keine Antwort von OpenAI erhalten", 502); + } + + let insights: InsightsOutput; + try { + insights = JSON.parse(content); + } catch { + return apiError("OpenAI-Antwort konnte nicht verarbeitet werden", 502); + } + + // Cache the result + await prisma.user.update({ + where: { id: userId }, + data: { + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify(insights), + }, + }); + + // Audit log (no sensitive content) + await auditLog("insights.generate", { + userId, + ipAddress: getClientIp(request), + details: { + privacyMode: dbUser.insightsPrivacyMode, + tokensUsed: openaiJson.usage?.total_tokens ?? null, + }, + }); + + annotate({ + action: { name: "insights.generate" }, + meta: { + cached: false, + privacyMode: dbUser.insightsPrivacyMode, + tokensUsed: openaiJson.usage?.total_tokens ?? null, + }, + }); + + return apiSuccess({ insights, cached: false }); +}); diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts new file mode 100644 index 000000000..a0d2e2b7e --- /dev/null +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { + generateMedicationComplianceStatusForUser, + resolveMedicationComplianceStatusLocale, +} from "@/lib/insights/medication-compliance-status"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const localeParam = request.nextUrl.searchParams.get("locale"); + const locale = resolveMedicationComplianceStatusLocale( + localeParam ?? user.locale ?? "de", + ); + + const result = await generateMedicationComplianceStatusForUser( + user.id, + { + locale, + force: false, + }, + ); + + annotate({ action: { name: "insights.medication-compliance-status" } }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts new file mode 100644 index 000000000..d21259f9d --- /dev/null +++ b/src/app/api/insights/mood-status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { + generateMoodStatusForUser, + resolveMoodStatusLocale, +} from "@/lib/insights/mood-status"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const localeParam = request.nextUrl.searchParams.get("locale"); + const locale = resolveMoodStatusLocale( + localeParam ?? user.locale ?? "de", + ); + + const result = await generateMoodStatusForUser(user.id, { + locale, + force: false, + }); + + annotate({ action: { name: "insights.mood-status" } }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts new file mode 100644 index 000000000..fc8f1cec9 --- /dev/null +++ b/src/app/api/insights/pulse-status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { + generatePulseStatusForUser, + resolvePulseStatusLocale, +} from "@/lib/insights/pulse-status"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const localeParam = request.nextUrl.searchParams.get("locale"); + const locale = resolvePulseStatusLocale( + localeParam ?? user.locale ?? "de", + ); + + const result = await generatePulseStatusForUser(user.id, { + locale, + force: false, + }); + + annotate({ action: { name: "insights.pulse-status" } }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts new file mode 100644 index 000000000..9179a2eb7 --- /dev/null +++ b/src/app/api/insights/settings/route.ts @@ -0,0 +1,86 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { encrypt } from "@/lib/crypto"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * Get insights configuration status. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + openaiKeyEncrypted: true, + insightsPrivacyMode: true, + insightsCachedAt: true, + }, + }); + + annotate({ action: { name: "insights.settings.get" } }); + + return apiSuccess({ + hasKey: !!dbUser?.openaiKeyEncrypted, + privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated", + lastInsightAt: dbUser?.insightsCachedAt ?? null, + }); +}); + +/** + * Update insights settings (API key and/or privacy mode). + */ +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson>(request); + + if (jsonError) return jsonError; + const data: Record = {}; + + // Update API key if provided + if (typeof body.apiKey === "string") { + const key = (body.apiKey as string).trim(); + if (key === "") { + // Remove key + data.openaiKeyEncrypted = null; + data.insightsCachedAt = null; + data.insightsCachedText = null; + } else { + if (!key.startsWith("sk-")) { + return apiError( + "Ungueltiges API-Key-Format (muss mit sk- beginnen)", + 422, + ); + } + data.openaiKeyEncrypted = encrypt(key); + } + } + + // Update privacy mode if provided + if (typeof body.privacyMode === "string") { + const mode = body.privacyMode as string; + if (!["aggregated", "raw"].includes(mode)) { + return apiError("Ungueltiger Privacy-Modus", 422); + } + data.insightsPrivacyMode = mode; + // Clear cache when privacy mode changes + data.insightsCachedAt = null; + data.insightsCachedText = null; + } + + if (Object.keys(data).length === 0) { + return apiError("Keine Aenderungen", 422); + } + + await prisma.user.update({ + where: { id: user.id }, + data, + }); + + annotate({ action: { name: "insights.settings.update" } }); + + return apiSuccess({ updated: true }); +}); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts new file mode 100644 index 000000000..9af144681 --- /dev/null +++ b/src/app/api/insights/targets/route.ts @@ -0,0 +1,589 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess } from "@/lib/api-response"; +import { + classifyBMI, + classifyBP, + classifySleepDuration, + classifyBodyFat, + classifySteps, + getWeightRange, + getSleepDurationRange, + getStepsRange, + getBpTargetsByAge, +} from "@/lib/analytics/classifications"; +import { calculateCompliance } from "@/lib/analytics/compliance"; +import { pairByTimestamp } from "@/lib/analytics/correlations"; +import { + classifyPulseByTarget, + getPersonalizedPulseTarget, +} from "@/lib/analytics/pulse-targets"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +function getAge(dateOfBirth: Date): number { + const today = new Date(); + let age = today.getFullYear() - dateOfBirth.getFullYear(); + const monthDiff = today.getMonth() - dateOfBirth.getMonth(); + if ( + monthDiff < 0 || + (monthDiff === 0 && today.getDate() < dateOfBirth.getDate()) + ) { + age--; + } + return age; +} + +interface TargetItem { + type: string; + label: string; + current: number | null; + average30: number | null; + trend: "up" | "down" | "stable" | null; + unit: string; + range: { min: number; max: number } | null; + classification: { category: string; color: string } | null; + source: string; + details?: { + medications?: Array<{ + name: string; + compliance7: number; + compliance30: number; + }>; + }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const userId = user.id; + + // Fetch user profile + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + heightCm: true, + dateOfBirth: true, + gender: true, + }, + }); + + const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; + const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null; + const heightCm = dbUser?.heightCm ?? null; + + // Fetch latest measurements for each type + const types: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "SLEEP_DURATION", + "BODY_FAT", + "ACTIVITY_STEPS", + ]; + + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + // Fetch all measurements in the last 30 days + the latest for each type + const recentMeasurements = await prisma.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: thirtyDaysAgo }, + }, + orderBy: { measuredAt: "desc" }, + select: { type: true, value: true, measuredAt: true }, + }); + + // Also get the absolute latest measurement per type (even if older than 30 days) + const latestByType: Record = {}; + const avg30ByType: Record = {}; + + for (const t of types) { + // Latest value + const latest = await prisma.measurement.findFirst({ + where: { userId, type: t }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + latestByType[t] = latest?.value ?? null; + + // 30-day average + const recentOfType = recentMeasurements.filter((m) => m.type === t); + if (recentOfType.length > 0) { + const sum = recentOfType.reduce((acc, m) => acc + m.value, 0); + avg30ByType[t] = Math.round((sum / recentOfType.length) * 10) / 10; + } else { + avg30ByType[t] = null; + } + } + + // Compute trend (compare first half of 30-day data to second half) + function computeTrend( + type: MeasurementType, + ): "up" | "down" | "stable" | null { + const data = recentMeasurements + .filter((m) => m.type === type) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + if (data.length < 4) return null; + + const mid = Math.floor(data.length / 2); + const firstHalf = data.slice(0, mid); + const secondHalf = data.slice(mid); + + const avgFirst = + firstHalf.reduce((s, m) => s + m.value, 0) / firstHalf.length; + const avgSecond = + secondHalf.reduce((s, m) => s + m.value, 0) / secondHalf.length; + + const diff = avgSecond - avgFirst; + const threshold = avgFirst * 0.02; // 2% change threshold + + if (diff > threshold) return "up"; + if (diff < -threshold) return "down"; + return "stable"; + } + + // Build target items + const targets: TargetItem[] = []; + + // 1. Weight + const weightRange = heightCm ? getWeightRange(heightCm) : null; + let weightClassification: { category: string; color: string } | null = null; + if (latestByType.WEIGHT != null && heightCm) { + const heightM = heightCm / 100; + const bmi = latestByType.WEIGHT / (heightM * heightM); + const cls = classifyBMI(bmi); + weightClassification = { category: cls.category, color: cls.color }; + } + targets.push({ + type: "WEIGHT", + label: "Gewicht", + current: latestByType.WEIGHT ?? null, + average30: avg30ByType.WEIGHT ?? null, + trend: computeTrend("WEIGHT"), + unit: "kg", + range: weightRange, + classification: weightClassification, + source: "WHO BMI", + }); + + // 2. Blood Pressure (sys/dia combined) + const bpRange = age != null ? getBpTargetsByAge(age, gender) : null; + let bpClassification: { category: string; color: string } | null = null; + if ( + latestByType.BLOOD_PRESSURE_SYS != null && + latestByType.BLOOD_PRESSURE_DIA != null + ) { + const cls = classifyBP( + latestByType.BLOOD_PRESSURE_SYS, + latestByType.BLOOD_PRESSURE_DIA, + ); + bpClassification = { category: cls.category, color: cls.color }; + } + targets.push({ + type: "BLOOD_PRESSURE", + label: "Blutdruck", + current: latestByType.BLOOD_PRESSURE_SYS ?? null, + average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null, + trend: computeTrend("BLOOD_PRESSURE_SYS"), + unit: "mmHg", + range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null, + classification: bpClassification, + source: "ESC/ESH 2018", + // Extra fields for diastolic + } as TargetItem); + + // 2b. Blood pressure target-hit rate over 30 days + if (bpRange) { + const sysPoints = recentMeasurements + .filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS") + .map((measurement) => ({ + date: measurement.measuredAt, + value: measurement.value, + })); + const diaPoints = recentMeasurements + .filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA") + .map((measurement) => ({ + date: measurement.measuredAt, + value: measurement.value, + })); + + const bpPairs = pairByTimestamp(sysPoints, diaPoints, 5 * 60 * 1000); + const bpInTargetCount = bpPairs.filter( + (pair) => + pair.a >= bpRange.sysLow && + pair.a <= bpRange.sysHigh && + pair.b >= bpRange.diaLow && + pair.b <= bpRange.diaHigh, + ).length; + const bpInTargetRate = + bpPairs.length > 0 + ? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10 + : null; + + targets.push({ + type: "BLOOD_PRESSURE_IN_TARGET", + label: "Blutdruck im Zielbereich", + current: bpInTargetRate, + average30: bpInTargetRate, + trend: null, + unit: "%", + range: { min: 70, max: 100 }, + classification: + bpInTargetRate != null + ? bpInTargetRate >= 70 + ? { category: "Gut", color: "#50fa7b" } + : bpInTargetRate >= 40 + ? { category: "Moderat", color: "#f1fa8c" } + : { category: "Niedrig", color: "#ff5555" } + : null, + source: "ESC/ESH 2018", + }); + } + + // 3. Pulse + const pulseTarget = getPersonalizedPulseTarget(age, gender); + let pulseClassification: { category: string; color: string } | null = null; + if (latestByType.PULSE != null) { + const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget); + pulseClassification = { category: cls.category, color: cls.color }; + } + targets.push({ + type: "PULSE", + label: "Ruhepuls", + current: latestByType.PULSE ?? null, + average30: avg30ByType.PULSE ?? null, + trend: computeTrend("PULSE"), + unit: "bpm", + range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax }, + classification: pulseClassification, + source: pulseTarget.source, + }); + + // 4. Sleep Duration + const sleepRange = getSleepDurationRange(); + let sleepClassification: { category: string; color: string } | null = null; + if (latestByType.SLEEP_DURATION != null) { + const cls = classifySleepDuration(latestByType.SLEEP_DURATION); + sleepClassification = { category: cls.category, color: cls.color }; + } + targets.push({ + type: "SLEEP_DURATION", + label: "Schlafdauer", + current: latestByType.SLEEP_DURATION ?? null, + average30: avg30ByType.SLEEP_DURATION ?? null, + trend: computeTrend("SLEEP_DURATION"), + unit: "h", + range: sleepRange, + classification: sleepClassification, + source: "AASM/SRS", + }); + + // 5. BMI (derived from weight + height) + if (heightCm) { + const heightM = heightCm / 100; + const heightSq = heightM * heightM; + const latestWeight = latestByType.WEIGHT ?? null; + const currentBmi = + latestWeight != null + ? Math.round((latestWeight / heightSq) * 10) / 10 + : null; + + // Compute 30-day average BMI from average weight + const avgWeight = avg30ByType.WEIGHT ?? null; + const avgBmi = + avgWeight != null ? Math.round((avgWeight / heightSq) * 10) / 10 : null; + + let bmiClassification: { category: string; color: string } | null = null; + if (currentBmi != null) { + const cls = classifyBMI(currentBmi); + bmiClassification = { category: cls.category, color: cls.color }; + } + + targets.push({ + type: "BMI", + label: "BMI", + current: currentBmi, + average30: avgBmi, + trend: computeTrend("WEIGHT"), // BMI trend follows weight trend + unit: "kg/m\u00B2", + range: { min: 18.5, max: 24.9 }, + classification: bmiClassification, + source: "WHO", + }); + } + + // 6. Body Fat + let bodyFatClassification: { category: string; color: string } | null = null; + if (latestByType.BODY_FAT != null && age != null) { + const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age); + bodyFatClassification = { category: cls.category, color: cls.color }; + } + // Body fat ranges depend on gender/age; provide simplified ranges + let bodyFatRange: { min: number; max: number } | null = null; + if (gender === "MALE") { + bodyFatRange = { min: 14, max: 24 }; + } else if (gender === "FEMALE") { + bodyFatRange = { min: 21, max: 31 }; + } else if (age != null) { + // Average of male/female fitness+average ranges + bodyFatRange = { min: 17.5, max: 27.5 }; + } + targets.push({ + type: "BODY_FAT", + label: "Koerperfett", + current: latestByType.BODY_FAT ?? null, + average30: avg30ByType.BODY_FAT ?? null, + trend: computeTrend("BODY_FAT"), + unit: "%", + range: bodyFatRange, + classification: bodyFatClassification, + source: "ACE", + }); + + // 7. Activity Steps + const stepsRange = getStepsRange(); + let stepsClassification: { category: string; color: string } | null = null; + if (avg30ByType.ACTIVITY_STEPS != null) { + const cls = classifySteps(avg30ByType.ACTIVITY_STEPS); + stepsClassification = { category: cls.category, color: cls.color }; + } + targets.push({ + type: "ACTIVITY_STEPS", + label: "Schritte/Tag", + current: latestByType.ACTIVITY_STEPS ?? null, + average30: avg30ByType.ACTIVITY_STEPS ?? null, + trend: computeTrend("ACTIVITY_STEPS"), + unit: "Schritte", + range: stepsRange, + classification: stepsClassification, + source: "WHO", + }); + + // 8. Medication Compliance (average across active medications) + const activeMedications = await prisma.medication.findMany({ + where: { userId, active: true }, + include: { schedules: true }, + orderBy: { name: "asc" }, + }); + + if (activeMedications.length > 0) { + const thirtyDaysAgoIntake = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const intakeEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + medicationId: { + in: activeMedications.map((medication) => medication.id), + }, + scheduledFor: { gte: thirtyDaysAgoIntake }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + medicationId: true, + takenAt: true, + skipped: true, + scheduledFor: true, + }, + }); + + const medicationStats = activeMedications.map((medication) => { + const medicationEvents = intakeEvents.filter( + (event) => event.medicationId === medication.id, + ); + const compliance7 = calculateCompliance( + medicationEvents, + medication.schedules, + 7, + medication.createdAt, + ); + const compliance30 = calculateCompliance( + medicationEvents, + medication.schedules, + 30, + medication.createdAt, + ); + + return { + name: medication.name, + compliance7: compliance7.rate, + compliance30: compliance30.rate, + totalExpected7: compliance7.totalExpected, + taken7: compliance7.taken, + totalExpected30: compliance30.totalExpected, + taken30: compliance30.taken, + }; + }); + + const totalExpected7 = medicationStats.reduce( + (sum, medication) => sum + medication.totalExpected7, + 0, + ); + const totalTaken7 = medicationStats.reduce( + (sum, medication) => sum + medication.taken7, + 0, + ); + const totalExpected30 = medicationStats.reduce( + (sum, medication) => sum + medication.totalExpected30, + 0, + ); + const totalTaken30 = medicationStats.reduce( + (sum, medication) => sum + medication.taken30, + 0, + ); + + const complianceRate7 = + totalExpected7 > 0 + ? Math.round( + (Math.min(1, totalTaken7 / totalExpected7) * 100 + Number.EPSILON) * + 10, + ) / 10 + : null; + const complianceRate30 = + totalExpected30 > 0 + ? Math.round( + (Math.min(1, totalTaken30 / totalExpected30) * 100 + + Number.EPSILON) * + 10, + ) / 10 + : complianceRate7; + + targets.push({ + type: "MEDICATION_COMPLIANCE", + label: "Einnahmetreue", + current: complianceRate7, + average30: complianceRate30, + trend: null, + unit: "%", + range: { min: 90, max: 100 }, + classification: + complianceRate7 != null + ? complianceRate7 >= 90 + ? { category: "Sehr gut", color: "#50fa7b" } + : complianceRate7 >= 70 + ? { category: "Gut", color: "#f1fa8c" } + : { category: "Niedrig", color: "#ff5555" } + : null, + source: "7-Tage", + details: { + medications: medicationStats.map((medication) => ({ + name: medication.name, + compliance7: medication.compliance7, + compliance30: medication.compliance30, + })), + }, + }); + } + + // 9. Mood targets (if mood data exists) + const moodEntries = await prisma.moodEntry.findMany({ + where: { userId }, + orderBy: { moodLoggedAt: "desc" }, + select: { score: true, moodLoggedAt: true }, + }); + + if (moodEntries.length >= 3) { + const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const recentMood = moodEntries.filter( + (entry) => entry.moodLoggedAt >= thirtyDaysAgoMood, + ); + const latestMoodScore = moodEntries[0]?.score ?? null; + + const moodAvg30 = + recentMood.length > 0 + ? Math.round( + (recentMood.reduce((sum, e) => sum + e.score, 0) / + recentMood.length) * + 10, + ) / 10 + : null; + + // Mood trend: compare first half to second half of last 30 days + let moodTrend: "up" | "down" | "stable" | null = null; + if (recentMood.length >= 4) { + const sorted = [...recentMood].sort( + (a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(), + ); + const mid = Math.floor(sorted.length / 2); + const firstHalf = sorted.slice(0, mid); + const secondHalf = sorted.slice(mid); + const avgFirst = + firstHalf.reduce((s, e) => s + e.score, 0) / firstHalf.length; + const avgSecond = + secondHalf.reduce((s, e) => s + e.score, 0) / secondHalf.length; + const diff = avgSecond - avgFirst; + if (diff > 0.2) moodTrend = "up"; + else if (diff < -0.2) moodTrend = "down"; + else moodTrend = "stable"; + } + + const moodClassification = + latestMoodScore != null + ? latestMoodScore >= 3.5 + ? { category: "Gut", color: "#50fa7b" } + : latestMoodScore >= 2 + ? { category: "Moderat", color: "#f1fa8c" } + : { category: "Niedrig", color: "#ff5555" } + : null; + + targets.push({ + type: "MOOD_SCORE", + label: "Stimmung", + current: latestMoodScore, + average30: moodAvg30, + trend: moodTrend, + unit: "/ 5", + range: { min: 3.5, max: 5 }, + classification: moodClassification, + source: "moodLog", + }); + + // Mood stability: standard deviation of recent scores (lower = more stable) + if (recentMood.length >= 5) { + const mean = moodAvg30 ?? latestMoodScore ?? 3; + const variance = + recentMood.reduce((sum, e) => sum + (e.score - mean) ** 2, 0) / + recentMood.length; + const stdDev = Math.round(Math.sqrt(variance) * 100) / 100; + + const stabilityClassification = + stdDev <= 0.5 + ? { category: "Sehr stabil", color: "#50fa7b" } + : stdDev <= 1.0 + ? { category: "Stabil", color: "#f1fa8c" } + : { category: "Schwankend", color: "#ff5555" }; + + targets.push({ + type: "MOOD_STABILITY", + label: "Stimmungsstabilitaet", + current: stdDev, + average30: stdDev, + trend: null, + unit: "\u03C3", + range: { min: 0, max: 0.5 }, + classification: stabilityClassification, + source: "moodLog", + }); + } + } + + annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } }); + + return apiSuccess({ + targets, + // Extra diastolic data for BP display + bpDiastolic: { + current: latestByType.BLOOD_PRESSURE_DIA ?? null, + average30: avg30ByType.BLOOD_PRESSURE_DIA ?? null, + range: bpRange ? { min: bpRange.diaLow, max: bpRange.diaHigh } : null, + }, + profile: { + heightCm, + age, + gender, + }, + }); +}); diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts new file mode 100644 index 000000000..3d6d61f43 --- /dev/null +++ b/src/app/api/insights/weight-status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api-response"; +import { + generateWeightStatusForUser, + resolveWeightStatusLocale, +} from "@/lib/insights/weight-status"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const localeParam = request.nextUrl.searchParams.get("locale"); + const locale = resolveWeightStatusLocale( + localeParam ?? user.locale ?? "de", + ); + + const result = await generateWeightStatusForUser(user.id, { + locale, + force: false, + }); + + annotate({ action: { name: "insights.weight-status" } }); + + return apiSuccess(result); +}); diff --git a/src/app/api/integrations/moodlog/status/route.ts b/src/app/api/integrations/moodlog/status/route.ts new file mode 100644 index 000000000..1835aa1c3 --- /dev/null +++ b/src/app/api/integrations/moodlog/status/route.ts @@ -0,0 +1,33 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } 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 () => { + const { user } = await requireAuth(); + annotate({ action: { name: "moodlog.status" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + moodLogUrlEncrypted: true, + moodLogEnabled: true, + moodLogLastSyncedAt: true, + moodLogWebhookSecret: true, + }, + }); + + const entryCount = await prisma.moodEntry.count({ + where: { userId: user.id }, + }); + + return apiSuccess({ + configured: Boolean(dbUser?.moodLogUrlEncrypted), + enabled: dbUser?.moodLogEnabled ?? false, + lastSyncedAt: dbUser?.moodLogLastSyncedAt ?? null, + entryCount, + webhookSecret: dbUser?.moodLogWebhookSecret ?? null, + }); +}); diff --git a/src/app/api/integrations/moodlog/sync/route.ts b/src/app/api/integrations/moodlog/sync/route.ts new file mode 100644 index 000000000..2caa18b10 --- /dev/null +++ b/src/app/api/integrations/moodlog/sync/route.ts @@ -0,0 +1,35 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { syncMoodLogEntries } from "@/lib/moodlog/sync"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "moodlog.sync" } }); + + // Check global toggle + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moodLogGlobal: true }, + }); + if (appSettings && !appSettings.moodLogGlobal) { + return apiError("moodLog-Integration ist deaktiviert", 403); + } + + let fullSync = false; + try { + const body = await request.json(); + fullSync = body?.fullSync === true; + } catch { + // No body or invalid JSON — use defaults + } + + const imported = await syncMoodLogEntries(user.id, { + fullSync, + }); + return apiSuccess({ imported }); +}); diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts new file mode 100644 index 000000000..646325a96 --- /dev/null +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -0,0 +1,128 @@ +import { NextRequest } from "next/server"; +import { timingSafeEqual } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/api-response"; +import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "moodlog.webhook" } }); + + // 1. Rate limit (30/min per IP) + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit(`moodlog-webhook:${ip}`, 30, 60_000); + if (!rl.allowed) { + return apiError("Rate limit exceeded", 429); + } + + // 2. Check global toggle + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moodLogGlobal: true }, + }); + if (appSettings && !appSettings.moodLogGlobal) { + return apiError("moodLog integration is disabled", 403); + } + + // 3. Get webhook secret from header + const webhookSecret = request.headers.get("x-webhook-secret"); + if (!webhookSecret) { + return apiError("Missing X-Webhook-Secret header", 401); + } + + // 4. Find all enabled users with webhook secrets and compare timing-safe + const candidates = await prisma.user.findMany({ + where: { + moodLogEnabled: true, + moodLogWebhookSecret: { not: null }, + }, + select: { id: true, moodLogWebhookSecret: true }, + }); + + const receivedBuf = Buffer.from(webhookSecret, "utf8"); + const user = candidates.find((c) => { + if (!c.moodLogWebhookSecret) return false; + const expectedBuf = Buffer.from(c.moodLogWebhookSecret, "utf8"); + if (expectedBuf.length !== receivedBuf.length) return false; + return timingSafeEqual(expectedBuf, receivedBuf); + }); + + if (!user) { + return apiError("Invalid webhook secret", 401); + } + + getEvent()?.setAuth({ user_id: user.id, auth_method: "webhook_secret" }); + + // 5. Parse body + let body: Record; + try { + body = await request.json(); + } catch { + return apiError("Invalid JSON", 400); + } + + // Handle test pings from moodLog's "Test senden" button + if (body.event === "webhook.test") { + annotate({ meta: { webhook_event: "webhook.test" } }); + return new Response(null, { status: 200 }); + } + + const parsed = moodLogWebhookPayloadSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid payload", 400); + } + + const { event, entry } = parsed.data; + annotate({ meta: { webhook_event: event } }); + + // 5. Process event (non-blocking — fire and forget with error logging) + const moodLoggedAt = new Date(entry.time); + + try { + if (event === "mood.deleted") { + await prisma.moodEntry.deleteMany({ + where: { + userId: user.id, + date: entry.date, + moodLoggedAt, + }, + }); + } else { + // mood.created or mood.updated + await prisma.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId: user.id, + date: entry.date, + moodLoggedAt, + }, + }, + update: { + mood: entry.mood, + score: entry.score, + tags: entry.tags ? JSON.stringify(entry.tags) : null, + source: "MOODLOG", + }, + create: { + userId: user.id, + date: entry.date, + mood: entry.mood, + score: entry.score, + tags: entry.tags ? JSON.stringify(entry.tags) : null, + source: "MOODLOG", + moodLoggedAt, + }, + }); + } + } catch (err) { + getEvent()?.addWarning("DB operation failed: " + err); + } + + // 6. Return 200 immediately + return new Response(null, { status: 200 }); +}); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts new file mode 100644 index 000000000..a8c34a550 --- /dev/null +++ b/src/app/api/measurements/[id]/route.ts @@ -0,0 +1,113 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { updateMeasurementSchema } from "@/lib/validations/measurement"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + + const measurement = await prisma.measurement.findUnique({ + where: { id }, + }); + + if (!measurement || measurement.userId !== user.id) { + return apiError("Messwert nicht gefunden", 404); + } + + annotate({ + action: { + name: "measurement.get", + entity_type: "measurement", + entity_id: id, + }, + }); + + return apiSuccess(measurement); +}); + +export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + + const existing = await prisma.measurement.findUnique({ + where: { id }, + }); + + if (!existing || existing.userId !== user.id) { + return apiError("Messwert nicht gefunden", 404); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = updateMeasurementSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const data = parsed.data; + const measurement = await prisma.measurement.update({ + where: { id }, + data: { + ...(data.value !== undefined && { value: data.value }), + ...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }), + ...(data.notes !== undefined && { notes: data.notes }), + }, + }); + + await auditLog("measurement.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { measurementId: id }, + }); + + annotate({ + action: { + name: "measurement.update", + entity_type: "measurement", + entity_id: id, + }, + }); + + return apiSuccess(measurement); +}); + +export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + + const existing = await prisma.measurement.findUnique({ + where: { id }, + }); + + if (!existing || existing.userId !== user.id) { + return apiError("Messwert nicht gefunden", 404); + } + + await prisma.measurement.delete({ where: { id } }); + + await auditLog("measurement.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { measurementId: id, type: existing.type }, + }); + + annotate({ + action: { + name: "measurement.delete", + entity_type: "measurement", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts new file mode 100644 index 000000000..bd04cea61 --- /dev/null +++ b/src/app/api/measurements/route.ts @@ -0,0 +1,157 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + createMeasurementSchema, + createBatchMeasurementSchema, + listMeasurementsSchema, + getUnitForType, +} from "@/lib/validations/measurement"; +import { NextRequest } from "next/server"; +import type { + MeasurementType, + MeasurementSource, +} from "@/generated/prisma/client"; +import { Prisma } from "@/generated/prisma/client"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const params = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listMeasurementsSchema.safeParse(params); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { type, from, to, limit, offset, sortBy, sortDir } = parsed.data; + + const where = { + userId: user.id, + ...(type && { type: type as MeasurementType }), + ...(from || to + ? { + measuredAt: { + ...(from && { gte: from }), + ...(to && { lte: to }), + }, + } + : {}), + }; + + const [measurements, total] = await Promise.all([ + prisma.measurement.findMany({ + where, + orderBy: { [sortBy]: sortDir }, + take: limit, + skip: offset, + }), + prisma.measurement.count({ where }), + ]); + + annotate({ action: { name: "measurement.list" }, meta: { total, type } }); + + return apiSuccess({ + measurements, + meta: { total, limit, offset }, + }); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + + // Batch mode (array of measurements, e.g. combined BP + Pulse) + if (Array.isArray(body)) { + const parsed = createBatchMeasurementSchema.safeParse({ + measurements: body, + }); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const results = await prisma.$transaction( + parsed.data.measurements.map((m) => + prisma.measurement.create({ + data: { + userId: user.id, + type: m.type as MeasurementType, + value: m.value, + unit: getUnitForType(m.type), + source: (m.source ?? "MANUAL") as MeasurementSource, + measuredAt: m.measuredAt, + notes: m.notes ?? null, + }, + }), + ), + ); + + await auditLog("measurement.create.batch", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + count: results.length, + types: parsed.data.measurements.map((m) => m.type), + }, + }); + + annotate({ + action: { name: "measurement.create.batch" }, + meta: { + count: results.length, + types: parsed.data.measurements.map((m) => m.type), + }, + }); + + return apiSuccess(results, 201); + } + + // Single mode (existing behavior) + const parsed = createMeasurementSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { type, value, measuredAt, notes, source } = parsed.data; + + // Handle unique constraint violation + let measurement; + try { + measurement = await prisma.measurement.create({ + data: { + userId: user.id, + type: type as MeasurementType, + value, + unit: getUnitForType(type), + source: (source ?? "MANUAL") as MeasurementSource, + measuredAt, + notes: notes ?? null, + }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { + return apiError("Ein Messwert mit diesen Daten existiert bereits", 409); + } + throw err; + } + + await auditLog("measurement.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { type, measurementId: measurement.id }, + }); + + annotate({ + action: { + name: "measurement.create", + entity_type: "measurement", + entity_id: measurement.id, + }, + }); + + return apiSuccess(measurement, 201); +}); diff --git a/src/app/api/medications/[id]/api-endpoint/route.ts b/src/app/api/medications/[id]/api-endpoint/route.ts new file mode 100644 index 000000000..9ccf52fab --- /dev/null +++ b/src/app/api/medications/[id]/api-endpoint/route.ts @@ -0,0 +1,177 @@ +import { randomBytes } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { hashToken } from "@/lib/auth/hmac"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string }> }; + +function medicationScope(medicationId: string): string { + return `medication:${medicationId}:ingest`; +} + +export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (!(await isApiGloballyEnabled())) { + return apiError("API ist global deaktiviert", 403); + } + + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const scope = medicationScope(id); + const now = new Date(); + + const activeTokenCount = await prisma.apiToken.count({ + where: { + userId: user.id, + revoked: false, + permissions: { has: scope }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + }); + + annotate({ + action: { + name: "medication.api_endpoint.get", + entity_type: "medication", + entity_id: id, + }, + meta: { active_token_count: activeTokenCount }, + }); + + return apiSuccess({ + enabled: activeTokenCount > 0, + activeTokenCount, + }); +}); + +export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (!(await isApiGloballyEnabled())) { + return apiError("API ist global deaktiviert", 403); + } + + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + let enabled = false; + try { + const body = await request.json(); + enabled = body?.enabled === true; + } catch { + return apiError("Ungültige Anfrage", 422); + } + + const scope = medicationScope(id); + + if (enabled) { + const existing = await prisma.apiToken.count({ + where: { + userId: user.id, + revoked: false, + permissions: { has: scope }, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + }); + + if (existing > 0) { + annotate({ + action: { + name: "medication.api_endpoint.enable", + entity_type: "medication", + entity_id: id, + }, + meta: { already_enabled: true, active_token_count: existing }, + }); + + return apiSuccess({ + enabled: true, + activeTokenCount: existing, + token: null, + created: false, + }); + } + + const rawToken = `hlk_${randomBytes(32).toString("hex")}`; + const tokenHashValue = hashToken(rawToken); + + await prisma.apiToken.create({ + data: { + userId: user.id, + name: `API Endpoint: ${medication.name}`, + tokenHash: tokenHashValue, + permissions: ["medication:ingest", scope], + expiresAt: null, + }, + }); + + await auditLog("medication.api_endpoint.enable", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, medicationName: medication.name }, + }); + + annotate({ + action: { + name: "medication.api_endpoint.enable", + entity_type: "medication", + entity_id: id, + }, + meta: { created: true }, + }); + + return apiSuccess( + { + enabled: true, + activeTokenCount: 1, + token: rawToken, + created: true, + }, + 201, + ); + } + + const revoked = await prisma.apiToken.updateMany({ + where: { + userId: user.id, + revoked: false, + permissions: { has: scope }, + }, + data: { revoked: true }, + }); + + await auditLog("medication.api_endpoint.disable", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + medicationName: medication.name, + revokedTokens: revoked.count, + }, + }); + + annotate({ + action: { + name: "medication.api_endpoint.disable", + entity_type: "medication", + entity_id: id, + }, + meta: { revoked_token_count: revoked.count }, + }); + + return apiSuccess({ + enabled: false, + revokedTokenCount: revoked.count, + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts new file mode 100644 index 000000000..3a18fbf19 --- /dev/null +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -0,0 +1,133 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { + calculateCompliance, + classifyIntakeTiming, +} from "@/lib/analytics/compliance"; +import type { DailyComplianceEntry } from "@/lib/analytics/compliance"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler(async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { medicationId: id, userId: user.id }, + orderBy: { scheduledFor: "desc" }, + }); + + const mapped = events.map((e) => ({ + takenAt: e.takenAt, + skipped: e.skipped, + scheduledFor: e.scheduledFor, + })); + + const createdAt = medication.createdAt; + const compliance7 = calculateCompliance( + mapped, + medication.schedules, + 7, + createdAt, + ); + const compliance30 = calculateCompliance( + mapped, + medication.schedules, + 30, + createdAt, + ); + + // Build daily compliance map for heatmap/line chart (90 days) + const now = new Date(); + const schedulesPerDay = medication.schedules.length; + const dailyCompliance: Record = {}; + + for (let d = 0; d < 90; d++) { + const dayStart = new Date(now.getTime() - (d + 1) * 24 * 60 * 60 * 1000); + const dayEnd = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); + + // Skip days before medication was created + if (dayEnd <= createdAt) continue; + + const dateKey = dayStart.toISOString().slice(0, 10); + + const dayEvents = mapped.filter( + (e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd, + ); + + const takenEvents = dayEvents.filter( + (e) => e.takenAt !== null && !e.skipped, + ); + + // Classify timing for each taken event against the best-matching schedule + let onTime = 0; + let late = 0; + let veryLate = 0; + + for (const evt of takenEvents) { + if (medication.schedules.length === 0) { + // No schedule info: treat all taken as on_time + onTime++; + continue; + } + + // Match event to the closest schedule window by scheduledFor time + const evtHour = evt.scheduledFor.getUTCHours(); + const evtMin = evt.scheduledFor.getUTCMinutes(); + + let bestSchedule = medication.schedules[0]; + let bestDist = Infinity; + + for (const sched of medication.schedules) { + const [sh, sm] = sched.windowStart.split(":").map(Number); + const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm)); + if (dist < bestDist) { + bestDist = dist; + bestSchedule = sched; + } + } + + const timing = classifyIntakeTiming( + evt.takenAt, + bestSchedule.windowStart, + bestSchedule.windowEnd, + dayStart, // the scheduled date + ); + + if (timing === "on_time") onTime++; + else if (timing === "late") late++; + else veryLate++; + } + + dailyCompliance[dateKey] = { + expected: schedulesPerDay, + taken: takenEvents.length, + skipped: dayEvents.filter((e) => e.skipped).length, + onTime, + late, + veryLate, + }; + } + + annotate({ + action: { + name: "medication.compliance", + entity_type: "medication", + entity_id: id, + }, + meta: { compliance7: compliance7.rate, compliance30: compliance30.rate }, + }); + + return apiSuccess({ compliance7, compliance30, dailyCompliance }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts new file mode 100644 index 000000000..64778430b --- /dev/null +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -0,0 +1,102 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { updateIntakeEventSchema } from "@/lib/validations/medication"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string; eventId: string }> }; + +export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id, eventId } = await params; + + const event = await prisma.medicationIntakeEvent.findUnique({ + where: { id: eventId }, + }); + + if ( + !event || + event.userId !== user.id || + event.medicationId !== id + ) { + return apiError("Einnahme nicht gefunden", 404); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = updateIntakeEventSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const data = parsed.data; + const updated = await prisma.medicationIntakeEvent.update({ + where: { id: eventId }, + data: { + ...(data.takenAt !== undefined && { takenAt: data.takenAt }), + ...(data.skipped !== undefined && { skipped: data.skipped }), + ...(data.scheduledFor !== undefined && { + scheduledFor: data.scheduledFor, + }), + }, + }); + + await auditLog("medication.intake.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { eventId, medicationId: id }, + }); + + annotate({ + action: { + name: "medication.intake.update", + entity_type: "intake_event", + entity_id: eventId, + }, + meta: { medication_id: id }, + }); + + return apiSuccess(updated); +}); + +export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id, eventId } = await params; + + const event = await prisma.medicationIntakeEvent.findUnique({ + where: { id: eventId }, + }); + + if ( + !event || + event.userId !== user.id || + event.medicationId !== id + ) { + return apiError("Einnahme nicht gefunden", 404); + } + + await prisma.medicationIntakeEvent.delete({ where: { id: eventId } }); + + const ip = getClientIp(request) ?? "unknown"; + await auditLog("medication.intake.delete", { + userId: user.id, + ipAddress: ip, + details: { eventId, medicationId: id }, + }); + + annotate({ + action: { + name: "medication.intake.delete", + entity_type: "intake_event", + entity_id: eventId, + }, + meta: { medication_id: id }, + }); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts new file mode 100644 index 000000000..e18d38af1 --- /dev/null +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -0,0 +1,112 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +const importEntrySchema = z.object({ + datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + uhrzeit: z.string().regex(/^\d{2}:\d{2}:\d{2}$/), + zaehler: z.union([z.number().int(), z.string()]).optional(), +}); + +const importSchema = z.array(importEntrySchema).min(1).max(1000); + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + + // Support both direct arrays and object-with-array payloads + const payload = Array.isArray(body) + ? body + : typeof body === "object" && body !== null + ? Object.values(body).find((value) => Array.isArray(value)) + : null; + + const parsed = importSchema.safeParse(payload); + if (!parsed.success) { + return apiError( + `Ungültiges Format: ${parsed.error.issues[0].message}`, + 422, + ); + } + + const entries = parsed.data; + let imported = 0; + let skippedDuplicates = 0; + let skippedInvalid = 0; + + for (const entry of entries) { + // Parse as local datetime first; if invalid, fall back to CET offset. + let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`); + if (isNaN(takenAt.getTime())) { + takenAt = new Date(`${entry.datum}T${entry.uhrzeit}+01:00`); + } + if (isNaN(takenAt.getTime())) { + skippedInvalid++; + continue; + } + + // Prefer explicit counter; otherwise use timestamp-based dedup key. + const idempotencyKey = entry.zaehler + ? `import-${id}-${String(entry.zaehler)}` + : `import-${id}-${takenAt.getTime()}`; + + const existing = await prisma.medicationIntakeEvent.findUnique({ + where: { idempotencyKey }, + }); + if (existing) { + skippedDuplicates++; + continue; + } + + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: id, + scheduledFor: takenAt, + takenAt, + skipped: false, + source: "IMPORT", + idempotencyKey, + }, + }); + imported++; + } + + await auditLog("medication.intake.import", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + imported, + skippedDuplicates, + skippedInvalid, + total: entries.length, + }, + }); + + annotate({ + action: { + name: "medication.intake.import", + entity_type: "medication", + entity_id: id, + }, + meta: { imported, skippedDuplicates, skippedInvalid, total: entries.length }, + }); + + return apiSuccess({ imported, skippedDuplicates, skippedInvalid }, 201); +}); diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts new file mode 100644 index 000000000..641152733 --- /dev/null +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -0,0 +1,39 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const { count } = await prisma.medicationIntakeEvent.deleteMany({ + where: { medicationId: id, userId: user.id }, + }); + + await auditLog("medication.intake.purge", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, name: medication.name, deletedCount: count }, + }); + + annotate({ + action: { + name: "medication.intake.purge", + entity_type: "medication", + entity_id: id, + }, + meta: { purged_count: count }, + }); + + return apiSuccess({ purged: true, count }); +}); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts new file mode 100644 index 000000000..d3c61bd57 --- /dev/null +++ b/src/app/api/medications/[id]/intake/route.ts @@ -0,0 +1,139 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + intakeSchema, + listIntakeEventsSchema, +} from "@/lib/validations/medication"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = intakeSchema.safeParse({ + ...(body as Record), + medicationId: id, + }); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data; + + // Idempotency check (explicit key or server-side dedup window) + if (idempotencyKey) { + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { + idempotencyKey, + userId: user.id, + medicationId: id, + }, + }); + if (existing) { + return apiSuccess(existing); + } + } else { + // Server-side dedup: prevent double-logging within 60 seconds + const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({ + where: { + userId: user.id, + medicationId: id, + skipped, + createdAt: { gte: new Date(Date.now() - 60_000) }, + }, + orderBy: { createdAt: "desc" }, + }); + if (recentDuplicate) { + return apiSuccess(recentDuplicate); + } + } + + const [event] = await prisma.$transaction([ + prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: id, + scheduledFor: scheduledFor ?? takenAt ?? new Date(), + takenAt: skipped ? null : (takenAt ?? new Date()), + skipped, + source: "WEB", + idempotencyKey: idempotencyKey ?? null, + }, + }), + // Reset snooze when medication is taken + ...(!skipped + ? [ + prisma.medication.update({ + where: { id }, + data: { snoozedUntil: null }, + }), + ] + : []), + ]); + + await auditLog("medication.intake", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, eventId: event.id, skipped }, + }); + + annotate({ + action: { + name: "medication.intake", + entity_type: "intake_event", + entity_id: event.id, + }, + meta: { medication_id: id, skipped }, + }); + + return apiSuccess(event, 201); +}); + +export const GET = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const searchParams = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listIntakeEventsSchema.safeParse(searchParams); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { limit, offset, sortBy, sortDir } = parsed.data; + const where = { medicationId: id, userId: user.id }; + + const [events, total] = await Promise.all([ + prisma.medicationIntakeEvent.findMany({ + where, + orderBy: { [sortBy]: sortDir }, + take: limit, + skip: offset, + }), + prisma.medicationIntakeEvent.count({ where }), + ]); + + annotate({ + action: { name: "medication.intake.list" }, + meta: { medication_id: id, total }, + }); + + return apiSuccess({ events, meta: { total, limit, offset } }); +}); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts new file mode 100644 index 000000000..ad35852b3 --- /dev/null +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -0,0 +1,114 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { phaseConfigSchema } from "@/lib/validations/phase-config"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const config = await prisma.reminderPhaseConfig.findUnique({ + where: { medicationId: id }, + }); + + annotate({ + action: { + name: "medication.phase_config.get", + entity_type: "medication", + entity_id: id, + }, + }); + + // Return config or defaults + return apiSuccess( + config ?? { + greenValue: 60, + greenMode: "MINUTES", + yellowValue: 30, + yellowMode: "MINUTES", + orangeValue: 0, + orangeMode: "MINUTES", + redValue: 240, + redMode: "MINUTES", + }, + ); +}); + +export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = phaseConfigSchema.safeParse(body); + if (!parsed.success) { + return apiError("Ungültige Eingabe", 400); + } + + const config = await prisma.reminderPhaseConfig.upsert({ + where: { medicationId: id }, + create: { + medicationId: id, + ...parsed.data, + }, + update: parsed.data, + }); + + annotate({ + action: { + name: "medication.phase_config.update", + entity_type: "medication", + entity_id: id, + }, + }); + + return apiSuccess(config); +}); + +export const DELETE = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + await prisma.reminderPhaseConfig.deleteMany({ + where: { medicationId: id }, + }); + + annotate({ + action: { + name: "medication.phase_config.reset", + entity_type: "medication", + entity_id: id, + }, + }); + + return apiSuccess({ reset: true }); +}); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts new file mode 100644 index 000000000..9ebf344df --- /dev/null +++ b/src/app/api/medications/[id]/route.ts @@ -0,0 +1,206 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { updateMedicationSchema } from "@/lib/validations/medication"; +import { + deleteMedicationCategory, + getMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; +import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + + if (!medication || medication.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + let category = "OTHER"; + try { + const categories = await getMedicationCategories([id]); + category = categories[id] ?? "OTHER"; + } catch { + // Category enrichment is optional + } + + annotate({ + action: { + name: "medication.get", + entity_type: "medication", + entity_id: id, + }, + }); + + return apiSuccess({ ...medication, category }); +}); + +export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const existing = await prisma.medication.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = updateMedicationSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { name, dose, category, active, notificationsEnabled, schedules } = + parsed.data; + + const pausedAtPatch = + active === undefined + ? {} + : active + ? { pausedAt: null as Date | null } + : existing.active + ? { pausedAt: new Date() } + : {}; + + // If schedules provided, replace all + if (schedules) { + await prisma.medicationSchedule.deleteMany({ + where: { medicationId: id }, + }); + } + + const baseUpdateData = { + ...(name !== undefined && { name }), + ...(dose !== undefined && { dose }), + ...(active !== undefined && { active }), + ...(notificationsEnabled !== undefined && { notificationsEnabled }), + ...(schedules && { + schedules: { + create: schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: s.daysOfWeek ?? [], + intervalWeeks: s.intervalWeeks ?? 1, + }), + })), + }, + }), + }; + + const withoutNotifications = { ...baseUpdateData } as Record< + string, + unknown + >; + delete withoutNotifications.notificationsEnabled; + const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; + const hasNotificationsPatch = notificationsEnabled !== undefined; + + const updateCandidates: Array> = [ + { ...baseUpdateData, ...pausedAtPatch }, + ]; + if (hasPausedAtPatch) { + updateCandidates.push(baseUpdateData); + } + if (hasNotificationsPatch) { + updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); + if (hasPausedAtPatch) { + updateCandidates.push(withoutNotifications); + } + } + + let medication; + let lastUpdateErr: unknown; + for (const candidate of updateCandidates) { + try { + medication = await prisma.medication.update({ + where: { id }, + data: candidate, + include: { schedules: true }, + }); + break; + } catch (updateErr) { + lastUpdateErr = updateErr; + } + } + if (!medication) throw lastUpdateErr; + + const normalizedCategory = + category !== undefined + ? await setMedicationCategory(id, category) + : ((await getMedicationCategories([id]))[id] ?? "OTHER"); + + await auditLog("medication.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id }, + }); + + annotate({ + action: { + name: "medication.update", + entity_type: "medication", + entity_id: id, + }, + }); + + return apiSuccess({ + ...medication, + category: normalizedCategory, + }); +}); + +export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const existing = await prisma.medication.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Medikament nicht gefunden", 404); + } + + // Revoke API tokens scoped to this medication + const medicationScope = `medication:${id}:ingest`; + await prisma.apiToken.updateMany({ + where: { + userId: user.id, + revoked: false, + permissions: { has: medicationScope }, + }, + data: { revoked: true }, + }); + + await deleteMedicationCategory(id); + await prisma.medication.delete({ where: { id } }); + + await auditLog("medication.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, name: existing.name }, + }); + + annotate({ + action: { + name: "medication.delete", + entity_type: "medication", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/medications/intake-summary/route.ts b/src/app/api/medications/intake-summary/route.ts new file mode 100644 index 000000000..24a2bf174 --- /dev/null +++ b/src/app/api/medications/intake-summary/route.ts @@ -0,0 +1,102 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiError, apiSuccess } from "@/lib/api-response"; + +const querySchema = z.object({ + from: z.iso + .datetime({ offset: true }) + .transform((value) => new Date(value)) + .optional(), + to: z.iso + .datetime({ offset: true }) + .transform((value) => new Date(value)) + .optional(), +}); + +function toBerlinDayKey(date: Date): string { + return new Intl.DateTimeFormat("sv-SE", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(date); +} + +interface DailySummary { + date: string; + total: number; + medications: Record; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { from, to } = parsed.data; + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + skipped: false, + takenAt: { + not: null, + ...(from && { gte: from }), + ...(to && { lte: to }), + }, + }, + select: { + takenAt: true, + medication: { + select: { + name: true, + }, + }, + }, + orderBy: { takenAt: "asc" }, + }); + + const medicationNames = new Set(); + const byDay = new Map(); + + for (const event of events) { + if (!event.takenAt) continue; + + const medicationName = event.medication.name; + const dayKey = toBerlinDayKey(event.takenAt); + medicationNames.add(medicationName); + + const existing = byDay.get(dayKey) ?? { + date: dayKey, + total: 0, + medications: {}, + }; + + existing.total += 1; + existing.medications[medicationName] = + (existing.medications[medicationName] ?? 0) + 1; + byDay.set(dayKey, existing); + } + + const points = [...byDay.values()].sort((a, b) => + a.date.localeCompare(b.date), + ); + + annotate({ + action: { name: "medication.intake_summary" }, + meta: { point_count: points.length, medication_count: medicationNames.size }, + }); + + return apiSuccess({ + points, + medications: [...medicationNames].sort((a, b) => a.localeCompare(b, "de")), + }); +}); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts new file mode 100644 index 000000000..cfeaba7c4 --- /dev/null +++ b/src/app/api/medications/route.ts @@ -0,0 +1,148 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { createMedicationSchema } from "@/lib/validations/medication"; +import { + getMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; +import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { getUserTodayBounds } from "@/lib/timezone"; +import { NextRequest } from "next/server"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + // Compute today's UTC range for event counting + const userTz = user.timezone || "Europe/Berlin"; + const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds( + new Date(), + userTz, + ); + + // Run all three queries in parallel + const [medications, latestIntakes, todayEvents] = await Promise.all([ + prisma.medication.findMany({ + where: { userId: user.id }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + where: { + userId: user.id, + skipped: false, + takenAt: { not: null }, + }, + _max: { takenAt: true }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + where: { + userId: user.id, + scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, + }, + _count: { id: true }, + }), + ]); + + const lastTakenAtByMedicationId = Object.fromEntries( + latestIntakes.map((entry) => [ + entry.medicationId, + entry._max.takenAt ? entry._max.takenAt.toISOString() : null, + ]), + ); + const todayEventCountByMedId = Object.fromEntries( + todayEvents.map( + (entry: { medicationId: string; _count: { id: number } }) => [ + entry.medicationId, + entry._count.id, + ], + ), + ); + + let categoryMap: Record = {}; + try { + categoryMap = await getMedicationCategories(medications.map((m) => m.id)); + } catch (error) { + getEvent()?.addWarning("Medication categories could not be loaded"); + } + + annotate({ + action: { name: "medication.list" }, + meta: { count: medications.length }, + }); + + return apiSuccess( + medications.map((m) => ({ + ...m, + category: categoryMap[m.id] ?? "OTHER", + lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, + todayEventCount: todayEventCountByMedId[m.id] ?? 0, + })), + ); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = createMedicationSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { name, dose, category, schedules } = parsed.data; + + const medication = await prisma.medication.create({ + data: { + userId: user.id, + name, + dose, + schedules: { + create: schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: s.daysOfWeek ?? [], + intervalWeeks: s.intervalWeeks ?? 1, + }), + })), + }, + }, + include: { schedules: true }, + }); + + const normalizedCategory = await setMedicationCategory( + medication.id, + category, + ); + + await auditLog("medication.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: medication.id, name }, + }); + + annotate({ + action: { + name: "medication.create", + entity_type: "medication", + entity_id: medication.id, + }, + }); + + return apiSuccess( + { + ...medication, + category: normalizedCategory, + }, + 201, + ); +}); diff --git a/src/app/api/monitoring/csp-report/route.ts b/src/app/api/monitoring/csp-report/route.ts new file mode 100644 index 000000000..fc823167b --- /dev/null +++ b/src/app/api/monitoring/csp-report/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { apiHandler } from "@/lib/api-handler"; + +/** + * CSP violation report endpoint. + * Receives browser-reported Content-Security-Policy violations + * and logs them as structured wide events for monitoring via Loki. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const ip = getClientIp(request); + + // Rate limit to prevent abuse (50 reports per minute per IP) + const rl = await checkRateLimit(`csp-report:${ip}`, 50, 60_000); + if (!rl.allowed) { + return new NextResponse(null, { status: 429 }); + } + + let payload: Record = {}; + try { + const raw = await request.text(); + if (raw.length > 10_000) { + return new NextResponse(null, { status: 413 }); + } + payload = JSON.parse(raw) as Record; + } catch { + return new NextResponse(null, { status: 400 }); + } + + // Browsers send either { "csp-report": { ... } } or { ... } depending on report-uri vs report-to + const report = + (payload["csp-report"] as Record | undefined) ?? payload; + + annotate({ + action: { name: "csp.violation" }, + meta: { + blocked_uri: String(report["blocked-uri"] ?? report["blockedURL"] ?? ""), + violated_directive: String( + report["violated-directive"] ?? report["effectiveDirective"] ?? "", + ), + document_uri: String( + report["document-uri"] ?? report["documentURL"] ?? "", + ), + source_file: String(report["source-file"] ?? report["sourceFile"] ?? ""), + line_number: report["line-number"] ?? report["lineNumber"] ?? null, + disposition: String(report["disposition"] ?? ""), + }, + }); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts new file mode 100644 index 000000000..26394d29a --- /dev/null +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -0,0 +1,61 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getGlitchtipSettings } from "@/lib/monitoring-settings"; +import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; + +const clientErrorSchema = z.object({ + message: z.string().min(1).max(2000), + stack: z.string().max(20000).optional(), + level: z.enum(["error", "warning", "info"]).optional(), + type: z.string().max(120).optional(), + url: z.string().max(2000).optional(), + userAgent: z.string().max(1000).optional(), +}); + +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "monitoring.glitchtip.report" } }); + + const ip = getClientIp(request); + const rl = await checkRateLimit(`glitchtip:${ip}`, 20, 60 * 1000); + if (!rl.allowed) return apiError("Rate limit exceeded", 429); + + const settings = await getGlitchtipSettings(); + if (!settings.glitchtipEnabled || !settings.glitchtipDsn) { + return apiSuccess({ skipped: true }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return apiError("Ungültige JSON-Daten", 422); + } + + const parsed = clientErrorSchema.safeParse(body); + if (!parsed.success) return apiError("Ungültige Fehlerdaten", 422); + + const delivery = await sendGlitchtipEvent({ + dsn: settings.glitchtipDsn, + input: { + environment: settings.glitchtipEnvironment || "production", + message: parsed.data.message, + stack: parsed.data.stack, + level: parsed.data.level ?? "error", + type: parsed.data.type, + url: parsed.data.url, + userAgent: parsed.data.userAgent, + sourceTag: "healthlog-client", + }, + }); + + if (!delivery.ok) { + getEvent()?.addWarning("Glitchtip event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details); + return apiError("Glitchtip konnte Fehler nicht annehmen", 502); + } + + return apiSuccess({ sent: true }); +}); diff --git a/src/app/api/monitoring/settings/route.ts b/src/app/api/monitoring/settings/route.ts new file mode 100644 index 000000000..bf03de6ef --- /dev/null +++ b/src/app/api/monitoring/settings/route.ts @@ -0,0 +1,17 @@ +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + annotate({ action: { name: "monitoring.settings.get" } }); + + const settings = await getPublicMonitoringSettings(); + return apiSuccess({ + umamiEnabled: settings.umamiEnabled, + umamiWebsiteId: settings.umamiWebsiteId, + glitchtipEnabled: settings.glitchtipEnabled, + }); +}); diff --git a/src/app/api/monitoring/umami-script/route.ts b/src/app/api/monitoring/umami-script/route.ts new file mode 100644 index 000000000..fee29827b --- /dev/null +++ b/src/app/api/monitoring/umami-script/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; + +export const dynamic = "force-dynamic"; + +const NOOP_SCRIPT = "/* umami disabled */"; + +export const GET = apiHandler(async () => { + annotate({ action: { name: "monitoring.umami-script" } }); + + const settings = await getPublicMonitoringSettings(); + if ( + !settings.umamiEnabled || + !settings.umamiScriptUrl || + !settings.umamiWebsiteId + ) { + return new NextResponse(NOOP_SCRIPT, { + status: 200, + headers: { + "Content-Type": "application/javascript; charset=utf-8", + "Cache-Control": "public, max-age=60", + }, + }); + } + + const response = await fetch(settings.umamiScriptUrl, { + next: { revalidate: 3600 }, + }); + if (!response.ok) { + return new NextResponse(NOOP_SCRIPT, { + status: 200, + headers: { + "Content-Type": "application/javascript; charset=utf-8", + "Cache-Control": "public, max-age=60", + }, + }); + } + + const script = await response.text(); + return new NextResponse(script, { + status: 200, + headers: { + "Content-Type": "application/javascript; charset=utf-8", + "Cache-Control": "public, max-age=1800", + }, + }); +}); diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts new file mode 100644 index 000000000..29392c6a6 --- /dev/null +++ b/src/app/api/mood-entries/[id]/route.ts @@ -0,0 +1,117 @@ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + updateMoodEntrySchema, + getScoreForMood, +} from "@/lib/validations/moodlog"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +type RouteParams = { params: Promise<{ id: string }> }; + +function toBerlinDate(date: Date): string { + return new Intl.DateTimeFormat("sv-SE", { + timeZone: "Europe/Berlin", + }).format(date); +} + +function parseTags(tags: string | null): string[] { + if (!tags) return []; + try { + return JSON.parse(tags) as string[]; + } catch { + return []; + } +} + +export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + + const entry = await prisma.moodEntry.findUnique({ where: { id } }); + + if (!entry || entry.userId !== user.id) { + return apiError("Stimmungseintrag nicht gefunden", 404); + } + + annotate({ action: { name: "mood-entries.get" }, meta: { moodEntryId: id } }); + + return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); +}); + +export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + + const existing = await prisma.moodEntry.findUnique({ where: { id } }); + + if (!existing || existing.userId !== user.id) { + return apiError("Stimmungseintrag nicht gefunden", 404); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = updateMoodEntrySchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const data = parsed.data; + + const updateData: Record = {}; + if (data.mood !== undefined) { + updateData.mood = data.mood; + updateData.score = getScoreForMood(data.mood); + } + if (data.moodLoggedAt !== undefined) { + updateData.moodLoggedAt = data.moodLoggedAt; + updateData.date = toBerlinDate(data.moodLoggedAt); + } + if (data.tags !== undefined) { + updateData.tags = data.tags ? JSON.stringify(data.tags) : null; + } + + const entry = await prisma.moodEntry.update({ + where: { id }, + data: updateData, + }); + + await auditLog("moodEntry.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { moodEntryId: id }, + }); + + annotate({ action: { name: "mood-entries.update" }, meta: { moodEntryId: id } }); + + return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); +}); + +export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + + const existing = await prisma.moodEntry.findUnique({ where: { id } }); + + if (!existing || existing.userId !== user.id) { + return apiError("Stimmungseintrag nicht gefunden", 404); + } + + await prisma.moodEntry.delete({ where: { id } }); + + await auditLog("moodEntry.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { moodEntryId: id, mood: existing.mood }, + }); + + annotate({ action: { name: "mood-entries.delete" }, meta: { moodEntryId: id } }); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts new file mode 100644 index 000000000..135b8af8a --- /dev/null +++ b/src/app/api/mood-entries/route.ts @@ -0,0 +1,122 @@ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + createMoodEntrySchema, + listMoodEntriesSchema, + getScoreForMood, +} from "@/lib/validations/moodlog"; +import { NextRequest } from "next/server"; +import { Prisma } from "@/generated/prisma/client"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +function toBerlinDate(date: Date): string { + return new Intl.DateTimeFormat("sv-SE", { + timeZone: "Europe/Berlin", + }).format(date); +} + +function parseTags(tags: string | null): string[] { + if (!tags) return []; + try { + return JSON.parse(tags) as string[]; + } catch { + return []; + } +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const params = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listMoodEntriesSchema.safeParse(params); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { mood, from, to, limit, offset, sortBy, sortDir } = parsed.data; + + const where = { + userId: user.id, + ...(mood && { mood }), + ...(from || to + ? { + date: { + ...(from && { gte: from }), + ...(to && { lte: to }), + }, + } + : {}), + }; + + const [entries, total] = await Promise.all([ + prisma.moodEntry.findMany({ + where, + orderBy: { [sortBy]: sortDir }, + take: limit, + skip: offset, + }), + prisma.moodEntry.count({ where }), + ]); + + annotate({ action: { name: "mood-entries.list" }, meta: { total, limit, offset } }); + + const entriesWithParsedTags = entries.map((e) => ({ + ...e, + tags: parseTags(e.tags), + })); + + return apiSuccess({ + entries: entriesWithParsedTags, + meta: { total, limit, offset }, + }); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = createMoodEntrySchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { mood, tags, moodLoggedAt, source } = parsed.data; + const date = toBerlinDate(moodLoggedAt); + const score = getScoreForMood(mood); + + try { + const entry = await prisma.moodEntry.create({ + data: { + userId: user.id, + date, + mood, + score, + tags: tags ? JSON.stringify(tags) : null, + source: source ?? "MANUAL", + moodLoggedAt, + }, + }); + + await auditLog("moodEntry.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { moodEntryId: entry.id, mood }, + }); + + annotate({ action: { name: "mood-entries.create" }, meta: { moodEntryId: entry.id, mood } }); + + return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { + return apiError( + "Ein Stimmungseintrag mit diesen Daten existiert bereits", + 409, + ); + } + throw err; + } +}); diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts new file mode 100644 index 000000000..85cd975ad --- /dev/null +++ b/src/app/api/mood/analytics/route.ts @@ -0,0 +1,53 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess } from "@/lib/api-response"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +/** Aggregate multiple mood entries per day into daily averages. */ +function aggregateDailyAverages( + records: Array<{ date: string; score: number }>, +) { + const byDay = new Map(); + for (const record of records) { + const current = byDay.get(record.date) ?? { sum: 0, count: 0 }; + current.sum += record.score; + current.count += 1; + byDay.set(record.date, current); + } + return Array.from(byDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + date: day, + score: Math.round((stats.sum / stats.count) * 100) / 100, + samples: stats.count, + })); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const moodEntries = await prisma.moodEntry.findMany({ + where: { userId: user.id }, + orderBy: { moodLoggedAt: "asc" }, + select: { date: true, score: true, moodLoggedAt: true }, + }); + + const entries = aggregateDailyAverages( + moodEntries.map((e) => ({ date: e.date, score: e.score })), + ); + + // Build DataPoint array for summarize() using moodLoggedAt as the date + const dataPoints: DataPoint[] = moodEntries.map((e) => ({ + date: e.moodLoggedAt, + value: e.score, + })); + + const summary = summarize(dataPoints); + + annotate({ action: { name: "mood.analytics" }, meta: { entryCount: moodEntries.length } }); + + return apiSuccess({ entries, summary }); +}); diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts new file mode 100644 index 000000000..9dba90d2b --- /dev/null +++ b/src/app/api/notifications/preferences/route.ts @@ -0,0 +1,127 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { notificationPreferenceSchema } from "@/lib/validations/notifications"; +import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; +import type { ChannelType } from "@/lib/notifications/types"; +import { decrypt, encrypt } from "@/lib/crypto"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "notifications.preferences.get" } }); + + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + + const globalToggles: Record = { + TELEGRAM: appSettings?.telegramGlobal ?? true, + NTFY: appSettings?.ntfyGlobal ?? true, + WEB_PUSH: appSettings?.webPushGlobal ?? true, + }; + + const channels = await prisma.notificationChannel.findMany({ + where: { userId: user.id }, + include: { preferences: true }, + }); + + // Auto-migrate legacy Telegram config from User model if no channel record exists + const hasTelegramChannel = channels.some((ch) => ch.type === "TELEGRAM"); + if (!hasTelegramChannel) { + try { + const legacyUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + telegramBotToken: true, + telegramChatId: true, + telegramEnabled: true, + }, + }); + + if (legacyUser?.telegramBotToken && legacyUser.telegramChatId) { + const botToken = decrypt(legacyUser.telegramBotToken); + const channelConfig = encrypt( + JSON.stringify({ botToken, chatId: legacyUser.telegramChatId }), + ); + + const created = await prisma.notificationChannel.upsert({ + where: { + userId_type: { userId: user.id, type: "TELEGRAM" }, + }, + create: { + userId: user.id, + type: "TELEGRAM", + enabled: legacyUser.telegramEnabled ?? true, + config: channelConfig, + }, + update: {}, + include: { preferences: true }, + }); + + channels.push(created); + } + } catch (err) { + getEvent()?.addWarning("Legacy Telegram migration failed"); + } + } + + const channelData = channels.map((ch) => ({ + id: ch.id, + type: ch.type, + label: CHANNEL_TYPE_LABELS[ch.type as ChannelType] ?? ch.type, + enabled: ch.enabled, + globallyEnabled: globalToggles[ch.type] ?? true, + })); + + const preferences = channels.flatMap((ch) => + ch.preferences.map((p) => ({ + channelId: p.channelId, + eventType: p.eventType, + enabled: p.enabled, + })), + ); + + return apiSuccess({ + channels: channelData, + preferences, + eventTypes: EVENT_TYPES as unknown as string[], + }); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "notifications.preferences.update" } }); + + let body: unknown; + try { + body = await request.json(); + } catch { + return apiError("Ungültige JSON-Daten", 422); + } + + const parsed = notificationPreferenceSchema.safeParse(body); + if (!parsed.success) return apiError("Ungültige Daten", 422); + + const { channelId, eventType, enabled } = parsed.data; + + const channel = await prisma.notificationChannel.findFirst({ + where: { id: channelId, userId: user.id }, + }); + if (!channel) return apiError("Kanal nicht gefunden", 404); + + const preference = await prisma.notificationPreference.upsert({ + where: { + channelId_eventType: { channelId, eventType }, + }, + create: { channelId, eventType, enabled }, + update: { enabled }, + }); + + return apiSuccess({ + channelId: preference.channelId, + eventType: preference.eventType, + enabled: preference.enabled, + }); +}); diff --git a/src/app/api/notifications/vapid/route.ts b/src/app/api/notifications/vapid/route.ts new file mode 100644 index 000000000..57cdeb138 --- /dev/null +++ b/src/app/api/notifications/vapid/route.ts @@ -0,0 +1,19 @@ +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { getVapidConfig } from "@/lib/notifications/vapid-config"; + +/** + * GET /api/notifications/vapid + * Returns the VAPID public key for Web Push subscription. + */ +export const GET = apiHandler(async () => { + annotate({ action: { name: "notifications.vapid" } }); + + const config = await getVapidConfig(); + if (!config?.publicKey) { + return apiError("Web Push is not configured", 503); + } + + return apiSuccess({ publicKey: config.publicKey }); +}); diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts new file mode 100644 index 000000000..5e1dda797 --- /dev/null +++ b/src/app/api/notifications/web-push/route.ts @@ -0,0 +1,109 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { encrypt } from "@/lib/crypto"; +import { z } from "zod/v4"; + +const subscribeSchema = z.object({ + endpoint: z.string().url(), + keys: z.object({ + p256dh: z.string().min(1), + auth: z.string().min(1), + }), +}); + +const unsubscribeSchema = z.object({ + endpoint: z.string().url(), +}); + +/** + * POST /api/notifications/web-push + * Save a Web Push subscription for the current user. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "notifications.web-push.subscribe" } }); + + let body: unknown; + try { + body = await request.json(); + } catch { + return apiError("Ungültige JSON-Daten", 422); + } + + const parsed = subscribeSchema.safeParse(body); + if (!parsed.success) return apiError("Ungültige Daten", 422); + + const { endpoint, keys } = parsed.data; + const userAgent = request.headers.get("user-agent") ?? undefined; + + // Upsert subscription (encrypt sensitive keys) + await prisma.pushSubscription.upsert({ + where: { + userId_endpoint: { + userId: user.id, + endpoint, + }, + }, + create: { + userId: user.id, + endpoint, + p256dh: encrypt(keys.p256dh), + auth: encrypt(keys.auth), + userAgent, + }, + update: { + p256dh: encrypt(keys.p256dh), + auth: encrypt(keys.auth), + userAgent, + }, + }); + + // Ensure a WEB_PUSH notification channel exists for this user + const existingChannel = await prisma.notificationChannel.findFirst({ + where: { userId: user.id, type: "WEB_PUSH" }, + }); + + if (!existingChannel) { + await prisma.notificationChannel.create({ + data: { + userId: user.id, + type: "WEB_PUSH", + enabled: true, + config: encrypt(JSON.stringify({})), + }, + }); + } + + return apiSuccess({ subscribed: true }); +}); + +/** + * DELETE /api/notifications/web-push + * Remove a Web Push subscription. + */ +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "notifications.web-push.unsubscribe" } }); + + let body: unknown; + try { + body = await request.json(); + } catch { + return apiError("Ungültige JSON-Daten", 422); + } + + const parsed = unsubscribeSchema.safeParse(body); + if (!parsed.success) return apiError("Ungültige Daten", 422); + + await prisma.pushSubscription.deleteMany({ + where: { + userId: user.id, + endpoint: parsed.data.endpoint, + }, + }); + + return apiSuccess({ unsubscribed: true }); +}); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts new file mode 100644 index 000000000..92349eb7e --- /dev/null +++ b/src/app/api/onboarding/complete/route.ts @@ -0,0 +1,55 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +const onboardingSchema = z.object({ + heightCm: z.number().min(50).max(300).optional(), + dateOfBirth: z.string().optional(), + gender: z.enum(["MALE", "FEMALE"]).optional(), +}); + +/** + * Complete the onboarding flow. Saves optional profile data and marks + * onboarding as completed. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "onboarding.complete" } }); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const result = z.safeParse(onboardingSchema, body); + if (!result.success) { + return apiError("Ungültige Eingabe", 422); + } + + const data: Record = { + onboardingCompletedAt: new Date(), + }; + + if (result.data.heightCm) { + data.heightCm = result.data.heightCm; + } + + if (result.data.dateOfBirth) { + const dob = new Date(result.data.dateOfBirth); + if (!isNaN(dob.getTime())) { + data.dateOfBirth = dob; + } + } + + if (result.data.gender) { + data.gender = result.data.gender; + } + + await prisma.user.update({ + where: { id: user.id }, + data, + }); + + return apiSuccess({ completed: true }); +}); diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts new file mode 100644 index 000000000..e1cc7219b --- /dev/null +++ b/src/app/api/send/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; + +export const dynamic = "force-dynamic"; + +// Allowed URL patterns for the Umami proxy to prevent SSRF. +// Only permits HTTPS URLs (or localhost in dev) to known analytics paths. +function isAllowedUmamiUrl(url: string): boolean { + try { + const parsed = new URL(url); + const isDev = process.env.NODE_ENV === "development"; + if (!isDev && parsed.protocol !== "https:") return false; + // Block requests to private/internal networks + const hostname = parsed.hostname; + if ( + hostname === "169.254.169.254" || // cloud metadata + hostname === "localhost" || + hostname.startsWith("10.") || + hostname.startsWith("192.168.") || + hostname === "127.0.0.1" || + hostname === "[::1]" || + hostname === "0.0.0.0" || + hostname.endsWith(".internal") || + hostname.endsWith(".local") || + // 172.16.0.0/12 (172.16.x.x - 172.31.x.x) + (hostname.startsWith("172.") && + (() => { + const second = parseInt(hostname.split(".")[1] ?? "0", 10); + return second >= 16 && second <= 31; + })()) + ) { + if (!isDev) return false; + } + return true; + } catch { + return false; + } +} + +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]; + + // Remove script file segment (e.g. script.js, umami.js) if present. + 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.filter(isAllowedUmamiUrl))); + } catch { + return []; + } +} + +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "umami.proxy" } }); + + const settings = await getPublicMonitoringSettings(); + if (!settings.umamiEnabled) { + return NextResponse.json({}, { status: 204 }); + } + + const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl); + if (targetUrls.length === 0 || !settings.umamiWebsiteId) { + return NextResponse.json({}, { status: 204 }); + } + + const body = await request.arrayBuffer(); + // Limit proxy body size to 64 KB to prevent abuse + if (body.byteLength > 65536) { + return NextResponse.json({}, { status: 413 }); + } + + let lastResponseStatus = 404; + + for (const targetUrl of targetUrls) { + const upstream = await fetch(targetUrl, { + method: "POST", + headers: { + "content-type": + request.headers.get("content-type") || "application/json", + "user-agent": request.headers.get("user-agent") || "healthlog-proxy", + }, + body, + cache: "no-store", + }); + + lastResponseStatus = upstream.status; + + // Try next candidate only for path misses. + if (upstream.status === 404) { + continue; + } + + return new NextResponse(null, { status: upstream.status }); + } + + return new NextResponse(null, { status: lastResponseStatus }); +}); diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts new file mode 100644 index 000000000..56267ffb7 --- /dev/null +++ b/src/app/api/settings/account/route.ts @@ -0,0 +1,66 @@ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { destroyAllSessions } from "@/lib/auth/session"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +/** + * Permanently delete the user account and ALL associated data. + * Cascading deletes in the schema handle all related records. + */ +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + let confirm = ""; + try { + const body = await request.json(); + confirm = typeof body?.confirm === "string" ? body.confirm : ""; + } catch { + return apiError("Ungueltige Anfrage", 422); + } + + if (confirm !== "DELETE_ACCOUNT") { + return apiError( + "Bestaetigung fehlt. Sende { confirm: 'DELETE_ACCOUNT' }", + 422, + ); + } + + // Prevent last admin from deleting themselves + if (user.role === "ADMIN") { + const adminCount = await prisma.user.count({ where: { role: "ADMIN" } }); + if (adminCount <= 1) { + return apiError( + "Der letzte Admin-Account kann nicht geloescht werden", + 400, + ); + } + } + + const userId = user.id; + const username = user.username; + + // Log before deletion (userId will be SetNull in audit_logs after cascade) + await auditLog("user.account.delete", { + userId, + ipAddress: getClientIp(request), + details: { username }, + }); + + // Destroy all sessions first + await destroyAllSessions(userId); + + // Delete user — all related data is removed via onDelete: Cascade + await prisma.user.delete({ where: { id: userId } }); + + annotate({ + action: { name: "settings.account.delete" }, + meta: { username }, + }); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts new file mode 100644 index 000000000..0f88d334e --- /dev/null +++ b/src/app/api/settings/data/route.ts @@ -0,0 +1,117 @@ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +/** + * Delete all user-owned health/integration data while keeping the account. + */ +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + let confirm = ""; + try { + const body = await request.json(); + confirm = typeof body?.confirm === "string" ? body.confirm : ""; + } catch { + return apiError("Ungueltige Anfrage", 422); + } + + if (confirm !== "DELETE") { + return apiError("Bestaetigung fehlt", 422); + } + + const userId = user.id; + + const result = await prisma.$transaction(async (tx) => { + const measurements = await tx.measurement.deleteMany({ + where: { userId }, + }); + const intakeEvents = await tx.medicationIntakeEvent.deleteMany({ + where: { userId }, + }); + const medications = await tx.medication.deleteMany({ + where: { userId }, + }); + const moodEntries = await tx.moodEntry.deleteMany({ + where: { userId }, + }); + const apiTokens = await tx.apiToken.deleteMany({ + where: { userId }, + }); + const withingsConnections = await tx.withingsConnection.deleteMany({ + where: { userId }, + }); + const notificationChannels = await tx.notificationChannel.deleteMany({ + where: { userId }, + }); + const pushSubscriptions = await tx.pushSubscription.deleteMany({ + where: { userId }, + }); + const dataBackups = await tx.dataBackup.deleteMany({ + where: { userId }, + }); + const achievements = await tx.userAchievement.deleteMany({ + where: { userId }, + }); + const telegramDeletions = await tx.telegramScheduledDeletion.deleteMany({ + where: { userId }, + }); + const auditLogs = await tx.auditLog.deleteMany({ + where: { userId }, + }); + + await tx.user.update({ + where: { id: userId }, + 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, + moodLogUrlEncrypted: null, + moodLogApiKeyEncrypted: null, + moodLogEnabled: false, + moodLogLastSyncedAt: null, + moodLogWebhookSecret: null, + onboardingCompletedAt: null, + }, + }); + + return { + measurements: measurements.count, + intakeEvents: intakeEvents.count, + medications: medications.count, + moodEntries: moodEntries.count, + apiTokens: apiTokens.count, + withingsConnections: withingsConnections.count, + notificationChannels: notificationChannels.count, + pushSubscriptions: pushSubscriptions.count, + dataBackups: dataBackups.count, + achievements: achievements.count, + telegramDeletions: telegramDeletions.count, + auditLogs: auditLogs.count, + }; + }); + + await auditLog("user.data.clear", { + userId, + ipAddress: getClientIp(request), + details: result, + }); + + annotate({ action: { name: "settings.data.clear" }, meta: result }); + + return apiSuccess({ cleared: true, ...result }); +}); diff --git a/src/app/api/settings/global-services/route.ts b/src/app/api/settings/global-services/route.ts new file mode 100644 index 000000000..363bf0581 --- /dev/null +++ b/src/app/api/settings/global-services/route.ts @@ -0,0 +1,16 @@ +import { apiSuccess } from "@/lib/api-response"; +import { getGlobalServiceAvailability } from "@/lib/app-settings"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAuth(); + + const availability = await getGlobalServiceAvailability(); + + annotate({ action: { name: "settings.global-services.get" } }); + + return apiSuccess(availability); +}); diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts new file mode 100644 index 000000000..66a893ead --- /dev/null +++ b/src/app/api/settings/moodlog/route.ts @@ -0,0 +1,80 @@ +import { NextRequest } from "next/server"; +import { randomBytes } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { encrypt } from "@/lib/crypto"; +import { moodLogCredentialsSchema } from "@/lib/validations/moodlog"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + let body: unknown; + try { + body = await request.json(); + } catch { + return apiError("Invalid JSON", 400); + } + + const parsed = moodLogCredentialsSchema.safeParse(body); + if (!parsed.success) { + return apiError("Ungueltige Eingabe", 422); + } + + const { url, apiKey } = parsed.data; + + // Generate webhook secret if not yet set + const existing = await prisma.user.findUnique({ + where: { id: user.id }, + select: { moodLogWebhookSecret: true }, + }); + + const webhookSecret = + existing?.moodLogWebhookSecret ?? `mb_${randomBytes(32).toString("hex")}`; + + await prisma.user.update({ + where: { id: user.id }, + data: { + moodLogUrlEncrypted: encrypt(url), + moodLogApiKeyEncrypted: encrypt(apiKey), + moodLogEnabled: true, + moodLogWebhookSecret: webhookSecret, + }, + }); + + annotate({ action: { name: "settings.moodlog.update" } }); + + return apiSuccess({ + configured: true, + enabled: true, + webhookSecret, + }); +}); + +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + + // Delete all mood entries for this user + await prisma.moodEntry.deleteMany({ + where: { userId: user.id }, + }); + + // Clear credentials + await prisma.user.update({ + where: { id: user.id }, + data: { + moodLogUrlEncrypted: null, + moodLogApiKeyEncrypted: null, + moodLogEnabled: false, + moodLogLastSyncedAt: null, + moodLogWebhookSecret: null, + }, + }); + + annotate({ action: { name: "settings.moodlog.disconnect" } }); + + return apiSuccess({ disconnected: true }); +}); diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts new file mode 100644 index 000000000..a28b27570 --- /dev/null +++ b/src/app/api/settings/ntfy/route.ts @@ -0,0 +1,92 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { ntfySettingsSchema } from "@/lib/validations/notifications"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * GET: Return current ntfy config (without auth token). + * PUT: Update ntfy config. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const channel = await prisma.notificationChannel.findUnique({ + where: { + userId_type: { userId: user.id, type: "NTFY" }, + }, + }); + + if (!channel) { + return apiSuccess({ + enabled: false, + serverUrl: "https://ntfy.sh", + topic: "", + hasAuthToken: false, + }); + } + + const config = JSON.parse(decrypt(channel.config)) as { + serverUrl: string; + topic: string; + authToken?: string; + }; + + annotate({ action: { name: "settings.ntfy.get" } }); + + return apiSuccess({ + enabled: channel.enabled, + serverUrl: config.serverUrl, + topic: config.topic, + hasAuthToken: !!config.authToken, + }); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = ntfySettingsSchema.safeParse(body); + if (!parsed.success) return apiError("Ungueltige Daten", 422); + + const { serverUrl, topic, authToken, enabled } = parsed.data; + + if (enabled && (!serverUrl || !topic)) { + return apiError( + "Server-URL und Topic sind erforderlich, wenn ntfy aktiviert ist", + 422, + ); + } + + const config = JSON.stringify({ + serverUrl: serverUrl || "https://ntfy.sh", + topic: topic || "", + ...(authToken ? { authToken } : {}), + }); + + const encryptedConfig = encrypt(config); + + await prisma.notificationChannel.upsert({ + where: { + userId_type: { userId: user.id, type: "NTFY" }, + }, + create: { + userId: user.id, + type: "NTFY", + enabled, + config: encryptedConfig, + }, + update: { + enabled, + config: encryptedConfig, + }, + }); + + annotate({ action: { name: "settings.ntfy.update" }, meta: { enabled } }); + + return apiSuccess({ saved: true }); +}); diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts new file mode 100644 index 000000000..34e574e96 --- /dev/null +++ b/src/app/api/settings/ntfy/test/route.ts @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; +import type { NtfyChannelConfig } from "@/lib/notifications/types"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * POST: Send a test notification via ntfy. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`ntfy-test:${user.id}`, 5, 5 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximal 5 Tests in 5 Minuten", 429); + } + + const channel = await prisma.notificationChannel.findUnique({ + where: { + userId_type: { userId: user.id, type: "NTFY" }, + }, + }); + + if (!channel) { + return apiError("ntfy ist nicht konfiguriert", 400); + } + + const config = JSON.parse(decrypt(channel.config)) as NtfyChannelConfig; + + if (!config.serverUrl || !config.topic) { + return apiError("Server-URL und Topic sind erforderlich", 400); + } + + const success = await sendViaNtfy(config, { + eventType: "SYSTEM_ALERT", + userId: user.id, + title: "HealthLog Test", + message: + "HealthLog: Verbindung erfolgreich! ntfy-Benachrichtigungen sind aktiv.", + }); + + if (!success) { + return apiError("Testnachricht konnte nicht gesendet werden", 500); + } + + annotate({ action: { name: "settings.ntfy.test" }, meta: { success: true } }); + + return apiSuccess({ sent: true }); +}); diff --git a/src/app/api/settings/reminder-thresholds/route.ts b/src/app/api/settings/reminder-thresholds/route.ts new file mode 100644 index 000000000..0f2bb76da --- /dev/null +++ b/src/app/api/settings/reminder-thresholds/route.ts @@ -0,0 +1,16 @@ +import { apiSuccess } from "@/lib/api-response"; +import { getReminderThresholds } from "@/lib/app-settings"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAuth(); + + const thresholds = await getReminderThresholds(); + + annotate({ action: { name: "settings.reminder-thresholds.get" } }); + + return apiSuccess(thresholds); +}); diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts new file mode 100644 index 000000000..07548394f --- /dev/null +++ b/src/app/api/settings/telegram/route.ts @@ -0,0 +1,198 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram"; +import { telegramSettingsSchema } from "@/lib/validations/telegram"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * Get Telegram notification settings for the current user. + * Never returns the bot token in plaintext. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + telegramBotToken: true, + telegramChatId: true, + telegramEnabled: true, + }, + }); + + annotate({ action: { name: "settings.telegram.get" } }); + + return apiSuccess({ + enabled: dbUser?.telegramEnabled ?? false, + hasBotToken: !!dbUser?.telegramBotToken, + chatId: dbUser?.telegramChatId ?? null, + }); +}); + +/** + * Update Telegram notification settings. + */ +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const current = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + telegramBotToken: true, + telegramChatId: true, + telegramEnabled: true, + }, + }); + if (!current) return apiError("Benutzer nicht gefunden", 404); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const result = z.safeParse(telegramSettingsSchema, body); + if (!result.success) { + return apiError("Ungueltige Eingabe", 422); + } + + const { botToken, chatId, enabled } = result.data; + const trimmedToken = botToken?.trim(); + const trimmedChatId = chatId?.trim(); + + const hasTokenAfter = + botToken !== undefined ? !!trimmedToken : !!current.telegramBotToken; + const hasChatIdAfter = + chatId !== undefined ? !!trimmedChatId : !!current.telegramChatId; + + if (enabled && (!hasTokenAfter || !hasChatIdAfter)) { + return apiError( + "Fuer aktiviertes Telegram werden Bot-Token und Chat-ID benoetigt", + 422, + ); + } + + const currentTokenPlain = current.telegramBotToken + ? decrypt(current.telegramBotToken) + : null; + const tokenForWebhook = trimmedToken || currentTokenPlain; + + if (enabled && tokenForWebhook) { + const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL; + if (!appUrl) { + return apiError( + "Server-Konfiguration fehlerhaft: APP_URL (oder NEXT_PUBLIC_APP_URL) fehlt.", + 500, + ); + } + + let appBaseUrl: URL; + try { + appBaseUrl = new URL(appUrl); + } catch { + return apiError( + "Server-Konfiguration fehlerhaft: APP_URL ist ungueltig.", + 500, + ); + } + + if (appBaseUrl.protocol !== "https:") { + return apiError( + "Telegram-Webhook benoetigt eine oeffentliche HTTPS-URL (kein http/localhost).", + 422, + ); + } + + const localHosts = new Set(["localhost", "127.0.0.1", "::1"]); + if (localHosts.has(appBaseUrl.hostname.toLowerCase())) { + return apiError( + "Telegram-Webhook benoetigt eine oeffentlich erreichbare Domain (localhost ist nicht erlaubt).", + 422, + ); + } + + if ( + appBaseUrl.port && + !["80", "88", "443", "8443"].includes(appBaseUrl.port) + ) { + return apiError( + "Telegram-Webhook erlaubt nur die Ports 80, 88, 443 oder 8443.", + 422, + ); + } + + const webhookUrl = new URL("/api/telegram/webhook", appBaseUrl).toString(); + const webhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET; + const ok = await setTelegramWebhook( + tokenForWebhook, + webhookUrl, + webhookSecret, + ); + if (!ok) { + return apiError( + "Telegram-Webhook konnte nicht gesetzt werden. Pruefe Bot-Token und Erreichbarkeit.", + 422, + ); + } + } + + const data: Record = { telegramEnabled: enabled }; + + if (botToken !== undefined) { + data.telegramBotToken = trimmedToken ? encrypt(trimmedToken) : null; + } + + if (chatId !== undefined) { + data.telegramChatId = trimmedChatId || null; + } + + await prisma.user.update({ + where: { id: user.id }, + data, + }); + + // Sync to NotificationChannel so the dispatcher can find Telegram + const finalToken = trimmedToken ? trimmedToken : currentTokenPlain; + const finalChatId = trimmedChatId ?? current.telegramChatId; + + if (finalToken && finalChatId) { + const channelConfig = encrypt( + JSON.stringify({ botToken: finalToken, chatId: finalChatId }), + ); + await prisma.notificationChannel.upsert({ + where: { + userId_type: { + userId: user.id, + type: "TELEGRAM", + }, + }, + create: { + userId: user.id, + type: "TELEGRAM", + enabled, + config: channelConfig, + }, + update: { + enabled, + config: channelConfig, + }, + }); + } else if (!enabled) { + // Remove the channel record if disabled and no credentials + await prisma.notificationChannel.deleteMany({ + where: { + userId: user.id, + type: "TELEGRAM", + }, + }); + } + + if (!enabled && tokenForWebhook) { + await deleteTelegramWebhook(tokenForWebhook).catch(() => {}); + } + + annotate({ action: { name: "settings.telegram.update" }, meta: { enabled } }); + + return apiSuccess({ updated: true }); +}); diff --git a/src/app/api/settings/telegram/test/route.ts b/src/app/api/settings/telegram/test/route.ts new file mode 100644 index 000000000..a6fd56214 --- /dev/null +++ b/src/app/api/settings/telegram/test/route.ts @@ -0,0 +1,49 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { sendTelegramMessage } from "@/lib/telegram"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * Send a test Telegram message to verify the bot token and chat ID. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`telegram-test:${user.id}`, 5, 5 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximal 5 Tests in 5 Minuten", 429); + } + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { telegramBotToken: true, telegramChatId: true }, + }); + + if (!dbUser?.telegramBotToken || !dbUser?.telegramChatId) { + return apiError( + "Bot-Token und Chat-ID muessen zuerst gespeichert werden", + 422, + ); + } + + const botToken = decrypt(dbUser.telegramBotToken); + const ok = await sendTelegramMessage( + botToken, + dbUser.telegramChatId, + "HealthLog: Verbindung erfolgreich! Telegram-Benachrichtigungen sind aktiv.", + ); + + if (!ok) { + return apiError( + "Nachricht konnte nicht gesendet werden. Pruefe Bot-Token und Chat-ID.", + 422, + ); + } + + annotate({ action: { name: "settings.telegram.test" }, meta: { success: true } }); + + return apiSuccess({ sent: true }); +}); diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts new file mode 100644 index 000000000..ad4316c7d --- /dev/null +++ b/src/app/api/telegram/webhook/route.ts @@ -0,0 +1,633 @@ +import { timingSafeEqual } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import { + answerTelegramCallbackQuery, + deleteMessage, + sendTelegramMessage, +} from "@/lib/telegram"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/api-response"; +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; + +interface TelegramUpdate { + update_id: number; + message?: { + message_id?: number; + text?: string; + chat?: { id: number | string }; + }; + callback_query?: { + id: string; + data?: string; + from?: { id?: number | string }; + message?: { + message_id?: number; + chat?: { id: number | string }; + }; + }; +} + +function hasValidSecret(request: NextRequest): boolean { + const expected = process.env.TELEGRAM_WEBHOOK_SECRET; + if (!expected) { + getEvent()?.addWarning("TELEGRAM_WEBHOOK_SECRET not configured"); + return false; + } + const received = request.headers.get("x-telegram-bot-api-secret-token"); + if (!received) return false; + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(received, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function toChatId(value: number | string | undefined | null): string | null { + if (value === undefined || value === null) return null; + return String(value); +} + +function escapeHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +async function cleanupReminderTracking(medicationId: string): Promise { + try { + await prisma.telegramReminderMessage.deleteMany({ + where: { medicationId }, + }); + } catch { + // Best-effort cleanup + } +} + +const AUTO_DELETE_DELAY_MS = 60 * 60 * 1000; // 1 hour + +async function scheduleAutoDelete( + userId: string, + chatId: string, + messageIds: number[], +): Promise { + const deleteAfter = new Date(Date.now() + AUTO_DELETE_DELAY_MS); + try { + await prisma.telegramScheduledDeletion.createMany({ + data: messageIds.map((messageId) => ({ + userId, + chatId, + messageId, + deleteAfter, + })), + }); + } catch { + // Best-effort + } +} + +async function findTelegramUser(chatId: string) { + return prisma.user.findFirst({ + where: { + telegramEnabled: true, + telegramChatId: chatId, + telegramBotToken: { not: null }, + }, + select: { + id: true, + telegramBotToken: true, + }, + }); +} + +async function markMedicationTaken( + userId: string, + medicationId: string, + idempotencyKey: string, +): Promise<{ ok: boolean; message: string; medicationName?: string }> { + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId, active: true }, + select: { id: true, name: true }, + }); + if (!medication) { + return { ok: false, message: "Medikament nicht gefunden oder inaktiv." }; + } + + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { userId, medicationId: medication.id, idempotencyKey }, + select: { id: true }, + }); + + if (!existing) { + await prisma.$transaction([ + prisma.medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: new Date(), + takenAt: new Date(), + skipped: false, + source: "REMINDER", + idempotencyKey, + }, + }), + prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: null }, + }), + ]); + } + + return { + ok: true, + message: existing + ? `${medication.name} war bereits erfasst.` + : `${medication.name} als eingenommen erfasst.`, + medicationName: medication.name, + }; +} + +async function handleCallback(update: TelegramUpdate) { + const callback = update.callback_query; + if (!callback) return; + + const chatId = + toChatId(callback.message?.chat?.id) ?? toChatId(callback.from?.id); + if (!chatId) return; + + const user = await findTelegramUser(chatId); + if (!user?.telegramBotToken) return; + const botToken = decrypt(user.telegramBotToken); + + const data = callback.data ?? ""; + const messageId = callback.message?.message_id; + + if (data.startsWith("taken:")) { + const medicationId = data.slice("taken:".length).trim(); + if (!medicationId) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + "Ungültige Aktion.", + ); + return; + } + + const msgId = messageId ?? "na"; + const idempotencyKey = + `telegram:cb:${chatId}:${msgId}:${medicationId}`.slice(0, 128); + + const result = await markMedicationTaken( + user.id, + medicationId, + idempotencyKey, + ); + await answerTelegramCallbackQuery(botToken, callback.id, result.message); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("snooze:")) { + // Format: "snooze:{medicationId}:{minutes}" + const parts = data.split(":"); + const medicationId = parts[1]; + const minutes = parseInt(parts[2], 10); + if (!medicationId || !Number.isFinite(minutes)) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + "Ungültige Aktion.", + ); + return; + } + + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId: user.id, active: true }, + select: { id: true, name: true }, + }); + if (!medication) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + "Medikament nicht gefunden.", + ); + return; + } + + await prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: new Date(Date.now() + minutes * 60000) }, + }); + + const label = minutes <= 60 ? "1 Stunde" : "3 Stunden"; + await answerTelegramCallbackQuery( + botToken, + callback.id, + `${medication.name} für ${label} zurückgestellt.`, + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("skip:")) { + const medicationId = data.slice("skip:".length).trim(); + if (!medicationId) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + "Ungültige Aktion.", + ); + return; + } + + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId: user.id, active: true }, + select: { id: true, name: true }, + }); + if (!medication) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + "Medikament nicht gefunden.", + ); + return; + } + + const msgId = messageId ?? "na"; + const idempotencyKey = + `telegram:skip:${chatId}:${msgId}:${medicationId}`.slice(0, 128); + + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { userId: user.id, medicationId: medication.id, idempotencyKey }, + select: { id: true }, + }); + + if (!existing) { + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 59, 999); + + await prisma.$transaction([ + prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: medication.id, + scheduledFor: new Date(), + takenAt: null, + skipped: true, + source: "REMINDER", + idempotencyKey, + }, + }), + prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: endOfDay }, + }), + ]); + } + + await answerTelegramCallbackQuery( + botToken, + callback.id, + `${medication.name} übersprungen.`, + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("ack:")) { + const medicationId = data.slice("ack:".length).trim(); + if (!medicationId) { + await answerTelegramCallbackQuery(botToken, callback.id, "Ungültige Aktion."); + return; + } + + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId: user.id }, + select: { id: true, name: true }, + }); + + await answerTelegramCallbackQuery( + botToken, + callback.id, + medication ? `${medication.name} bestätigt.` : "Bestätigt.", + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("add:")) { + // Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}" + const withoutPrefix = data.slice("add:".length); + const umidIdx = withoutPrefix.indexOf(":umid:"); + const medicationId = umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim(); + if (!medicationId) { + await answerTelegramCallbackQuery(botToken, callback.id, "Ungültige Aktion."); + return; + } + + const msgId = messageId ?? "na"; + const idempotencyKey = + `telegram:add:${chatId}:${msgId}:${medicationId}`.slice(0, 128); + + const result = await markMedicationTaken(user.id, medicationId, idempotencyKey); + await answerTelegramCallbackQuery(botToken, callback.id, result.message); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + // Also delete the user's /add command message if encoded + const userMsgMatch = data.match(/:umid:(\d+)$/); + if (userMsgMatch) { + const userMsgId = parseInt(userMsgMatch[1], 10); + if (Number.isFinite(userMsgId)) { + await deleteMessage(botToken, chatId, userMsgId).catch(() => {}); + } + } + } else if (data.startsWith("cancel_add")) { + // Delete the bot's selection message + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + // Delete the user's original /add command message if encoded + const userMsgMatch = data.match(/:umid:(\d+)$/); + if (userMsgMatch) { + const userMsgId = parseInt(userMsgMatch[1], 10); + if (Number.isFinite(userMsgId)) { + await deleteMessage(botToken, chatId, userMsgId).catch(() => {}); + } + } + await answerTelegramCallbackQuery(botToken, callback.id, "Abgebrochen."); + } else { + await answerTelegramCallbackQuery( + botToken, + callback.id, + "Unbekannte Aktion.", + ); + } +} + +async function handleTextMessage(update: TelegramUpdate) { + const message = update.message; + const text = message?.text?.trim(); + const chatId = toChatId(message?.chat?.id); + if (!text || !chatId) return; + + const user = await findTelegramUser(chatId); + if (!user?.telegramBotToken) return; + const botToken = decrypt(user.telegramBotToken); + + if (/^\/help\b/i.test(text) || /^\/start\b/i.test(text) || /^hilfe$/i.test(text)) { + const userMsgId = message?.message_id; + const resp = await sendTelegramMessage( + botToken, + chatId, + `Verfügbare Befehle:\n\n` + + `/help — Diese Hilfe anzeigen\n` + + `/add — Einnahme erfassen\n` + + `/start — Bot starten\n\n` + + `Textbefehle:\n` + + `genommen <Name> — Einnahme bestätigen\n\n` + + `Über die Buttons in Erinnerungen:\n` + + `• Genommen — Einnahme bestätigen\n` + + `• 🕐 1h / 🕐 3h — Erinnerung verschieben\n` + + `• ⏭ Überspringen — Einnahme überspringen\n` + + `• ✓ Bestätigen — Verpasste Einnahme bestätigen`, + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + if (/^\/add\b/i.test(text)) { + const userMsgId = message?.message_id; + const meds = await prisma.medication.findMany({ + where: { userId: user.id, active: true }, + select: { id: true, name: true, dose: true }, + orderBy: { name: "asc" }, + }); + + if (meds.length === 0) { + const resp = await sendTelegramMessage( + botToken, + chatId, + "Keine aktiven Medikamente gefunden.", + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + if (meds.length === 1) { + const idempotencyKey = + `telegram:add:${update.update_id}:${meds[0].id}`.slice(0, 128); + const result = await markMedicationTaken( + user.id, + meds[0].id, + idempotencyKey, + ); + const resp = await sendTelegramMessage( + botToken, + chatId, + result.ok + ? `${escapeHtml(result.message)}` + : `${escapeHtml(result.message)}`, + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + // Encode user's message ID in callback data so cancel/add can delete it + const umidSuffix = userMsgId ? `:umid:${userMsgId}` : ""; + const keyboard = { + inline_keyboard: [ + ...meds.map((med) => [ + { + text: `${med.name} (${med.dose})`, + callback_data: `add:${med.id}${umidSuffix}`, + }, + ]), + [ + { + text: "Abbrechen", + callback_data: `cancel_add${umidSuffix}`, + }, + ], + ], + }; + const resp = await sendTelegramMessage( + botToken, + chatId, + "Welches Medikament eingenommen?", + { parseMode: "HTML", replyMarkup: keyboard }, + ); + // Fallback auto-delete if user never interacts with the selection + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + // Greeting responses + const greetings = ["hi", "hallo", "hey", "moin"]; + const lowerText = text.toLowerCase(); + const matchedGreeting = greetings.find((g) => lowerText === g); + if (matchedGreeting) { + const userMsgId = message?.message_id; + const reply = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); + const resp = await sendTelegramMessage(botToken, chatId, `${reply}! 👋`); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + if (!/^genommen\b/i.test(text)) return; + + const userMsgId = message?.message_id; + const nameInput = text.replace(/^genommen\b/i, "").trim(); + + let medicationId: string | null = null; + if (nameInput) { + const med = await prisma.medication.findFirst({ + where: { + userId: user.id, + active: true, + name: { equals: nameInput, mode: "insensitive" }, + }, + select: { id: true }, + }); + medicationId = med?.id ?? null; + } else { + const meds = await prisma.medication.findMany({ + where: { userId: user.id, active: true }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + take: 2, + }); + if (meds.length === 1) { + medicationId = meds[0].id; + } else { + const resp = await sendTelegramMessage( + botToken, + chatId, + "Bitte Medikament angeben, z.B. genommen Ramipril.", + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + } + + if (!medicationId) { + const resp = await sendTelegramMessage( + botToken, + chatId, + "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + const idempotencyKey = + `telegram:text:${update.update_id}:${medicationId}`.slice(0, 128); + const result = await markMedicationTaken( + user.id, + medicationId, + idempotencyKey, + ); + const resp = await sendTelegramMessage( + botToken, + chatId, + result.ok + ? `✅ ${escapeHtml(result.message)}` + : `⚠️ ${escapeHtml(result.message)}`, + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } +} + +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "telegram.webhook" } }); + + const ip = getClientIp(request); + const rl = await checkRateLimit(`telegram-webhook:${ip}`, 120, 60 * 1000); + if (!rl.allowed) { + return NextResponse.json( + { status: "rate_limited" }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + if (!hasValidSecret(request)) { + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + + getEvent()?.setAuth({ auth_method: "telegram_webhook" }); + + let update: TelegramUpdate; + try { + update = (await request.json()) as TelegramUpdate; + } catch { + return NextResponse.json({ status: "invalid json" }, { status: 200 }); + } + if (!update || typeof update.update_id !== "number") { + return NextResponse.json({ status: "ignored" }, { status: 200 }); + } + + annotate({ meta: { update_id: update.update_id } }); + + if (update.callback_query) { + await handleCallback(update); + return NextResponse.json({ status: "ok" }, { status: 200 }); + } + + if (update.message?.text) { + await handleTextMessage(update); + return NextResponse.json({ status: "ok" }, { status: 200 }); + } + + return NextResponse.json({ status: "ignored" }, { status: 200 }); +}); + +export const GET = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "telegram.webhook.verify" } }); + + if (!hasValidSecret(request)) { + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + getEvent()?.setAuth({ auth_method: "telegram_webhook" }); + return NextResponse.json({ status: "ok" }, { status: 200 }); +}); diff --git a/src/app/api/tokens/[id]/route.ts b/src/app/api/tokens/[id]/route.ts new file mode 100644 index 000000000..226167f7a --- /dev/null +++ b/src/app/api/tokens/[id]/route.ts @@ -0,0 +1,33 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * Revoke an API token. + */ +export const DELETE = apiHandler(async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + annotate({ action: { name: "tokens.revoke" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API ist global deaktiviert", 403); + } + + const { id } = await params; + const token = await prisma.apiToken.findUnique({ where: { id } }); + + if (!token || token.userId !== user.id) { + return apiError("Token nicht gefunden", 404); + } + + await prisma.apiToken.update({ + where: { id }, + data: { revoked: true }, + }); + + return apiSuccess({ revoked: true }); +}); diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts new file mode 100644 index 000000000..85f7cd65e --- /dev/null +++ b/src/app/api/tokens/route.ts @@ -0,0 +1,77 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { hashToken } from "@/lib/auth/hmac"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { randomBytes } from "node:crypto"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +const createTokenSchema = z.object({ + name: z.string().min(1, "Name erforderlich").max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "tokens.list" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API ist global deaktiviert", 403); + } + + const tokens = await prisma.apiToken.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + permissions: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + revoked: true, + }, + orderBy: { createdAt: "desc" }, + }); + + return apiSuccess(tokens); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "tokens.create" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API ist global deaktiviert", 403); + } + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const parsed = createTokenSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { name, expiresInDays } = parsed.data; + + // Generate a random token + const rawToken = `hlk_${randomBytes(32).toString("hex")}`; + const tokenHashValue = hashToken(rawToken); + + await prisma.apiToken.create({ + data: { + userId: user.id, + name, + tokenHash: tokenHashValue, + permissions: ["medication:ingest"], + expiresAt: expiresInDays + ? new Date(Date.now() + expiresInDays * 86400000) + : null, + }, + }); + + // Return the raw token ONCE — it can never be retrieved again + return apiSuccess({ token: rawToken, name }, 201); +}); diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts new file mode 100644 index 000000000..18d437ae8 --- /dev/null +++ b/src/app/api/withings/callback/route.ts @@ -0,0 +1,114 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { encrypt } from "@/lib/crypto"; +import { exchangeCode } from "@/lib/withings/client"; +import { getUserWithingsCredentials } from "@/lib/withings/credentials"; +import { setupWebhook } from "@/lib/withings/sync"; +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; + +/** + * OAuth callback from Withings. Exchanges code for tokens and stores them. + */ +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.callback" } }); + + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const storedState = request.cookies.get("withings_state")?.value; + + // CSRF check (timing-safe comparison to prevent timing attacks) + if ( + !state || + !storedState || + state.length !== storedState.length || + !timingSafeEqual(Buffer.from(state), Buffer.from(storedState)) + ) { + return NextResponse.redirect( + new URL( + "/settings?withings=error&reason=state", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + + // Verify state contains current user's ID + const [stateUserId] = state.split(":"); + if (stateUserId !== user.id) { + return NextResponse.redirect( + new URL( + "/settings?withings=error&reason=user", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + + if (!code) { + return NextResponse.redirect( + new URL( + "/settings?withings=error&reason=nocode", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + + try { + const creds = await getUserWithingsCredentials(user.id); + if (!creds) { + return NextResponse.redirect( + new URL( + "/settings?withings=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + + const tokens = await exchangeCode(code, creds); + + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + + // Upsert connection (user may be reconnecting) + await prisma.withingsConnection.upsert({ + where: { userId: user.id }, + update: { + withingsUserId: tokens.userid, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + }, + create: { + userId: user.id, + withingsUserId: tokens.userid, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token), + tokenExpiresAt: expiresAt, + }, + }); + + // Subscribe to webhooks in background + setupWebhook(user.id).catch((err) => getEvent()?.addWarning("Webhook setup failed: " + err)); + + await auditLog("withings.connect", { + userId: user.id, + details: { withingsUserId: tokens.userid }, + }); + + const response = NextResponse.redirect( + new URL("/settings?withings=connected", process.env.NEXT_PUBLIC_APP_URL!), + ); + response.cookies.delete("withings_state"); + return response; + } catch (err) { + getEvent()?.setError(err); + return NextResponse.redirect( + new URL( + "/settings?withings=error&reason=token", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } +}); diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts new file mode 100644 index 000000000..f9bced141 --- /dev/null +++ b/src/app/api/withings/connect/route.ts @@ -0,0 +1,40 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { getAuthorizationUrl } from "@/lib/withings/client"; +import { getUserWithingsCredentials } from "@/lib/withings/credentials"; +import { randomBytes } from "node:crypto"; +import { NextResponse } from "next/server"; + +/** + * Redirects the user to Withings OAuth authorization page. + * State param = userId:random for CSRF protection. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.connect" } }); + + const creds = await getUserWithingsCredentials(user.id); + if (!creds) { + return apiError( + "Bitte zuerst Withings Client-ID und Client-Secret in den Einstellungen hinterlegen.", + 400, + ); + } + + const stateNonce = randomBytes(16).toString("hex"); + const state = `${user.id}:${stateNonce}`; + + const url = getAuthorizationUrl(state, creds); + + const response = NextResponse.redirect(url); + response.cookies.set("withings_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 600, + path: "/", + }); + + return response; +}); diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts new file mode 100644 index 000000000..37ce5da1f --- /dev/null +++ b/src/app/api/withings/credentials/route.ts @@ -0,0 +1,80 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { encrypt } from "@/lib/crypto"; +import { withingsCredentialsSchema } from "@/lib/validations/withings"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +/** + * Check if user has Withings credentials stored. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.credentials.get" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + withingsClientIdEncrypted: true, + withingsClientSecretEncrypted: true, + }, + }); + + return apiSuccess({ + hasCredentials: + !!dbUser?.withingsClientIdEncrypted && + !!dbUser?.withingsClientSecretEncrypted, + }); +}); + +/** + * Save Withings API credentials (encrypted). + */ +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.credentials.update" } }); + + const { data: body, error: jsonError } = await safeJson(request); + + if (jsonError) return jsonError; + const result = z.safeParse(withingsCredentialsSchema, body); + if (!result.success) { + return apiError("Client-ID und Client-Secret sind erforderlich", 422); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + withingsClientIdEncrypted: encrypt(result.data.clientId), + withingsClientSecretEncrypted: encrypt(result.data.clientSecret), + }, + }); + + return apiSuccess({ updated: true }); +}); + +/** + * Delete Withings credentials and disconnect. + */ +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.credentials.delete" } }); + + // Remove connection first + await prisma.withingsConnection + .delete({ where: { userId: user.id } }) + .catch(() => {}); + + // Remove credentials + await prisma.user.update({ + where: { id: user.id }, + data: { + withingsClientIdEncrypted: null, + withingsClientSecretEncrypted: null, + }, + }); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/withings/disconnect/route.ts b/src/app/api/withings/disconnect/route.ts new file mode 100644 index 000000000..acd73b32b --- /dev/null +++ b/src/app/api/withings/disconnect/route.ts @@ -0,0 +1,43 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { decrypt } from "@/lib/crypto"; +import { unsubscribeWebhook } from "@/lib/withings/client"; +import { getWithingsWebhookCallbackUrl } from "@/lib/withings/sync"; + +/** + * Disconnect Withings integration for the current user. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.disconnect" } }); + + const connection = await prisma.withingsConnection.findUnique({ + where: { userId: user.id }, + }); + + if (!connection) { + return apiError("Keine Withings-Verbindung vorhanden", 404); + } + + // Try to unsubscribe webhook (best-effort) + try { + const accessToken = decrypt(connection.accessToken); + const callbackUrl = getWithingsWebhookCallbackUrl(); + await unsubscribeWebhook(accessToken, callbackUrl); + } catch { + // Ignore — token might be expired + } + + await prisma.withingsConnection.delete({ + where: { userId: user.id }, + }); + + await auditLog("withings.disconnect", { + userId: user.id, + }); + + return apiSuccess({ disconnected: true }); +}); diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts new file mode 100644 index 000000000..9d6397c32 --- /dev/null +++ b/src/app/api/withings/status/route.ts @@ -0,0 +1,89 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { getUserWithingsCredentials } from "@/lib/withings/credentials"; +import { refreshAccessToken } from "@/lib/withings/client"; + +/** + * Get Withings connection status for the current user. + * "configured" now checks per-user credentials instead of env vars. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.status" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + withingsClientIdEncrypted: true, + withingsClientSecretEncrypted: true, + }, + }); + + const configured = + !!dbUser?.withingsClientIdEncrypted && !!dbUser?.withingsClientSecretEncrypted; + + const connection = await prisma.withingsConnection.findUnique({ + where: { userId: user.id }, + select: { + withingsUserId: true, + accessToken: true, + refreshToken: true, + lastSyncedAt: true, + tokenExpiresAt: true, + createdAt: true, + }, + }); + + if (!connection) { + return apiSuccess({ connected: false, configured }); + } + + let tokenExpiresAt = connection.tokenExpiresAt; + const now = Date.now(); + let tokenExpired = tokenExpiresAt.getTime() <= now; + let tokenRefreshFailed = false; + + // Keep status reliable: if token is expired (or about to expire), refresh it + // before reporting "abgelaufen" in the UI. + const shouldRefresh = tokenExpiresAt.getTime() - 60_000 <= now; + if (shouldRefresh) { + try { + const creds = await getUserWithingsCredentials(user.id); + if (creds) { + const refreshToken = decrypt(connection.refreshToken); + const refreshed = await refreshAccessToken(refreshToken, creds); + tokenExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000); + tokenExpired = tokenExpiresAt.getTime() <= Date.now(); + + await prisma.withingsConnection.update({ + where: { userId: user.id }, + data: { + accessToken: encrypt(refreshed.access_token), + refreshToken: encrypt(refreshed.refresh_token), + tokenExpiresAt, + }, + }); + } else if (tokenExpired) { + tokenRefreshFailed = true; + } + } catch (error) { + if (tokenExpired) { + tokenRefreshFailed = true; + } + getEvent()?.addWarning("Token refresh failed"); + } + } + + return apiSuccess({ + connected: true, + configured, + lastSyncedAt: connection.lastSyncedAt, + connectedAt: connection.createdAt, + tokenExpired, + tokenRefreshFailed, + tokenExpiresAt, + }); +}); diff --git a/src/app/api/withings/sync/route.ts b/src/app/api/withings/sync/route.ts new file mode 100644 index 000000000..fd93fa800 --- /dev/null +++ b/src/app/api/withings/sync/route.ts @@ -0,0 +1,26 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { syncUserMeasurements } from "@/lib/withings/sync"; +import { NextRequest } from "next/server"; + +/** + * Manually trigger a Withings sync for the current user. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "withings.sync" } }); + + let fullSync = false; + try { + const body = await request.json(); + fullSync = body?.fullSync === true; + } catch { + // no body provided -> default incremental sync + } + + const imported = await syncUserMeasurements(user.id, { + fullSync, + }); + return apiSuccess({ imported, fullSync }); +}); diff --git a/src/app/api/withings/webhook/route.ts b/src/app/api/withings/webhook/route.ts new file mode 100644 index 000000000..2980b5db9 --- /dev/null +++ b/src/app/api/withings/webhook/route.ts @@ -0,0 +1,105 @@ +import { timingSafeEqual } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { syncUserMeasurements } from "@/lib/withings/sync"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { getClientIp, safeJson } from "@/lib/api-response"; +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; + +function hasValidWebhookSecret(request: NextRequest): boolean { + const expected = process.env.WITHINGS_WEBHOOK_SECRET; + if (!expected) { + getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured"); + return false; + } + const received = request.nextUrl.searchParams.get("secret"); + if (!received) return false; + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(received, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Withings webhook notification endpoint. + * Withings sends a POST when new measurements are available. + * The webhook sends: userid, startdate, enddate, appli + */ +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "withings.webhook" } }); + + const ip = getClientIp(request); + const rl = await checkRateLimit(`withings-webhook:${ip}`, 30, 60 * 1000); + if (!rl.allowed) { + return NextResponse.json( + { status: "rate_limited" }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + if (!hasValidWebhookSecret(request)) { + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + + getEvent()?.setAuth({ auth_method: "webhook_secret" }); + + // Withings sends form-encoded or JSON depending on version + const contentType = request.headers.get("content-type") ?? ""; + let withingsUserId: string | null = null; + + if (contentType.includes("application/x-www-form-urlencoded")) { + const formData = await request.formData(); + withingsUserId = formData.get("userid") as string; + } else { + const { data: body, error: jsonError } = await safeJson<{ userid?: string | number }>(request); + if (jsonError) return jsonError; + withingsUserId = body.userid?.toString() ?? null; + } + + if (!withingsUserId) { + return NextResponse.json({ status: "ignored" }, { status: 200 }); + } + + annotate({ meta: { withings_user_id: withingsUserId } }); + + // Find user by Withings user ID + const connection = await prisma.withingsConnection.findFirst({ + where: { withingsUserId }, + }); + + if (!connection) { + getEvent()?.addWarning("Webhook for unknown withings user: " + withingsUserId); + return NextResponse.json({ status: "unknown_user" }, { status: 200 }); + } + + // Sync measurements (non-blocking response for Withings) + syncUserMeasurements(connection.userId).catch((err) => { + getEvent()?.addWarning("Sync failed for user " + connection.userId + ": " + err); + }); + + return NextResponse.json({ status: "ok" }, { status: 200 }); +}); + +/** + * Withings sends a HEAD request to verify the webhook URL. + */ +export const HEAD = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "withings.webhook.verify" } }); + + if (!hasValidWebhookSecret(request)) { + return new NextResponse(null, { status: 401 }); + } + getEvent()?.setAuth({ auth_method: "webhook_secret" }); + return new NextResponse(null, { status: 200 }); +}); + +export const GET = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "withings.webhook.verify" } }); + + if (!hasValidWebhookSecret(request)) { + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + getEvent()?.setAuth({ auth_method: "webhook_secret" }); + return NextResponse.json({ status: "ok" }, { status: 200 }); +}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx new file mode 100644 index 000000000..5fbc765ad --- /dev/null +++ b/src/app/auth/login/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, Lock, Loader2 } from "lucide-react"; +import { Logo } from "@/components/ui/logo"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [mode, setMode] = useState<"passkey" | "password">("passkey"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { data: registrationEnabled } = useQuery({ + queryKey: ["auth", "registration-status"], + queryFn: async () => { + const res = await fetch("/api/auth/registration-status", { + cache: "no-store", + }); + if (!res.ok) return true; + const json = await res.json(); + return Boolean(json.data?.registrationEnabled ?? true); + }, + staleTime: 60 * 1000, + }); + + function getRedirectTarget(): string { + const next = searchParams.get("next"); + if (!next) return "/"; + // Prevent open redirects: only allow local absolute paths. + if (next.startsWith("/") && !next.startsWith("//")) { + return next; + } + return "/"; + } + + async function handlePasskeyLogin() { + setError(null); + setLoading(true); + + try { + const optRes = await fetch("/api/auth/passkey/login-options", { + method: "POST", + }); + const optJson = await optRes.json(); + if (!optRes.ok) { + setError(optJson.error); + setLoading(false); + return; + } + + const { options, challengeId } = optJson.data; + const { startAuthentication } = await import( + "@simplewebauthn/browser" + ); + const credential = await startAuthentication({ optionsJSON: options }); + + const verifyRes = await fetch("/api/auth/passkey/login-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ challengeId, credential }), + }); + + const verifyJson = await verifyRes.json(); + if (!verifyRes.ok) { + setError(verifyJson.error || t("auth.loginFailed")); + setLoading(false); + return; + } + + await queryClient.invalidateQueries({ queryKey: ["auth"] }); + router.push(getRedirectTarget()); + } catch { + setError(t("auth.passkeyFailed")); + } finally { + setLoading(false); + } + } + + async function handlePasswordLogin(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const json = await res.json(); + if (!res.ok) { + setError(json.error); + setLoading(false); + return; + } + + await queryClient.invalidateQueries({ queryKey: ["auth"] }); + router.push(getRedirectTarget()); + } catch { + setError(t("auth.loginFailed")); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
+ +
+
+

HealthLog

+
+
+ +
+ + +
+ + + {t("common.or")} + + +
+ + {mode === "passkey" ? ( + + ) : ( +
+
+ + setEmail(e.target.value)} + required + autoComplete="username" + placeholder={t("auth.emailOrUsernamePlaceholder")} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + placeholder="********" + /> +
+ + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {registrationEnabled === true && ( +

+ {t("auth.noAccount")}{" "} + + {t("auth.register")} + +

+ )} +
+
+
+ ); +} diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx new file mode 100644 index 000000000..6b1404426 --- /dev/null +++ b/src/app/auth/register/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, UserPlus } from "lucide-react"; +import { Logo } from "@/components/ui/logo"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordStrength } from "@/components/ui/password-strength"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function RegisterPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { t } = useTranslations(); + + async function handleRegister(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, username, password }), + }); + + const json = await res.json(); + if (!res.ok) { + setError(json.error); + setLoading(false); + return; + } + + await queryClient.invalidateQueries({ queryKey: ["auth"] }); + router.push("/"); + } catch { + setError(t("auth.registerFailed")); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
+ +
+
+

+ {t("auth.createAccount")} +

+

+ {t("auth.newAccount")} +

+
+
+ +
+
+ + setEmail(e.target.value)} + placeholder={t("auth.emailPlaceholder")} + required + autoComplete="email" + /> +
+ +
+ + setUsername(e.target.value)} + placeholder="user" + required + autoComplete="username" + minLength={3} + maxLength={30} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder={t("auth.passwordMinLength")} + required + autoComplete="new-password" + /> + +

+ {t("auth.passkeySetupLater")} +

+
+ + {error && ( +
+ {error} +
+ )} + + + +

+ {t("auth.hasAccount")}{" "} + + {t("auth.login")} + +

+
+
+
+ ); +} diff --git a/src/app/bugreport/page.tsx b/src/app/bugreport/page.tsx new file mode 100644 index 000000000..fe6f81d80 --- /dev/null +++ b/src/app/bugreport/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState, useRef } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Loader2, Upload, X, CheckCircle2, ImageIcon, Bug } from "lucide-react"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function BugReportPage() { + const { isAuthenticated } = useAuth(); + const [description, setDescription] = useState(""); + const [screenshot, setScreenshot] = useState(null); + const [screenshotName, setScreenshotName] = useState(null); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + const fileRef = useRef(null); + const { t } = useTranslations(); + + async function handleScreenshot(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith("image/")) { + setResult({ type: "error", message: t("bugreport.imageOnly") }); + return; + } + + if (file.size > 5 * 1024 * 1024) { + setResult({ + type: "error", + message: t("bugreport.tooLarge"), + }); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + setScreenshot(reader.result as string); + setScreenshotName(file.name); + }; + reader.readAsDataURL(file); + } + + function removeScreenshot() { + setScreenshot(null); + setScreenshotName(null); + if (fileRef.current) fileRef.current.value = ""; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setResult(null); + setLoading(true); + + try { + const res = await fetch("/api/bugreport", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + description, + ...(screenshot ? { screenshot } : {}), + }), + }); + + const json = await res.json(); + if (res.ok) { + setResult({ + type: "success", + message: t("bugreport.success"), + }); + setDescription(""); + removeScreenshot(); + } else { + setResult({ + type: "error", + message: json.error || t("bugreport.errorCreating"), + }); + } + } catch { + setResult({ type: "error", message: t("common.networkError") }); + } finally { + setLoading(false); + } + } + + if (!isAuthenticated) { + return ( +
+

+ {t("bugreport.title")} +

+

+ {t("bugreport.loginRequired")} +

+
+ ); + } + + return ( +
+
+

+ {t("bugreport.title")} +

+

+ {t("bugreport.subtitle")} +

+
+ +
+
+
+ +