0b7b571a1e
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
attendees column widest, tighter padding, navy/white/gray + red highlight only.
- Header label "م" → "#" (ar.json).
Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
requests work without an existing meeting. db push applied.
Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full rewrite:
- All routes (including /me) gated by requireExecutiveAccess. Fine-grained
middleware: requireMutate / requireApprove / requireRequest /
requireAdminAudit on the appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
{status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
{action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired directly
to the same shared upsertFontSettingsHandler (no method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- Removed all `as never` casts via $inferInsert/Partial typing.
Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES
(admin + executive_*).
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Notifications visibility now uses canRead (was canViewAudit).
- Approvals: review() sends {status, reviewNotes} and a new
"Send back for edits" (needs_edit) button is rendered alongside
Approve/Reject.
- PdfSection: primary "Print + Archive" button (testid em-print) does
archive then print in one click; em-print-only and em-archive remain.
- AuditSection: 6th column renders the new in-file AuditDiffSummary
component (compact "field: old → new" diff with truncation).
- Print page (executive-meetings-print.tsx): kept its inline bilingual T
table — it loads in a standalone print window before the i18n provider
mounts, so an inline dictionary is the right pattern. No hardcoded UI
text remains in the main page.
i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
.audit.col.changes, .audit.{created, removed, moreChanges},
.audit.entity.pdf_archive,
.audit.action.{approved, rejected, needs_edit, withdraw, apply,
duplicate, replace_attendees, done},
.pdf.{print = "Print + archive"/"طباعة وأرشفة", printOnly,
archivedAndPrinted}.
Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201,
nested POST /:id/requests 201, PATCH /requests/:id status approval 200,
audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
200 with persisted data.
- Architect re-reviewed twice: VERDICT: APPROVED on the second pass
after fixing /me gating and the PATCH font-settings dispatch.
Out of scope (handled in follow-ups #110/#111/#112): real notifications
delivery, server-side PDF rendering, mobile polish.
Drift: none material. Followed plan T1–T8.
242 lines
6.2 KiB
TypeScript
242 lines
6.2 KiB
TypeScript
import { type Request, type Response, type NextFunction } from "express";
|
||
import { db } from "@workspace/db";
|
||
import {
|
||
usersTable,
|
||
userRolesTable,
|
||
rolesTable,
|
||
rolePermissionsTable,
|
||
permissionsTable,
|
||
groupRolesTable,
|
||
userGroupsTable,
|
||
} from "@workspace/db";
|
||
import { eq, and, inArray, or } from "drizzle-orm";
|
||
|
||
/**
|
||
* Effective roles for a user = direct user_roles ∪ roles attached to any of
|
||
* the user's groups via group_roles. Returns distinct role names.
|
||
*/
|
||
export async function getEffectiveRoleIds(userId: number): Promise<number[]> {
|
||
const rows = await db
|
||
.select({ roleId: rolesTable.id })
|
||
.from(rolesTable)
|
||
.where(
|
||
or(
|
||
inArray(
|
||
rolesTable.id,
|
||
db
|
||
.select({ rid: userRolesTable.roleId })
|
||
.from(userRolesTable)
|
||
.where(eq(userRolesTable.userId, userId)),
|
||
),
|
||
inArray(
|
||
rolesTable.id,
|
||
db
|
||
.select({ rid: groupRolesTable.roleId })
|
||
.from(groupRolesTable)
|
||
.innerJoin(
|
||
userGroupsTable,
|
||
eq(userGroupsTable.groupId, groupRolesTable.groupId),
|
||
)
|
||
.where(eq(userGroupsTable.userId, userId)),
|
||
),
|
||
),
|
||
);
|
||
return Array.from(new Set(rows.map((r) => r.roleId)));
|
||
}
|
||
|
||
declare module "express-session" {
|
||
interface SessionData {
|
||
userId: number;
|
||
}
|
||
}
|
||
|
||
export async function requireAuth(
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const [user] = await db
|
||
.select()
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
next();
|
||
}
|
||
|
||
export async function requireAdmin(
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const [user] = await db
|
||
.select({ isActive: usersTable.isActive })
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const effectiveIds = await getEffectiveRoleIds(req.session.userId);
|
||
let isAdmin = false;
|
||
if (effectiveIds.length > 0) {
|
||
const adminRows = await db
|
||
.select({ id: rolesTable.id })
|
||
.from(rolesTable)
|
||
.where(and(inArray(rolesTable.id, effectiveIds), eq(rolesTable.name, "admin")));
|
||
isAdmin = adminRows.length > 0;
|
||
}
|
||
|
||
if (!isAdmin) {
|
||
res.status(403).json({ error: "Forbidden" });
|
||
return;
|
||
}
|
||
|
||
next();
|
||
}
|
||
|
||
export function requirePermission(name: string) {
|
||
return async function (
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const [user] = await db
|
||
.select({ isActive: usersTable.isActive })
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const effectiveIds = await getEffectiveRoleIds(req.session.userId);
|
||
if (effectiveIds.length === 0) {
|
||
res.status(403).json({ error: "Forbidden" });
|
||
return;
|
||
}
|
||
const rows = await db
|
||
.select({ permName: permissionsTable.name })
|
||
.from(rolePermissionsTable)
|
||
.innerJoin(
|
||
permissionsTable,
|
||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||
)
|
||
.where(
|
||
and(
|
||
inArray(rolePermissionsTable.roleId, effectiveIds),
|
||
eq(permissionsTable.name, name),
|
||
),
|
||
);
|
||
|
||
if (rows.length === 0) {
|
||
res.status(403).json({ error: "Forbidden" });
|
||
return;
|
||
}
|
||
|
||
next();
|
||
};
|
||
}
|
||
|
||
export async function userHasPermission(
|
||
userId: number,
|
||
name: string,
|
||
): Promise<boolean> {
|
||
const effectiveIds = await getEffectiveRoleIds(userId);
|
||
if (effectiveIds.length === 0) return false;
|
||
const rows = await db
|
||
.select({ id: rolePermissionsTable.permissionId })
|
||
.from(rolePermissionsTable)
|
||
.innerJoin(
|
||
permissionsTable,
|
||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||
)
|
||
.where(
|
||
and(
|
||
inArray(rolePermissionsTable.roleId, effectiveIds),
|
||
eq(permissionsTable.name, name),
|
||
),
|
||
);
|
||
return rows.length > 0;
|
||
}
|
||
|
||
export async function getUserRoles(userId: number): Promise<string[]> {
|
||
const effectiveIds = await getEffectiveRoleIds(userId);
|
||
if (effectiveIds.length === 0) return [];
|
||
const roles = await db
|
||
.select({ roleName: rolesTable.name })
|
||
.from(rolesTable)
|
||
.where(inArray(rolesTable.id, effectiveIds));
|
||
return Array.from(new Set(roles.map((r) => r.roleName)));
|
||
}
|
||
|
||
const EXECUTIVE_READ_ROLES: ReadonlyArray<string> = [
|
||
"admin",
|
||
"executive_ceo",
|
||
"executive_office_manager",
|
||
"executive_coord_lead",
|
||
"executive_coordinator",
|
||
"executive_viewer",
|
||
];
|
||
|
||
/**
|
||
* Gate any read access to the Executive Meetings module. Allows admin and
|
||
* any of the executive_* roles. Mutate / approve / audit-view sub-roles
|
||
* are layered on top via local helpers in the routes file.
|
||
*/
|
||
export async function requireExecutiveAccess(
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||
return;
|
||
}
|
||
const [user] = await db
|
||
.select({ isActive: usersTable.isActive })
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||
return;
|
||
}
|
||
const ids = await getEffectiveRoleIds(req.session.userId);
|
||
if (ids.length === 0) {
|
||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||
return;
|
||
}
|
||
const rows = await db
|
||
.select({ name: rolesTable.name })
|
||
.from(rolesTable)
|
||
.where(inArray(rolesTable.id, ids));
|
||
const names = new Set(rows.map((r) => r.name));
|
||
const ok = EXECUTIVE_READ_ROLES.some((r) => names.has(r));
|
||
if (!ok) {
|
||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|