diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx
index 53c3681a..79f894b7 100644
--- a/artifacts/tx-os/src/App.tsx
+++ b/artifacts/tx-os/src/App.tsx
@@ -1,4 +1,4 @@
-import { Switch, Route, Router as WouterRouter } from "wouter";
+import { Switch, Route, Redirect, Router as WouterRouter, useLocation } from "wouter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -33,6 +33,14 @@ const queryClient = new QueryClient({
},
});
+function ExecutiveMeetingsRedirect() {
+ const [location] = useLocation();
+ const suffix =
+ typeof window !== "undefined" ? window.location.search + window.location.hash : "";
+ const subPath = location.replace(/^\/executive-meetings/, "");
+ return ;
+}
+
function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
@@ -58,7 +66,14 @@ function Router() {
-
+
+ {/* Back-compat redirect: the page used to live at
+ /executive-meetings. Stored PDFs, email links, and bookmarks
+ still point there, so catch the old path (and any deep
+ sub-path) and forward to /meetings while preserving the
+ user's query string and hash. */}
+
+
diff --git a/artifacts/tx-os/tests/meetings-route-redirect.spec.mjs b/artifacts/tx-os/tests/meetings-route-redirect.spec.mjs
new file mode 100644
index 00000000..e4684508
--- /dev/null
+++ b/artifacts/tx-os/tests/meetings-route-redirect.spec.mjs
@@ -0,0 +1,72 @@
+import { test, expect } from "@playwright/test";
+
+async function loginViaUi(page, username, password) {
+ await page.goto("/login");
+ await page.locator("#username").fill(username);
+ await page.locator("#password").fill(password);
+ await Promise.all([
+ page.waitForURL((url) => !url.pathname.endsWith("/login"), {
+ timeout: 15_000,
+ }),
+ page.locator('form button[type="submit"]').click(),
+ ]);
+}
+
+test("/meetings loads the schedule directly", async ({ page }) => {
+ await page.addInitScript(() => {
+ try {
+ window.localStorage.setItem("tx-lang", "en");
+ } catch {
+ /* ignore */
+ }
+ });
+ await loginViaUi(page, "admin", "admin123");
+ await page.goto("/meetings");
+ await expect(page).toHaveURL(/\/meetings(?:\?|#|$)/);
+ await expect(page.getByTestId("em-edit-mode-toggle")).toBeVisible();
+});
+
+test("/executive-meetings redirects to /meetings (back-compat for old links)", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ try {
+ window.localStorage.setItem("tx-lang", "en");
+ } catch {
+ /* ignore */
+ }
+ });
+ await loginViaUi(page, "admin", "admin123");
+ await page.goto("/executive-meetings?date=2025-01-15#schedule");
+ await expect(page).toHaveURL(/\/meetings\?date=2025-01-15#schedule$/);
+ await expect(page.getByTestId("em-edit-mode-toggle")).toBeVisible();
+});
+
+test("the apps list returns the new /meetings route for the executive-meetings slug", async ({
+ page,
+ request,
+}) => {
+ // Home tile launches via openApp(app) which navigates to app.route
+ // straight from the API. Hitting the apps API directly proves the
+ // seed migration ran and the home grid will follow automatically.
+ await page.addInitScript(() => {
+ try {
+ window.localStorage.setItem("tx-lang", "en");
+ } catch {
+ /* ignore */
+ }
+ });
+ await loginViaUi(page, "admin", "admin123");
+ const cookies = await page.context().cookies();
+ const cookieHeader = cookies
+ .map((c) => `${c.name}=${c.value}`)
+ .join("; ");
+ const res = await request.get("/api/apps", {
+ headers: { Cookie: cookieHeader },
+ });
+ expect(res.ok()).toBeTruthy();
+ const apps = await res.json();
+ const meetings = apps.find((a) => a.slug === "executive-meetings");
+ expect(meetings).toBeTruthy();
+ expect(meetings.route).toBe("/meetings");
+});
diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts
index a919147d..d01d6a10 100644
--- a/scripts/src/seed.ts
+++ b/scripts/src/seed.ts
@@ -218,7 +218,7 @@ async function main() {
descriptionAr: "جدولة اجتماعات المكتب التنفيذي ومتابعتها",
descriptionEn: "Schedule and track executive office meetings",
iconName: "CalendarClock",
- route: "/executive-meetings",
+ route: "/meetings",
color: "#0B1E3F",
isActive: true,
isSystem: false,
@@ -239,7 +239,7 @@ async function main() {
notes: "/notes",
"my-orders": "/my-orders",
"orders-incoming": "/orders/incoming",
- "executive-meetings": "/executive-meetings",
+ "executive-meetings": "/meetings",
calendar: "/calendar",
documents: "/documents",
};
@@ -258,6 +258,19 @@ async function main() {
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
console.log("Apps created");
+ // Force the executive-meetings row onto the canonical /meetings route.
+ // The slug is in BUILTIN_APP_SLUGS, so the SPA owns its route — any
+ // drift in the DB (including legacy /executive-meetings or other
+ // experimental values) would launch the home tile at a path that
+ // has no React route and silently break the app. `onConflictDoNothing`
+ // above skips updating existing rows, so this UPDATE is what carries
+ // the route migration into already-deployed environments. The WHERE
+ // by slug keeps it idempotent across re-seeds.
+ await db
+ .update(appsTable)
+ .set({ route: "/meetings" })
+ .where(eq(appsTable.slug, "executive-meetings"));
+
// Restrict admin app to users with apps:manage permission
const allPerms = await db.select().from(permissionsTable);
const appsManagePerm = allPerms.find((p) => p.name === "apps:manage");