From 19200140c1781dcfdd1550c34e90f6d339812209 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 07:13:55 +0000 Subject: [PATCH] Task #121: Hide Executive Meetings icon from non-executive users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the home-screen "Executive Meetings" app icon behind a new `executive_meetings.access` permission so only admins and the five executive_* roles see it. Reuses the existing `app_permissions` → `getVisibleAppsForUser` machinery; no route or middleware changes. Changes - scripts/src/seed.ts * Added permission `executive_meetings.access`. * Granted it to: admin, executive_ceo, executive_office_manager, executive_coord_lead, executive_coordinator, executive_viewer. * Linked it to apps.slug = 'executive-meetings' via app_permissions. * All inserts use onConflictDoNothing — re-running the seeder is safe. - artifacts/api-server/scripts/gate-executive-meetings.mjs (new) * One-shot, idempotent pg migration that performs the same three inserts in a single transaction (BEGIN/ROLLBACK on error). Prints JSON summary with grant counts. Already executed against dev DB (newRoleGrantsThisRun=6, newAppLinksThisRun=1). - artifacts/api-server/tests/executive-meetings-visibility.test.mjs (new) * Regression: creates a fresh `order_receiver`-only user, asserts /api/apps does NOT include `executive-meetings`; then grants `executive_coordinator` and asserts it does. Per-run username prefix + defensive sweep + after-hook cleanup. Out of scope (not modified) - artifacts/api-server/src/routes/apps.ts (getVisibleAppsForUser) - artifacts/api-server/src/routes/executive-meetings.ts (requireExecutiveAccess) - Any UI files Verification - `node artifacts/api-server/scripts/gate-executive-meetings.mjs` → ok=true, 6 role grants, 1 app link. - `node --test tests/executive-meetings-visibility.test.mjs` → 2/2 pass. - TypeScript clean for scripts package. - Architect review: PASS, no blocking issues. --- .../scripts/gate-executive-meetings.mjs | 132 ++++++++++++++++++ .../executive-meetings-visibility.test.mjs | 120 ++++++++++++++++ scripts/src/seed.ts | 57 ++++++++ 3 files changed, 309 insertions(+) create mode 100644 artifacts/api-server/scripts/gate-executive-meetings.mjs create mode 100644 artifacts/api-server/tests/executive-meetings-visibility.test.mjs diff --git a/artifacts/api-server/scripts/gate-executive-meetings.mjs b/artifacts/api-server/scripts/gate-executive-meetings.mjs new file mode 100644 index 00000000..c08c19c7 --- /dev/null +++ b/artifacts/api-server/scripts/gate-executive-meetings.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * One-shot, idempotent migration for Task #121. + * + * Adds the `executive_meetings.access` permission, grants it to admin + all + * executive_* roles, and links it to the `executive-meetings` app via + * `app_permissions` so the home-screen icon is hidden from users who don't + * have an executive role. + * + * Safe to run multiple times — every insert uses ON CONFLICT DO NOTHING. + * + * Usage (from repo root): + * DATABASE_URL=... node artifacts/api-server/scripts/gate-executive-meetings.mjs + */ +import pg from "pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error("DATABASE_URL must be set"); + process.exit(1); +} + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +const EXEC_ROLE_NAMES = [ + "admin", + "executive_ceo", + "executive_office_manager", + "executive_coord_lead", + "executive_coordinator", + "executive_viewer", +]; + +async function main() { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) Permission row. + await client.query( + `INSERT INTO permissions (name, description_ar, description_en) + VALUES ($1, $2, $3) + ON CONFLICT (name) DO NOTHING`, + [ + "executive_meetings.access", + "الوصول إلى وحدة الاجتماعات التنفيذية", + "Access the Executive Meetings module", + ], + ); + + const permRes = await client.query( + `SELECT id FROM permissions WHERE name = $1`, + ["executive_meetings.access"], + ); + if (permRes.rowCount === 0) { + throw new Error("Failed to find executive_meetings.access permission"); + } + const permId = permRes.rows[0].id; + + // 2) Role grants. + const grantRes = await client.query( + `INSERT INTO role_permissions (role_id, permission_id) + SELECT r.id, $1 FROM roles r WHERE r.name = ANY($2::text[]) + ON CONFLICT DO NOTHING + RETURNING role_id`, + [permId, EXEC_ROLE_NAMES], + ); + + // 3) App link. + const appRes = await client.query( + `SELECT id FROM apps WHERE slug = $1`, + ["executive-meetings"], + ); + if (appRes.rowCount === 0) { + throw new Error( + "executive-meetings app not found in the apps table; aborting", + ); + } + const appId = appRes.rows[0].id; + const appLinkRes = await client.query( + `INSERT INTO app_permissions (app_id, permission_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + RETURNING app_id`, + [appId, permId], + ); + + await client.query("COMMIT"); + + // Final state for the operator. + const totalGrants = await client.query( + `SELECT COUNT(*)::int AS c + FROM role_permissions rp + JOIN roles r ON r.id = rp.role_id + WHERE rp.permission_id = $1 AND r.name = ANY($2::text[])`, + [permId, EXEC_ROLE_NAMES], + ); + const totalLinks = await client.query( + `SELECT COUNT(*)::int AS c + FROM app_permissions + WHERE permission_id = $1`, + [permId], + ); + + console.log( + JSON.stringify( + { + ok: true, + permissionId: permId, + executiveAppId: appId, + newRoleGrantsThisRun: grantRes.rowCount ?? 0, + newAppLinksThisRun: appLinkRes.rowCount ?? 0, + executiveRoleGrantsTotal: totalGrants.rows[0].c, + appPermissionLinksTotal: totalLinks.rows[0].c, + }, + null, + 2, + ), + ); + } catch (err) { + await client.query("ROLLBACK").catch(() => {}); + throw err; + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/artifacts/api-server/tests/executive-meetings-visibility.test.mjs b/artifacts/api-server/tests/executive-meetings-visibility.test.mjs new file mode 100644 index 00000000..e2112e35 --- /dev/null +++ b/artifacts/api-server/tests/executive-meetings-visibility.test.mjs @@ -0,0 +1,120 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +// Per-run uniqueness: every row this test creates is tagged with this stamp +// so the after-hook can clean up (and a defensive sweep can mop up any +// leftovers from a prior aborted run). +const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; +const USERNAME_PREFIX = `emvis_${STAMP}_`; + +let receiverUserId; +let receiverUsername; +let receiverCookie; + +async function login(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + return setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); +} + +async function listApps(cookie) { + const res = await fetch(`${API_BASE}/api/apps`, { + headers: { cookie }, + }); + assert.equal(res.status, 200, `apps expected 200, got ${res.status}`); + return res.json(); +} + +before(async () => { + // Defensive sweep: kill any leftover users from a previous aborted run + // whose username matches our prefix family. user_roles / user_groups + // cascade via FKs. + await pool.query( + `DELETE FROM users WHERE username LIKE 'emvis\\_%' ESCAPE '\\'`, + ); + + receiverUsername = `${USERNAME_PREFIX}receiver`; + const u = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, + preferred_language, is_active) + VALUES ($1, $2, $3, 'EM Visibility Test', 'en', true) + RETURNING id`, + [receiverUsername, `${receiverUsername}@ex.com`, TEST_PASSWORD_HASH], + ); + receiverUserId = u.rows[0].id; + + // Give them only the 'order_receiver' role — explicitly NOT any + // executive_* role. They should also not be in any admin group. + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = 'order_receiver'`, + [receiverUserId], + ); + + receiverCookie = await login(receiverUsername, TEST_PASSWORD); +}); + +after(async () => { + if (receiverUserId) { + await pool.query( + `DELETE FROM user_roles WHERE user_id = $1`, + [receiverUserId], + ); + await pool.query( + `DELETE FROM user_groups WHERE user_id = $1`, + [receiverUserId], + ); + await pool.query(`DELETE FROM users WHERE id = $1`, [receiverUserId]); + } + await pool.end(); +}); + +test("user with only order_receiver role does NOT see the executive-meetings app", async () => { + const apps = await listApps(receiverCookie); + const slugs = apps.map((a) => a.slug); + assert.ok( + !slugs.includes("executive-meetings"), + `expected executive-meetings NOT in apps for non-executive user; got slugs: ${slugs.join(", ")}`, + ); +}); + +test("after granting executive_coordinator, the same user DOES see executive-meetings", async () => { + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = 'executive_coordinator' + ON CONFLICT DO NOTHING`, + [receiverUserId], + ); + + // Re-login so the new role is reflected in the session's permission cache + // (some setups cache derived permissions on the session object). + receiverCookie = await login(receiverUsername, TEST_PASSWORD); + + const apps = await listApps(receiverCookie); + const slugs = apps.map((a) => a.slug); + assert.ok( + slugs.includes("executive-meetings"), + `expected executive-meetings in apps after granting executive_coordinator; got slugs: ${slugs.join(", ")}`, + ); +}); diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index 56697e71..cd79af24 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -488,6 +488,63 @@ async function main() { await db.insert(rolesTable).values(execRoles).onConflictDoNothing(); console.log("Executive Meetings roles created"); + // Executive meetings: gate the home-screen icon to executive roles only. + // This adds a single permission, grants it to admin + the 5 executive + // roles, and links it to the `executive-meetings` app via app_permissions + // so getVisibleAppsForUser will hide the icon for everyone else. + // All three inserts are idempotent. + await db + .insert(permissionsTable) + .values({ + name: "executive_meetings.access", + descriptionAr: "الوصول إلى وحدة الاجتماعات التنفيذية", + descriptionEn: "Access the Executive Meetings module", + }) + .onConflictDoNothing(); + + const [execAccessPerm] = await db + .select() + .from(permissionsTable) + .where(eq(permissionsTable.name, "executive_meetings.access")); + + if (execAccessPerm) { + const execRoleNames = [ + "admin", + "executive_ceo", + "executive_office_manager", + "executive_coord_lead", + "executive_coordinator", + "executive_viewer", + ]; + const allRolesNow = await db.select().from(rolesTable); + const execRoleRows = allRolesNow.filter((r) => execRoleNames.includes(r.name)); + if (execRoleRows.length > 0) { + await db + .insert(rolePermissionsTable) + .values( + execRoleRows.map((r) => ({ + roleId: r.id, + permissionId: execAccessPerm.id, + })), + ) + .onConflictDoNothing(); + } + + const [execApp] = await db + .select() + .from(appsTable) + .where(eq(appsTable.slug, "executive-meetings")); + if (execApp) { + await db + .insert(appPermissionsTable) + .values({ appId: execApp.id, permissionId: execAccessPerm.id }) + .onConflictDoNothing(); + console.log( + "App permissions set: executive-meetings app restricted to executive_meetings.access permission", + ); + } + } + // Executive meetings: sample data for today (idempotent per-date) const today = new Date().toISOString().slice(0, 10); const existingForToday = await db