chore: streamline root validation and app loading (#635)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const global: typeof globalThis;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user