b54d4e35d9
Original task #163: replace the no-op outbox-log behaviour in `sendExecutiveMeetingEmail` with real SMTP delivery so approvers actually get pinged in their inbox when an executive-meeting request is awaiting review. Implementation - Added `nodemailer` (and `@types/nodemailer`) as a dependency of `@workspace/api-server`. nodemailer was already in the build's external list, so the bundle stays slim and resolves it at runtime. - Rewrote `sendExecutiveMeetingEmail` in `artifacts/api-server/src/lib/executive-meeting-notify.ts`: - Lazily builds a cached nodemailer transporter from `SMTP_HOST` / `SMTP_PORT` (default 587) / `SMTP_USER` / `SMTP_PASS`, with optional `SMTP_SECURE` and `SMTP_FROM`. Cache is keyed on a config signature, so changing env vars in tests / hot reloads naturally rebuilds the transporter. - When `SMTP_HOST` is unset the previous outbox-style log is kept verbatim as a fallback. Once `SMTP_HOST` is set the fallback branch is no longer reached. - Picks subject/body in the recipient's preferred language (Arabic vs English) with a sensible fallback. - Sends one mail per addressed recipient in parallel; per-recipient failures are caught and logged at warn level. The outer try/catch keeps the function from ever throwing into the transaction caller. SMTP `rejected` arrays are also logged at warn so bounces are visible. Code-review comment follow-ups (applied in this commit) - Transport cache signature now hashes `SMTP_PASS` (sha256, first 16 hex chars) instead of just "***", so rotating to a new password actually rebuilds the cached transporter without leaking plaintext. - Fallback log message now distinguishes "no SMTP_HOST configured" from "SMTP misconfigured" so operators can tell why a delivery was skipped. Verification - Typecheck passes for the modified file (other unrelated pre-existing typecheck failures remain). - `pnpm --filter @workspace/api-server run build` succeeds. - API server boots cleanly with the new dependency. Deviations / scope - No automated tests added; the existing test harness is integration- style and the project already tracks "Add automated tests for the notification fan-out logic" as a separate task. - Persisting per-recipient delivery state into the `executive_meeting_notifications` audit table and a startup `transporter.verify()` were intentionally deferred and proposed as follow-ups (#232 and #233).
388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { eq, inArray } from "drizzle-orm";
|
|
import nodemailer, { type Transporter } from "nodemailer";
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lazily-built nodemailer transporter, cached for the life of the
|
|
* process. We rebuild it if the relevant SMTP_* env vars change so
|
|
* tests / hot-config tweaks don't need a server restart.
|
|
*/
|
|
type CachedTransporter = {
|
|
signature: string;
|
|
transporter: Transporter;
|
|
from: string;
|
|
};
|
|
let cachedTransporter: CachedTransporter | null = null;
|
|
|
|
function smtpConfigSignature(): string {
|
|
const passFingerprint = process.env.SMTP_PASS
|
|
? createHash("sha256")
|
|
.update(process.env.SMTP_PASS)
|
|
.digest("hex")
|
|
.slice(0, 16)
|
|
: "";
|
|
return [
|
|
process.env.SMTP_HOST ?? "",
|
|
process.env.SMTP_PORT ?? "",
|
|
process.env.SMTP_USER ?? "",
|
|
passFingerprint,
|
|
process.env.SMTP_SECURE ?? "",
|
|
process.env.SMTP_FROM ?? "",
|
|
].join("|");
|
|
}
|
|
|
|
function getTransporter(): CachedTransporter | null {
|
|
const host = process.env.SMTP_HOST;
|
|
if (!host) return null;
|
|
const signature = smtpConfigSignature();
|
|
if (cachedTransporter && cachedTransporter.signature === signature) {
|
|
return cachedTransporter;
|
|
}
|
|
const portRaw = process.env.SMTP_PORT;
|
|
const port = portRaw ? Number(portRaw) : 587;
|
|
if (!Number.isFinite(port) || port <= 0) {
|
|
logger.warn(
|
|
{ portRaw },
|
|
"SMTP_PORT is not a valid number — refusing to build SMTP transporter",
|
|
);
|
|
return null;
|
|
}
|
|
const secureRaw = (process.env.SMTP_SECURE ?? "").toLowerCase();
|
|
const secure =
|
|
secureRaw === "true" || secureRaw === "1" || secureRaw === "yes"
|
|
? true
|
|
: secureRaw === "false" || secureRaw === "0" || secureRaw === "no"
|
|
? false
|
|
: port === 465;
|
|
const user = process.env.SMTP_USER;
|
|
const pass = process.env.SMTP_PASS;
|
|
const transporter = nodemailer.createTransport({
|
|
host,
|
|
port,
|
|
secure,
|
|
auth: user && pass ? { user, pass } : undefined,
|
|
});
|
|
const from =
|
|
process.env.SMTP_FROM && process.env.SMTP_FROM.length > 0
|
|
? process.env.SMTP_FROM
|
|
: user && user.length > 0
|
|
? user
|
|
: "no-reply@localhost";
|
|
cachedTransporter = { signature, transporter, from };
|
|
return cachedTransporter;
|
|
}
|
|
|
|
function pickLang(
|
|
lang: string | null | undefined,
|
|
values: { ar: string | null; en: string | null },
|
|
): string {
|
|
const isArabic =
|
|
typeof lang === "string" && lang.toLowerCase().startsWith("ar");
|
|
const primary = isArabic ? values.ar : values.en;
|
|
const fallback = isArabic ? values.en : values.ar;
|
|
return (primary ?? fallback ?? "").trim();
|
|
}
|
|
|
|
/**
|
|
* Best-effort email side-channel. When SMTP is configured we deliver
|
|
* via nodemailer; otherwise we fall back to logging the outbound
|
|
* message at info level so operators can see what would have gone
|
|
* out. 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;
|
|
|
|
const cached = getTransporter();
|
|
if (!cached) {
|
|
const reason = process.env.SMTP_HOST
|
|
? "SMTP misconfigured — not delivered"
|
|
: "no SMTP_HOST configured — not delivered";
|
|
logger.info(
|
|
{
|
|
notificationType,
|
|
recipients: addressed.map((r) => ({
|
|
id: r.id,
|
|
email: r.email,
|
|
lang: r.preferredLanguage,
|
|
})),
|
|
subject,
|
|
body,
|
|
},
|
|
`executive-meeting email outbox (${reason})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { transporter, from } = cached;
|
|
await Promise.all(
|
|
addressed.map(async (r) => {
|
|
const langSubject = pickLang(r.preferredLanguage, {
|
|
ar: subject.ar,
|
|
en: subject.en,
|
|
});
|
|
const langBody = pickLang(r.preferredLanguage, body);
|
|
try {
|
|
const info = await transporter.sendMail({
|
|
from,
|
|
to: r.email!,
|
|
subject: langSubject,
|
|
text: langBody,
|
|
});
|
|
const accepted = Array.isArray(info.accepted) ? info.accepted : [];
|
|
const rejected = Array.isArray(info.rejected) ? info.rejected : [];
|
|
if (rejected.length > 0) {
|
|
logger.warn(
|
|
{
|
|
notificationType,
|
|
recipientId: r.id,
|
|
email: r.email,
|
|
rejected,
|
|
response: info.response,
|
|
messageId: info.messageId,
|
|
},
|
|
"executive-meeting email rejected by SMTP server",
|
|
);
|
|
} else {
|
|
logger.info(
|
|
{
|
|
notificationType,
|
|
recipientId: r.id,
|
|
email: r.email,
|
|
accepted,
|
|
messageId: info.messageId,
|
|
},
|
|
"executive-meeting email sent",
|
|
);
|
|
}
|
|
} catch (err) {
|
|
logger.warn(
|
|
{
|
|
err,
|
|
notificationType,
|
|
recipientId: r.id,
|
|
email: r.email,
|
|
},
|
|
"executive-meeting email send failed",
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
} 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,
|
|
};
|
|
}
|