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");