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.
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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user