Major UI refresh on the nav-design-improvements branch. Drops 2013-era
inline styles and consolidates patterns behind shared primitives.
Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
the standards-guide blue (#0075AD) with utility classes for numerics
(.num / .num-lg / .num-xl), metric labels, surface tints, and the
wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
"Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page
Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
health table, worker pulse cards (analyzer / RMM / sync scheduler),
token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
integrations (e.g. SentinelOne) — no failure noise from broken-on-
purpose entries. Aliases supported (sentinelone → s1, etc.)
Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
area chart, 30-day mean resolution time line chart, today's active
engineers leaderboard
Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
status, classification, source, company type, publish, active /
yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)
Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
PageHeader rule (consistent across flat links and submenu triggers);
active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config
Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs
DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow
Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below
Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
import * as nodemailer from "nodemailer";
|
|
import type { Gap } from "@/lib/types/analyzer";
|
|
|
|
// SMTP configuration from environment variables
|
|
const smtpConfig = {
|
|
host: process.env.SMTP_HOST || "smtp.example.com",
|
|
port: parseInt(process.env.SMTP_PORT || "587"),
|
|
secure: process.env.SMTP_PORT === "465", // true for 465, false for other ports
|
|
auth: {
|
|
user: process.env.SMTP_USER || "",
|
|
pass: process.env.SMTP_PASSWORD || "",
|
|
},
|
|
};
|
|
|
|
const fromAddress = process.env.SMTP_FROM || "noreply@example.com";
|
|
|
|
// Display-name + address for the From header so recipient mail clients show
|
|
// "Pulse" rather than the bare mailbox.
|
|
const FROM_HEADER = { name: "Pulse", address: fromAddress };
|
|
|
|
// Create reusable transporter
|
|
let transporter: nodemailer.Transporter | null = null;
|
|
|
|
function getTransporter(): nodemailer.Transporter {
|
|
if (!transporter) {
|
|
transporter = nodemailer.createTransport(smtpConfig);
|
|
}
|
|
return transporter;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Magic-link sign-in
|
|
// =============================================================================
|
|
|
|
interface MagicLinkEmailParams {
|
|
email: string;
|
|
url: string;
|
|
token: string;
|
|
}
|
|
|
|
export async function sendMagicLinkEmail({
|
|
email,
|
|
url,
|
|
}: MagicLinkEmailParams): Promise<void> {
|
|
const transport = getTransporter();
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Sign in to Pulse</title>
|
|
</head>
|
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
|
<div style="max-width: 600px; margin: 0 auto;">
|
|
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
|
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
|
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">Operations console</div>
|
|
</div>
|
|
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">Sign in to your account</h2>
|
|
<p style="margin: 0 0 24px; color:#334155;">Click the button below to sign in. This link will expire in 5 minutes.</p>
|
|
<div style="text-align: center; margin: 24px 0;">
|
|
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
|
Sign in to Pulse
|
|
</a>
|
|
</div>
|
|
<p style="color: #64748b; font-size: 13px; margin: 0;">If you didn't request this email, you can safely ignore it.</p>
|
|
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
|
<p style="color: #94a3b8; font-size: 12px; margin:0;">
|
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
|
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const text = `
|
|
Sign in to Pulse
|
|
|
|
Click the link below to sign in to your account. This link will expire in 5 minutes.
|
|
|
|
${url}
|
|
|
|
If you didn't request this email, you can safely ignore it.
|
|
`;
|
|
|
|
await transport.sendMail({
|
|
from: FROM_HEADER,
|
|
to: email,
|
|
subject: "Sign in to Pulse",
|
|
text,
|
|
html,
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Invitation
|
|
// =============================================================================
|
|
|
|
interface InvitationEmailParams {
|
|
email: string;
|
|
inviterName: string;
|
|
url: string;
|
|
}
|
|
|
|
export async function sendInvitationEmail({
|
|
email,
|
|
inviterName,
|
|
url,
|
|
}: InvitationEmailParams): Promise<void> {
|
|
const transport = getTransporter();
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>You're invited to Pulse</title>
|
|
</head>
|
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
|
<div style="max-width: 600px; margin: 0 auto;">
|
|
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
|
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
|
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">Operations console</div>
|
|
</div>
|
|
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">You're invited!</h2>
|
|
<p style="margin: 0 0 12px; color:#334155;"><strong>${escapeHtml(inviterName)}</strong> has invited you to join Pulse.</p>
|
|
<p style="margin: 0 0 24px; color:#334155;">Click the button below to accept the invitation and set up your account.</p>
|
|
<div style="text-align: center; margin: 24px 0;">
|
|
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
|
Accept invitation
|
|
</a>
|
|
</div>
|
|
<p style="color: #64748b; font-size: 13px; margin: 0;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
|
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
|
<p style="color: #94a3b8; font-size: 12px; margin: 0;">
|
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
|
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const text = `
|
|
You're invited to Pulse!
|
|
|
|
${inviterName} has invited you to join Pulse.
|
|
|
|
Click the link below to accept the invitation and set up your account:
|
|
|
|
${url}
|
|
|
|
If you weren't expecting this invitation, you can safely ignore this email.
|
|
`;
|
|
|
|
await transport.sendMail({
|
|
from: FROM_HEADER,
|
|
to: email,
|
|
subject: "You're invited to Pulse",
|
|
text,
|
|
html,
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Analysis share
|
|
// =============================================================================
|
|
|
|
interface AnalysisShareEmailParams {
|
|
recipientEmail: string;
|
|
senderName: string;
|
|
senderEmail: string;
|
|
ticketNumber: string;
|
|
ticketTitle?: string | null;
|
|
analysisVersion: number;
|
|
summary: string | null;
|
|
nextStep: string | null;
|
|
nextStepRationale?: string | null;
|
|
whatWasDone?: string[] | null;
|
|
whatShouldHaveBeenDone?: string[] | null;
|
|
gaps?: Gap[] | null;
|
|
confidenceScore?: number | null;
|
|
modelTier?: "haiku" | "sonnet" | "opus" | null;
|
|
analysisUrl: string;
|
|
note?: string;
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
const GAP_TONES: Record<Gap["severity"], { border: string; bg: string; label: string }> = {
|
|
high: { border: "#ef4444", bg: "#fef2f2", label: "HIGH" },
|
|
medium: { border: "#f59e0b", bg: "#fffbeb", label: "MEDIUM" },
|
|
low: { border: "#3b82f6", bg: "#eff6ff", label: "LOW" },
|
|
};
|
|
|
|
const MODEL_LABEL: Record<NonNullable<AnalysisShareEmailParams["modelTier"]>, string> = {
|
|
haiku: "Haiku",
|
|
sonnet: "Haiku → Sonnet",
|
|
opus: "Haiku → Sonnet → Opus",
|
|
};
|
|
|
|
function bulletListHtml(items: string[]): string {
|
|
return `<ul style="margin: 8px 0 16px; padding-left: 20px; color: #334155;">
|
|
${items
|
|
.map(
|
|
(item) =>
|
|
`<li style="margin: 4px 0;">${escapeHtml(item)}</li>`
|
|
)
|
|
.join("")}
|
|
</ul>`;
|
|
}
|
|
|
|
function gapsHtml(gaps: Gap[]): string {
|
|
return gaps
|
|
.map((g) => {
|
|
const tone = GAP_TONES[g.severity] ?? GAP_TONES.low;
|
|
return `<div style="border-left: 3px solid ${tone.border}; background: ${tone.bg}; padding: 10px 14px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
|
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: ${tone.border}; margin-bottom: 4px;">${tone.label}</div>
|
|
<div style="color: #0f172a;">${escapeHtml(g.description)}</div>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function sectionHeaderHtml(title: string): string {
|
|
return `<h3 style="font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; margin: 24px 0 8px; padding-bottom: 6px; border-bottom: 1px solid #e2e8f0;">${escapeHtml(title)}</h3>`;
|
|
}
|
|
|
|
export async function sendAnalysisShareEmail({
|
|
recipientEmail,
|
|
senderName,
|
|
senderEmail,
|
|
ticketNumber,
|
|
ticketTitle,
|
|
analysisVersion,
|
|
summary,
|
|
nextStep,
|
|
nextStepRationale,
|
|
whatWasDone,
|
|
whatShouldHaveBeenDone,
|
|
gaps,
|
|
confidenceScore,
|
|
modelTier,
|
|
analysisUrl,
|
|
note,
|
|
}: AnalysisShareEmailParams): Promise<void> {
|
|
const transport = getTransporter();
|
|
|
|
const subjectLine =
|
|
`Pulse analysis · ${ticketNumber} v${analysisVersion}` +
|
|
(summary ? ` — ${summary.slice(0, 80)}` : "");
|
|
|
|
const titleLine = ticketTitle
|
|
? `<div style="color: #475569; font-size: 14px; margin-top: 4px;">${escapeHtml(ticketTitle)}</div>`
|
|
: "";
|
|
|
|
const summarySection = summary
|
|
? `${sectionHeaderHtml("Summary")}<p style="color: #0f172a; margin: 0 0 8px; font-size: 15px; line-height: 1.55;">${escapeHtml(summary)}</p>`
|
|
: "";
|
|
|
|
const nextStepSection = nextStep
|
|
? `${sectionHeaderHtml("Next step")}<p style="color: #0f172a; margin: 0 0 8px; font-weight: 500;">${escapeHtml(nextStep)}</p>${
|
|
nextStepRationale
|
|
? `<p style="color: #475569; margin: 4px 0 16px; font-size: 14px;">${escapeHtml(nextStepRationale)}</p>`
|
|
: ""
|
|
}`
|
|
: "";
|
|
|
|
const whatWasDoneSection =
|
|
whatWasDone && whatWasDone.length > 0
|
|
? `${sectionHeaderHtml("What was done")}${bulletListHtml(whatWasDone)}`
|
|
: "";
|
|
|
|
const whatShouldHaveBeenDoneSection =
|
|
whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0
|
|
? `${sectionHeaderHtml("What should have been done")}${bulletListHtml(whatShouldHaveBeenDone)}`
|
|
: "";
|
|
|
|
const gapsSection =
|
|
gaps && gaps.length > 0
|
|
? `${sectionHeaderHtml("Gaps")}${gapsHtml(gaps)}`
|
|
: "";
|
|
|
|
const noteSection = note
|
|
? `<div style="background: #f8fafc; border-left: 3px solid #2563eb; padding: 12px 16px; margin: 0 0 24px; border-radius: 0 4px 4px 0;">
|
|
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: #2563eb; margin-bottom: 4px;">NOTE FROM ${escapeHtml(senderName).toUpperCase()}</div>
|
|
<div style="color: #0f172a; white-space: pre-wrap;">${escapeHtml(note)}</div>
|
|
</div>`
|
|
: "";
|
|
|
|
const confidenceBadge =
|
|
typeof confidenceScore === "number"
|
|
? `<span style="display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f1f5f9; color: #475569; font-size: 11px; font-weight: 600; letter-spacing: 0.02em;">${Math.round(confidenceScore * 100)}% confidence</span>`
|
|
: "";
|
|
|
|
const modelBadge = modelTier
|
|
? `<span style="color:#64748b; font-size: 12px;">${MODEL_LABEL[modelTier]}</span>`
|
|
: "";
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${escapeHtml(subjectLine)}</title>
|
|
</head>
|
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
|
<div style="max-width: 640px; margin: 0 auto;">
|
|
|
|
<!-- Header -->
|
|
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
|
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
|
<div style="color: #94a3b8; font-size: 12px; margin-top: 2px;">Ticket analysis</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
|
|
|
<!-- Ticket header -->
|
|
<div style="margin-bottom: 4px;">
|
|
<span style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 13px; color: #2563eb; font-weight: 600;">${escapeHtml(ticketNumber)}</span>
|
|
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">v${analysisVersion}</span>
|
|
${confidenceBadge ? `<span style="margin-left: 8px;">${confidenceBadge}</span>` : ""}
|
|
</div>
|
|
${titleLine}
|
|
|
|
<!-- Sender -->
|
|
<p style="color: #475569; font-size: 14px; margin: 16px 0 24px;">
|
|
<strong style="color: #0f172a;">${escapeHtml(senderName)}</strong> shared this with you.
|
|
</p>
|
|
|
|
${noteSection}
|
|
${summarySection}
|
|
${nextStepSection}
|
|
${whatWasDoneSection}
|
|
${whatShouldHaveBeenDoneSection}
|
|
${gapsSection}
|
|
|
|
<!-- CTA -->
|
|
<div style="text-align: center; margin: 32px 0 16px;">
|
|
<a href="${analysisUrl}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
|
Open full analysis in Pulse
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0 16px;">
|
|
<table style="width: 100%; font-size: 12px; color: #94a3b8;">
|
|
<tr>
|
|
<td style="padding: 0;">${modelBadge}</td>
|
|
<td style="padding: 0; text-align: right;">Reply to <a href="mailto:${escapeHtml(senderEmail)}" style="color: #64748b;">${escapeHtml(senderEmail)}</a></td>
|
|
</tr>
|
|
</table>
|
|
<p style="color: #94a3b8; font-size: 11px; margin: 12px 0 0; word-break: break-all;">
|
|
<a href="${analysisUrl}" style="color: #94a3b8;">${analysisUrl}</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
// Plain-text fallback — keep the same content sections so non-HTML clients
|
|
// see the full analysis, not a truncated nag to "open in browser".
|
|
const textParts: string[] = [];
|
|
textParts.push(
|
|
`${senderName} shared a Pulse ticket analysis with you.`,
|
|
`Ticket: ${ticketNumber}${ticketTitle ? ` — ${ticketTitle}` : ""} (analysis v${analysisVersion})`,
|
|
""
|
|
);
|
|
if (note) {
|
|
textParts.push(`Note from ${senderName}:`, note, "");
|
|
}
|
|
if (summary) {
|
|
textParts.push("SUMMARY", summary, "");
|
|
}
|
|
if (nextStep) {
|
|
textParts.push("NEXT STEP", nextStep);
|
|
if (nextStepRationale) textParts.push(` Rationale: ${nextStepRationale}`);
|
|
textParts.push("");
|
|
}
|
|
if (whatWasDone && whatWasDone.length > 0) {
|
|
textParts.push("WHAT WAS DONE");
|
|
whatWasDone.forEach((i) => textParts.push(` - ${i}`));
|
|
textParts.push("");
|
|
}
|
|
if (whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0) {
|
|
textParts.push("WHAT SHOULD HAVE BEEN DONE");
|
|
whatShouldHaveBeenDone.forEach((i) => textParts.push(` - ${i}`));
|
|
textParts.push("");
|
|
}
|
|
if (gaps && gaps.length > 0) {
|
|
textParts.push("GAPS");
|
|
gaps.forEach((g) =>
|
|
textParts.push(` [${g.severity.toUpperCase()}] ${g.description}`)
|
|
);
|
|
textParts.push("");
|
|
}
|
|
textParts.push(`Open in Pulse: ${analysisUrl}`);
|
|
|
|
await transport.sendMail({
|
|
from: FROM_HEADER,
|
|
to: recipientEmail,
|
|
replyTo: senderEmail,
|
|
subject: subjectLine,
|
|
text: textParts.join("\n"),
|
|
html,
|
|
});
|
|
}
|
|
|
|
// Verify SMTP connection
|
|
export async function verifyEmailConnection(): Promise<boolean> {
|
|
try {
|
|
const transport = getTransporter();
|
|
await transport.verify();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("SMTP connection verification failed:", error);
|
|
return false;
|
|
}
|
|
}
|