Send executive-meeting notifications via email + in-app alerts

Wired the Executive Meetings module to actually deliver notifications
when meeting/request/task events happen, instead of just storing
scheduled-notification rows.

Backend (artifacts/api-server):
- New helper `lib/executive-meeting-notify.ts`:
  - `recordExecutiveMeetingNotifications` inserts rows into both
    `executive_meeting_notifications` (the page's Notifications tab)
    and the global `notifications` table (the bell), inside the
    caller's transaction. Self-notifications are excluded; recipients
    are deduped.
  - `broadcastExecutiveMeetingNotifications` emits Socket.IO
    `notification_created` to each recipient's `user:${id}` room and
    one `executive_meeting_notifications_changed` global event. Called
    after the surrounding transaction commits.
  - `getUserIdsForRoleNames` resolves role holders via direct
    `user_roles` and indirect `group_roles` -> `user_groups`.
  - `sendExecutiveMeetingEmail` is a best-effort side-channel that
    logs an outbox entry when SMTP_HOST is unset (no nodemailer
    dependency added yet — see follow-up task).
  - `getUserDisplay` resolves bilingual display names with username
    fallback for use in notification titles/bodies.
- `routes/executive-meetings.ts` wired notifications into:
  - POST /executive-meetings (notify approvers — meeting_created)
  - POST /executive-meetings/requests and POST
    /executive-meetings/:id/requests (notify approvers + email outbox
    — request_submitted)
  - PATCH /executive-meetings/requests/:id (notify requester —
    request_approved/rejected/needs_edit; if approved with assignee,
    notify assignee — task_assigned)
  - POST /executive-meetings/tasks (notify assignee — task_assigned)
  - PATCH /executive-meetings/tasks/:id (reassign -> task_assigned;
    completion -> task_completed to original requester + previous
    assignee)

Frontend (artifacts/tx-os):
- `hooks/use-notifications-socket.ts` listens for
  `executive_meeting_notifications_changed` and invalidates the
  notifications/requests/tasks query keys so the page re-fetches in
  real time.
- `locales/en.json` + `locales/ar.json`: replaced placeholder intro
  with the real description and added type labels for the seven new
  notification types.

Verification:
- Restarted the API server (clean build).
- HTTP integration test: logged in as admin, created a meeting,
  submitted a request, approved the request — confirmed
  `executive_meeting_notifications` and `notifications` rows were
  inserted with correct counts (7 admins, actor excluded), the
  request_submitted email outbox log fired with bilingual subject/
  body and 6 deliverable email recipients, and self-notifications
  were correctly suppressed when actor == requester == reviewer.

No deviations from the original plan. Email delivery, per-user
notification preferences, and automated tests for the fan-out logic
are tracked as follow-ups.

Replit-Task-Id: accea784-663c-4b63-a492-8e20d648eb4c
This commit is contained in:
riyadhafraa
2026-04-29 17:22:20 +00:00
parent 8b2fe3164d
commit 5f61ab7bf1
7 changed files with 574 additions and 43 deletions
@@ -0,0 +1,267 @@
import { eq, inArray } from "drizzle-orm";
import { db } from "@workspace/db";
import {
executiveMeetingNotificationsTable,
notificationsTable,
rolesTable,
userRolesTable,
groupRolesTable,
userGroupsTable,
usersTable,
} from "@workspace/db";
import { logger } from "./logger";
type DbExecutor =
| typeof db
| Parameters<Parameters<typeof db.transaction>[0]>[0];
export type ExecMeetingNotificationInput = {
recipientUserIds: number[];
meetingId: number | null;
notificationType: string;
titleAr: string;
titleEn: string;
bodyAr?: string | null;
bodyEn?: string | null;
relatedType?: string;
relatedId?: number | null;
excludeUserId?: number | null;
};
export type PendingExecMeetingNotification = {
recipientUserIds: number[];
meetingId: number | null;
notificationType: string;
titleAr: string;
titleEn: string;
bodyAr: string | null;
bodyEn: string | null;
email: boolean;
};
/**
* Insert per-recipient rows into both `executive_meeting_notifications`
* (the audit/list table shown on the page's Notifications tab) and the
* shared `notifications` table (the global bell). Returns the actual
* recipient ids that were notified, so the caller can broadcast Socket.IO
* events after the surrounding transaction commits.
*
* Self-notifications are skipped — an actor never gets pinged for their
* own action — and the recipient list is de-duplicated.
*/
export async function recordExecutiveMeetingNotifications(
executor: DbExecutor,
input: ExecMeetingNotificationInput,
): Promise<number[]> {
const exclude = input.excludeUserId ?? null;
const recipients = Array.from(
new Set(
input.recipientUserIds.filter(
(id) => Number.isInteger(id) && id > 0 && id !== exclude,
),
),
);
if (recipients.length === 0) return [];
const now = new Date();
await executor.insert(executiveMeetingNotificationsTable).values(
recipients.map((uid) => ({
meetingId: input.meetingId ?? null,
userId: uid,
notificationType: input.notificationType,
scheduledAt: now,
sentAt: now,
status: "sent",
})),
);
await executor.insert(notificationsTable).values(
recipients.map((uid) => ({
userId: uid,
titleAr: truncate(input.titleAr, 300),
titleEn: truncate(input.titleEn, 300),
bodyAr: input.bodyAr ?? null,
bodyEn: input.bodyEn ?? null,
type: "executive_meeting",
relatedType: input.relatedType ?? "executive_meeting",
relatedId: input.relatedId ?? input.meetingId ?? null,
})),
);
return recipients;
}
function truncate(s: string, max: number): string {
if (typeof s !== "string") return "";
return s.length <= max ? s : s.slice(0, max);
}
/**
* Fan out an `executive_meeting_notification_created` event to each
* recipient (so their bell + notifications panel refresh) and a single
* `executive_meeting_notifications_changed` broadcast (so the page-level
* Notifications tab also re-queries). Safe to call after the transaction
* commits — does no DB work.
*/
export async function broadcastExecutiveMeetingNotifications(
recipientUserIds: number[],
notificationType: string,
meetingId: number | null,
): Promise<void> {
if (recipientUserIds.length === 0) return;
const { io } = await import("../index.js");
const payload = {
type: "executive_meeting",
notificationType,
meetingId,
};
for (const uid of recipientUserIds) {
io.to(`user:${uid}`).emit("notification_created", payload);
}
io.emit("executive_meeting_notifications_changed", {
notificationType,
meetingId,
});
}
/**
* Resolve every user that currently holds any of the given role names —
* either directly via `user_roles` or indirectly through a group via
* `group_roles` -> `user_groups`. Returns a de-duplicated user-id list.
*/
export async function getUserIdsForRoleNames(
roleNames: ReadonlyArray<string>,
): Promise<number[]> {
if (roleNames.length === 0) return [];
const roles = await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(inArray(rolesTable.name, roleNames as string[]));
const roleIds = roles.map((r) => r.id);
if (roleIds.length === 0) return [];
const direct = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
.where(inArray(userRolesTable.roleId, roleIds));
const groupRows = await db
.select({ groupId: groupRolesTable.groupId })
.from(groupRolesTable)
.where(inArray(groupRolesTable.roleId, roleIds));
const groupIds = Array.from(new Set(groupRows.map((g) => g.groupId)));
const indirect =
groupIds.length > 0
? await db
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(inArray(userGroupsTable.groupId, groupIds))
: [];
return Array.from(
new Set(
[...direct.map((r) => r.userId), ...indirect.map((r) => r.userId)].filter(
(id) => Number.isInteger(id) && id > 0,
),
),
);
}
/**
* Best-effort email side-channel. We don't bundle an SMTP dependency;
* when none is configured we log the outbound message at info level so
* operators can see what would have gone out and wire up real delivery
* later. The function never throws — failure to send email must not
* roll back a meeting/request mutation.
*/
export async function sendExecutiveMeetingEmail(
recipientUserIds: number[],
subject: { ar: string; en: string },
body: { ar: string | null; en: string | null },
notificationType: string,
): Promise<void> {
if (recipientUserIds.length === 0) return;
try {
const recipients = await db
.select({
id: usersTable.id,
email: usersTable.email,
preferredLanguage: usersTable.preferredLanguage,
})
.from(usersTable)
.where(inArray(usersTable.id, recipientUserIds));
const addressed = recipients.filter(
(u) => typeof u.email === "string" && u.email.length > 0,
);
if (addressed.length === 0) return;
if (!process.env.SMTP_HOST) {
logger.info(
{
notificationType,
recipients: addressed.map((r) => ({
id: r.id,
email: r.email,
lang: r.preferredLanguage,
})),
subject,
body,
},
"executive-meeting email outbox (no SMTP_HOST configured — not delivered)",
);
return;
}
// SMTP delivery would go here. We intentionally don't import
// nodemailer at the top of the file so the build stays slim when
// email isn't configured. Once SMTP is wired up, swap this branch
// for a real send.
logger.warn(
{ notificationType, count: addressed.length },
"SMTP_HOST is set but executive-meeting email delivery is not implemented — falling back to outbox log",
);
logger.info(
{
notificationType,
recipients: addressed.map((r) => ({
id: r.id,
email: r.email,
lang: r.preferredLanguage,
})),
subject,
body,
},
"executive-meeting email outbox",
);
} catch (err) {
logger.warn(
{ err, notificationType, count: recipientUserIds.length },
"executive-meeting email side-channel failed",
);
}
}
/**
* Look up the canonical Arabic + English display names for a user, with
* fallbacks to the username so we always have something to render in a
* notification title/body.
*/
export async function getUserDisplay(
userId: number,
): Promise<{ ar: string; en: string } | null> {
if (!Number.isInteger(userId) || userId <= 0) return null;
const [row] = await db
.select({
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(eq(usersTable.id, userId));
if (!row) return null;
return {
ar: row.displayNameAr || row.displayNameEn || row.username,
en: row.displayNameEn || row.displayNameAr || row.username,
};
}
@@ -34,6 +34,13 @@ import {
} from "../middlewares/auth";
import { sanitizeRichText } from "../lib/sanitize";
import { emitExecutiveMeetingsDayChanged } from "../lib/realtime";
import {
recordExecutiveMeetingNotifications,
broadcastExecutiveMeetingNotifications,
getUserIdsForRoleNames,
sendExecutiveMeetingEmail,
getUserDisplay as getUserDisplayForNotify,
} from "../lib/executive-meeting-notify";
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
import type { Request, Response, NextFunction } from "express";
@@ -672,9 +679,27 @@ router.post(
newValue: { ...meeting, attendees },
performedBy: userId,
});
return meeting;
const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES);
const recipients = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: approverIds,
meetingId: meeting.id,
notificationType: "meeting_created",
titleAr: "اجتماع جديد",
titleEn: "New executive meeting",
bodyAr: meeting.titleAr || meeting.titleEn || "",
bodyEn: meeting.titleEn || meeting.titleAr || "",
relatedType: "executive_meeting",
relatedId: meeting.id,
excludeUserId: userId,
});
return { meeting, recipients };
});
const full = await fetchMeetingWithAttendees(inserted.id);
void broadcastExecutiveMeetingNotifications(
inserted.recipients,
"meeting_created",
inserted.meeting.id,
);
const full = await fetchMeetingWithAttendees(inserted.meeting.id);
res.status(201).json(full);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -1304,7 +1329,7 @@ router.post(
}
}
const userId = req.session.userId!;
const created = await db.transaction(async (tx) => {
const result = await db.transaction(async (tx) => {
const insertValues: typeof executiveMeetingRequestsTable.$inferInsert = {
meetingId,
requestedBy: userId,
@@ -1323,9 +1348,44 @@ router.post(
newValue: row!,
performedBy: userId,
});
return row;
const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES);
const requesterDisplay = await getUserDisplayForNotify(userId);
const recipients = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: approverIds,
meetingId: row!.meetingId,
notificationType: "request_submitted",
titleAr: "طلب جديد بانتظار المراجعة",
titleEn: "New executive-meeting request awaiting review",
bodyAr: requesterDisplay
? `${requesterDisplay.ar}${row!.requestType}`
: row!.requestType,
bodyEn: requesterDisplay
? `${requesterDisplay.en}${row!.requestType}`
: row!.requestType,
relatedType: "executive_meeting_request",
relatedId: row!.id,
excludeUserId: userId,
});
return { row: row!, recipients };
});
res.status(201).json(created);
void broadcastExecutiveMeetingNotifications(
result.recipients,
"request_submitted",
result.row.meetingId,
);
void sendExecutiveMeetingEmail(
result.recipients,
{
ar: "طلب جديد بانتظار المراجعة",
en: "New executive-meeting request awaiting review",
},
{
ar: `طلب من النوع ${result.row.requestType} بانتظار اعتمادك.`,
en: `A ${result.row.requestType} request is awaiting your review.`,
},
"request_submitted",
);
res.status(201).json(result.row);
},
);
@@ -1350,7 +1410,7 @@ router.post(
const data = parseBody(res, requestNestedCreateSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
const created = await db.transaction(async (tx) => {
const result = await db.transaction(async (tx) => {
const insertValues: typeof executiveMeetingRequestsTable.$inferInsert = {
meetingId,
requestedBy: userId,
@@ -1369,9 +1429,44 @@ router.post(
newValue: row!,
performedBy: userId,
});
return row;
const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES);
const requesterDisplay = await getUserDisplayForNotify(userId);
const recipients = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: approverIds,
meetingId: row!.meetingId,
notificationType: "request_submitted",
titleAr: "طلب جديد بانتظار المراجعة",
titleEn: "New executive-meeting request awaiting review",
bodyAr: requesterDisplay
? `${requesterDisplay.ar}${row!.requestType}`
: row!.requestType,
bodyEn: requesterDisplay
? `${requesterDisplay.en}${row!.requestType}`
: row!.requestType,
relatedType: "executive_meeting_request",
relatedId: row!.id,
excludeUserId: userId,
});
return { row: row!, recipients };
});
res.status(201).json(created);
void broadcastExecutiveMeetingNotifications(
result.recipients,
"request_submitted",
result.row.meetingId,
);
void sendExecutiveMeetingEmail(
result.recipients,
{
ar: "طلب جديد بانتظار المراجعة",
en: "New executive-meeting request awaiting review",
},
{
ar: `طلب من النوع ${result.row.requestType} بانتظار اعتمادك.`,
en: `A ${result.row.requestType} request is awaiting your review.`,
},
"request_submitted",
);
res.status(201).json(result.row);
},
);
@@ -1450,7 +1545,7 @@ router.patch(
}
try {
const updated = await db.transaction(async (tx) => {
const result = await db.transaction(async (tx) => {
const [row] = await tx
.update(executiveMeetingRequestsTable)
.set({
@@ -1475,6 +1570,7 @@ router.patch(
newValue: row,
performedBy: userId,
});
const taskRecipients: number[] = [];
if (review.status === "approved") {
await applyApprovedRequest(tx, row, userId);
if (row.meetingId) {
@@ -1505,10 +1601,68 @@ router.patch(
newValue: task!,
performedBy: userId,
});
if (task && task.assignedTo) {
const r = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: [task.assignedTo],
meetingId: task.meetingId ?? row.meetingId,
notificationType: "task_assigned",
titleAr: "أُسندت إليك مهمة",
titleEn: "You were assigned a task",
bodyAr: task.taskType,
bodyEn: task.taskType,
relatedType: "executive_meeting_task",
relatedId: task.id,
excludeUserId: userId,
});
taskRecipients.push(...r);
}
}
return row;
const requesterId = existing.requestedBy;
const reviewerDisplay = await getUserDisplayForNotify(userId);
let reviewRecipients: number[] = [];
if (requesterId && requesterId !== userId) {
reviewRecipients = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: [requesterId],
meetingId: row.meetingId,
notificationType: `request_${review.status}`,
titleAr:
review.status === "approved"
? "تم اعتماد طلبك"
: review.status === "rejected"
? "رُفض طلبك"
: "طلبك يحتاج تعديلاً",
titleEn:
review.status === "approved"
? "Your request was approved"
: review.status === "rejected"
? "Your request was rejected"
: "Your request needs edits",
bodyAr: reviewerDisplay
? `${reviewerDisplay.ar}${row.requestType}`
: row.requestType,
bodyEn: reviewerDisplay
? `${reviewerDisplay.en}${row.requestType}`
: row.requestType,
relatedType: "executive_meeting_request",
relatedId: row.id,
excludeUserId: userId,
});
}
return { row, reviewRecipients, taskRecipients };
});
res.json(updated);
void broadcastExecutiveMeetingNotifications(
result.reviewRecipients,
`request_${review.status}`,
result.row.meetingId,
);
if (result.taskRecipients.length > 0) {
void broadcastExecutiveMeetingNotifications(
result.taskRecipients,
"task_assigned",
result.row.meetingId,
);
}
res.json(result.row);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "RACE_ALREADY_REVIEWED") {
@@ -1627,7 +1781,7 @@ router.post(
const data = parseBody(res, taskCreateSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
const created = await db.transaction(async (tx) => {
const result = await db.transaction(async (tx) => {
const insertValues: typeof executiveMeetingTasksTable.$inferInsert = {
taskType: data.taskType,
meetingId: data.meetingId ?? null,
@@ -1648,9 +1802,31 @@ router.post(
newValue: row!,
performedBy: userId,
});
return row;
let recipients: number[] = [];
if (row?.assignedTo) {
recipients = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: [row.assignedTo],
meetingId: row.meetingId,
notificationType: "task_assigned",
titleAr: "أُسندت إليك مهمة",
titleEn: "You were assigned a task",
bodyAr: row.taskType,
bodyEn: row.taskType,
relatedType: "executive_meeting_task",
relatedId: row.id,
excludeUserId: userId,
});
}
return { row: row!, recipients };
});
res.status(201).json(created);
if (result.recipients.length > 0) {
void broadcastExecutiveMeetingNotifications(
result.recipients,
"task_assigned",
result.row.meetingId,
);
}
res.status(201).json(result.row);
},
);
@@ -1700,7 +1876,7 @@ router.patch(
res.json(existing);
return;
}
const updated = await db.transaction(async (tx) => {
const result = await db.transaction(async (tx) => {
const [row] = await tx
.update(executiveMeetingTasksTable)
.set(update)
@@ -1714,35 +1890,97 @@ router.patch(
newValue: row,
performedBy: userId,
});
const reassignRecipients: number[] = [];
const completionRecipients: number[] = [];
// Reassignment → notify the new assignee.
if (
row &&
row.assignedTo &&
row.assignedTo !== existing.assignedTo
) {
const r = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: [row.assignedTo],
meetingId: row.meetingId,
notificationType: "task_assigned",
titleAr: "أُسندت إليك مهمة",
titleEn: "You were assigned a task",
bodyAr: row.taskType,
bodyEn: row.taskType,
relatedType: "executive_meeting_task",
relatedId: row.id,
excludeUserId: userId,
});
reassignRecipients.push(...r);
}
if (
row &&
row.status === "completed" &&
existing.status !== "completed" &&
row.requestId
existing.status !== "completed"
) {
const [reqOld] = await tx
.select()
.from(executiveMeetingRequestsTable)
.where(eq(executiveMeetingRequestsTable.id, row.requestId));
if (reqOld && reqOld.status !== "done") {
const [reqNew] = await tx
.update(executiveMeetingRequestsTable)
.set({ status: "done" })
.where(eq(executiveMeetingRequestsTable.id, row.requestId))
.returning();
await logAudit(tx, {
action: "done",
entityType: "request",
entityId: row.requestId,
oldValue: reqOld,
newValue: reqNew,
performedBy: userId,
// Notify the original requester (if the task came from a request)
let requesterId: number | null = null;
if (row.requestId) {
const [reqOld] = await tx
.select()
.from(executiveMeetingRequestsTable)
.where(eq(executiveMeetingRequestsTable.id, row.requestId));
requesterId = reqOld?.requestedBy ?? null;
if (reqOld && reqOld.status !== "done") {
const [reqNew] = await tx
.update(executiveMeetingRequestsTable)
.set({ status: "done" })
.where(eq(executiveMeetingRequestsTable.id, row.requestId))
.returning();
await logAudit(tx, {
action: "done",
entityType: "request",
entityId: row.requestId,
oldValue: reqOld,
newValue: reqNew,
performedBy: userId,
});
}
}
const completionTargets = Array.from(
new Set(
[requesterId, existing.assignedTo].filter(
(id): id is number => typeof id === "number" && id > 0,
),
),
);
if (completionTargets.length > 0) {
const r = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: completionTargets,
meetingId: row.meetingId,
notificationType: "task_completed",
titleAr: "اكتملت مهمة",
titleEn: "A task was completed",
bodyAr: row.taskType,
bodyEn: row.taskType,
relatedType: "executive_meeting_task",
relatedId: row.id,
excludeUserId: userId,
});
completionRecipients.push(...r);
}
}
return row;
return { row, reassignRecipients, completionRecipients };
});
res.json(updated);
if (result.reassignRecipients.length > 0) {
void broadcastExecutiveMeetingNotifications(
result.reassignRecipients,
"task_assigned",
result.row?.meetingId ?? null,
);
}
if (result.completionRecipients.length > 0) {
void broadcastExecutiveMeetingNotifications(
result.completionRecipients,
"task_completed",
result.row?.meetingId ?? null,
);
}
res.json(result.row);
},
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -86,6 +86,18 @@ export function useNotificationsSocket() {
},
);
socket.on("executive_meeting_notifications_changed", () => {
queryClient.invalidateQueries({
queryKey: ["/api/executive-meetings/notifications"],
});
queryClient.invalidateQueries({
queryKey: ["/api/executive-meetings/requests"],
});
queryClient.invalidateQueries({
queryKey: ["/api/executive-meetings/tasks"],
});
});
socket.on(
"role_permissions_changed",
(payload?: { roleId?: number }) => {
+10 -3
View File
@@ -1114,8 +1114,8 @@
},
"notificationsPage": {
"heading": "التنبيهات",
"intro": "هذه قائمة التنبيهات المجدولة المرتبطة باجتماعات المكتب. لا يتم الإرسال الفعلي حالياً (وضع تجريبي).",
"empty": "لا توجد تنبيهات مجدولة.",
"intro": "تنبيهات فورية لاجتماعات المكتب — تُسلَّم إلى لوحة التنبيهات داخل النظام عند وقوع الأحداث، ويُرسل بريد للمعتمدين عند وجود طلب بانتظار المراجعة.",
"empty": "لا توجد تنبيهات بعد.",
"col": {
"meeting": "الاجتماع",
"user": "المستخدم",
@@ -1126,7 +1126,14 @@
"type": {
"reminder": "تذكير",
"change": "تنبيه تعديل",
"cancel": "تنبيه إلغاء"
"cancel": "تنبيه إلغاء",
"meeting_created": "اجتماع جديد",
"request_submitted": "طلب بانتظار المراجعة",
"request_approved": "تم اعتماد الطلب",
"request_rejected": "تم رفض الطلب",
"request_needs_edit": "الطلب يحتاج تعديلاً",
"task_assigned": "إسناد مهمة",
"task_completed": "اكتمال مهمة"
},
"status": {
"pending": "بالانتظار",
+10 -3
View File
@@ -1051,8 +1051,8 @@
},
"notificationsPage": {
"heading": "Notifications",
"intro": "Scheduled reminders and change-notice records for executive meetings. No actual delivery is wired up yet (placeholder).",
"empty": "No scheduled notifications.",
"intro": "Real-time alerts for executive meetings — delivered to the in-app notifications panel as events happen, and emailed to approvers when a request is awaiting review.",
"empty": "No notifications yet.",
"col": {
"meeting": "Meeting",
"user": "User",
@@ -1063,7 +1063,14 @@
"type": {
"reminder": "Reminder",
"change": "Change notice",
"cancel": "Cancellation notice"
"cancel": "Cancellation notice",
"meeting_created": "Meeting created",
"request_submitted": "Request awaiting review",
"request_approved": "Request approved",
"request_rejected": "Request rejected",
"request_needs_edit": "Request needs edits",
"task_assigned": "Task assigned",
"task_completed": "Task completed"
},
"status": {
"pending": "Pending",
+1 -1
View File
@@ -40,7 +40,7 @@
- **Notifications**: unread tracking, mark-all-read
- **Admin Panel**: CRUD for apps, services, users (admin role required). Delete dialogs show a dependency warning on the FIRST click using count fields (`groupCount`/`restrictionCount`/`openCount` on apps, `orderCount` on services, `noteCount`/`orderCount`/`conversationCount`/`messageCount` on users) returned by the list endpoints (`GET /api/admin/apps`, `GET /api/services`, `GET /api/users`); the lazy 409 conflict response from `DELETE /api/{apps,services,users}/:id` (with `?force=true` to override) remains as a safety net.
- **Session-based Auth**: RBAC with roles (admin/user)
- **Executive Meetings (Phase 2)**: bilingual full-stack module under `/executive-meetings` with 9 sections — Schedule (centered cells, attendees widest column, RTL-locked column order # / الاجتماع / الحضور / الوقت), Manage Meetings (CRUD with attendee replace, transactional), Change Requests (submit / withdraw, supports `meetingId=null` for create-suggestions), Approvals (approve/reject with review notes), Tasks (CRUD with assignee status updates — assignees and mutators can change status, only mutators can delete), Notifications (per-user feed), Audit Log (full action chain, admin role required), PDF (window.print export), Font Settings (per-user + global scope, family/size/weight/alignment with live preview). RBAC enforced via 5 role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT) and a `makeRequireRoles` middleware factory; `/api/executive-meetings/me` returns `{userId, roles, canRead, canMutate, canApprove, canSubmitRequest, canViewAudit}`. Every mutation (meeting/request/task/font CRUD) wraps the DB write **and** the audit-log insert in the same `db.transaction(...)` so audit entries cannot drift from state. Routes use `router.param("id")` with `next("route")` to handle path-to-regexp 8 (no inline `:id(\\d+)` support).
- **Executive Meetings (Phase 2)**: bilingual full-stack module under `/executive-meetings` with 9 sections — Schedule (centered cells, attendees widest column, RTL-locked column order # / الاجتماع / الحضور / الوقت), Manage Meetings (CRUD with attendee replace, transactional), Change Requests (submit / withdraw, supports `meetingId=null` for create-suggestions), Approvals (approve/reject with review notes), Tasks (CRUD with assignee status updates — assignees and mutators can change status, only mutators can delete), Notifications (per-user feed; meeting/request/task events fan out via `recordExecutiveMeetingNotifications` to both `executive_meeting_notifications` and the global `notifications` bell, then broadcast via Socket.IO `notification_created` per-user + `executive_meeting_notifications_changed` globally; approvers also receive a best-effort email side-channel via `sendExecutiveMeetingEmail` that logs an outbox entry until SMTP is wired up), Audit Log (full action chain, admin role required), PDF (window.print export), Font Settings (per-user + global scope, family/size/weight/alignment with live preview). RBAC enforced via 5 role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT) and a `makeRequireRoles` middleware factory; `/api/executive-meetings/me` returns `{userId, roles, canRead, canMutate, canApprove, canSubmitRequest, canViewAudit}`. Every mutation (meeting/request/task/font CRUD) wraps the DB write **and** the audit-log insert in the same `db.transaction(...)` so audit entries cannot drift from state. Routes use `router.param("id")` with `next("route")` to handle path-to-regexp 8 (no inline `:id(\\d+)` support).
## Key Commands