/** * Chat & Notification Integrations Library * Microsoft Teams, Discord, Enhanced Email */ import { pgTable, text, timestamp, boolean, jsonb } from "drizzle-orm/pg-core"; import { getDatabase, schema } from "@/db"; import { eq, and, gte, inArray, desc } from "drizzle-orm"; import { logger } from "./logger"; import { repositories } from "@/db/schema/repositories"; // ============================================================================ // SCHEMA // ============================================================================ export const chatIntegrations = pgTable("chat_integrations", { id: text("id").primaryKey(), repositoryId: text("repository_id") .references(() => repositories.id, { onDelete: "cascade" }), organizationId: text("organization_id"), provider: text("provider").notNull(), // slack, teams, discord, email name: text("name").notNull(), webhookUrl: text("webhook_url"), apiToken: text("api_token"), channelId: text("channel_id"), isEnabled: boolean("is_enabled").default(true), events: jsonb("events").$type().default([]), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export const emailSettings = pgTable("email_settings", { id: text("id").primaryKey(), organizationId: text("organization_id"), smtpHost: text("smtp_host"), smtpPort: text("smtp_port"), smtpUser: text("smtp_user"), smtpPass: text("smtp_pass"), // Encrypted fromAddress: text("from_address"), fromName: text("from_name"), isEnabled: boolean("is_enabled").default(true), createdAt: timestamp("created_at").notNull().defaultNow(), }); export type ChatIntegration = typeof chatIntegrations.$inferSelect; export type EmailSettings = typeof emailSettings.$inferSelect; // ============================================================================ // NOTIFICATION EVENT TYPES // ============================================================================ export type NotificationEvent = | "pr_opened" | "pr_merged" | "pr_closed" | "pr_review_requested" | "pr_review_submitted" | "pr_comment" | "issue_opened" | "issue_closed" | "issue_assigned" | "ci_failed" | "ci_passed" | "deploy_started" | "deploy_completed" | "security_alert"; export interface NotificationPayload { event: NotificationEvent; title: string; message: string; url?: string; actor?: { name: string; avatar?: string }; repository?: { name: string; url: string }; metadata?: Record; } // ============================================================================ // MICROSOFT TEAMS INTEGRATION // ============================================================================ export async function sendTeamsNotification( webhookUrl: string, payload: NotificationPayload ): Promise { try { const card = buildTeamsAdaptiveCard(payload); const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(card), }); if (!response.ok) { logger.error({ status: response.status }, "Teams notification failed"); return false; } return true; } catch (error) { logger.error({ error }, "Teams notification error"); return false; } } function buildTeamsAdaptiveCard(payload: NotificationPayload): Record { const themeColor = getEventColor(payload.event); return { "@type": "MessageCard", "@context": "http://schema.org/extensions", themeColor, summary: payload.title, sections: [ { activityTitle: payload.title, activitySubtitle: payload.repository?.name || "", activityImage: payload.actor?.avatar, facts: [ { name: "Event", value: formatEventName(payload.event) }, ...(payload.actor ? [{ name: "By", value: payload.actor.name }] : []), ], markdown: true, text: payload.message, }, ], potentialAction: payload.url ? [ { "@type": "OpenUri", name: "View Details", targets: [{ os: "default", uri: payload.url }], }, ] : [], }; } // ============================================================================ // DISCORD INTEGRATION // ============================================================================ export async function sendDiscordNotification( webhookUrl: string, payload: NotificationPayload ): Promise { try { const embed = buildDiscordEmbed(payload); const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ embeds: [embed] }), }); if (!response.ok) { logger.error({ status: response.status }, "Discord notification failed"); return false; } return true; } catch (error) { logger.error({ error }, "Discord notification error"); return false; } } function buildDiscordEmbed(payload: NotificationPayload): Record { const color = getEventColorInt(payload.event); return { title: payload.title, description: payload.message, url: payload.url, color, author: payload.actor ? { name: payload.actor.name, icon_url: payload.actor.avatar, } : undefined, footer: payload.repository ? { text: payload.repository.name, } : undefined, timestamp: new Date().toISOString(), fields: [ { name: "Event", value: formatEventName(payload.event), inline: true, }, ], }; } // ============================================================================ // ENHANCED EMAIL INTEGRATION // ============================================================================ export interface EmailOptions { to: string | string[]; subject: string; html?: string; text?: string; replyTo?: string; cc?: string[]; bcc?: string[]; attachments?: { filename: string; content: string | Buffer }[]; } export async function sendEmail( settings: EmailSettings, options: EmailOptions ): Promise { if (!settings.isEnabled || !settings.smtpHost) { logger.warn("Email not configured"); return false; } try { // Use nodemailer-like interface const transport = createTransport(settings); await transport.sendMail({ from: `${settings.fromName} <${settings.fromAddress}>`, to: Array.isArray(options.to) ? options.to.join(", ") : options.to, subject: options.subject, html: options.html, text: options.text, replyTo: options.replyTo, cc: options.cc?.join(", "), bcc: options.bcc?.join(", "), }); return true; } catch (error) { logger.error({ error }, "Email send failed"); return false; } } function createTransport(settings: EmailSettings) { // Simulated transport - in production use nodemailer return { sendMail: async (options: Record) => { // SMTP connection logic const response = await fetch(`https://${settings.smtpHost}:${settings.smtpPort}/send`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Basic ${Buffer.from(`${settings.smtpUser}:${settings.smtpPass}`).toString("base64")}`, }, body: JSON.stringify(options), }); if (!response.ok) throw new Error("SMTP error"); return response; }, }; } export function buildNotificationEmail(payload: NotificationPayload): { subject: string; html: string } { const eventName = formatEventName(payload.event); const subject = `[${payload.repository?.name || "OpenCodeHub"}] ${payload.title}`; const html = `

${eventName}

${payload.actor ? `
${payload.actor.avatar ? `${payload.actor.name}` : ""} ${payload.actor.name}
` : ""}

${payload.title}

${payload.message}

${payload.url ? `View Details` : ""}
`; return { subject, html }; } // ============================================================================ // UNIFIED NOTIFICATION DISPATCH // ============================================================================ export async function dispatchNotification( repositoryId: string, payload: NotificationPayload ): Promise<{ sent: number; failed: number }> { const db = getDatabase(); let sent = 0; let failed = 0; try { const integrations = await db.query.chatIntegrations?.findMany({ where: and( eq(schema.chatIntegrations.repositoryId, repositoryId), eq(schema.chatIntegrations.isEnabled, true) ), }) || []; for (const integration of integrations) { // Check if this integration wants this event const events = (integration.events as string[]) || []; if (events.length > 0 && !events.includes(payload.event)) { continue; } let success = false; switch (integration.provider) { case "slack": success = await sendSlackNotification(integration.webhookUrl || "", payload); break; case "teams": success = await sendTeamsNotification(integration.webhookUrl || "", payload); break; case "discord": success = await sendDiscordNotification(integration.webhookUrl || "", payload); break; } if (success) sent++; else failed++; } } catch (error) { logger.error({ error }, "Notification dispatch failed"); } return { sent, failed }; } // Slack notification (wrapper for existing implementation) async function sendSlackNotification(webhookUrl: string, payload: NotificationPayload): Promise { try { const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ attachments: [ { color: getEventColor(payload.event), author_name: payload.actor?.name, author_icon: payload.actor?.avatar, title: payload.title, title_link: payload.url, text: payload.message, footer: payload.repository?.name, ts: Math.floor(Date.now() / 1000), }, ], }), }); return response.ok; } catch { return false; } } // ============================================================================ // CONFIGURATION FUNCTIONS // ============================================================================ export async function configureChatIntegration(options: { repositoryId?: string; organizationId?: string; provider: "slack" | "teams" | "discord"; name: string; webhookUrl: string; events?: NotificationEvent[]; }): Promise { const db = getDatabase(); const integration = { id: crypto.randomUUID(), repositoryId: options.repositoryId || null, organizationId: options.organizationId || null, provider: options.provider, name: options.name, webhookUrl: options.webhookUrl, apiToken: null, channelId: null, isEnabled: true, events: options.events || [], createdAt: new Date(), updatedAt: new Date(), }; // @ts-expect-error - Drizzle multi-db union type issue await db.insert(schema.chatIntegrations).values(integration); logger.info({ provider: options.provider }, "Chat integration configured"); return integration as ChatIntegration; } export async function configureEmailSettings(options: { organizationId?: string; smtpHost: string; smtpPort: string; smtpUser: string; smtpPass: string; fromAddress: string; fromName: string; }): Promise { const db = getDatabase(); const settings = { id: crypto.randomUUID(), organizationId: options.organizationId || null, smtpHost: options.smtpHost, smtpPort: options.smtpPort, smtpUser: options.smtpUser, smtpPass: options.smtpPass, fromAddress: options.fromAddress, fromName: options.fromName, isEnabled: true, createdAt: new Date(), }; // @ts-expect-error - Drizzle multi-db union type issue await db.insert(schema.emailSettings).values(settings); logger.info("Email settings configured"); return settings as EmailSettings; } // ============================================================================ // HELPER FUNCTIONS // ============================================================================ function getEventColor(event: NotificationEvent): string { const colors: Record = { pr_opened: "#6f42c1", pr_merged: "#28a745", pr_closed: "#dc3545", pr_review_requested: "#fd7e14", pr_review_submitted: "#17a2b8", pr_comment: "#6c757d", issue_opened: "#28a745", issue_closed: "#6c757d", issue_assigned: "#17a2b8", ci_failed: "#dc3545", ci_passed: "#28a745", deploy_started: "#fd7e14", deploy_completed: "#28a745", security_alert: "#dc3545", }; return colors[event] || "#6c757d"; } function getEventColorInt(event: NotificationEvent): number { const hex = getEventColor(event).replace("#", ""); return parseInt(hex, 16); } function formatEventName(event: NotificationEvent): string { const names: Record = { pr_opened: "Pull Request Opened", pr_merged: "Pull Request Merged", pr_closed: "Pull Request Closed", pr_review_requested: "Review Requested", pr_review_submitted: "Review Submitted", pr_comment: "New Comment", issue_opened: "Issue Opened", issue_closed: "Issue Closed", issue_assigned: "Issue Assigned", ci_failed: "CI Failed", ci_passed: "CI Passed", deploy_started: "Deployment Started", deploy_completed: "Deployment Completed", security_alert: "Security Alert", }; return names[event] || event; } // ============================================================================ // DIGEST EMAILS // ============================================================================ export interface DigestOptions { userId: string; period: "daily" | "weekly"; includeEvents: NotificationEvent[]; } export async function generateDigestEmail(options: DigestOptions): Promise<{ subject: string; html: string; itemCount: number; }> { const db = getDatabase(); const now = new Date(); const from = new Date(now); if (options.period === "daily") { from.setDate(from.getDate() - 1); } else { from.setDate(from.getDate() - 7); } const whereClauses = [ eq(schema.notifications.userId, options.userId), gte(schema.notifications.createdAt, from), eq(schema.notifications.isArchived, false), ]; if (options.includeEvents.length > 0) { whereClauses.push(inArray(schema.notifications.type, options.includeEvents)); } const notifications = await db.query.notifications.findMany({ where: and(...whereClauses), orderBy: [desc(schema.notifications.createdAt)], with: { repository: true, actor: true, }, limit: 50, }); const periodLabel = options.period === "daily" ? "Daily" : "Weekly"; const itemCount = notifications.length; const unreadCount = notifications.filter((n) => !n.isRead).length; const eventCounts = new Map(); for (const n of notifications) { eventCounts.set(n.type, (eventCounts.get(n.type) || 0) + 1); } const escapeHtml = (value: string) => value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); const subject = `Your ${periodLabel} OpenCodeHub Digest`; const summaryItems = Array.from(eventCounts.entries()) .sort((a, b) => b[1] - a[1]) .map(([event, count]) => `
  • ${count} ${escapeHtml(formatEventName(event as NotificationEvent))}
  • `) .join(""); const itemRows = notifications .slice(0, 20) .map((n) => { const title = escapeHtml(n.title); const body = escapeHtml(n.body || ""); const repo = n.repository?.name ? ` in ${escapeHtml(n.repository.name)}` : ""; const actor = n.actor?.username ? ` by ${escapeHtml(n.actor.username)}` : ""; const url = n.url || "#"; const createdAt = n.createdAt.toISOString().replace("T", " ").slice(0, 16); return `
    •
    ${escapeHtml(formatEventName(n.type as NotificationEvent))}${repo}${actor}
    ${body ? `
    ${body}
    ` : ""}
    ${createdAt}
    `; }) .join(""); const html = `

    ${periodLabel} Digest

    ${itemCount} notification${itemCount === 1 ? "" : "s"} (${unreadCount} unread)

    📬 Activity Summary

    ${summaryItems ? `
      ${summaryItems}
    ` : "

    No notifications for this period.

    "}

    📝 Recent Items

    ${itemRows || "

    No recent items.

    "}
    `; return { subject, html, itemCount }; }