From 545793fdd2593cb769611f5822061fd8c3b9a3d0 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 16 May 2026 20:45:26 +0200 Subject: [PATCH] chore: streamline root validation and app loading (#635) --- README.md | 7 ++++ backend/src/plugins/auth.ts | 2 +- backend/src/routes/auth.ts | 2 +- backend/src/routes/doses.ts | 2 +- backend/src/routes/export.ts | 4 +-- docs/CONFIGURATION.md | 4 ++- docs/DEVELOPMENT.md | 13 +++++++- docs/PUSH_NOTIFICATIONS.md | 37 ++++++++++++++++++++- frontend/src/App.tsx | 59 +++++++++++++++++++++++----------- frontend/src/test/App.test.tsx | 31 +++++++++++++----- frontend/src/test/globals.d.ts | 1 + frontend/src/vite-env.d.ts | 1 + frontend/vite.config.ts | 29 +++++++++++++++++ package.json | 4 ++- 14 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 frontend/src/test/globals.d.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/README.md b/README.md index 796acdc..ee40282 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,13 @@ Detailed configuration references: Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). +For cross-stack maintenance work and pre-PR validation, the repository root now exposes: + +```bash +npm run check +npm run build +``` + # Acknowledgements This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic. diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 44e8435..d6ce3ef 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -136,7 +136,7 @@ async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Prom } const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId)); - if (!user || !user.isActive) { + if (!user?.isActive) { reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" }); throw new Error("USER_NOT_FOUND"); } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6b3a1e8..eb6f796 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -438,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) { // Get user const [user] = await db.select().from(users).where(eq(users.id, decoded.sub)); - if (!user || !user.isActive) { + if (!user?.isActive) { return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" }); } diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index 7787c39..b18f34f 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -6,7 +6,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { computeMedicationCurrentStock } from "../services/current-stock.js"; -import { dismissDosesForUser, markDoseTakenForUser } from "../services/dose-tracking-service.js"; +import { markDoseTakenForUser } from "../services/dose-tracking-service.js"; import type { AuthUser } from "../types/fastify.js"; import { applyOpenApiRouteStandards, diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index a716e79..53a8cc3 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -140,8 +140,6 @@ const settingsSchemaBase = z.object({ shareMedicationOverview: z.boolean().default(false), }); -const exportSettingsSchema = settingsSchemaBase.optional(); - const importSettingsSchema = settingsSchemaBase .extend({ // Accept the removed field from legacy exports so old backups still import, @@ -297,7 +295,7 @@ function imageToBase64(imageUrl: string | null): string | null { // Save base64 image to file and return filename function base64ToImage(base64: string, medicationId: number): string | null { - if (!base64 || !base64.startsWith("data:")) return null; + if (!base64.startsWith("data:")) return null; try { // Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..." diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a92f85c..997c527 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -11,7 +11,7 @@ Configure MedAssist with environment variables in `.env`. Start from `.env.examp | `PORT` | `3000` | Backend API port | | `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS | | `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders | -| `PUBLIC_APP_URL` | — | Public base URL for notification action links | +| `PUBLIC_APP_URL` | — | Public base URL for notification action links. Must be reachable by phones, browsers, and notification providers; do not point this to `localhost` or an internal Docker hostname. | | `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, or `silent` | | `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP | | `OPENAPI_DOCS_ENABLED` | `auto` | Explicitly enable or disable `/docs` and `/docs/json` | @@ -108,6 +108,8 @@ Push notification setup, provider support, and URL examples are documented in [P Recommended provider: `ntfy`, especially for intake reminders with direct actions. +Notification action links use `PUBLIC_APP_URL` as their base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`. + ## Default User Settings Default values for newly created users are documented in [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6b2f520..fb1a948 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -30,6 +30,17 @@ These development overrides are documented here intentionally and are not part o ```bash npm run lint +npm run check +npm run build cd backend && npm run test:run cd frontend && npm run test:run -``` \ No newline at end of file +``` + +Recommended local maintenance preflight before opening or updating a PR: + +```bash +npm run check +npm run build +``` + +Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice. \ No newline at end of file diff --git a/docs/PUSH_NOTIFICATIONS.md b/docs/PUSH_NOTIFICATIONS.md index 1862eeb..e0977d6 100644 --- a/docs/PUSH_NOTIFICATIONS.md +++ b/docs/PUSH_NOTIFICATIONS.md @@ -25,6 +25,23 @@ When an ntfy intake action succeeds, MedAssist publishes the confirmation as the Configure push notifications in the app under `Settings -> Push`, or set defaults for new users with environment variables. +Notification action links such as `Take`, `Skip`, and `View` use `PUBLIC_APP_URL` as their base URL. Set this to the public MedAssist URL that the receiving device can actually reach. + +Good examples: + +```text +https://med.example.com +https://medtest.example.com +``` + +Bad examples for notification actions: + +```text +http://localhost:3000 +http://backend-dev:3000 +http://192.168.x.x:3000 +``` + Push-related default variables: | Variable | Default | Description | @@ -72,4 +89,22 @@ telegram://TOKEN@telegram?chats=CHAT_ID telegram://TOKEN@telegram?chats=@your_channel,-1001234567890 ``` -For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/). \ No newline at end of file +For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/). + +## Troubleshooting + +### ntfy `Take` / `Skip` fails with a connection timeout + +If the ntfy client shows an error such as `failed to connect to ... port 443`, the failure usually happens before MedAssist can process the action token. + +Check these points first: + +1. `PUBLIC_APP_URL` points to your real public MedAssist URL, not to `localhost`, a Docker service name, or another internal-only address. +2. The same URL opens from the same phone and network outside the notification flow. +3. If the failure only happens on your home Wi-Fi, retry once on mobile data. That strongly helps distinguish an app issue from missing NAT loopback / hairpin routing on the local network. + +### ntfy shows an old actionable entry after a successful action + +MedAssist updates the notification state after a successful ntfy action and removes the stale actionable entry using the original ntfy message ID when available. + +If an outdated actionable entry still remains visible, verify that the action actually reached MedAssist and that your ntfy server accepted both the confirmation publish and the follow-up delete of the original message. \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b50c10c..c261708 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { AboutModal, @@ -13,7 +14,17 @@ import { AppHeader } from "./components/AppHeader"; import { AuthPage, AuthProvider, useAuth } from "./components/Auth"; import { AppProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context"; import { useScrollLock } from "./hooks/useScrollLock"; -import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages"; + +const DashboardPage = lazy(() => import("./pages/DashboardPage").then((module) => ({ default: module.DashboardPage }))); +const MedicationsPage = lazy(() => + import("./pages/MedicationsPage").then((module) => ({ default: module.MedicationsPage })) +); +const PlannerPage = lazy(() => import("./pages/PlannerPage").then((module) => ({ default: module.PlannerPage }))); +const SchedulePage = lazy(() => import("./pages/SchedulePage").then((module) => ({ default: module.SchedulePage }))); +const SettingsPage = lazy(() => import("./pages/SettingsPage").then((module) => ({ default: module.SettingsPage }))); +const SharedOverviewPage = lazy(() => + import("./pages/SharedOverviewPage").then((module) => ({ default: module.SharedOverviewPage })) +); // Vite injects this at build time from package.json declare const __APP_VERSION__: string; @@ -21,19 +32,27 @@ export const FRONTEND_VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_V const GITHUB_REPO = "DanielVolz/medassist-ng"; export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`; +function RouteLoadingFallback() { + const { t } = useTranslation(); + + return
{t("common.loading")}
; +} + // ============================================================================= // Main App Wrapper with Auth // ============================================================================= export default function App() { return ( - - {/* Public share route - accessible without auth */} - } /> - } /> - {/* All other routes go through AppRouter */} - } /> - + }> + + {/* Public share route - accessible without auth */} + } /> + } /> + {/* All other routes go through AppRouter */} + } /> + + ); } @@ -505,20 +524,22 @@ function AppContent() { {/* About Modal */} - - } /> - } /> + }> + + } /> + } /> - } /> + } /> - } /> + } /> - } /> + } /> - } /> - {/* Catch-all: redirect unknown routes to dashboard */} - } /> - + } /> + {/* Catch-all: redirect unknown routes to dashboard */} + } /> + + {/* Medication Detail Modal */} { }; }); -vi.mock("../pages", () => ({ +vi.mock("../pages/DashboardPage", () => ({ DashboardPage: () => { const location = useLocation(); return ( @@ -68,10 +68,25 @@ vi.mock("../pages", () => ({ ); }, +})); + +vi.mock("../pages/MedicationsPage", () => ({ MedicationsPage: () =>
medications-page
, +})); + +vi.mock("../pages/PlannerPage", () => ({ PlannerPage: () =>
planner-page
, +})); + +vi.mock("../pages/SchedulePage", () => ({ SchedulePage: () =>
schedule-page
, +})); + +vi.mock("../pages/SettingsPage", () => ({ SettingsPage: () =>
settings-page
, +})); + +vi.mock("../pages/SharedOverviewPage", () => ({ SharedOverviewPage: () =>
shared-overview-page
, })); @@ -262,7 +277,7 @@ describe("App", () => { expect(screen.getByText("auth-page")).toBeInTheDocument(); }); - it("renders app shell when auth is disabled", () => { + it("renders app shell when auth is disabled", async () => { render( @@ -270,10 +285,10 @@ describe("App", () => { ); expect(screen.getByText("app-header")).toBeInTheDocument(); - expect(screen.getByText("dashboard-page")).toBeInTheDocument(); + expect(await screen.findByText("dashboard-page")).toBeInTheDocument(); }); - it("preserves notification query params when redirecting root to dashboard", () => { + it("preserves notification query params when redirecting root to dashboard", async () => { const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000"; render( @@ -282,8 +297,8 @@ describe("App", () => { ); - expect(screen.getByText("dashboard-page")).toBeInTheDocument(); - expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search); + expect(await screen.findByText("dashboard-page")).toBeInTheDocument(); + expect(await screen.findByTestId("dashboard-location-search")).toHaveTextContent(search); }); it("renders initializing state when auth state is missing", () => { @@ -370,14 +385,14 @@ describe("App", () => { expect(shareContextMock.resetShareDialogState).toHaveBeenCalled(); }); - it("redirects unknown routes to dashboard", () => { + it("redirects unknown routes to dashboard", async () => { render( ); - expect(screen.getByText("dashboard-page")).toBeInTheDocument(); + expect(await screen.findByText("dashboard-page")).toBeInTheDocument(); }); it("popstate closes image lightbox before other modals", () => { diff --git a/frontend/src/test/globals.d.ts b/frontend/src/test/globals.d.ts new file mode 100644 index 0000000..adf3a00 --- /dev/null +++ b/frontend/src/test/globals.d.ts @@ -0,0 +1 @@ +declare const global: typeof globalThis; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c51644e..56edafa 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -47,6 +47,35 @@ export default defineConfig({ __APP_VERSION__: JSON.stringify(packageJson.version || "unknown"), __LOG_LEVEL__: JSON.stringify(process.env.LOG_LEVEL || "warn"), }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes("node_modules")) { + return undefined; + } + + if (id.includes("react-router-dom")) { + return "router-vendor"; + } + + if (id.includes("react-i18next") || id.includes("i18next-browser-languagedetector") || id.includes("i18next")) { + return "i18n-vendor"; + } + + if (id.includes("lucide-react")) { + return "icons-vendor"; + } + + if (id.includes("react") || id.includes("scheduler")) { + return "react-vendor"; + } + + return "vendor"; + }, + }, + }, + }, server: { port: 5173, strictPort: true, diff --git a/package.json b/package.json index d19c5e9..3944d76 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "scripts": { "prepare": "husky", "lint": "cd backend && npm run lint && cd ../frontend && npm run lint", - "lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix" + "lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix", + "check": "cd backend && npm run check && cd ../frontend && npm run check", + "build": "cd backend && npm run build && cd ../frontend && npm run build" }, "devDependencies": { "@biomejs/biome": "^2.4.15",