chore: streamline root validation and app loading (#635)

This commit is contained in:
Daniel Volz
2026-05-16 20:45:26 +02:00
committed by GitHub
parent 2f5fc2d9e9
commit 545793fdd2
14 changed files with 159 additions and 37 deletions
+7
View File
@@ -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.
+1 -1
View File
@@ -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");
}
+1 -1
View File
@@ -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" });
}
+1 -1
View File
@@ -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,
+1 -3
View File
@@ -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..."
+3 -1
View File
@@ -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).
+12 -1
View File
@@ -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
```
```
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.
+36 -1
View File
@@ -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/).
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.
+40 -19
View File
@@ -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 <div style={{ padding: "1rem", textAlign: "center" }}>{t("common.loading")}</div>;
}
// =============================================================================
// Main App Wrapper with Auth
// =============================================================================
export default function App() {
return (
<AuthProvider>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
</Suspense>
</AuthProvider>
);
}
@@ -505,20 +524,22 @@ function AppContent() {
{/* About Modal */}
<AboutModal isOpen={showAbout} onClose={closeAbout} />
<Routes>
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/medications" element={<MedicationsPage />} />
<Route path="/medications" element={<MedicationsPage />} />
<Route path="/planner" element={<PlannerPage />} />
<Route path="/planner" element={<PlannerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/schedule" element={<SchedulePage />} />
{/* Catch-all: redirect unknown routes to dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
<Route path="/schedule" element={<SchedulePage />} />
{/* Catch-all: redirect unknown routes to dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Suspense>
{/* Medication Detail Modal */}
<MedDetailModal
+23 -8
View File
@@ -58,7 +58,7 @@ vi.mock("../context", async () => {
};
});
vi.mock("../pages", () => ({
vi.mock("../pages/DashboardPage", () => ({
DashboardPage: () => {
const location = useLocation();
return (
@@ -68,10 +68,25 @@ vi.mock("../pages", () => ({
</div>
);
},
}));
vi.mock("../pages/MedicationsPage", () => ({
MedicationsPage: () => <div>medications-page</div>,
}));
vi.mock("../pages/PlannerPage", () => ({
PlannerPage: () => <div>planner-page</div>,
}));
vi.mock("../pages/SchedulePage", () => ({
SchedulePage: () => <div>schedule-page</div>,
}));
vi.mock("../pages/SettingsPage", () => ({
SettingsPage: () => <div>settings-page</div>,
}));
vi.mock("../pages/SharedOverviewPage", () => ({
SharedOverviewPage: () => <div>shared-overview-page</div>,
}));
@@ -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(
<MemoryRouter initialEntries={["/dashboard"]}>
<App />
@@ -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", () => {
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={["/unknown-route"]}>
<App />
</MemoryRouter>
);
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
});
it("popstate closes image lightbox before other modals", () => {
+1
View File
@@ -0,0 +1 @@
declare const global: typeof globalThis;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+29
View File
@@ -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,
+3 -1
View File
@@ -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",