From 0fe1e4b5622ca781b6cb94a41edd4eea34ca3232 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Wed, 29 Apr 2026 17:22:20 +0000 Subject: [PATCH] Send executive-meeting notifications via email + in-app alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/lib/executive-meeting-notify.ts | 267 +++++++++++++++ .../src/routes/executive-meetings.ts | 310 ++++++++++++++++-- artifacts/tx-os/public/opengraph.jpg | Bin 24665 -> 24623 bytes .../src/hooks/use-notifications-socket.ts | 12 + artifacts/tx-os/src/locales/ar.json | 13 +- artifacts/tx-os/src/locales/en.json | 13 +- 6 files changed, 573 insertions(+), 42 deletions(-) create mode 100644 artifacts/api-server/src/lib/executive-meeting-notify.ts 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 56b2c04592bb392c391d16c7cd1a71eb45f957cb..690531f77259cb1a9daf5ea0a324f96b72be36e3 100644 GIT binary patch delta 19011 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 delta 19044 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 { + 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",