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:
@@ -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 }) => {
|
||||
|
||||
@@ -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": "بالانتظار",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user