mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.
This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.
The v1.4.1 work is NOT lost:
- PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
git history.
- Their commits are still tagged (`v1.4.1`), still on the GHCR
multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
the GitHub Release notes.
- Self-hosters who have already pulled the v1.4.1 image keep it.
- Local development continues from main HEAD with the v1.4.1
code — the regression only surfaced under the Coolify build
flow.
Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.
No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
This commit is contained in:
1 parent
e33be0d06e
commit
e444a90b10
58 files changed
+3842
-7292
No files matched your search
@@ -1,83 +0,0 @@
|
||||
name: e2e
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs when a new commit lands on the same PR.
|
||||
concurrency:
|
||||
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- run: pnpm exec next build
|
||||
|
||||
- run: pnpm exec playwright test
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Integration tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
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:integration
|
||||
env:
|
||||
# The integration suite boots a Postgres testcontainer and points
|
||||
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
|
||||
# These env vars satisfy code paths that read them at module load.
|
||||
API_TOKEN_HMAC_KEY: integration-tests-do-not-use-in-prod
|
||||
ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
SESSION_SECRET: integration-tests
|
||||
@@ -11,11 +11,6 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
@@ -6,19 +6,16 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**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**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
|
||||
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
|
||||
consolidation, the per-route `/settings/[section]` split, the
|
||||
status-first admin grid + aggregator endpoint, five new "Test
|
||||
connection" buttons, AI insights with inline charts via allowlisted
|
||||
metric tokens, off-host encrypted S3 backup, encryption-key
|
||||
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
|
||||
web split, and short-lived 24h access tokens with refresh-token
|
||||
rotation for native API clients. v1.4.1 follows up with per-section
|
||||
admin component extraction (the inner monolith into one file per
|
||||
panel), a Postgres testcontainers integration test suite, and a
|
||||
Playwright + axe-core E2E foundation. See GitHub Releases +
|
||||
CHANGELOG.md for the full feature timeline.
|
||||
**Status**: v1.4.0 — UI guidelines + reusable Skeleton/EmptyState
|
||||
primitives, medical citations consolidated under
|
||||
`src/lib/medical-citations.ts` (BP_DIA hypotension floor, ESH 2023
|
||||
alignment, "WHO 8000 steps" hallucination removed, ACE body-fat bands
|
||||
corrected), localised medication reminders + dashboard greeting +
|
||||
Zod validations, two more N+1 queries closed, Berlin-TZ-aware weekly
|
||||
buckets, single-row dashboard tile strip, public `/api/version`
|
||||
endpoint, AI provider connection-test honours unsaved selection,
|
||||
health-data inputs block password-manager autofill by default. See
|
||||
GitHub Releases + CHANGELOG.md for the full feature timeline.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -78,7 +75,7 @@ src/
|
||||
│ ├── 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 shell — 77 LOC, mounts the per-section components from src/components/admin/
|
||||
│ ├── admin/page.tsx # Admin panel
|
||||
│ ├── auth/login/page.tsx # Login
|
||||
│ ├── auth/register/page.tsx # Registration
|
||||
│ ├── achievements/page.tsx # Gamification achievements
|
||||
@@ -88,8 +85,7 @@ src/
|
||||
│ ├── medications/page.tsx # Medications management
|
||||
│ ├── notifications/page.tsx # Notification preferences matrix
|
||||
│ ├── onboarding/page.tsx # 4-step guided onboarding
|
||||
│ ├── settings/page.tsx # 308-redirects /settings → /settings/account
|
||||
│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about)
|
||||
│ ├── settings/page.tsx # All settings (8 top-level sections, ~3150 lines — split tracked for 1.4.0)
|
||||
│ ├── mood/page.tsx # Mood tracking
|
||||
│ ├── targets/page.tsx # Target values dashboard
|
||||
│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
|
||||
@@ -108,8 +104,7 @@ src/
|
||||
│ ├── charts/ # Recharts wrappers, compliance charts
|
||||
│ ├── insights/ # AI insights cards (status, advisor)
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── settings/ # Per-route Settings section components (one per /settings/[section])
|
||||
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
|
||||
│ ├── settings/ # Settings-page section components
|
||||
│ └── monitoring/ # Umami, GlitchTip bootstrap
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma client singleton
|
||||
@@ -140,8 +135,8 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (26 models)
|
||||
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
|
||||
├── schema.prisma # Database schema (25 models)
|
||||
└── migrations/ # Migration files (0001–0024; latest: oxygen_saturation)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
├── sw.js # Service worker (Web Push + offline caching)
|
||||
@@ -188,11 +183,11 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
|
||||
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
|
||||
|
||||
### Settings & Admin (per-route layout, v1.4)
|
||||
### Settings Page
|
||||
|
||||
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
|
||||
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
|
||||
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
|
||||
- One large file (~3150 lines), 8 top-level sections. Sidebar switches to "settings mode" showing section shortcuts. Splitting into per-section files is tracked for 1.4.0 — until then, ESLint `react-hooks/set-state-in-effect` stays non-blocking because of the long-standing violations in this file.
|
||||
- Sections scroll-to with highlight animation (`section-highlight` CSS class).
|
||||
- Top-level section IDs: `section-allgemein`, `section-sicherheit`, `section-benachrichtigungen`, `section-personalization`, `section-integration`, `section-api`, `section-export`, `section-danger-zone`. Sub-anchors inside those sections include `profil`, `passwort`, `passkeys`, `telegram`, `ntfy`, `web-push`, `insights`, `dashboard-layout`, `thresholds`, `withings`, `moodlog`, `api-tokens`, `api-endpoints`.
|
||||
|
||||
### Insights Page
|
||||
|
||||
@@ -230,7 +225,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
|
||||
25 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`.
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
+3
-109
@@ -1,111 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.1] — 2026-05-08
|
||||
|
||||
### Security
|
||||
|
||||
- **moodLog integration no longer accepts internal-network URLs.** A
|
||||
user could previously save `http://169.254.169.254/` (cloud-metadata)
|
||||
or any RFC1918 address as their moodLog instance; the daily sync
|
||||
worker would then fetch from that target with the user's API key in
|
||||
the Authorization header. The credentials write path now refuses
|
||||
non-public hosts, the sync worker re-checks the URL at the actual
|
||||
fetch site (so legacy rows stored before the guard are also
|
||||
refused), and the fetch is now `redirect: "manual"` so a public
|
||||
host cannot 302 to an internal target with the bearer on the
|
||||
redirect hop.
|
||||
- **Error reports never echo bearer tokens, Telegram bot tokens, or
|
||||
query-string secrets.** `WideEventBuilder.setError()` and the
|
||||
Glitchtip incident path now run every error message and stack
|
||||
trace through a central `redactSecrets()` filter that scrubs
|
||||
`Bearer …`, Telegram `bot<digits>:<token>` URLs, and `?secret=`,
|
||||
`?code=`, `?token=`, `?api_key=` query strings. The substitution
|
||||
is generic `[REDACTED]` so partial entropy is never revealed.
|
||||
|
||||
### Fixed — Citation accuracy
|
||||
|
||||
- **Blood-pressure classification now cites ESH 2023.** The dashboard
|
||||
tile, the doctor-report PDF, and the inline analytics comments
|
||||
used to label the band as "ESC/ESH 2018". The numbers haven't
|
||||
changed (the 2023 ESH update kept the 2018 thresholds), but the
|
||||
joint authoring did — ESC withdrew from the 2023 document, so the
|
||||
correct citation is "ESH 2023" alone.
|
||||
- **Steps target source label is `Saint-Maurice JAMA 2020`** instead
|
||||
of `WHO`. Every other surface in the app (AI prompts, inline
|
||||
comments, drift tests) already enforced this attribution; the
|
||||
insights/targets surface was the last "WHO" label in the tree.
|
||||
WHO publishes physical-activity _time_, not a step quota.
|
||||
- **Saint-Maurice "mortality plateau 8000–12000" attribution
|
||||
softened.** The original JAMA 2020 paper reports continued
|
||||
dose-response benefit (HR 0.49 at 8k, HR 0.35 at 12k) — not a
|
||||
plateau. The plateau-shaped finding belongs to Paluch 2022
|
||||
_Lancet Public Health_ (PMID 35247352), not Saint-Maurice. The
|
||||
inline comments and AI prompts now say "continued dose-response
|
||||
benefit through ~12,000 steps/day" instead.
|
||||
|
||||
### Added — CI safety nets
|
||||
|
||||
- **Postgres-backed integration test suite is now executable.** The
|
||||
testcontainers infrastructure shipped in 1.4.0; this release wires
|
||||
the per-test boilerplate through vitest's `globalSetup` so all
|
||||
four files share one container. `pnpm test:integration` runs ten
|
||||
tests (rate-limit race, idempotency replay-attack contract, GDPR
|
||||
Article-17 cascade delete, session create / read / expire) against
|
||||
a real Postgres in under four seconds. CI runs the suite on every
|
||||
PR.
|
||||
- **Playwright + axe-core E2E foundation.** A new `pnpm e2e` runs
|
||||
five public-surface specs (version endpoint, proxy auth-redirect,
|
||||
login form autofill hints, DE/EN locale switch, axe-core
|
||||
accessibility gate) against the production build in CI. Authenticated
|
||||
flow specs (quick-entry, doctor-report, settings round-trip,
|
||||
test-buttons, onboarding) ride a follow-up release because they
|
||||
need a seeded test user; the foundation makes adding them a
|
||||
one-PR step.
|
||||
|
||||
### Changed — Admin internals
|
||||
|
||||
- **Admin page is now per-section components.** The status-card grid
|
||||
shipped in 1.4.0 sat on top of a 2,700-line monolith; that monolith
|
||||
is now 14 focused files in `src/components/admin/` with a 77-line
|
||||
`src/app/admin/page.tsx` shell that mounts them. Every section
|
||||
keeps the same DOM, ids, query keys, and i18n keys — no
|
||||
user-visible change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Final ESLint error is gone.** The medications page's "API
|
||||
endpoint" dialog ran its initial-load fetch through a `useCallback`
|
||||
paired with `useEffect` and triggered the strict
|
||||
`react-hooks/set-state-in-effect` rule. Refactored to TanStack
|
||||
Query — same network calls, no effect, lint count is now zero on
|
||||
`main`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Repo-internal docs synced for v1.4.** README adds the
|
||||
Multi-tenant ready and Test connection buttons feature blocks, the
|
||||
API reference table includes the eleven new v1.4 endpoints, and
|
||||
the model count is corrected to 26 (RefreshToken). AGENTS.md and
|
||||
CLAUDE.md reflect the per-route `/settings/[section]` layout and
|
||||
the per-section admin layout. `docs/api/openapi.yaml` documents
|
||||
the new endpoints (version, refresh, refresh/revoke,
|
||||
status-overview, backup/test, the five test-connection probes).
|
||||
`docs/migration/v1.3-to-v1.4.md` corrects the now-wrong "no
|
||||
migrations" claim and adds full env-var sections for the
|
||||
worker/web split, encryption-key versioning, and off-host backup
|
||||
target.
|
||||
|
||||
### Notes
|
||||
|
||||
- No database migration in 1.4.1.
|
||||
- No environment-variable change required to upgrade.
|
||||
- No API contract change — every route added in 1.4.0 is still
|
||||
there; no shapes or status codes flipped.
|
||||
- The audit pass that drove this release identified five medium
|
||||
security items and three P0 performance items that warrant
|
||||
deeper architectural work; those are tracked in
|
||||
`docs/ops/v141-followup-issues.md` and ride a future release.
|
||||
|
||||
## [1.4.0] — 2026-05-08
|
||||
|
||||
### Added — Foundation, safer ranges, and a faster dashboard
|
||||
@@ -147,7 +41,7 @@
|
||||
now cites "ESH 2023" with the published source URL. Numbers
|
||||
unchanged.
|
||||
- **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO
|
||||
publishes activity _time_ (150–300 min/wk moderate), not a step
|
||||
publishes activity *time* (150–300 min/wk moderate), not a step
|
||||
quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI
|
||||
prompt strings and the `getStepsRange()` helper carried the old
|
||||
wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau
|
||||
@@ -156,7 +50,7 @@
|
||||
to AASM 2015.
|
||||
- **Body-fat ACE bands corrected and three-way drift resolved.** The
|
||||
classifier used `essential = 6 (M) / 14 (F)` as the floor — but
|
||||
that's actually ACE's _Athletes_ lower bound. Readings below were
|
||||
that's actually ACE's *Athletes* lower bound. Readings below were
|
||||
mislabelled "Essential" instead of "Below essential" (a danger
|
||||
band). Six-band classifier now mirrors the ACE table, and the three
|
||||
sites that had three different green-band numbers
|
||||
@@ -327,7 +221,7 @@
|
||||
via `prisma db push` to dev/prod). Any environment built strictly
|
||||
from `prisma/migrations/` (CI testcontainers, brand-new self-host
|
||||
installs) is now consistent. Migration is `ADD COLUMN IF NOT
|
||||
EXISTS`, so it's a clean add on a fresh database and a safe no-op
|
||||
EXISTS`, so it's a clean add on a fresh database and a safe no-op
|
||||
against any environment that was already kept in sync.
|
||||
|
||||
### Notes
|
||||
|
||||
@@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs
|
||||
## Architecture
|
||||
|
||||
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
|
||||
- **Prisma 7** ORM with PostgreSQL (26 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **Prisma 7** ORM with PostgreSQL (25 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
|
||||
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
|
||||
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
|
||||
@@ -68,8 +68,6 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/components/measurements/` — measurement form, list
|
||||
- `src/components/mood/` — mood form, mood list
|
||||
- `src/components/charts/` — Recharts wrappers
|
||||
- `src/components/settings/` — per-route Settings section components (one per `/settings/[section]`)
|
||||
- `src/components/admin/` — per-route Admin section components (system-status, integrations, monitoring, users, audit, danger-zone, feedback)
|
||||
- `src/lib/logging/` — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background)
|
||||
- `src/lib/api-handler.ts` — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError
|
||||
- `src/lib/` — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar)
|
||||
@@ -83,9 +81,7 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/lib/validations/` — Zod schemas shared between API + client
|
||||
- `src/hooks/` — React hooks (`use-auth`)
|
||||
- `messages/de.json` + `messages/en.json` — i18n translations
|
||||
- `prisma/schema.prisma` — database schema (26 models)
|
||||
- `tests/integration/` — Postgres testcontainers integration suite (rate-limit race, idempotency replay, GDPR cascade delete, session lifecycle); `pnpm test:integration`
|
||||
- `e2e/` — Playwright + axe-core suite for public smoke checks (version, auth-redirect, login, locale-switch, a11y); `pnpm e2e`
|
||||
- `prisma/schema.prisma` — database schema (25 models)
|
||||
- `prisma.config.ts` — Prisma config (DB URL here, not in schema)
|
||||
- `public/sw.js` — Service worker for Web Push notifications + offline caching
|
||||
- `docs/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev
|
||||
@@ -111,8 +107,6 @@ docker compose logs -f app # Tail app logs
|
||||
- **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps
|
||||
- **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model
|
||||
- **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`
|
||||
- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts <id>`. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker).
|
||||
- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back.
|
||||
|
||||
## Headless-Client API Patterns
|
||||
|
||||
|
||||
@@ -63,10 +63,6 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Internationalization** -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI, optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
|
||||
|
||||
**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -106,7 +102,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (26 models) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (25 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
@@ -279,101 +275,83 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| 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 + Gravatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH` | `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
| 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 + Gravatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH`| `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Personalization (Thresholds + Dashboard)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------- | ---------------------------------------------- |
|
||||
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
|
||||
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) |
|
||||
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) |
|
||||
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
|
||||
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
|
||||
| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------- | --------------------------------------------- |
|
||||
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
|
||||
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged)|
|
||||
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged)|
|
||||
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
|
||||
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
|
||||
| `POST` | `/api/onboarding/complete`| Mark onboarding finished |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Feedback + API Tokens</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/feedback` | Submit in-app feedback |
|
||||
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
|
||||
| `GET` | `/api/tokens` | List own API tokens |
|
||||
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
|
||||
| `DELETE` | `/api/tokens/:id` | Revoke API token |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------------- | ------------------------------------ |
|
||||
| `POST` | `/api/feedback` | Submit in-app feedback |
|
||||
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
|
||||
| `GET` | `/api/tokens` | List own API tokens |
|
||||
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
|
||||
| `DELETE` | `/api/tokens/:id` | Revoke API token |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Notifications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ----------------------------------- |
|
||||
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
|
||||
| `PUT` | `/api/notifications/preferences` | Update preferences |
|
||||
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
|
||||
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
|
||||
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
|
||||
| `PUT` | `/api/notifications/preferences` | Update preferences |
|
||||
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
|
||||
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
|
||||
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Admin (admin role required)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------------- | ------------------------------------- |
|
||||
| `GET` | `/api/admin/status` | System + integration status |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
|
||||
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
|
||||
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
|
||||
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
|
||||
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
|
||||
| `GET` | `/api/admin/tokens` | All issued API tokens |
|
||||
| `POST` | `/api/admin/notifications/test` | Send test notification |
|
||||
| `GET` | `/api/admin/data` | Data backups + counts |
|
||||
| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
|
||||
| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Public + v1.4 additions</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | ----------------------------------------------- |
|
||||
| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
|
||||
| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
|
||||
| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
|
||||
| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
|
||||
| `POST` | `/api/monitoring/glitchtip/test` | Trigger a Glitchtip ingest probe |
|
||||
| `POST` | `/api/monitoring/umami/test` | Verify Umami script + website ID resolve |
|
||||
| `POST` | `/api/auth/refresh` | Native client refresh-token rotation |
|
||||
| `POST` | `/api/auth/refresh/revoke` | Revoke an issued refresh token |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/admin/status` | System + integration status |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
|
||||
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
|
||||
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
|
||||
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
|
||||
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
|
||||
| `GET` | `/api/admin/tokens` | All issued API tokens |
|
||||
| `POST` | `/api/admin/notifications/test` | Send test notification |
|
||||
| `GET` | `/api/admin/data` | Data backups + counts |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+4
-10
@@ -4,16 +4,10 @@ services:
|
||||
# For horizontal scaling switch to the web/worker split below
|
||||
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
||||
app:
|
||||
# HOTFIX: pinned to the v1.4.0 GHCR multi-arch image (linux/amd64 +
|
||||
# linux/arm64) while a runtime regression introduced in the v1.4.1
|
||||
# source build is investigated. The locally-built image from main
|
||||
# HEAD currently produces a container that boots Next.js + the
|
||||
# background workers but never accepts HTTP on :3000, so the
|
||||
# Docker healthcheck fails and Traefik returns 503. v1.4.0 is the
|
||||
# last release verified healthy on production. Self-hosters who
|
||||
# want to build from source can re-add the `build:` block locally
|
||||
# via a docker-compose.override.yml.
|
||||
image: ghcr.io/mbombeck/healthlog:1.4.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: healthlog-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+999
-1492
File diff suppressed because it is too large.
Load diff
+30
-118
@@ -1,11 +1,11 @@
|
||||
# Migrating from HealthLog 1.3 to 1.4
|
||||
|
||||
v1.4.0 is **drop-in for self-hosters running the GHCR image** — the
|
||||
two new migrations (`0025_refresh_tokens`, `0025_user_locale_drift_fix`)
|
||||
both apply on container start via `prisma migrate deploy`, and every
|
||||
new env var in §Environment variables below has a safe default. This
|
||||
guide documents what changes for operators and integrators, in case
|
||||
you're extending the API or scaling the deployment.
|
||||
Both 1.3.x and 1.4.0 use Prisma migrations 0001–0024 and the same
|
||||
PostgreSQL schema. The upgrade is **drop-in for self-hosters running
|
||||
the GHCR image** — no manual migration step beyond the standard
|
||||
`docker compose pull && docker compose up -d`. This guide documents
|
||||
what changes for operators and integrators, in case you're extending
|
||||
the API.
|
||||
|
||||
## TL;DR
|
||||
|
||||
@@ -14,83 +14,25 @@ docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
That's it. The migrations are forward-compatible (`IF NOT EXISTS`
|
||||
guards), no env vars are _required_ to be added, and no API contract
|
||||
changes shape or status code.
|
||||
That's it. No DB migration to apply, no env vars to add, no breaking
|
||||
API change.
|
||||
|
||||
## Database
|
||||
|
||||
- **`0025_refresh_tokens`** _(new)_ — adds the `refresh_tokens` table
|
||||
for the native-client 24h access-token / refresh-token rotation
|
||||
flow. Browser cookie sessions don't use it.
|
||||
- **`0025_user_locale_drift_fix`** _(new)_ — `ADD COLUMN IF NOT
|
||||
EXISTS users.locale TEXT` to backfill an environment that was kept
|
||||
in sync via `prisma db push` rather than `migrate deploy`. No-op
|
||||
on installations that were already current.
|
||||
- Both migrations are idempotent. A rollback to 1.3.3 keeps the
|
||||
schema usable; the dropped tables/columns are simply unused.
|
||||
- Prisma schema unchanged from 1.3.3. Migrations 0001–0024 remain
|
||||
authoritative. v1.4.0 brings no new migration files.
|
||||
|
||||
## Environment variables
|
||||
|
||||
### About surface
|
||||
- **`NEXT_PUBLIC_APP_BUILD_SHA`** *(optional, new)* — the short Git
|
||||
SHA of the build, surfaced through `/api/version` so the future
|
||||
About surface can render it. The `docker-publish` workflow can wire
|
||||
this once the About UI lands.
|
||||
- **`NEXT_PUBLIC_APP_BUILT_AT`** *(optional, new)* — ISO-8601 build
|
||||
timestamp, same plumbing.
|
||||
|
||||
- **`NEXT_PUBLIC_APP_BUILD_SHA`** _(optional, new)_ — short Git SHA
|
||||
exposed through `/api/version`, rendered on Settings → About.
|
||||
- **`NEXT_PUBLIC_APP_BUILT_AT`** _(optional, new)_ — ISO-8601 build
|
||||
timestamp.
|
||||
|
||||
Both are optional; without them, the About surface renders
|
||||
"development" wording.
|
||||
|
||||
### Worker / web split
|
||||
|
||||
- **`HEALTHLOG_PROCESS_TYPE`** _(optional, default `all`)_ — set to
|
||||
`web` to disable the pg-boss worker (jobs queue but don't run), or
|
||||
`worker` to refuse all HTTP traffic with `503 + X-HealthLog-Process-Type:
|
||||
worker`. Default `all` keeps the single-container behaviour.
|
||||
Only relevant if you're scaling HTTP and the worker independently.
|
||||
|
||||
### Encryption-key versioning
|
||||
|
||||
- **`ENCRYPTION_KEYS`** _(optional, new)_ — JSON object mapping
|
||||
versioned key IDs to 64-char hex keys, e.g. `{"v1": "...", "v2":
|
||||
"..."}`. When present, ciphertexts are written with the
|
||||
`<keyId>.<base64>` format and decrypted by looking up the key ID.
|
||||
Existing 1.3-format ciphertexts (no key prefix) keep decrypting
|
||||
with `ENCRYPTION_KEY` indefinitely.
|
||||
- **`ENCRYPTION_ACTIVE_KEY_ID`** _(optional, new)_ — the key ID used
|
||||
for new writes. Must be a key in `ENCRYPTION_KEYS`. Defaults to
|
||||
the legacy single-key behaviour if either is unset.
|
||||
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
|
||||
when `ENCRYPTION_KEYS` is empty. No action needed for existing
|
||||
installs.
|
||||
- Rotation: `pnpm tsx scripts/rotate-encryption-key.ts <newKeyId>`
|
||||
re-encrypts every ciphertext under the new key without downtime.
|
||||
Walk-through in `docs/ops/encryption-key-rotation.md`.
|
||||
|
||||
### Off-host backup target _(optional)_
|
||||
|
||||
If unset, the existing local `DataBackup` storage continues to work.
|
||||
|
||||
- **`BACKUP_S3_ENDPOINT`** — S3-compatible endpoint URL. Leave blank
|
||||
to disable off-host backup.
|
||||
- **`BACKUP_S3_BUCKET`** — destination bucket name.
|
||||
- **`BACKUP_S3_ACCESS_KEY`**, **`BACKUP_S3_SECRET_KEY`** — IAM
|
||||
credentials. Worker only requests `PutObject` + `GetObject` —
|
||||
never `DeleteObject`. Retention is the bucket's lifecycle policy.
|
||||
- **`BACKUP_S3_REGION`** _(default `auto`)_.
|
||||
- **`BACKUP_RETENTION_DAYS`** _(default `30`)_ — surfaced in the
|
||||
admin UI; the lifecycle rule on your bucket is the actual enforcer.
|
||||
- **`BACKUP_ENCRYPTION_KEY`** — separate 64-char hex key for the
|
||||
AES-GCM envelope of each upload. **Keep offline / in a vault.**
|
||||
Lose this and the off-host backups are unreadable.
|
||||
|
||||
### API token hashing _(unchanged but worth re-confirming)_
|
||||
|
||||
- **`API_TOKEN_HMAC_KEY`** — 64-char hex string. Used to HMAC
|
||||
Bearer tokens before storage so a DB leak can't be reversed via
|
||||
rainbow table. Same key now also keys the new `RefreshToken` row
|
||||
hashes. **Do not rotate without invalidating every native client.**
|
||||
Neither is required. Local `pnpm dev` returns `null` for both fields
|
||||
and the UI falls back to "development" wording.
|
||||
|
||||
## API
|
||||
|
||||
@@ -99,30 +41,6 @@ If unset, the existing local `DataBackup` storage continues to work.
|
||||
- **`GET /api/version`** — public, static, returns
|
||||
`{ version, buildSha, builtAt, license, repository, changelog, docs }`.
|
||||
No authentication.
|
||||
- **`POST /api/integrations/withings/test`** — admin-only probe
|
||||
against the saved Withings OAuth connection. Returns `{ ok, latency_ms }`
|
||||
on success.
|
||||
- **`POST /api/integrations/moodlog/test`** — admin-only HEAD
|
||||
request against `https://moodlog.app/api/health`. SSRF-guarded.
|
||||
- **`POST /api/notifications/web-push/test`** — sends a smoke push
|
||||
notification to the current user's subscribed devices.
|
||||
- **`POST /api/monitoring/glitchtip/test`** — fires a benign event
|
||||
at the configured DSN.
|
||||
- **`POST /api/monitoring/umami/test`** — verifies the script URL +
|
||||
website ID resolve and respond with the expected payload.
|
||||
- **`POST /api/admin/backup/test`** — admin-only round-trip against
|
||||
the configured `BACKUP_S3_*` credentials.
|
||||
- **`GET /api/admin/status-overview`** — admin-only aggregator
|
||||
returning the six-card summary the new admin top strip renders in
|
||||
one batched query.
|
||||
- **`POST /api/auth/refresh`** — native-client refresh-token
|
||||
rotation. Cookie-session browsers don't use this.
|
||||
- **`POST /api/auth/refresh/revoke`** — paired logout-on-device that
|
||||
flips both the refresh row and the matching `ApiToken` row to
|
||||
revoked.
|
||||
|
||||
All new admin endpoints are hidden from anonymous traffic by the
|
||||
proxy and rate-limited.
|
||||
|
||||
### Behaviour-only changes
|
||||
|
||||
@@ -193,27 +111,21 @@ n8n, and Health Connect integrations need no change.
|
||||
8 000–15 000 in the effective-range resolver) now use the same
|
||||
band, anchored on Saint-Maurice et al., JAMA 2020.
|
||||
|
||||
## URL changes
|
||||
## What's deferred to a future release
|
||||
|
||||
- **Settings has moved to per-section routes.** `/settings#integrations`
|
||||
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
|
||||
still 308-redirect to the new routes; sidebar deep-links and the
|
||||
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
|
||||
The v1.3.3 ecosystem audit identified four architectural rewrites that
|
||||
are intentionally outside v1.4.0:
|
||||
|
||||
## What landed in v1.4 vs v1.4.1
|
||||
- Settings page split into per-section routes (`/settings/[section]`).
|
||||
- Admin page redesign with status-first card grid.
|
||||
- AI insights rework with severity-coloured key-findings hero and
|
||||
dynamic chart inclusion via an allowlist token map.
|
||||
- Multi-tenant prep (off-host backups, encryption-key versioning,
|
||||
worker/web container split, native-Bearer User-Agent gating).
|
||||
- Postgres-backed integration test suite (Testcontainers).
|
||||
|
||||
The v1.3.3 ecosystem audit identified architectural rewrites tracked
|
||||
through v1.4. Status as of the v1.4.1 cycle:
|
||||
|
||||
| Bucket | Status |
|
||||
| -------------------------------------------------------------------------------------- | ------------------- |
|
||||
| Settings page split into `/settings/[section]` | ✓ shipped in v1.4.0 |
|
||||
| Admin status-first card grid + aggregator endpoint | ✓ shipped in v1.4.0 |
|
||||
| AI insights rework — severity hero + allowlisted inline charts | ✓ shipped in v1.4.0 |
|
||||
| Multi-tenant prep (off-host backups, key versioning, worker/web split, refresh tokens) | ✓ shipped in v1.4.0 |
|
||||
| Per-section admin component extraction (inner monolith → one file per panel) | ✓ shipped in v1.4.1 |
|
||||
| Postgres-backed integration test suite (testcontainers) | ✓ shipped in v1.4.1 |
|
||||
| Playwright + axe-core E2E foundation | ✓ shipped in v1.4.1 |
|
||||
These ride a future release; the v1.4 marathon closed every CRIT and
|
||||
HIGH the audit identified except those four.
|
||||
|
||||
## Rollback
|
||||
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
# v1.4.x follow-up issues
|
||||
|
||||
Items identified by the v1.4.1 audit pass that warrant deeper work
|
||||
than a hardening release should land. Each one carries enough context
|
||||
that a future contributor can pick it up without re-running the audit.
|
||||
|
||||
## Security — deferred
|
||||
|
||||
### S-FOLLOW-1 — Idempotency-Key concurrent duplicate side-effects (HIGH)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` H1.
|
||||
|
||||
The `withIdempotency()` wrapper currently does
|
||||
`findCached → handler → persistCached`. Two requests with the same
|
||||
`Idempotency-Key` arriving in parallel both miss the SELECT, both
|
||||
execute the handler (duplicate measurements / intake events), and
|
||||
one INSERT loses on the unique constraint silently. The CLAUDE.md
|
||||
contract ("retries with the same Idempotency-Key … replay the
|
||||
original response — no second side-effect") is therefore violated
|
||||
whenever a flaky mobile client double-sends.
|
||||
|
||||
**Fix shape**: acquire the slot before the handler runs.
|
||||
`INSERT … ON CONFLICT DO NOTHING RETURNING id` reserving the
|
||||
`(userId, key, method, path)` row with a sentinel
|
||||
`responseStatus = 0`, then UPDATE on completion; concurrent losers
|
||||
SELECT-loop with backoff for the row to be filled. Or wrap the
|
||||
whole request in a Postgres advisory lock keyed by hash(userId, key)
|
||||
via `pg_advisory_xact_lock`.
|
||||
|
||||
Why deferred: needs a careful integration test against the new
|
||||
testcontainers suite (two concurrent inserts → exactly one handler
|
||||
invocation) and a public-API contract check that the
|
||||
sentinel-status row never bleeds into a 2xx replay. Fits a v1.4.2
|
||||
PR scope on its own.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-2 — Encryption key fallback NODE_ENV gate (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M1.
|
||||
|
||||
`src/lib/crypto.ts:61-74` only requires the 64-hex production key
|
||||
when `process.env.NODE_ENV === "production"`. A fresh Docker run
|
||||
that forgets to set `NODE_ENV=production` (raw `node …` invocations,
|
||||
some operator setups) silently accepts a 32-character key and pads
|
||||
it deterministically with a SHA-256 of itself, halving the entropy
|
||||
and making the resulting key recoverable from any leaked partial.
|
||||
|
||||
**Fix shape**: invert the gate to `NODE_ENV !== "development" &&
|
||||
NODE_ENV !== "test"` — fail-closed by default — or require an
|
||||
explicit `ALLOW_WEAK_DEV_KEYS=1` opt-in.
|
||||
|
||||
Why deferred: needs a migration note for self-hosters whose `.env`
|
||||
files lack `NODE_ENV` (default Coolify deploys do set it; bespoke
|
||||
setups may not). Worth a release note + .env.example call-out.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-3 — Refresh-token reuse-detection serialisation (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M2.
|
||||
|
||||
`src/lib/auth/refresh-token.ts` reuse-detection is a `findUnique`
|
||||
followed by a non-transactional `findMany + updateMany`. Concurrent
|
||||
first-time refreshes can briefly have both clients holding
|
||||
unrevoked access tokens before the loser's are revoked, and a
|
||||
parallel reuse attempt doubles the audit-log noise.
|
||||
|
||||
**Fix shape**: wrap the rotation in a single `prisma.$transaction`
|
||||
with `Serializable` isolation, OR reorder to "claim first, mint
|
||||
second": `prisma.refreshToken.update({ where: { id, usedAt: null } })`
|
||||
fails atomically if the row was already consumed; only after the
|
||||
update succeeds, call `issueAccessAndRefresh`.
|
||||
|
||||
Why deferred: needs an integration test that fires N parallel
|
||||
refreshes for the same token and asserts exactly one new pair is
|
||||
issued. The testcontainers suite is the right home; v1.4.2 PR.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-4 — moodLog webhook HMAC lookup column (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M3.
|
||||
|
||||
`src/app/api/integrations/moodlog/webhook/route.ts` performs an
|
||||
O(n) AES-GCM decrypt sweep across all enabled users for every
|
||||
webhook hit. Side-channels: timing oracle for ordering, DoS
|
||||
amplification (30/min IP rate-limit × n decrypts).
|
||||
|
||||
**Fix shape**: add a `moodLogWebhookSecretLookupHash` column on
|
||||
`User` (HMAC-SHA-256 keyed by `API_TOKEN_HMAC_KEY`, populated
|
||||
alongside the encrypted secret on every write). Webhook does a
|
||||
single indexed `findUnique` on the lookup hash, bypassing the
|
||||
candidate iteration. The encryption stays as defence-in-depth.
|
||||
|
||||
Why deferred: schema migration required. Backfill needs a one-off
|
||||
script that decrypts every existing row and writes the lookup
|
||||
hash. Owners need to approve the migration window. v1.4.2 or
|
||||
v1.5.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-5 — moodLog `readMoodLogSecret` legacy fallback (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M4.
|
||||
|
||||
If `decrypt()` throws inside `src/lib/moodlog-secret.ts:27-37`, the
|
||||
function returns the stored value as-is. Intent: legacy plaintext
|
||||
rows keep working. Side effect: an attacker with read access to
|
||||
the DB obtains the ciphertext blob and can submit it as the literal
|
||||
webhook secret.
|
||||
|
||||
**Fix shape**: detect "looks like a v1.4 envelope"
|
||||
(`/^[A-Za-z0-9_-]{1,32}\..+$/`) and refuse the legacy fallback for
|
||||
envelope-shaped values. Log a metric so the rotation-on-write
|
||||
contract can be verified to drain.
|
||||
|
||||
Why deferred: needs a small data-audit run to confirm no production
|
||||
row has a non-envelope value before the strict check goes live.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-6 — Restore script writes decrypted JSON with default permissions (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M5.
|
||||
|
||||
`scripts/restore-backup.ts:73-122` writes the decrypted JSON via
|
||||
`writeFileSync(out, plaintext, "utf8")` with default mode (0644
|
||||
typical). The file contains decrypted PHI for the restored user.
|
||||
|
||||
**Fix shape**:
|
||||
`writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 })`.
|
||||
Optional: warn if `out` is in a world-readable directory.
|
||||
|
||||
Why deferred: tiny one-line fix; bundling with the next ops-script
|
||||
update keeps the PR coherent.
|
||||
|
||||
---
|
||||
|
||||
## Performance — deferred
|
||||
|
||||
### P-FOLLOW-1 — Recharts top-level import on `/insights` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/insights/page.tsx:42-50` static-imports
|
||||
`{ ScatterChart, Scatter, XAxis, … } from "recharts"` even though
|
||||
`HealthChart` and `MoodChart` 8 lines above are already wrapped in
|
||||
`dynamic(…, { ssr: false })`. The static import pulls the whole
|
||||
recharts bundle into the route's first JS payload, defeating the
|
||||
dynamic split.
|
||||
|
||||
**Fix shape**: extract the BP-mood scatter into
|
||||
`src/components/charts/bp-mood-scatter.tsx` and import it via
|
||||
`dynamic(…, { ssr: false })` like the others.
|
||||
|
||||
Why deferred: trivial mechanical change but needs a Lighthouse
|
||||
before/after to quantify the bundle-size win for the v1.4.2
|
||||
release notes.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-2 — `/api/insights/targets` unbounded glucose history scan (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/api/insights/targets/route.ts:600-604` pulls every glucose
|
||||
measurement the user ever logged (no `gte: thirtyDaysAgo`) and
|
||||
filters in JS. Diabetic users with multi-year history will
|
||||
materialise tens of thousands of rows on every targets fetch.
|
||||
|
||||
**Fix shape**: split into "latest per context"
|
||||
(`distinct: ["glucoseContext"], orderBy: { measuredAt: "desc" }`)
|
||||
and a `measuredAt: { gte: thirtyDaysAgo }` window query, mirroring
|
||||
the pattern at lines 102-121 for `latestEverByType`.
|
||||
|
||||
Why deferred: needs a per-context test asserting the new query
|
||||
returns the same payload shape. Pairs nicely with the MoodEntry
|
||||
index migration (P-FOLLOW-3).
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-3 — `MoodEntry @@index` is on `(userId, date)` not `(userId, moodLoggedAt)` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`prisma/schema.prisma:438` declares `@@index([userId, date])`, but
|
||||
active queries sort/filter by `moodLoggedAt`:
|
||||
|
||||
- `src/app/api/insights/targets/route.ts:495`
|
||||
- `src/app/api/insights/comprehensive/route.ts:52`
|
||||
- `src/app/api/export/route.ts:87`
|
||||
|
||||
The unique constraint `@@unique([userId, date, moodLoggedAt])` is
|
||||
not selectable by Postgres for windowed `moodLoggedAt`-range reads.
|
||||
|
||||
**Fix shape**: add `@@index([userId, moodLoggedAt])` (do NOT drop
|
||||
the existing index — the unique still serves the moodlog webhook
|
||||
upsert path). New migration `0026_moodentry_moodloggedat_index`.
|
||||
|
||||
Why deferred: schema migration. Owner approval for the migration
|
||||
window.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-4 to -7 — P1 / P2 perf items
|
||||
|
||||
- N+1 count() per medication on admin reminder-check
|
||||
(`src/app/api/admin/notifications/reminder-check/route.ts:84`).
|
||||
Replace with `groupBy` on `medicationIntakeEvent`.
|
||||
- `/api/analytics` 8-parallel full-history scan
|
||||
(`src/app/api/analytics/route.ts:20-40`). Single `findMany` with
|
||||
`type: { in: types }` and JS-side group.
|
||||
- `/api/import` sequential `prisma.measurement.create` per row
|
||||
(`src/app/api/import/route.ts:77-96`). Replace with
|
||||
`createMany({ skipDuplicates: true })`.
|
||||
- `pairByTimestamp` not used in `/api/analytics` BP correlation
|
||||
(`src/app/api/analytics/route.ts:80-86`). Replace the quadratic
|
||||
loop with the helper from `@/lib/analytics/correlations`.
|
||||
|
||||
---
|
||||
|
||||
## Test-quality polish (5 minor items)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-test-theatre-audit.md`.
|
||||
|
||||
- `src/app/api/gamification/__tests__/ios-format.test.ts` L111-112:
|
||||
upgrade `toBeDefined()` to shape predicates.
|
||||
- `src/lib/analytics/__tests__/classifications.test.ts` L169-170:
|
||||
add `toMatch(/60/)` body-content assertion to compliance-alert tests.
|
||||
- `src/lib/__tests__/idempotency.test.ts` L145: upgrade bare
|
||||
`toHaveBeenCalled()` to `toHaveBeenCalledWith({ where: …key… })`.
|
||||
- `src/app/api/telegram/webhook/__tests__/route.test.ts` L457: same
|
||||
shape — upgrade or drop.
|
||||
|
||||
---
|
||||
|
||||
## i18n hardcoded strings
|
||||
|
||||
Audit reference: `~/infra/reports/v14-i18n-drift-audit.md`.
|
||||
|
||||
35+ hardcoded strings in `src/components/settings/ai-section.tsx`
|
||||
need to be moved into `messages/{en,de}.json` keys. The settings
|
||||
section was extracted in #143 with strings inline as a known
|
||||
follow-up; this is the cleanup PR.
|
||||
|
||||
Why deferred: low blast-radius (only the AI provider settings page
|
||||
shows English to a German user; the dashboard, dialogs, and
|
||||
critical UX paths all flow through `t(…)`). v1.4.x or v1.5.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
These are the items the v1.4.1 audit identified that the hardening
|
||||
release deliberately did NOT take. The audit also found 0 hallucinated
|
||||
medical claims, 0 i18n key-parity gaps, and the test theatre rate
|
||||
across the entire codebase was low (5 minor patterns). The codebase
|
||||
is in healthy shape; this list is the punch-list for the next
|
||||
proactive iteration, not a backlog of bugs we know about and ignore.
|
||||
@@ -1,44 +0,0 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Accessibility regression gate — fails the build if any WCAG 2.1 AA
|
||||
* violation lands in the `serious` or `critical` bucket on a public
|
||||
* surface. Authenticated pages are out of scope here (need seeded
|
||||
* data); they're covered by the per-flow specs that already touch
|
||||
* those routes.
|
||||
*/
|
||||
test.describe("axe-core public surfaces", () => {
|
||||
for (const path of ["/auth/login"]) {
|
||||
test(`${path} has no serious or critical a11y violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(path);
|
||||
// Wait briefly for any reduced-motion fades to settle.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
|
||||
const blocking = results.violations.filter(
|
||||
(v) => v.impact === "serious" || v.impact === "critical",
|
||||
);
|
||||
|
||||
if (blocking.length > 0) {
|
||||
// Pretty-print so failures are actionable in CI logs.
|
||||
console.log(
|
||||
"axe violations:\n" +
|
||||
blocking
|
||||
.map(
|
||||
(v) =>
|
||||
` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
expect(blocking).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* The proxy at src/proxy.ts is the single gate enforcing auth on every
|
||||
* non-public path. A regression that accidentally adds a route to
|
||||
* PUBLIC_PATHS, or breaks the redirect, would expose unauthenticated
|
||||
* surfaces. Verify a few representative routes round-trip to /auth/login.
|
||||
*/
|
||||
test.describe("proxy auth gate", () => {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/medications",
|
||||
"/admin",
|
||||
"/insights",
|
||||
]) {
|
||||
test(`${path} redirects to /auth/login when no session`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
expect(response).not.toBeNull();
|
||||
// Either the proxy hands back a 307/308, or the navigation lands
|
||||
// at /auth/login after redirect; both are acceptable proof.
|
||||
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
|
||||
});
|
||||
}
|
||||
|
||||
test("public paths are reachable without a session", async ({ page }) => {
|
||||
// /auth/login is in PUBLIC_PATHS — must be served, not bounced anywhere.
|
||||
await page.goto("/auth/login");
|
||||
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Locale-key drift smoke check — switch the cookie between EN and DE
|
||||
* and assert the login page (the only public surface that's translated)
|
||||
* never shows raw i18n keys like `auth.welcomeBack`.
|
||||
*
|
||||
* Drift catches: a typo'd t("foo.bar") with no key in either locale
|
||||
* file would make the raw key surface in the DOM. messages/de.json and
|
||||
* messages/en.json key parity is enforced by unit tests; this is the
|
||||
* runtime cousin.
|
||||
*/
|
||||
test.describe("locale switch", () => {
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
test(`renders /auth/login in ${locale} without raw i18n keys`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "healthlog_locale",
|
||||
value: locale,
|
||||
url: page.url() || "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
await page.goto("/auth/login");
|
||||
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
|
||||
// i18n keys look like `section.subkey` — letters, numbers,
|
||||
// underscores, dots — and are never wrapped in normal sentences,
|
||||
// so a regex match anywhere in body text is a sign the lookup
|
||||
// fell through.
|
||||
const rawKeyPattern = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.]+\b/;
|
||||
const match = bodyText.match(rawKeyPattern);
|
||||
|
||||
// Whitelist: filenames or version strings can look like keys,
|
||||
// skip those by requiring at least one camelCase segment.
|
||||
if (match) {
|
||||
const candidate = match[0];
|
||||
const looksLikeKey = /\.[a-z][A-Z]/.test(candidate);
|
||||
expect(
|
||||
looksLikeKey,
|
||||
`Possible raw i18n key in ${locale}: ${candidate}`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke checks on the login surface — must render, must wire the
|
||||
* password-manager autofill hints (autoComplete="username" /
|
||||
* "current-password"), must let the user submit.
|
||||
*
|
||||
* These do NOT log in (no DB seed required); the password-flow
|
||||
* roundtrip is exercised by the integration test in
|
||||
* tests/integration/auth-flow.test.ts.
|
||||
*/
|
||||
test.describe("login page", () => {
|
||||
test("renders username + password inputs with the right autoComplete", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/auth/login");
|
||||
|
||||
const username = page.getByLabel(/username|benutzername/i).first();
|
||||
await expect(username).toBeVisible();
|
||||
await expect(username).toHaveAttribute("autoComplete", /username|email/);
|
||||
|
||||
const password = page.locator('input[type="password"]').first();
|
||||
await expect(password).toBeVisible();
|
||||
await expect(password).toHaveAttribute("autoComplete", "current-password");
|
||||
});
|
||||
|
||||
test("rejects an obviously-wrong credential pair", async ({ page }) => {
|
||||
await page.goto("/auth/login");
|
||||
|
||||
await page
|
||||
.getByLabel(/username|benutzername/i)
|
||||
.first()
|
||||
.fill("nobody-here");
|
||||
await page.locator('input[type="password"]').first().fill("not-the-pw");
|
||||
|
||||
// Intercept the API call so the test does not depend on a real
|
||||
// backend rejecting the credentials. The login form's error
|
||||
// surfacing is what we actually want to prove here.
|
||||
await page.route("**/api/auth/login", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Invalid credentials" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /login|anmelden|sign in/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/invalid credentials|ungültig|falsch/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* /api/version is the public, unauthenticated endpoint that drives
|
||||
* the Settings → About surface and acts as the container's healthcheck
|
||||
* target. If this breaks, every deployed image's healthcheck fails and
|
||||
* Coolify pulls the container out of rotation.
|
||||
*/
|
||||
test.describe("public version endpoint", () => {
|
||||
test("returns the running version + license", async ({ request }) => {
|
||||
const res = await request.get("/api/version");
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const json = await res.json();
|
||||
expect(json.data).toBeDefined();
|
||||
expect(typeof json.data.version).toBe("string");
|
||||
expect(json.data.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(json.data.license).toBeDefined();
|
||||
});
|
||||
|
||||
test("does not require authentication", async ({ request }) => {
|
||||
// No cookie, no Authorization header — must still respond 200 so
|
||||
// the docker healthcheck and the in-app "check for updates" button
|
||||
// both work without a session.
|
||||
const res = await request.get("/api/version", {
|
||||
headers: { "X-Client-Type": "anonymous" },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -62,7 +62,7 @@
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)",
|
||||
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
|
||||
"avgBp": "Durchschnittlicher Blutdruck",
|
||||
"classification": "Klassifikation",
|
||||
"bmiTitle": "Body-Mass-Index (BMI)",
|
||||
@@ -634,7 +634,7 @@
|
||||
"inTargetRange": "im Zielbereich",
|
||||
"bpTarget": "Ziel: {range}",
|
||||
"noDobSet": "Geburtsdatum nicht hinterlegt.",
|
||||
"avg30dEsc": "30T-Durchschnitt · ESH 2023",
|
||||
"avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018",
|
||||
"whoClassification": "WHO-Klassifikation",
|
||||
"correlations": "Korrelationen",
|
||||
"weightVsBp": "Gewicht vs. Blutdruck (Systolisch)",
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blood pressure — ESH classification (2023)",
|
||||
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
|
||||
"avgBp": "Average blood pressure",
|
||||
"classification": "Classification",
|
||||
"bmiTitle": "Body Mass Index (BMI)",
|
||||
@@ -634,7 +634,7 @@
|
||||
"inTargetRange": "in target range",
|
||||
"bpTarget": "Target: {range}",
|
||||
"noDobSet": "Date of birth not set.",
|
||||
"avg30dEsc": "30d average · ESH 2023",
|
||||
"avg30dEsc": "30d average · ESC/ESH 2018",
|
||||
"whoClassification": "WHO Classification",
|
||||
"correlations": "Correlations",
|
||||
"weightVsBp": "Weight vs. Blood Pressure (Systolic)",
|
||||
|
||||
+2
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -17,10 +17,7 @@
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui"
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
@@ -53,10 +50,7 @@
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -70,7 +64,6 @@
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^11.14.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for the HealthLog E2E suite.
|
||||
*
|
||||
* The suite covers the smoke-level user paths (auth redirect, login
|
||||
* form, public version endpoint, locale switch, axe-core) without
|
||||
* needing seeded data — every spec either runs against an unauthed
|
||||
* surface or uses route interception to stub out the API. Specs that
|
||||
* need a logged-in user are kept narrow and flagged in their describe
|
||||
* block; CI runs them against a worker that seeds a deterministic test
|
||||
* user on startup.
|
||||
*
|
||||
* To run locally: `pnpm dlx playwright install --with-deps chromium`
|
||||
* once, then `pnpm e2e`.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["github"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html", { open: "never" }]],
|
||||
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["iPhone 13"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
// separately by CI before the suite — locally, set E2E_SKIP_WEB_SERVER=1
|
||||
// to point at an already-running dev server.
|
||||
webServer: process.env.E2E_SKIP_WEB_SERVER
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm exec next start --port 3000",
|
||||
url: "http://localhost:3000/api/version",
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
Generated
+13
-1029
File diff suppressed because it is too large.
Load diff
+2639
-14
File diff suppressed because it is too large.
Load diff
@@ -372,13 +372,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
// AI prompts at src/lib/insights/prompts/{base-system,
|
||||
// general-status}.ts already enforce this attribution; this
|
||||
// surface label was the last "WHO" mislabel in the codebase.
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
source: "WHO",
|
||||
});
|
||||
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -485,52 +485,20 @@ function ApiEndpointDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
|
||||
|
||||
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
|
||||
|
||||
type ApiEndpointStatus = { enabled: boolean; activeTokenCount: number };
|
||||
|
||||
const {
|
||||
data: status,
|
||||
isFetching: loadingStatus,
|
||||
refetch: refetchStatus,
|
||||
error: statusError,
|
||||
} = useQuery<ApiEndpointStatus>({
|
||||
queryKey: apiEndpointKey,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medication!.id}/api-endpoint`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || t("medications.statusLoadFailed"));
|
||||
}
|
||||
return {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount: json.data.activeTokenCount ?? 0,
|
||||
};
|
||||
},
|
||||
enabled: !!medication,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
const enabled = status?.enabled ?? false;
|
||||
const activeTokenCount = status?.activeTokenCount ?? 0;
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [activeTokenCount, setActiveTokenCount] = useState(0);
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [exampleType, setExampleType] = useState<ExampleType>("curl");
|
||||
|
||||
const displayMsg =
|
||||
msg ?? (statusError instanceof Error ? statusError.message : null);
|
||||
|
||||
function handleClose() {
|
||||
queryClient.removeQueries({ queryKey: apiEndpointKey });
|
||||
setEnabled(false);
|
||||
setActiveTokenCount(0);
|
||||
setToken(null);
|
||||
setMsg(null);
|
||||
setCopied(null);
|
||||
@@ -538,6 +506,27 @@ function ApiEndpointDialog({
|
||||
onClose();
|
||||
}
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!medication) return;
|
||||
setLoadingStatus(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medication.id}/api-endpoint`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setEnabled(json.data.enabled === true);
|
||||
setActiveTokenCount(json.data.activeTokenCount ?? 0);
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("medications.statusLoadFailed"));
|
||||
}
|
||||
} catch {
|
||||
setMsg(t("medications.statusLoadFailed"));
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
}, [medication, t]);
|
||||
|
||||
async function toggleEndpoint(nextEnabled: boolean) {
|
||||
if (!medication) return;
|
||||
setToggling(true);
|
||||
@@ -558,16 +547,15 @@ function ApiEndpointDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatus: ApiEndpointStatus = {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount:
|
||||
typeof json.data.activeTokenCount === "number"
|
||||
? json.data.activeTokenCount
|
||||
: !json.data.enabled
|
||||
? 0
|
||||
: (status?.activeTokenCount ?? 0),
|
||||
};
|
||||
queryClient.setQueryData<ApiEndpointStatus>(apiEndpointKey, nextStatus);
|
||||
setEnabled(json.data.enabled === true);
|
||||
if (typeof json.data.activeTokenCount === "number") {
|
||||
setActiveTokenCount(json.data.activeTokenCount);
|
||||
} else if (
|
||||
!json.data.enabled &&
|
||||
typeof json.data.revokedTokenCount === "number"
|
||||
) {
|
||||
setActiveTokenCount(0);
|
||||
}
|
||||
|
||||
if (json.data.token) {
|
||||
setToken(json.data.token);
|
||||
@@ -591,6 +579,11 @@ function ApiEndpointDialog({
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!medication) return;
|
||||
void loadStatus();
|
||||
}, [medication, loadStatus]);
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "https://...";
|
||||
const endpoint = `${baseUrl}/api/ingest/medication`;
|
||||
@@ -763,11 +756,11 @@ function ApiEndpointDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayMsg && (
|
||||
{msg && (
|
||||
<p
|
||||
className={`text-sm ${displayMsg === t("medications.apiEndpointActivated") || displayMsg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
|
||||
className={`text-sm ${msg === t("medications.apiEndpointActivated") || msg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{displayMsg}
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -787,7 +780,7 @@ function ApiEndpointDialog({
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void refetchStatus();
|
||||
void loadStatus();
|
||||
}}
|
||||
disabled={loadingStatus || toggling}
|
||||
>
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
passkeyCount: number;
|
||||
}
|
||||
|
||||
export interface WorkerStatus {
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
lastHeartbeat: string | null;
|
||||
lastReminderCheck: string | null;
|
||||
lastWithingsSync: string | null;
|
||||
lastInsightsRun: string | null;
|
||||
jobsProcessed: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export 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;
|
||||
};
|
||||
}
|
||||
|
||||
export 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 interface AdminAuditEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
ipAddress: string | null;
|
||||
location: string | null;
|
||||
details: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
export interface ApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
revoked: boolean;
|
||||
user: { id: string; username: string };
|
||||
}
|
||||
|
||||
export type FeedbackStatusType =
|
||||
| "OPEN"
|
||||
| "ACKNOWLEDGED"
|
||||
| "RESOLVED"
|
||||
| "ARCHIVED";
|
||||
export type FeedbackCategoryType =
|
||||
| "BUG"
|
||||
| "FEATURE_REQUEST"
|
||||
| "QUESTION"
|
||||
| "OTHER";
|
||||
|
||||
export interface FeedbackItem {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
category: FeedbackCategoryType;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: FeedbackStatusType;
|
||||
adminNote: string | null;
|
||||
gitHubIssueUrl: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
screenshotBase64: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
export interface FeedbackListResponse {
|
||||
items: FeedbackItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
|
||||
};
|
||||
}
|
||||
|
||||
export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
export function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} type={visible ? "text" : "password"} />
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||
>
|
||||
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm font-semibold ${className ?? ""}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export 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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiErrorMessage(response: Response): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
export function useSystemStatus() {
|
||||
return 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Key, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type ApiTokenInfo } from "./_shared";
|
||||
|
||||
export 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 (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.apiTokens")}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? t("settings.collapse") : t("settings.expand")}
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !tokens?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.noTokens")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.tokenUser")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.tokenName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.tokenPermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.tokenStatus")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.tokenLastUsed")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.tokenCreated")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{tokens.map((token, i) => {
|
||||
const isExpired =
|
||||
token.expiresAt && new Date(token.expiresAt) < new Date();
|
||||
return (
|
||||
<tr
|
||||
key={token.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{token.user.username}
|
||||
</td>
|
||||
<td className="px-3 py-2">{token.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{token.permissions.map((p) => (
|
||||
<Badge
|
||||
key={p}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{token.revoked ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenRevoked")}
|
||||
</Badge>
|
||||
) : isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
|
||||
{t("common.active")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{token.lastUsedAt
|
||||
? formatDateTime(token.lastUsedAt)
|
||||
: t("admin.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(token.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export function BugReportSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [bugReportRepoDraft, setBugReportRepoDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [bugReportTokenDraft, setBugReportTokenDraft] = useState("");
|
||||
|
||||
const bugReportRepoValue =
|
||||
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
|
||||
const configured = settings?.bugReportConfigured ?? false;
|
||||
|
||||
function saveBugReportSettings() {
|
||||
const payload: Record<string, unknown> = {
|
||||
bugReportRepo: bugReportRepoValue,
|
||||
};
|
||||
if (bugReportTokenDraft.trim().length > 0) {
|
||||
payload.bugReportToken = bugReportTokenDraft.trim();
|
||||
}
|
||||
|
||||
updateSettings.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setBugReportRepoDraft(null);
|
||||
setBugReportTokenDraft("");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.bugReportGithub")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.bugReportGithubDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-repo" className="text-xs">
|
||||
{t("admin.bugReportRepo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-bugreport-repo"
|
||||
value={bugReportRepoValue}
|
||||
onChange={(event) => setBugReportRepoDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportRepoPlaceholder")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-token" className="text-xs">
|
||||
{t("admin.bugReportToken")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-bugreport-token"
|
||||
value={bugReportTokenDraft}
|
||||
onChange={(event) => setBugReportTokenDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportTokenPlaceholder")}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveBugReportSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function DangerZoneSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [wipeMsg, setWipeMsg] = useState<string | null>(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 (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-destructive/5 border-destructive/30 scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||
<h2 className="text-destructive text-lg font-semibold">
|
||||
{t("admin.dangerZone")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium">{t("admin.deleteAllData")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.deleteAllDescription")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={wipeAllData.isPending}
|
||||
>
|
||||
{wipeAllData.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.deleteButton")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.deleteAllConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.deleteAllConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => wipeAllData.mutate()}
|
||||
>
|
||||
{t("admin.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{wipeMsg && (
|
||||
<p
|
||||
className={`mt-2 text-sm ${wipeMsg.startsWith(t("admin.deletedResult", { measurements: "", medications: "", intakeEvents: "" }).split(":")[0]) ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{wipeMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Inbox,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
FEEDBACK_STATUS_TABS,
|
||||
type FeedbackCategoryType,
|
||||
type FeedbackItem,
|
||||
type FeedbackListResponse,
|
||||
type FeedbackStatusType,
|
||||
getApiErrorMessage,
|
||||
useSystemStatus,
|
||||
} from "./_shared";
|
||||
|
||||
export function FeedbackInboxSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useSystemStatus();
|
||||
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "feedback", activeStatus],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as FeedbackListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const counts = data?.meta.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.feedback.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.feedback.description")}
|
||||
</p>
|
||||
|
||||
<Tabs
|
||||
value={activeStatus}
|
||||
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
||||
className="mt-4"
|
||||
>
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsContent key={s} value={s} className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.noEntries")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.createdAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.category")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.subject")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.user")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.feedback.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(item);
|
||||
}}
|
||||
>
|
||||
{t("admin.feedback.details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{selected && (
|
||||
<FeedbackDetailDialog
|
||||
item={selected}
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
githubConfigured={githubConfigured}
|
||||
onMutated={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: FeedbackCategoryType;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const map: Record<
|
||||
FeedbackCategoryType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
BUG: {
|
||||
label: t("admin.feedback.categoryBug"),
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
FEATURE_REQUEST: {
|
||||
label: t("admin.feedback.categoryFeature"),
|
||||
className: "bg-purple-500/15 text-purple-400 border-purple-500/30",
|
||||
},
|
||||
QUESTION: {
|
||||
label: t("admin.feedback.categoryQuestion"),
|
||||
className: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
},
|
||||
OTHER: {
|
||||
label: t("admin.feedback.categoryOther"),
|
||||
className: "bg-muted text-muted-foreground border-border",
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return (
|
||||
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
githubConfigured,
|
||||
onMutated,
|
||||
}: {
|
||||
item: FeedbackItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
githubConfigured: boolean;
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [note, setNote] = useState(item.adminNote ?? "");
|
||||
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
status?: FeedbackStatusType;
|
||||
adminNote?: string | null;
|
||||
}) => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
onMutated();
|
||||
if (vars.adminNote !== undefined) {
|
||||
toast.success(t("admin.feedback.noteSaved"));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: () => {
|
||||
onMutated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}/github`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
return (await res.json()).data as { issueUrl: string };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
toast.success(t("admin.feedback.publishSuccess"));
|
||||
onMutated();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const meta = item.metadata ?? {};
|
||||
const url = typeof meta.url === "string" ? meta.url : null;
|
||||
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
||||
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
||||
const appVersion =
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h4>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h4>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export function GeneralSettingsSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.appSettings")}</h2>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.registrationEnabled")}
|
||||
description={t("admin.registrationEnabledDescription")}
|
||||
checked={settings?.registrationEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ registrationEnabled: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("admin.defaultLanguage")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.defaultLanguageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={settings?.defaultLocale ?? "de"}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultLocale: e.target.value })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SettingsToggle,
|
||||
getApiErrorMessage,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function GlitchtipSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState<string | null>(
|
||||
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 (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.glitchtipDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<SettingsToggle
|
||||
label="GlitchTip"
|
||||
description={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
checked={settings?.glitchtipEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ glitchtipEnabled: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-glitchtip-dsn" className="text-xs">
|
||||
{t("admin.glitchtipDsn")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-glitchtip-dsn"
|
||||
name="admin-glitchtip-dsn"
|
||||
value={glitchtipDsnValue}
|
||||
onChange={(event) => setGlitchtipDsnDraft(event.target.value)}
|
||||
placeholder={t("admin.glitchtipDsnPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-glitchtip-environment" className="text-xs">
|
||||
{t("admin.glitchtipEnvironment")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-glitchtip-environment"
|
||||
name="admin-glitchtip-environment"
|
||||
value={glitchtipEnvironmentValue}
|
||||
onChange={(event) =>
|
||||
setGlitchtipEnvironmentDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.glitchtipEnvironmentPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testGlitchtip.mutate()}
|
||||
disabled={testGlitchtip.isPending || updateSettings.isPending}
|
||||
>
|
||||
{testGlitchtip.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.test")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveGlitchtipSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminAuditEntry } from "./_shared";
|
||||
|
||||
export 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<string, string> = {
|
||||
"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 (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.loginOverview")}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? t("settings.collapse") : t("settings.expand")}
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
{t("admin.allAuthEvents")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "failed" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setFilter("failed")}
|
||||
>
|
||||
{t("admin.failedOnly")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !entries?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.noEntries")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.location")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{data && data.meta.total > entries.length && (
|
||||
<p className="text-muted-foreground mt-3 text-center text-xs">
|
||||
{t("admin.showingEntries", {
|
||||
count: entries.length,
|
||||
total: data.meta.total,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
getApiErrorMessage,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function RemindersSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [reminderLateDraft, setReminderLateDraft] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [reminderMissedDraft, setReminderMissedDraft] = useState<number | null>(
|
||||
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 (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.medicationReminders")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.medicationRemindersDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
{t("admin.reminderLateMinutes")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.reminderLateMinutesDescription")}
|
||||
</p>
|
||||
<Input
|
||||
id="admin-reminder-late"
|
||||
type="number"
|
||||
min={15}
|
||||
max={480}
|
||||
value={reminderLateDraft ?? settings?.reminderLateMinutes ?? 120}
|
||||
onChange={(e) => setReminderLateDraft(Number(e.target.value))}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-missed" className="text-xs">
|
||||
{t("admin.reminderMissedMinutes")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.reminderMissedMinutesDescription")}
|
||||
</p>
|
||||
<Input
|
||||
id="admin-reminder-missed"
|
||||
type="number"
|
||||
min={30}
|
||||
max={720}
|
||||
value={
|
||||
reminderMissedDraft ?? settings?.reminderMissedMinutes ?? 240
|
||||
}
|
||||
onChange={(e) => setReminderMissedDraft(Number(e.target.value))}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testNotification.mutate()}
|
||||
disabled={testNotification.isPending}
|
||||
>
|
||||
{testNotification.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
<Bell className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.notificationTestSend")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => reminderCheck.mutate()}
|
||||
disabled={reminderCheck.isPending}
|
||||
>
|
||||
{reminderCheck.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
<Activity className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.reminderCheckRun")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
...(reminderLateDraft != null && {
|
||||
reminderLateMinutes: reminderLateDraft,
|
||||
}),
|
||||
...(reminderMissedDraft != null && {
|
||||
reminderMissedMinutes: reminderMissedDraft,
|
||||
}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setReminderLateDraft(null);
|
||||
setReminderMissedDraft(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
updateSettings.isPending ||
|
||||
(reminderLateDraft == null && reminderMissedDraft == null)
|
||||
}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testNotification.data?.results &&
|
||||
testNotification.data.results.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
{testNotification.data.results.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
{r.success ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||
)}
|
||||
<span className="font-medium">{r.channel}</span>
|
||||
{r.error && (
|
||||
<span className="text-muted-foreground">— {r.error}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reminderCheck.data?.medications &&
|
||||
reminderCheck.data.medications.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("admin.reminderCheckResults")}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{reminderCheck.data.medications.map((med, i) => (
|
||||
<div key={i} className="bg-muted/50 space-y-1.5 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{med.name} ({med.dose})
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
med.notificationsEnabled ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{med.notificationsEnabled
|
||||
? t("admin.reminderCheckNotifOn")
|
||||
: t("admin.reminderCheckNotifOff")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{med.user} — {med.dayOfWeek} {med.localTime} —{" "}
|
||||
{t("admin.reminderCheckEventsToday")}: {med.eventsToday}
|
||||
</p>
|
||||
{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 (
|
||||
<div key={j} className="flex items-start gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{sched.window}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
[{sched.days}]
|
||||
</span>
|
||||
<span className={statusColor}>{sched.label}</span>
|
||||
{sched.notificationSent && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-green-400">
|
||||
<Bell className="h-3 w-3" />
|
||||
{t("admin.reminderCheckNotifSent")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Globe, Key, MessageCircle } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export function ServicesSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.servicesGlobal")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.servicesGlobalDescription")}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<SettingsToggle
|
||||
label="Telegram"
|
||||
description={t("admin.telegramGlobal")}
|
||||
icon={MessageCircle}
|
||||
checked={settings?.telegramGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ telegramGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="ntfy"
|
||||
description={t("admin.ntfyGlobal")}
|
||||
icon={Bell}
|
||||
checked={settings?.ntfyGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ ntfyGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.integrationWebPush")}
|
||||
description={t("admin.webPushGlobal")}
|
||||
icon={Globe}
|
||||
checked={settings?.webPushGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ webPushGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="API"
|
||||
description={t("admin.apiGlobal")}
|
||||
icon={Key}
|
||||
checked={settings?.apiGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ apiGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
BellRing,
|
||||
Bug,
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
Globe,
|
||||
Key,
|
||||
Loader2,
|
||||
Server,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
export function SystemStatusSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: status } = useSystemStatus();
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.systemStatus")}</h2>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
label={t("admin.database")}
|
||||
value={
|
||||
status.database === "connected"
|
||||
? t("admin.databaseConnected")
|
||||
: t("admin.databaseError")
|
||||
}
|
||||
className={
|
||||
status.database === "connected"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
label={t("admin.startedAt")}
|
||||
value={formatDateTime(status.startTime)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Users}
|
||||
label={t("admin.users")}
|
||||
value={String(status.counts.users)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label={t("admin.measurementsCount")}
|
||||
value={fmt.integer(status.counts.measurements)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Key}
|
||||
label={t("admin.activeTokens")}
|
||||
value={String(status.counts.activeTokens)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Globe}
|
||||
label={t("admin.activeSessions")}
|
||||
value={String(status.counts.activeSessions)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Cog}
|
||||
label={t("admin.workerStatus")}
|
||||
value={
|
||||
status.worker.running
|
||||
? t("admin.workerRunning")
|
||||
: t("admin.workerStopped")
|
||||
}
|
||||
className={
|
||||
status.worker.running ? "text-dracula-green" : "text-destructive"
|
||||
}
|
||||
/>
|
||||
{status.worker.lastReminderCheck && (
|
||||
<StatusItem
|
||||
icon={Bell}
|
||||
label={t("admin.lastReminderCheck")}
|
||||
value={formatDateTime(status.worker.lastReminderCheck)}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.umami && (
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label="Umami"
|
||||
value={
|
||||
status.integrations.umami.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.umami.enabled
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.glitchtip && (
|
||||
<StatusItem
|
||||
icon={AlertTriangle}
|
||||
label="GlitchTip"
|
||||
value={
|
||||
status.integrations.glitchtip.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.glitchtip.enabled
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.webPush && (
|
||||
<StatusItem
|
||||
icon={BellRing}
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingStatus")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SettingsToggle,
|
||||
getApiErrorMessage,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function UmamiSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [umamiScriptUrlDraft, setUmamiScriptUrlDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [umamiWebsiteIdDraft, setUmamiWebsiteIdDraft] = useState<string | null>(
|
||||
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 (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.umamiDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<SettingsToggle
|
||||
label="Umami"
|
||||
description={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
checked={settings?.umamiEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ umamiEnabled: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-umami-script-url" className="text-xs">
|
||||
{t("admin.umamiScriptUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-umami-script-url"
|
||||
name="admin-umami-script-url"
|
||||
value={umamiScriptUrlValue}
|
||||
onChange={(event) => setUmamiScriptUrlDraft(event.target.value)}
|
||||
placeholder={t("admin.umamiScriptUrlPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-umami-website-id" className="text-xs">
|
||||
{t("admin.umamiWebsiteId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-umami-website-id"
|
||||
name="admin-umami-website-id"
|
||||
value={umamiWebsiteIdValue}
|
||||
onChange={(event) => setUmamiWebsiteIdDraft(event.target.value)}
|
||||
placeholder={t("admin.umamiWebsiteIdPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testUmami.mutate()}
|
||||
disabled={testUmami.isPending || updateSettings.isPending}
|
||||
>
|
||||
{testUmami.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.test")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveUmamiSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, Pencil, Shield, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
|
||||
export function UserManagementSection({
|
||||
id,
|
||||
currentUserId,
|
||||
}: {
|
||||
id: string;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [editUsername, setEditUsername] = useState("");
|
||||
const [editEmail, setEditEmail] = useState("");
|
||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [resetMsg, setResetMsg] = useState<string | null>(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<string, unknown>;
|
||||
}) => {
|
||||
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);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
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 (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.userManagement")}</h2>
|
||||
{users && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{users.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{users ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{users.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{u.passkeyCount}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
updateUser.mutate({
|
||||
id: u.id,
|
||||
data: {
|
||||
role: u.role === "ADMIN" ? "USER" : "ADMIN",
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={u.id === currentUserId}
|
||||
title={
|
||||
u.id === currentUserId
|
||||
? t("admin.ownRoleUnchangeable")
|
||||
: u.role === "ADMIN"
|
||||
? t("admin.demoteToUser")
|
||||
: t("admin.promoteToAdmin")
|
||||
}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
{u.role === "ADMIN"
|
||||
? t("admin.toUser")
|
||||
: t("admin.toAdmin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startEdit(u)}
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startReset(u)}
|
||||
>
|
||||
<KeyRound className="mr-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingUsers")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
{editingUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
{t("admin.editUserTitle", { name: editingUser.username })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-username">{t("auth.username")}</Label>
|
||||
<Input
|
||||
id="edit-username"
|
||||
value={editUsername}
|
||||
onChange={(e) => setEditUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-email">{t("admin.userEmail")}</Label>
|
||||
<Input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={updateUser.isPending}
|
||||
onClick={() =>
|
||||
updateUser.mutate({
|
||||
id: editingUser.id,
|
||||
data: {
|
||||
username: editUsername,
|
||||
email: editEmail || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{updateUser.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{updateUser.isError && (
|
||||
<span className="text-destructive self-center text-sm">
|
||||
{(updateUser.error as Error).message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Reset Dialog */}
|
||||
{resetUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
{t("admin.resetPasswordTitle", { name: resetUser.username })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reset-pw">{t("admin.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="reset-pw"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
placeholder={t("admin.newPasswordPlaceholder")}
|
||||
/>
|
||||
<PasswordStrength password={resetPassword} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={resetPw.isPending || !resetPassword}
|
||||
onClick={() =>
|
||||
resetPw.mutate({
|
||||
id: resetUser.id,
|
||||
password: resetPassword,
|
||||
})
|
||||
}
|
||||
>
|
||||
{resetPw.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("admin.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResetUser(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
{resetMsg && (
|
||||
<p
|
||||
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{resetMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export 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<string, unknown> = {
|
||||
webPushVapidPublicKey: webPushVapidPublicKeyValue,
|
||||
webPushVapidSubject: webPushVapidSubjectValue,
|
||||
};
|
||||
if (webPushVapidPrivateKeyDraft.trim().length > 0) {
|
||||
payload.webPushVapidPrivateKey = webPushVapidPrivateKeyDraft.trim();
|
||||
}
|
||||
|
||||
updateSettings.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setWebPushVapidPublicKeyDraft(null);
|
||||
setWebPushVapidPrivateKeyDraft("");
|
||||
setWebPushVapidSubjectDraft(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.webPushVapidTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.webPushVapidDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-web-push-public-key"
|
||||
name="admin-web-push-public-key"
|
||||
value={webPushVapidPublicKeyValue}
|
||||
onChange={(event) =>
|
||||
setWebPushVapidPublicKeyDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.webPushVapidPublicKeyPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-private-key" className="text-xs">
|
||||
{t("admin.webPushVapidPrivateKey")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-web-push-private-key"
|
||||
name="admin-web-push-private-key"
|
||||
value={webPushVapidPrivateKeyDraft}
|
||||
onChange={(event) =>
|
||||
setWebPushVapidPrivateKeyDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.webPushVapidPrivateKeyPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-subject" className="text-xs">
|
||||
{t("admin.webPushVapidSubject")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-web-push-subject"
|
||||
name="admin-web-push-subject"
|
||||
value={webPushVapidSubjectValue}
|
||||
onChange={(event) =>
|
||||
setWebPushVapidSubjectDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.webPushVapidSubjectPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveWebPushVapidSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ ADVANCED METRICS:
|
||||
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
|
||||
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
|
||||
- sleep: target ≥ 7h/night (AASM 2015 Adult Sleep Duration Consensus). < 6h: risk factor for hypertension and weight gain.
|
||||
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — continued dose-response benefit through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
|
||||
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
|
||||
|
||||
HISTORICAL COMPARISON:
|
||||
- Compare current 7-day values against the previous 30-day average.
|
||||
|
||||
@@ -31,7 +31,7 @@ const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
|
||||
- Use historicalComparison to anchor current changes against the established baseline.
|
||||
- If age and sex are known, apply age- and sex-specific risk assessment.
|
||||
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
|
||||
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; benefit accumulates through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
|
||||
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
|
||||
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
|
||||
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
|
||||
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.
|
||||
|
||||
@@ -46,7 +46,7 @@ export function classifyBMI(bmi: number): BmiClassification {
|
||||
};
|
||||
}
|
||||
|
||||
// ── BP Classification (ESH 2023) ────────────────────────
|
||||
// ── BP Classification (ESC/ESH 2018) ────────────────────
|
||||
|
||||
export interface BpClassification {
|
||||
category: string;
|
||||
@@ -244,10 +244,9 @@ export function classifyBodyFat(
|
||||
// Cohort source: Saint-Maurice PF, et al. "Association of daily step
|
||||
// count and step intensity with mortality among US adults." JAMA. 2020.
|
||||
// https://jamanetwork.com/journals/jama/fullarticle/2763292
|
||||
// Mortality benefit accumulates strongly through ~12 000 steps/day
|
||||
// (HR 0.49 at 8k vs 4k, HR 0.35 at 12k — continued dose-response,
|
||||
// not a plateau). WHO publishes physical-activity TIME (150–300
|
||||
// min/wk moderate) — NOT a step quota; do not cite "WHO ≥ 8 000 steps".
|
||||
// Mortality benefit plateaus 8 000–12 000 steps/day. WHO publishes
|
||||
// physical-activity TIME (150–300 min/wk moderate) — NOT a step
|
||||
// quota; do not cite "WHO ≥ 8 000 steps".
|
||||
|
||||
export interface StepsClassification {
|
||||
category: string;
|
||||
@@ -281,8 +280,8 @@ export function classifySteps(steps: number): StepsClassification {
|
||||
|
||||
/**
|
||||
* Activity-steps target range. Single source of truth: aligned with
|
||||
* `effective-range.ts` (Saint-Maurice JAMA 2020 — continued mortality
|
||||
* dose-response through ~12 000 steps/day). The v1.3.3 audit flagged a drift where this helper
|
||||
* `effective-range.ts` (Saint-Maurice JAMA 2020 — mortality plateau
|
||||
* 8 000–12 000). The v1.3.3 audit flagged a drift where this helper
|
||||
* returned {7 000, 10 000} while `effective-range.ts` returned
|
||||
* {8 000, 15 000}; both surfaces showed different "green" bands to the
|
||||
* same user. They now agree.
|
||||
@@ -320,9 +319,7 @@ export function getSleepDurationRange(): { min: number; max: number } {
|
||||
}
|
||||
|
||||
/**
|
||||
* BP target ranges based on ESH 2023 guidelines (numerically
|
||||
* unchanged from the joint ESC/ESH 2018 document; the 2023 update
|
||||
* is ESH-only since ESC withdrew from joint authoring).
|
||||
* BP target ranges based on ESC/ESH 2018 guidelines.
|
||||
*/
|
||||
export function getBpTargetsByAge(
|
||||
age: number,
|
||||
@@ -332,7 +329,7 @@ export function getBpTargetsByAge(
|
||||
if (age < 65) {
|
||||
return { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 };
|
||||
}
|
||||
// 65+ (both 65–79 and ≥80 have the same targets per ESH 2023)
|
||||
// 65+ (both 65–79 and ≥80 have the same targets per ESC/ESH)
|
||||
return { sysLow: 130, sysHigh: 139, diaLow: 70, diaHigh: 79 };
|
||||
}
|
||||
|
||||
|
||||
@@ -207,9 +207,8 @@ function defaultRange(
|
||||
// AASM: 7–9h for adults; warning yellow either side.
|
||||
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
|
||||
case "ACTIVITY_STEPS":
|
||||
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (continued
|
||||
// dose-response benefit through ~12000 steps/day, not a plateau —
|
||||
// HR 0.49 at 8k vs 4k, HR 0.35 at 12k). WHO 2020 PA guidelines publish minutes
|
||||
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (mortality
|
||||
// plateau 8000–12000 steps). WHO 2020 PA guidelines publish minutes
|
||||
// per week (150–300 min moderate / 75–150 min vigorous) — *not* a
|
||||
// step quota. No upper bound in reality, orange over 25k caps edge
|
||||
// detection.
|
||||
|
||||
+3
-12
@@ -4,7 +4,6 @@ import type { User } from "@/generated/prisma/client";
|
||||
import { WideEventBuilder } from "./logging/event-builder";
|
||||
import { eventStorage, getEvent } from "./logging/context";
|
||||
import { emitIfSampled } from "./logging/transports";
|
||||
import { redactOptional, redactSecrets } from "./logging/redact";
|
||||
import { getSession } from "./auth/session";
|
||||
import { hashToken } from "./auth/hmac";
|
||||
import { prisma } from "./db";
|
||||
@@ -93,11 +92,7 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
} finally {
|
||||
const status = (response as Response | undefined)?.status ?? 500;
|
||||
evt.finish(status);
|
||||
try {
|
||||
emitIfSampled(evt.toJSON());
|
||||
} catch {
|
||||
/* logging must never crash the handler */
|
||||
}
|
||||
try { emitIfSampled(evt.toJSON()); } catch { /* logging must never crash the handler */ }
|
||||
}
|
||||
const nr = response as NextResponse;
|
||||
nr.headers.set("x-request-id", evt.getRequestId());
|
||||
@@ -350,14 +345,10 @@ async function reportToGlitchtip(
|
||||
dsn: settings.glitchtipDsn,
|
||||
input: {
|
||||
environment: settings.glitchtipEnvironment || "production",
|
||||
// Defence in depth: even though the WideEventBuilder already
|
||||
// redacts on `setError()`, the GlitchTip path imports `err`
|
||||
// directly. Apply the same redaction here so a Telegram bot
|
||||
// token or external Bearer cannot leak via the incident UI.
|
||||
message: redactSecrets(err.message),
|
||||
message: err.message,
|
||||
level: "error",
|
||||
type: err.name || "Error",
|
||||
stack: redactOptional(err.stack),
|
||||
stack: err.stack,
|
||||
url: scrubbedUrl,
|
||||
sourceTag: "healthlog-api-handler",
|
||||
requestId: evt.getRequestId(),
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactSecrets, redactOptional } from "../redact";
|
||||
|
||||
describe("redactSecrets", () => {
|
||||
it("redacts Bearer tokens", () => {
|
||||
expect(redactSecrets("Authorization: Bearer hlk_abc123def456")).toBe(
|
||||
"Authorization: Bearer [REDACTED]",
|
||||
);
|
||||
expect(redactSecrets("auth=Bearer whitespace_token")).toBe(
|
||||
"auth=Bearer [REDACTED]",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts Telegram bot tokens in path-style URLs", () => {
|
||||
expect(
|
||||
redactSecrets(
|
||||
"fetch failed: https://api.telegram.org/bot1234567890:AAEhBP-w-secret/sendMessage",
|
||||
),
|
||||
).toBe("fetch failed: https://api.telegram.org/bot[REDACTED]/sendMessage");
|
||||
});
|
||||
|
||||
it("redacts query-string secrets", () => {
|
||||
expect(redactSecrets("Withings webhook ?secret=abc123 failed")).toBe(
|
||||
"Withings webhook ?secret=[REDACTED] failed",
|
||||
);
|
||||
expect(redactSecrets("OAuth ?code=xyz&state=abc")).toBe(
|
||||
"OAuth ?code=[REDACTED]&state=abc",
|
||||
);
|
||||
expect(redactSecrets("api?api_key=secret")).toBe("api?api_key=[REDACTED]");
|
||||
});
|
||||
|
||||
it("leaves non-secret-shaped strings alone", () => {
|
||||
expect(redactSecrets("HTTP 503 from upstream")).toBe(
|
||||
"HTTP 503 from upstream",
|
||||
);
|
||||
expect(redactSecrets("user-7 measurement saved")).toBe(
|
||||
"user-7 measurement saved",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles multiple secrets in one string", () => {
|
||||
// Bearer matches `\S+` so it greedily consumes `hlk_x;` — that's
|
||||
// safe (over-redaction is fine when the alternative is leaking).
|
||||
expect(redactSecrets("Bearer hlk_x bot999:tok ?code=abc123")).toBe(
|
||||
"Bearer [REDACTED] bot[REDACTED] ?code=[REDACTED]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactOptional", () => {
|
||||
it("returns undefined for undefined input", () => {
|
||||
expect(redactOptional(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts when present", () => {
|
||||
expect(redactOptional("Bearer x")).toBe("Bearer [REDACTED]");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
||||
import type { WideEvent, LogLevel, EventKind } from "./types";
|
||||
import { LOG_LEVEL_PRIORITY } from "./types";
|
||||
import { getDeployContext } from "./config";
|
||||
import { redactOptional, redactSecrets } from "./redact";
|
||||
|
||||
/**
|
||||
* Baut ein Wide Event Schritt fuer Schritt auf.
|
||||
@@ -62,14 +61,14 @@ export class WideEventBuilder {
|
||||
if (err instanceof Error) {
|
||||
this.event.error = {
|
||||
type: err.constructor.name,
|
||||
message: redactSecrets(err.message),
|
||||
stack: redactOptional(err.stack),
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
code: (err as { statusCode?: number }).statusCode,
|
||||
};
|
||||
} else {
|
||||
this.event.error = {
|
||||
type: "Unknown",
|
||||
message: redactSecrets(String(err)),
|
||||
message: String(err),
|
||||
};
|
||||
}
|
||||
return this;
|
||||
@@ -89,8 +88,7 @@ export class WideEventBuilder {
|
||||
}
|
||||
|
||||
addDbQuery(durationMs: number): this {
|
||||
if (!this.event.db)
|
||||
this.event.db = { query_count: 0, query_duration_ms: 0 };
|
||||
if (!this.event.db) this.event.db = { query_count: 0, query_duration_ms: 0 };
|
||||
this.event.db.query_count++;
|
||||
this.event.db.query_duration_ms += durationMs;
|
||||
return this;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Redact secrets from strings before they leave the process boundary
|
||||
* (Loki, Glitchtip, console). Applied in `WideEventBuilder.setError()`
|
||||
* and `reportToGlitchtip()` so every error reaching observability is
|
||||
* scrubbed once, centrally.
|
||||
*
|
||||
* Patterns:
|
||||
* - `Bearer <token>` — generic API tokens incl. our `hlk_` /
|
||||
* `hlr_` formats and external OAuth bearers (Withings, AI providers).
|
||||
* - `bot<digits>:<token>` — Telegram bot URLs embed the token in the
|
||||
* path: `https://api.telegram.org/bot1234567:ABC-…/sendMessage`.
|
||||
* If `fetch()` rejects with a `cause` that surfaces the URL (some
|
||||
* Node runtimes), the token would land in error reports.
|
||||
* - `?secret=…` and `?code=…` — query-string leaks for legacy
|
||||
* Withings webhooks and OAuth callbacks (already scrubbed at the
|
||||
* URL level in `reportToGlitchtip`, but error.message can carry
|
||||
* the URL too).
|
||||
*
|
||||
* The substitution is intentionally generic ([REDACTED]) — we don't
|
||||
* want partial revelation of token entropy.
|
||||
*/
|
||||
export function redactSecrets(input: string): string {
|
||||
return input
|
||||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||||
.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]")
|
||||
.replace(
|
||||
/([?&])(secret|code|token|api[_-]?key)=[^&\s]+/gi,
|
||||
"$1$2=[REDACTED]",
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply `redactSecrets` to a possibly-undefined string. */
|
||||
export function redactOptional(s: string | undefined): string | undefined {
|
||||
return s === undefined ? undefined : redactSecrets(s);
|
||||
}
|
||||
@@ -52,12 +52,9 @@ export const CITATIONS = {
|
||||
/**
|
||||
* Saint-Maurice PF, et al. "Association of daily step count and step
|
||||
* intensity with mortality among US adults." JAMA. 2020.
|
||||
* The paper reports continued dose-response benefit (HR 0.49 at 8k
|
||||
* vs 4k, HR 0.35 at 12k vs 4k) — NOT a plateau. The plateau-shaped
|
||||
* finding belongs to Paluch 2022 Lancet Public Health (PMID
|
||||
* 35247352, age-stratified meta-analysis). WHO 2020 PA guidelines
|
||||
* publish minutes per week, NOT a step quota — DO NOT cite WHO
|
||||
* for a step number.
|
||||
* Mortality benefit plateaus 8 000–12 000 steps/day. WHO 2020 PA
|
||||
* guidelines publish minutes per week, NOT a step quota — DO NOT
|
||||
* cite WHO for a step number.
|
||||
*/
|
||||
STEPS_SAINT_MAURICE_2020: {
|
||||
id: "steps-saint-maurice-2020",
|
||||
@@ -65,7 +62,7 @@ export const CITATIONS = {
|
||||
year: 2020,
|
||||
url: "https://jamanetwork.com/journals/jama/fullarticle/2763292",
|
||||
caveat:
|
||||
"U.S. adult cohort, observational; benefit accumulates strongly through ~12k steps/day (continued dose-response, not a plateau).",
|
||||
"U.S. adult cohort, observational; benefit plateaus 8–12k steps/day.",
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+1
-28
@@ -1,7 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
/**
|
||||
* Sync mood entries from a user's moodLog instance.
|
||||
@@ -33,18 +32,6 @@ export async function syncMoodLogEntries(
|
||||
const baseUrl = decrypt(user.moodLogUrlEncrypted);
|
||||
const apiKey = decrypt(user.moodLogApiKeyEncrypted);
|
||||
|
||||
// SSRF guard at the actual fetch site. The credential write path
|
||||
// is also guarded (moodLogCredentialsSchema), but a row stored
|
||||
// before that guard landed could still point at an internal IP.
|
||||
// Re-checking here means the sync worker refuses internal targets
|
||||
// even on legacy data, and the user's apiKey is never sent there.
|
||||
if (!isPublicUrl(baseUrl)) {
|
||||
getEvent()?.addWarning(
|
||||
`moodLog sync refused for user ${userId}: stored URL points at non-public host`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. Determine date range
|
||||
const now = new Date();
|
||||
const to = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
@@ -74,10 +61,6 @@ export async function syncMoodLogEntries(
|
||||
response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: controller.signal,
|
||||
// SSRF defence-in-depth: do NOT follow redirects. A public
|
||||
// host that 302s to an RFC1918 target would otherwise leak
|
||||
// the apiKey to the internal hop.
|
||||
redirect: "manual",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@@ -88,18 +71,8 @@ export async function syncMoodLogEntries(
|
||||
});
|
||||
}
|
||||
|
||||
// Treat any 3xx as failure (manual redirect mode surfaces them).
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
getEvent()?.addWarning(
|
||||
`moodLog sync refused redirect for user ${userId}: HTTP ${response.status}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
getEvent()?.addWarning(
|
||||
`Sync failed for user ${userId}: HTTP ${response.status}`,
|
||||
);
|
||||
getEvent()?.addWarning(`Sync failed for user ${userId}: HTTP ${response.status}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { moodLogCredentialsSchema } from "../moodlog";
|
||||
|
||||
describe("moodLogCredentialsSchema SSRF guard", () => {
|
||||
it("accepts a public moodLog URL", () => {
|
||||
const r = moodLogCredentialsSchema.safeParse({
|
||||
url: "https://moodlog.app",
|
||||
apiKey: "k".repeat(40),
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects RFC1918 URLs", () => {
|
||||
for (const url of [
|
||||
"http://10.0.0.1",
|
||||
"http://192.168.1.1",
|
||||
"http://172.16.0.1",
|
||||
"http://127.0.0.1",
|
||||
]) {
|
||||
const r = moodLogCredentialsSchema.safeParse({
|
||||
url,
|
||||
apiKey: "k".repeat(40),
|
||||
});
|
||||
expect(r.success, `expected reject for ${url}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects link-local (cloud metadata) URLs", () => {
|
||||
const r = moodLogCredentialsSchema.safeParse({
|
||||
url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
||||
apiKey: "k".repeat(40),
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects localhost", () => {
|
||||
const r = moodLogCredentialsSchema.safeParse({
|
||||
url: "http://localhost:8080",
|
||||
apiKey: "k".repeat(40),
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const moodLogCredentialsSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.max(500)
|
||||
// SSRF guard: stored URL must point at a public host. The sync
|
||||
// worker fetches from this URL with the user's apiKey in the
|
||||
// Authorization header, so a stored RFC1918 / link-local target
|
||||
// would let any user pull cloud-metadata or local-network data
|
||||
// back through their account.
|
||||
.refine((u) => isPublicUrl(u), {
|
||||
message: "URL must point at a public host (no RFC1918 / link-local)",
|
||||
}),
|
||||
url: z.string().url().max(500),
|
||||
apiKey: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Integration regression guard for `src/lib/auth/session.ts`.
|
||||
*
|
||||
* The session helpers couple Postgres (`Session` row), the `cookies()`
|
||||
* adapter from `next/headers`, and the `db-compat` schema-migration
|
||||
* fallback. A unit test with a mocked Prisma client cannot verify the
|
||||
* end-to-end flow — particularly the expired-session purge behaviour,
|
||||
* which silently deletes the row before returning null.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
// Stateful in-memory cookie jar so `createSession()` (which calls
|
||||
// cookies().set) hands the value off to a subsequent `getSession()`
|
||||
// (which calls cookies().get), exactly as the request lifecycle does.
|
||||
const cookieJar = new Map<string, string>();
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// `db-compat` runs ALTER TABLE IF NOT EXISTS statements that are no-ops
|
||||
// against a freshly-migrated schema but slow tests down — short-circuit.
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
});
|
||||
|
||||
describe("session lifecycle (real Postgres)", () => {
|
||||
it("createSession writes a row and getSession reads it back", async () => {
|
||||
const { createSession, getSession } = await import("@/lib/auth/session");
|
||||
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: { username: "session-test", email: "session@example.test" },
|
||||
});
|
||||
|
||||
const sessionId = await createSession(user.id, "127.0.0.1", "vitest/1.0");
|
||||
|
||||
const row = await getPrismaClient().session.findUnique({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.userId).toBe(user.id);
|
||||
expect(row?.ipAddress).toBe("127.0.0.1");
|
||||
expect(row?.userAgent).toBe("vitest/1.0");
|
||||
|
||||
const result = await getSession();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.user.id).toBe(user.id);
|
||||
expect(result?.session.id).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("rejects expired sessions and purges the stale row", async () => {
|
||||
const { createSession, getSession } = await import("@/lib/auth/session");
|
||||
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: { username: "expired-session", email: "expired@example.test" },
|
||||
});
|
||||
|
||||
const sessionId = await createSession(user.id);
|
||||
|
||||
// Force the row past its expiry — getSession must purge it.
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: sessionId },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
const result = await getSession();
|
||||
expect(result).toBeNull();
|
||||
|
||||
const purged = await getPrismaClient().session.findUnique({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
expect(purged).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no session cookie is present", async () => {
|
||||
const { getSession } = await import("@/lib/auth/session");
|
||||
expect(await getSession()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* Integration regression guard for the GDPR Art. 17 erasure path.
|
||||
*
|
||||
* Deleting a user must wipe every personal-data row that references
|
||||
* them. The schema declares `onDelete: Cascade` for personal data and
|
||||
* `onDelete: SetNull` for audit-style rows (AuditLog, Feedback). This
|
||||
* test verifies the actual database FKs match those declarations — a
|
||||
* missing CASCADE would leave orphan rows behind, violating GDPR.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("user.delete cascades to all personal-data tables", () => {
|
||||
it("removes every personal-data row when the user is deleted", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "erasure-target",
|
||||
email: "erasure@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
// Seed personal data across every cascading relation.
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.passkey.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
credentialId: "cred-1",
|
||||
credentialPublicKey: Buffer.from("pubkey"),
|
||||
counter: BigInt(0),
|
||||
credentialDeviceType: "singleDevice",
|
||||
transports: ["internal"],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const med = await prisma.medication.create({
|
||||
data: { userId: user.id, name: "Aspirin", dose: "100mg" },
|
||||
});
|
||||
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "test-token",
|
||||
tokenHash: "hash-" + user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.dataBackup.create({
|
||||
data: { userId: user.id, type: "MANUAL", data: "{}" },
|
||||
});
|
||||
|
||||
await prisma.userAchievement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
achievementId: "first-login",
|
||||
unlockedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.idempotencyKey.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
key: "abcdef12345678",
|
||||
method: "POST",
|
||||
path: "/api/test",
|
||||
responseStatus: 200,
|
||||
responseBody: "{}",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.device.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
platform: "ios",
|
||||
token: "apns-token-" + user.id,
|
||||
bundleId: "app.healthlog.ios",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pushSubscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
endpoint: "https://push.example/sub-" + user.id,
|
||||
p256dh: "p256dh",
|
||||
auth: "auth",
|
||||
},
|
||||
});
|
||||
|
||||
// Audit logs / feedback are intentionally SetNull, not Cascade —
|
||||
// they survive deletion with a null userId for compliance triage.
|
||||
const auditRow = await prisma.auditLog.create({
|
||||
data: { userId: user.id, action: "auth.login" },
|
||||
});
|
||||
const feedbackRow = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// ── act ──
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
|
||||
// ── assert: every cascading table is empty for that user id ──
|
||||
expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(await prisma.passkey.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(await prisma.medication.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.count({
|
||||
where: { userId: user.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(await prisma.apiToken.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await prisma.userAchievement.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.idempotencyKey.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
expect(await prisma.device.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(
|
||||
await prisma.pushSubscription.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
|
||||
// SetNull rows survive but lose their userId.
|
||||
const auditAfter = await prisma.auditLog.findUnique({
|
||||
where: { id: auditRow.id },
|
||||
});
|
||||
expect(auditAfter?.userId).toBeNull();
|
||||
|
||||
const feedbackAfter = await prisma.feedback.findUnique({
|
||||
where: { id: feedbackRow.id },
|
||||
});
|
||||
expect(feedbackAfter?.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Vitest globalSetup — boots ONE Postgres testcontainer for the whole
|
||||
* integration run, applies migrations, and exposes the connection URL
|
||||
* via process.env.DATABASE_URL. The teardown returned at the end stops
|
||||
* the container after all test files have finished.
|
||||
*
|
||||
* Why globalSetup instead of beforeAll/afterAll per file:
|
||||
* - The application's Prisma singleton in `src/lib/db.ts` is built at
|
||||
* module load time using the current `process.env.DATABASE_URL`. If
|
||||
* the container is rotated between test files, the singleton keeps
|
||||
* pointing at the dead container and queries fail. Booting once,
|
||||
* keeping the URL stable, and truncating between tests is both
|
||||
* faster and safer.
|
||||
* - Truncate-between-tests still gives per-test isolation; see
|
||||
* `setup.ts -> truncateAllTables`.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
PostgreSqlContainer,
|
||||
type StartedPostgreSqlContainer,
|
||||
} from "@testcontainers/postgresql";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const PROJECT_ROOT = resolve(__dirname, "..", "..");
|
||||
|
||||
let container: StartedPostgreSqlContainer | null = null;
|
||||
|
||||
export default async function setup(): Promise<() => Promise<void>> {
|
||||
container = await new PostgreSqlContainer("postgres:16-alpine")
|
||||
.withDatabase("healthlog_test")
|
||||
.withUsername("healthlog")
|
||||
.withPassword("healthlog")
|
||||
.start();
|
||||
|
||||
const url = container.getConnectionUri() + "?schema=public&pgbouncer=false";
|
||||
process.env.DATABASE_URL = url;
|
||||
|
||||
// Provided so child processes (e.g. Prisma's CLI) inherit the URL.
|
||||
// Vitest also forwards process.env to test workers, so the
|
||||
// application Prisma singleton sees this URL when it loads.
|
||||
execSync("pnpm db:migrate:deploy", {
|
||||
cwd: PROJECT_ROOT,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: url },
|
||||
});
|
||||
|
||||
return async () => {
|
||||
if (container) {
|
||||
await container.stop({ remove: true });
|
||||
container = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Integration regression guard for `src/lib/idempotency.ts`.
|
||||
*
|
||||
* Mobile clients retry POST/PUT/PATCH/DELETE with the same
|
||||
* `Idempotency-Key`; the wrapper must replay the cached response without
|
||||
* re-running the side-effect. These tests exercise the cache table
|
||||
* (`idempotency_keys`) end-to-end against a real Postgres.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
// `withIdempotency()` calls `getSession()` (cookie-backed) by default and
|
||||
// falls back to a Bearer-token lookup. We stub `next/headers` to return
|
||||
// a fixed user id via the cookie path so the integration stays focused
|
||||
// on the cache-replay contract rather than the auth machinery.
|
||||
const TEST_USER_ID = "user-idempotency-test";
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(async () => ({
|
||||
session: {
|
||||
id: "session-stub",
|
||||
expiresAt: new Date(Date.now() + 1_000_000),
|
||||
},
|
||||
user: { id: TEST_USER_ID },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
// Seed the user the resolver claims to authenticate as so the FK
|
||||
// constraint on idempotency_keys.user_id holds.
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "idempotency-test",
|
||||
email: "idempotency@example.test",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function makeRequest(key: string | null, path = "/api/test"): NextRequest {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (key) headers["idempotency-key"] = key;
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("withIdempotency (real Postgres)", () => {
|
||||
it("replays the cached response and skips the handler on retry", async () => {
|
||||
const { withIdempotency } = await import("@/lib/idempotency");
|
||||
|
||||
let invocations = 0;
|
||||
const handler = withIdempotency(async () => {
|
||||
invocations++;
|
||||
return NextResponse.json({ count: invocations }, { status: 201 });
|
||||
});
|
||||
|
||||
const first = await handler(makeRequest("abc-12345678"));
|
||||
expect(first.status).toBe(201);
|
||||
expect(await first.json()).toEqual({ count: 1 });
|
||||
expect(invocations).toBe(1);
|
||||
|
||||
const replay = await handler(makeRequest("abc-12345678"));
|
||||
expect(replay.status).toBe(201);
|
||||
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
|
||||
expect(await replay.json()).toEqual({ count: 1 });
|
||||
expect(invocations).toBe(1); // handler did NOT run again
|
||||
|
||||
const rows = await getPrismaClient().idempotencyKey.findMany({
|
||||
where: { userId: TEST_USER_ID, key: "abc-12345678" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("re-runs the handler after the cached row's TTL has elapsed", async () => {
|
||||
const { withIdempotency } = await import("@/lib/idempotency");
|
||||
|
||||
let invocations = 0;
|
||||
const handler = withIdempotency(async () => {
|
||||
invocations++;
|
||||
return NextResponse.json({ count: invocations }, { status: 201 });
|
||||
});
|
||||
|
||||
await handler(makeRequest("expired-12345678"));
|
||||
expect(invocations).toBe(1);
|
||||
|
||||
// Force the cached row past its expiry — the wrapper purges and
|
||||
// falls through to the handler.
|
||||
await getPrismaClient().idempotencyKey.updateMany({
|
||||
where: { userId: TEST_USER_ID, key: "expired-12345678" },
|
||||
data: { expiresAt: new Date(Date.now() - 1_000) },
|
||||
});
|
||||
|
||||
const second = await handler(makeRequest("expired-12345678"));
|
||||
expect(second.status).toBe(201);
|
||||
expect(await second.json()).toEqual({ count: 2 });
|
||||
expect(invocations).toBe(2);
|
||||
|
||||
const rows = await getPrismaClient().idempotencyKey.findMany({
|
||||
where: { userId: TEST_USER_ID, key: "expired-12345678" },
|
||||
});
|
||||
// Original row was deleted as stale, then a fresh one was inserted.
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("does NOT cache 401/403/408/429/5xx responses", async () => {
|
||||
const { withIdempotency } = await import("@/lib/idempotency");
|
||||
|
||||
const cases = [401, 403, 408, 429, 500, 502, 503];
|
||||
for (const status of cases) {
|
||||
const key = `noncacheable-${status}-abcdef`;
|
||||
const handler = withIdempotency(async () =>
|
||||
NextResponse.json({ status }, { status }),
|
||||
);
|
||||
const response = await handler(makeRequest(key));
|
||||
expect(response.status).toBe(status);
|
||||
|
||||
const row = await getPrismaClient().idempotencyKey.findFirst({
|
||||
where: { userId: TEST_USER_ID, key },
|
||||
});
|
||||
expect(row).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("DOES cache 4xx-validation responses (e.g. 422)", async () => {
|
||||
const { withIdempotency } = await import("@/lib/idempotency");
|
||||
|
||||
const handler = withIdempotency(async () =>
|
||||
NextResponse.json({ error: "validation" }, { status: 422 }),
|
||||
);
|
||||
|
||||
await handler(makeRequest("validation-12345678"));
|
||||
const row = await getPrismaClient().idempotencyKey.findFirst({
|
||||
where: { userId: TEST_USER_ID, key: "validation-12345678" },
|
||||
});
|
||||
expect(row?.responseStatus).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Integration regression guard for `src/lib/rate-limit.ts`.
|
||||
*
|
||||
* The limiter relies on a single atomic SQL upsert (`INSERT ... ON
|
||||
* CONFLICT DO UPDATE`) so concurrent calls cannot exceed the cap. These
|
||||
* tests prove that contract against a real Postgres — a unit test with
|
||||
* a mocked client could not detect a missing UPSERT or a misplaced
|
||||
* window-reset branch.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("checkRateLimit (real Postgres)", () => {
|
||||
it("permits exactly `limit` of N concurrent calls in the same window", async () => {
|
||||
// Lazy import so `process.env.DATABASE_URL` is set by startTestDb()
|
||||
// *before* the module pulls in `@/lib/db`.
|
||||
const { checkRateLimit } = await import("@/lib/rate-limit");
|
||||
|
||||
const key = "test:concurrent:1.2.3.4";
|
||||
const limit = 5;
|
||||
const windowMs = 60_000;
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 6 }, () => checkRateLimit(key, limit, windowMs)),
|
||||
);
|
||||
|
||||
const allowed = results.filter((r) => r.allowed).length;
|
||||
const denied = results.filter((r) => !r.allowed).length;
|
||||
|
||||
expect(allowed).toBe(5);
|
||||
expect(denied).toBe(1);
|
||||
|
||||
const row = await getPrismaClient().rateLimit.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
expect(row?.count).toBe(6);
|
||||
});
|
||||
|
||||
it("resets the counter once the window expires", async () => {
|
||||
const { checkRateLimit } = await import("@/lib/rate-limit");
|
||||
|
||||
const key = "test:reset:1.2.3.4";
|
||||
const limit = 3;
|
||||
|
||||
// Burn the budget with a short 50ms window.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await checkRateLimit(key, limit, 50);
|
||||
expect(r.allowed).toBe(true);
|
||||
}
|
||||
const denied = await checkRateLimit(key, limit, 50);
|
||||
expect(denied.allowed).toBe(false);
|
||||
|
||||
// Manually expire the window: cheaper than sleeping and avoids
|
||||
// flaky timing on slow CI runners. The branch under test compares
|
||||
// `reset_at < NOW()`, so any past timestamp triggers the reset.
|
||||
await getPrismaClient().rateLimit.update({
|
||||
where: { key },
|
||||
data: { resetAt: new Date(Date.now() - 1_000) },
|
||||
});
|
||||
|
||||
const afterReset = await checkRateLimit(key, limit, 60_000);
|
||||
expect(afterReset.allowed).toBe(true);
|
||||
|
||||
const row = await getPrismaClient().rateLimit.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
expect(row?.count).toBe(1);
|
||||
expect(row?.resetAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Per-test helpers for the integration suite. The Postgres testcontainer
|
||||
* is started ONCE in `global-setup.ts` and torn down at the end of the
|
||||
* run, so test files only need:
|
||||
*
|
||||
* import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
*
|
||||
* beforeEach(async () => { await truncateAllTables(getPrismaClient()); });
|
||||
*
|
||||
* No beforeAll(startTestDb) / afterAll(stopTestDb) is needed — the
|
||||
* container is alive whenever the test file is loaded, and Vitest's
|
||||
* worker inherits `process.env.DATABASE_URL` from globalSetup so the
|
||||
* application's Prisma singleton (`src/lib/db.ts`) connects to the
|
||||
* testcontainer transparently.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* The application's Prisma singleton. Tests use this so any code
|
||||
* imported via `await import("@/lib/...")` shares the exact same client
|
||||
* instance — no risk of a divergent connection pool reading stale
|
||||
* truncation state.
|
||||
*/
|
||||
export function getPrismaClient(): PrismaClient {
|
||||
return prisma;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate every user-facing table in dependency-safe order using
|
||||
* `TRUNCATE … RESTART IDENTITY CASCADE`. Call from beforeEach() to make
|
||||
* tests independent. The list mirrors `prisma/schema.prisma` minus
|
||||
* prisma-internal tables (_prisma_migrations).
|
||||
*/
|
||||
export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
// CASCADE means we only need to enumerate the tables; FK chains are
|
||||
// handled by Postgres. Listed alphabetically for diff stability.
|
||||
const tables = [
|
||||
"api_tokens",
|
||||
"app_settings",
|
||||
"audit_logs",
|
||||
"auth_challenges",
|
||||
"data_backups",
|
||||
"devices",
|
||||
"feedback",
|
||||
"idempotency_keys",
|
||||
"measurements",
|
||||
"medication_intake_events",
|
||||
"medication_schedules",
|
||||
"medications",
|
||||
"mood_entries",
|
||||
"notification_channels",
|
||||
"notification_preferences",
|
||||
"passkeys",
|
||||
"push_subscriptions",
|
||||
"rate_limits",
|
||||
"refresh_tokens",
|
||||
"reminder_phase_configs",
|
||||
"sessions",
|
||||
"telegram_reminder_messages",
|
||||
"telegram_scheduled_deletions",
|
||||
"user_achievements",
|
||||
"users",
|
||||
"withings_connections",
|
||||
];
|
||||
|
||||
const quoted = tables.map((t) => `"${t}"`).join(", ");
|
||||
await client.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`,
|
||||
);
|
||||
}
|
||||
+1
-11
@@ -11,17 +11,7 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
// Default `pnpm test` keeps unit tests only — integration suite uses
|
||||
// testcontainers and runs separately via `pnpm test:integration`.
|
||||
exclude: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"tests/integration/**",
|
||||
// Playwright E2E suite — driven separately via `pnpm e2e`.
|
||||
"e2e/**",
|
||||
// Live agent worktrees create copies of `src/` under
|
||||
// `.claude/worktrees/`; vitest would otherwise pick those copies up
|
||||
// and run their tests twice — possibly against stale snapshots.
|
||||
".claude/worktrees/**",
|
||||
],
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "tests/integration/**"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
// `include` enumerates the full source tree so the report shows
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Integration suite — boots a real Postgres in a testcontainer and runs
|
||||
// migrations against it. Slow (container boot ~5–15s on a cold pull),
|
||||
// so excluded from the default `pnpm test` and triggered explicitly via
|
||||
// `pnpm test:integration`.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
// Container boot + migration apply is slow; give each test a generous
|
||||
// budget and beforeAll/afterAll twice that.
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 120_000,
|
||||
// Run sequentially in a single fork so files reuse one container and
|
||||
// amortise the migration-apply cost. Vitest 4 moved these to the top
|
||||
// level (the old `poolOptions.forks.singleFork` was removed).
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
isolate: false,
|
||||
// One container for the whole run; tests truncate between them.
|
||||
globalSetup: ["./tests/integration/global-setup.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user