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:
@@ -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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
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() {
|
function NotificationsSocketBridge() {
|
||||||
useNotificationsSocket();
|
useNotificationsSocket();
|
||||||
useAudioUnlock();
|
useAudioUnlock();
|
||||||
@@ -58,7 +66,14 @@ function Router() {
|
|||||||
<Route path="/notes" component={NotesPage} />
|
<Route path="/notes" component={NotesPage} />
|
||||||
<Route path="/my-orders" component={MyOrdersPage} />
|
<Route path="/my-orders" component={MyOrdersPage} />
|
||||||
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
<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="/embedded/:id" component={EmbeddedAppPage} />
|
||||||
<Route path="/" component={HomePage} />
|
<Route path="/" component={HomePage} />
|
||||||
<Route component={NotFound} />
|
<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
@@ -218,7 +218,7 @@ async function main() {
|
|||||||
descriptionAr: "جدولة اجتماعات المكتب التنفيذي ومتابعتها",
|
descriptionAr: "جدولة اجتماعات المكتب التنفيذي ومتابعتها",
|
||||||
descriptionEn: "Schedule and track executive office meetings",
|
descriptionEn: "Schedule and track executive office meetings",
|
||||||
iconName: "CalendarClock",
|
iconName: "CalendarClock",
|
||||||
route: "/executive-meetings",
|
route: "/meetings",
|
||||||
color: "#0B1E3F",
|
color: "#0B1E3F",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
@@ -239,7 +239,7 @@ async function main() {
|
|||||||
notes: "/notes",
|
notes: "/notes",
|
||||||
"my-orders": "/my-orders",
|
"my-orders": "/my-orders",
|
||||||
"orders-incoming": "/orders/incoming",
|
"orders-incoming": "/orders/incoming",
|
||||||
"executive-meetings": "/executive-meetings",
|
"executive-meetings": "/meetings",
|
||||||
calendar: "/calendar",
|
calendar: "/calendar",
|
||||||
documents: "/documents",
|
documents: "/documents",
|
||||||
};
|
};
|
||||||
@@ -258,6 +258,19 @@ async function main() {
|
|||||||
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
|
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
|
||||||
console.log("Apps created");
|
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
|
// Restrict admin app to users with apps:manage permission
|
||||||
const allPerms = await db.select().from(permissionsTable);
|
const allPerms = await db.select().from(permissionsTable);
|
||||||
const appsManagePerm = allPerms.find((p) => p.name === "apps:manage");
|
const appsManagePerm = allPerms.find((p) => p.name === "apps:manage");
|
||||||
|
|||||||
Reference in New Issue
Block a user