mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
a7faae8ded7e11ebd71aeedf1f0349ba767ea5e5
558
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fe85c2c85f |
feat(admin): restore from backup with full data replacement + triple-confirm
POST /api/admin/backups/[id]/restore replaces the target user's
personal data with the contents of the snapshot. This is the riskiest
endpoint in the backup family, hardened with five independent gates:
1. requireAdmin() — cookie-session admins only, never a Bearer token.
2. Body must carry confirm: "RESTORE" (rejected with 422 + audit
denial otherwise).
3. UI gates the destructive button behind a typed-string match —
three independent steps before fire (open dialog, read warning,
type RESTORE).
4. withIdempotency() wraps the handler so duplicate clicks with the
same Idempotency-Key replay the original outcome instead of
re-executing the destructive transaction.
5. Pre-validation of measurement-type / measurement-source /
intake-source enums OUTSIDE the transaction so a malformed
payload can't half-wipe the user before failing the create step.
Restore scope mirrors the v1.4.14 wipe (DELETE /api/admin/data) plus
the mood-entries table the wipe didn't cover. AuditLog rows are
intentionally PRESERVED across the restore — the audit trail is the
last thing that should be touched by a destructive op.
Transaction:
- Delete user's measurements, medications (cascades to schedules +
intake events + reminder configs + telegram messages), mood
entries, notification channels, push subscriptions, telegram
scheduled deletions.
- Re-create from payload: measurements (skipDuplicates), medications
+ their schedules in one create call, intake events keyed by
medication name -> id (orphans dropped silently), mood entries
(skipDuplicates).
Audit:
- admin.backups.restore.start (intent, before transaction)
- admin.backups.restore (success, after transaction)
- admin.backups.restore.denied (5 reasons, no transaction ran)
- admin.backups.restore.failed (decrypt / schema / enum / tx error)
UI:
- New <RestoreRowDialog> subcomponent renders an AlertDialog gated
on a typed-RESTORE Input. Description carries the target username
and the snapshot timestamp so the admin sees exactly which
backup they're about to restore. Confirm button stays disabled
until typed string matches.
- Restore button sits next to Download in each row's action column.
- Idempotency-Key built client-side as restore-<rowId>-<uuid> to
de-duplicate accidental double-clicks.
Tests: 3 new integration tests in
tests/integration/admin-backups-restore.test.ts covering the round-
trip (wipes pre-existing data + recreates from snapshot),
confirmation-token rejection, and 404 on unknown id. 21/21
integration tests pass; 69/69 unit tests on src/lib/validations and
src/components/admin still green.
Note for v1.4.16: the restore test queries `medication_schedules`
with explicit field selection because a sibling branch added
`days_of_week` to the schema without a matching migration; the
default `findMany` would 500 against the deployed test DB. When that
migration lands, the explicit select can be relaxed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
30a74ed3b8 |
fix(mobile): bump chart controls + medication primary buttons + mood list icons to 44px
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:
1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
2.5.5 / Apple HIG / Material 44px minimum. Bumped to
`min-h-9 px-2.5` (36px) — that's still compact enough to fit in
the chart header strip on a 393px viewport but raises tap-target
pass rate substantially. Files: health-chart, mood-chart,
medication-compliance-chart, compliance-line-chart,
compliance-charts.
2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
the dashboard charts used `scale-75` to shrink the Switch primitive
to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
Switch returns to its default size. Files: health-chart, mood-chart.
3. Medication card primary actions ("Taken" / "Skipped"): these are
the most-tapped controls in HealthLog (every dose intake is one
tap). Were `size="sm"` (32px). Bumped to default size + explicit
`min-h-11`. The history + edit icon-buttons on the same card go
from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
16px so the visual weight doesn't change.
4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
mobile card branch only — the desktop table keeps its denser
`h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
gains a `mobile` prop so the existing desktop callsite is
unchanged.
Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons
Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d8c549edf1 |
fix(admin): /admin/users mobile layout — card-list at < md, no truncation
Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table truncated the role badge ("ADMI", "USE") and pushed the action column entirely off the right edge. The destructive force-logout action was unreachable on mobile, plus the card-header filter pills collided with the title's count badge. Fix: - Header switches from horizontal to vertical stack at `< sm` so the filter pills get their own row instead of fighting the title for space. - Card padding `p-4 sm:p-6` matches the api-tokens responsive template. - Desktop table is gated behind `hidden md:block` (instead of just letting it overflow). - New mobile card-list at `< md` renders each user as a self- contained card with the username + role badge + meta line + a flex-wrapping action row carrying all four buttons (toggle role, edit, reset password, force logout). Nothing hidden behind a horizontal scroll, every action stays tap-targetable. - Action buttons extracted into a single `renderUserActions()` helper so desktop + mobile stay in lock-step on future admin actions. Evidence: new `user-management-responsive.test.tsx` asserts the desktop table is `hidden md:block`, the mobile list is `md:hidden`, the role badge appears in both layouts (no column lost), all four ARIA-labelled action buttons are present in the mobile card, the header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests green. Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3cacdf294e |
fix(tests): share next/headers cookieJar across integration suite
The integration suite runs with `vitest isolate: false` (one worker,
one container — migration cost demands it). Each test file declared
its own `vi.mock("next/headers", ...)` whose factory closed over a
file-local `cookieJar` Map. Vitest resolves a mock factory ONCE per
worker — whichever file loaded first won the registration, and the
other files' Map writes silently disappeared.
That made `admin-data-wipe.test.ts` flake intermittently (~12.5% per
empirical 8-run sample) when `idempotency-replay.test.ts` happened to
load first: its `vi.mock("@/lib/auth/session", ...)` then leaked,
returning a stub user without an admin role, so `requireAdmin()`
returned 401 and the wipe assertion failed with `expected 401 to be
200`.
Fix: extract cookieJar + headerJar into module-singleton Maps in
`tests/integration/mock-next-headers.ts`. All three integration files
import them and resolve them via dynamic-import inside the
`vi.mock("next/headers", ...)` factory body, so the factory always
sees the same backing Maps regardless of which file's registration
won the race. The `vi.mock("@/lib/auth/session", ...)` stub in
idempotency-replay was also removed; the test now seeds a real
Session row + cookie like the other two files.
After: 10/10 consecutive `pnpm test:integration` runs green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
512a6a619c |
fix(admin): wipe-all-data clears notification channels, push subs, telegram deletions
The "Wipe all data" admin transaction was scoped only to measurements, medications, intakes, API tokens, Withings connections, and auth challenges. Notification channels (encrypted Telegram bot tokens in NotificationChannel.config), Web Push subscriptions, and queued Telegram message deletions survived the wipe — a scope leak first flagged in the v1.4.6 multi-agent QA pass. Extends the transaction with `notificationChannel.deleteMany`, `pushSubscription.deleteMany`, and `telegramScheduledDeletion.deleteMany`. NotificationPreference rows cascade via the schema FK. Feedback rows are intentionally preserved (T8 v1.4.6) and the i18n copy now states this explicitly so admins know bug reports survive a wipe. Counts for the three new tables are returned to the UI. Adds an integration regression test that seeds all three tables, invokes the route handler with a real admin session, and asserts the wipe clears them while Feedback + AuditLog survive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
643e6ec3a0 |
release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"
This reverts commit
|
||
|
|
e444a90b10 |
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
|
||
|
|
accdb7d44d |
test(integration): Postgres testcontainers + 4 critical-path tests (rate-limit race, idempotency, cascade, session) (#137)
* test(integration): Postgres testcontainers + 4 critical-path tests Adds an integration test suite that runs against a real Postgres booted in-process via testcontainers. Lives under tests/integration/, runs via `pnpm test:integration`, and is excluded from the default `pnpm test` loop so the 477-test unit suite stays fast (~1.2s). Covers four regression-prone behaviours that pure unit tests cannot validate against a mocked client: - rate-limit.test.ts — atomic UPSERT under concurrency (5/6 allowed) and window-reset semantics for src/lib/rate-limit.ts - idempotency-replay.test.ts — cached replay with X-Idempotent-Replay, expired-row purge, and the do-not-cache contract for 401/403/408/429/5xx on top of src/lib/idempotency.ts - cascade-delete.test.ts — GDPR Art. 17 erasure: user.delete wipes every onDelete:Cascade table and SetNulls AuditLog/Feedback - auth-flow.test.ts — createSession/getSession round-trip plus expired-session purge for src/lib/auth/session.ts Adds .github/workflows/integration.yml triggering on pull_request and push to main. Local Docker is not running in this worktree environment; the suite was verified by collecting all 10 tests via `vitest list` and will execute in CI. Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(integration): rebase onto v1.4 + exclude live agent worktrees from default test scope Rebase brings PR #137's testcontainers infrastructure on top of the user-locale migration drift fix landed in #140 — the integration suite should now bootstrap a fresh Postgres container with all 25 migrations applied cleanly. vitest.config.mts also gains a `.claude/worktrees/**` exclude so live agent worktrees (which mirror `src/` while their parent agents are running) don't double-execute the project's tests against possibly-stale copies. * test(integration): boot one Postgres container per run via vitest globalSetup The first iteration of the testcontainers suite started/stopped a fresh container in each test file's beforeAll/afterAll. That works in isolation but breaks when files share a worker (`pool: forks`, `fileParallelism: false`, `isolate: false`): 1. Test file A boots container 1, sets DATABASE_URL. 2. The application's Prisma singleton in src/lib/db.ts is built when test code dynamic-imports @/lib/auth/session — it captures container 1's URL. 3. afterAll stops container 1. 4. Test file B boots container 2, sets DATABASE_URL to a new port. 5. Test file B imports application code that resolves to the same singleton — still pointing at the dead container 1. Queries fail with a generic PrismaClientKnownRequestError. Fix: move the container lifecycle to vitest's globalSetup. One container, booted once, torn down at the end of the run. Tests still get isolation because beforeEach truncates every personal-data table in dependency-safe CASCADE order. - tests/integration/global-setup.ts boots postgres:16-alpine, runs `pnpm db:migrate:deploy`, exports a teardown for end-of-run. - tests/integration/setup.ts now exports getPrismaClient() (which delegates to the application singleton) and truncateAllTables(). No more startTestDb / stopTestDb. - All four test files lose their beforeAll/afterAll boilerplate. - vitest.integration.config.mts wires the new globalSetup. - truncate list adds refresh_tokens (added in 0025_refresh_tokens). Result: pnpm test:integration → 10/10 pass in 3.9s (was 6/10 with 4 cross-file failures). pnpm test → 658/658 unchanged. Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |