import { google, Auth } from 'googleapis'; import { Readable } from 'stream'; import { readFileSync } from 'fs'; import path from 'path'; import { prisma } from './prisma'; import type { Prisma } from '@prisma/client'; // The MPM logo is embedded directly into the report HTML as a base64 data URI // so it renders regardless of network reachability (e.g. a LAN-only deployment // where Google's importer can't fetch an external URL). Read from disk once and // cache; `null` means the asset was missing and the report renders without it. let cachedLogoDataUri: string | null | undefined; function getLogoDataUri(): string | null { if (cachedLogoDataUri !== undefined) return cachedLogoDataUri; try { const logoPath = path.join(process.cwd(), 'public', 'mpm-logo-doc.png'); const b64 = readFileSync(logoPath).toString('base64'); cachedLogoDataUri = `data:image/png;base64,${b64}`; } catch (error) { console.error('MPM report logo could not be loaded; rendering without it:', error); cachedLogoDataUri = null; } return cachedLogoDataUri; } type ReportWithRelations = Prisma.ReportGetPayload<{ include: { tasks: true; user: true }; }>; export async function getGoogleAuth(userId: string) { const account = await prisma.account.findFirst({ where: { userId, provider: 'google' }, }); if (!account) { throw new Error('Google account not found'); } const auth = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET ); auth.setCredentials({ access_token: account.access_token, refresh_token: account.refresh_token, expiry_date: account.expires_at ? account.expires_at * 1000 : null, }); // Check if the token is expired or will expire in the next 1 minute // NextAuth stores expires_at in seconds const isExpired = account.expires_at ? (account.expires_at * 1000) < (Date.now() + 60000) : true; if (isExpired && account.refresh_token) { try { const { credentials } = await auth.refreshAccessToken(); auth.setCredentials(credentials); // Update database with new tokens await prisma.account.update({ where: { id: account.id }, data: { access_token: credentials.access_token, refresh_token: credentials.refresh_token || account.refresh_token, expires_at: credentials.expiry_date ? Math.floor(credentials.expiry_date / 1000) : null, }, }); console.log('Successfully refreshed Google access token for user:', userId); } catch (error) { console.error('Error refreshing access token:', error); // If refresh fails, we still return the auth object, but requests will fail with 401 } } return auth; } export async function uploadToDrive(auth: Auth.OAuth2Client, fileName: string, content: string, folderId?: string) { const drive = google.drive({ version: 'v3', auth }); const fileMetadata: { name: string; mimeType: string; parents?: string[] } = { name: fileName, mimeType: 'application/vnd.google-apps.document', // Automatically convert to Google Doc }; if (folderId) { fileMetadata.parents = [folderId]; } const media = { mimeType: 'text/html', body: Readable.from([content]), }; try { const response = await drive.files.create({ requestBody: fileMetadata, media: media, fields: 'id, webViewLink' }); return response.data; } catch (error) { console.error('Error uploading to Google Drive:', error); throw error; } } export async function updateDriveFile(auth: Auth.OAuth2Client, fileId: string, content: string) { const drive = google.drive({ version: 'v3', auth }); const media = { mimeType: 'text/html', body: Readable.from([content]), }; try { const response = await drive.files.update({ fileId, media, fields: 'id, webViewLink', }); return response.data; } catch (error) { console.error('Error updating Google Drive file:', error); throw error; } } function escapeHtml(value: unknown): string { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Only allow plain http(s) URLs into href attributes function safeLink(link: unknown): string | null { const url = String(link ?? '').trim(); return /^https?:\/\//i.test(url) ? url : null; } export function generateReportHTML(report: ReportWithRelations) { const dateObj = new Date(report.date); const dateStr = dateObj.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const plannedTasks = report.tasks.filter((t) => t.type === 'PLANNED'); const completedTasks = report.tasks.filter((t) => t.type === 'COMPLETED'); // MPM brand palette (matches the hex values used across MPM technical docs) const GOLD_DARK = '#998643'; // document titles, section headings const GOLD_MID = '#DCBB4F'; // accent rules / dividers const SHADE_DARK = '#232022'; // body text / table header background const SHADE_LIGHT = '#F5F1EC'; // key-column / table header text fill const OFF_WHITE = '#FAF7F2'; // alternating data-table rows const ACCENT = '#849698'; // secondary / muted text const BORDER = '#E7E0D5'; // hairline cell borders (warm gray) const bodyFont = "'Open Sans', Arial, sans-serif"; const headingFont = "'Montserrat', 'Open Sans', Arial, sans-serif"; const statusLabel = (report.status || 'DRAFT').charAt(0) + (report.status || 'DRAFT').slice(1).toLowerCase(); const docRef = `WFH-${new Date(report.date).toISOString().slice(0, 10)}`; // Data-table cell styles const cellStyle = `padding: 8px 12px; border: 1px solid ${BORDER}; font-family: ${bodyFont}; font-size: 10.5pt; color: ${SHADE_DARK}; vertical-align: top;`; const headerStyle = `padding: 9px 12px; background-color: ${SHADE_DARK}; border: 1px solid ${SHADE_DARK}; font-family: ${headingFont}; font-size: 10pt; font-weight: 600; text-align: left; color: ${SHADE_LIGHT};`; const rowBg = (i: number) => (i % 2 === 1 ? ` background-color: ${OFF_WHITE};` : ''); // Document Control-style key/value row const metaKey = `width: 26%; padding: 8px 12px; background-color: ${SHADE_LIGHT}; border: 1px solid ${BORDER}; font-family: ${headingFont}; font-weight: 600; font-size: 10pt; color: ${SHADE_DARK};`; const metaVal = `padding: 8px 12px; border: 1px solid ${BORDER}; background-color: #FFFFFF; font-size: 10.5pt; color: ${SHADE_DARK};`; const metaRow = (k: string, v: string) => `${k}${v}`; // Section label styled like the technical docs' "Document Control" heading. const sectionLabel = (text: string) => `

${text}

`; const sectionHeading = (text: string) => `

${text}

`; // A gold rule rendered as a filled 1-row table (survives Google Docs conversion, // where border-bottom on headings/divs does not). const goldRule = `
 
`; const emptyNote = (text: string) => `

${text}

`; // Embed the logo inline so it survives conversion without a reachable host. const logoDataUri = getLogoDataUri(); const logoHtml = logoDataUri ? `Message Point Media` : ''; return ` ${logoHtml}

Message Point Media

WFH Daily Report

Daily Work-From-Home Status  |  ${escapeHtml(report.user.name)}  |  ${dateStr}

${goldRule} ${sectionLabel('Report Details')} ${metaRow('Employee', escapeHtml(report.user.name))} ${metaRow('Date', dateStr)} ${metaRow('Manager', escapeHtml(report.managerName || 'N/A'))} ${metaRow('Status', statusLabel)} ${metaRow('Reference', docRef)}
${sectionHeading('1. Planned Tasks')} ${plannedTasks.length > 0 ? ` ${plannedTasks.map((t, i) => ` `).join('')}
Description Estimate Notes
${escapeHtml(t.description)} ${escapeHtml(t.timeEstimate || '-')} ${escapeHtml(t.notes || '-')}
` : emptyNote('No planned tasks for today.')} ${sectionHeading('2. Completed Tasks')} ${completedTasks.length > 0 ? ` ${completedTasks.map((t, i) => { const link = safeLink(t.link); return ` `; }).join('')}
Description Status Work Link
${escapeHtml(t.description)} ${escapeHtml(t.status || 'Done')} ${link ? `${escapeHtml(link)}` : '-'}
` : emptyNote('No completed tasks reported today.')} ${goldRule}

Prepared by ${escapeHtml(report.user.name)}

Message Point Media  ·  ${docRef}  ·  Generated by the WFH Daily Report app

Born to Innovate. Built to Last.

Compelling… Affordable… Dynamic… Messaging… It’s What We Do!

`; }