Files
TX/artifacts/api-server/src/lib/executive-meeting-notify.ts
T
Riyadh b54d4e35d9 Send executive-meeting notification emails for real (SMTP delivery)
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).
2026-04-30 18:29:43 +00:00

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,
};
}