Task #121: Hide Executive Meetings icon from non-executive users
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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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(", ")}`,
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user