diff --git a/artifacts/api-server/src/lib/executive-meeting-notify.ts b/artifacts/api-server/src/lib/executive-meeting-notify.ts new file mode 100644 index 00000000..7a26376f --- /dev/null +++ b/artifacts/api-server/src/lib/executive-meeting-notify.ts @@ -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[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 { + 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 { + 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, +): Promise { + 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 { + 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, + }; +} diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 09ccda3c..95026cda 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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); }, ); diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 56b2c045..690531f7 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index ce448522..3cd863d9 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -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 }) => { diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index c25f3e0b..34f88021 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "بالانتظار", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 6f2598c0..ea618308 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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",