Task #521: shorten meetings page URL to /meetings

- artifacts/tx-os/src/App.tsx: serve the schedule at /meetings; add an
  ExecutiveMeetingsRedirect that rewrites /executive-meetings (and any
  /executive-meetings/* sub-path) to /meetings while preserving the
  query string and hash, so existing PDFs, emails, and bookmarks keep
  working with one transparent hop.
- scripts/src/seed.ts: seed the apps row with route="/meetings" and
  update the expectedBuiltinRoutes drift guard to match. Add an
  idempotent UPDATE-by-slug after the apps insert so already-deployed
  rows (including any drift like /executive-meetings or /mms) get
  reconciled to /meetings on the next seed — safe because the slug is
  in BUILTIN_APP_SLUGS, meaning the SPA owns the route.
- artifacts/tx-os/tests/meetings-route-redirect.spec.mjs: new spec
  verifying that /meetings loads, /executive-meetings?date=...#... is
  redirected with query+hash intact, and /api/apps now reports
  route="/meetings" for the executive-meetings slug.

Out of scope (intentionally left untouched per task): API paths under
/api/executive-meetings/*, React Query keys, DB tables, the apps slug
"executive-meetings", page/component file names, the "Meetings" display
name, and the executive_meetings:* permission name.
This commit is contained in:
riyadhafraa
2026-05-13 07:06:14 +00:00
parent bfbf59cb20
commit d2b4f82736
3 changed files with 104 additions and 4 deletions
+17 -2
View File
@@ -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 <Redirect to={`/meetings${subPath}${suffix}`} replace />;
}
function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
@@ -58,7 +66,14 @@ function Router() {
<Route path="/notes" component={NotesPage} />
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/executive-meetings" component={ExecutiveMeetingsPage} />
<Route path="/meetings" component={ExecutiveMeetingsPage} />
{/* 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. */}
<Route path="/executive-meetings" component={ExecutiveMeetingsRedirect} />
<Route path="/executive-meetings/:rest*" component={ExecutiveMeetingsRedirect} />
<Route path="/embedded/:id" component={EmbeddedAppPage} />
<Route path="/" component={HomePage} />
<Route component={NotFound} />
@@ -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");
});
+15 -2
View File
@@ -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");