Files
wfh/src/lib/google-drive.ts
T

261 lines
12 KiB
TypeScript
Raw Normal View History

import { google, Auth } from 'googleapis';
2026-03-12 17:09:22 -05:00
import { Readable } from 'stream';
2026-07-03 10:09:25 -05:00
import { readFileSync } from 'fs';
import path from 'path';
2026-03-13 16:00:33 -05:00
import { prisma } from './prisma';
import type { Prisma } from '@prisma/client';
2026-07-03 10:09:25 -05:00
// 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 };
}>;
2026-03-12 17:09:22 -05:00
2026-03-13 16:00:33 -05:00
export async function getGoogleAuth(userId: string) {
const account = await prisma.account.findFirst({
where: { userId, provider: 'google' },
});
2026-03-12 17:09:22 -05:00
2026-03-13 16:00:33 -05:00
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) {
2026-03-12 17:09:22 -05:00
const drive = google.drive({ version: 'v3', auth });
const fileMetadata: { name: string; mimeType: string; parents?: string[] } = {
2026-03-12 17:09:22 -05:00
name: fileName,
mimeType: 'application/vnd.google-apps.document', // Automatically convert to Google Doc
};
if (folderId) {
fileMetadata.parents = [folderId];
}
const media = {
2026-03-13 16:16:59 -05:00
mimeType: 'text/html',
2026-03-12 17:09:22 -05:00
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) {
2026-03-13 11:13:45 -05:00
const drive = google.drive({ version: 'v3', auth });
const media = {
2026-03-13 16:16:59 -05:00
mimeType: 'text/html',
2026-03-13 11:13:45 -05:00
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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) {
2026-03-13 16:16:59 -05:00
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');
2026-03-13 16:16:59 -05:00
2026-07-03 10:35:42 -05:00
// 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
2026-07-03 10:09:25 -05:00
const SHADE_DARK = '#232022'; // body text / table header background
2026-07-03 10:35:42 -05:00
const SHADE_LIGHT = '#F5F1EC'; // key-column / table header text fill
const OFF_WHITE = '#FAF7F2'; // alternating data-table rows
2026-07-03 10:09:25 -05:00
const ACCENT = '#849698'; // secondary / muted text
2026-07-03 10:35:42 -05:00
const BORDER = '#E7E0D5'; // hairline cell borders (warm gray)
2026-07-03 10:09:25 -05:00
const bodyFont = "'Open Sans', Arial, sans-serif";
const headingFont = "'Montserrat', 'Open Sans', Arial, sans-serif";
2026-07-03 10:35:42 -05:00
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) =>
`<tr><td style="${metaKey}">${k}</td><td style="${metaVal}">${v}</td></tr>`;
// Section label styled like the technical docs' "Document Control" heading.
const sectionLabel = (text: string) =>
`<p style="font-family: ${headingFont}; font-weight: 600; font-size: 10.5pt; letter-spacing: 1.5px; text-transform: uppercase; color: ${GOLD_DARK}; margin: 0 0 8px 0;">${text}</p>`;
const sectionHeading = (text: string) =>
`<h2 style="font-family: ${headingFont}; font-weight: 600; font-size: 14pt; color: ${GOLD_DARK}; margin: 26px 0 10px 0;">${text}</h2>`;
// A gold rule rendered as a filled 1-row table (survives Google Docs conversion,
// where border-bottom on headings/divs does not).
const goldRule = `<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 12px 0 22px 0;"><tr><td style="height: 3px; line-height: 3px; font-size: 0; padding: 0; background-color: ${GOLD_MID};">&nbsp;</td></tr></table>`;
const emptyNote = (text: string) =>
`<p style="font-style: italic; color: ${ACCENT}; font-family: ${bodyFont}; margin: 0 0 8px 0;">${text}</p>`;
2026-07-03 10:09:25 -05:00
// Embed the logo inline so it survives conversion without a reachable host.
const logoDataUri = getLogoDataUri();
const logoHtml = logoDataUri
2026-07-03 10:35:42 -05:00
? `<img src="${logoDataUri}" alt="Message Point Media" style="height: 40px; margin-bottom: 14px;" />`
2026-07-03 10:09:25 -05:00
: '';
2026-03-13 16:16:59 -05:00
return `
<html>
2026-07-03 10:35:42 -05:00
<body style="font-family: ${bodyFont}; color: ${SHADE_DARK}; line-height: 1.5; max-width: 760px; margin: 0 auto; padding: 16px 24px;">
2026-07-03 10:09:25 -05:00
${logoHtml}
2026-07-03 10:35:42 -05:00
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 9pt; letter-spacing: 2px; text-transform: uppercase; color: ${SHADE_DARK}; margin: 0 0 2px 0;">Message Point Media</p>
<h1 style="font-family: ${headingFont}; font-weight: 700; font-size: 23pt; color: ${GOLD_DARK}; margin: 0 0 4px 0;">WFH Daily Report</h1>
<p style="font-size: 10.5pt; color: ${ACCENT}; margin: 0;">Daily Work-From-Home Status &nbsp;|&nbsp; ${escapeHtml(report.user.name)} &nbsp;|&nbsp; ${dateStr}</p>
${goldRule}
2026-07-03 10:09:25 -05:00
2026-07-03 10:35:42 -05:00
${sectionLabel('Report Details')}
<table style="width: 100%; border-collapse: collapse; margin-bottom: 4px;">
${metaRow('Employee', escapeHtml(report.user.name))}
${metaRow('Date', dateStr)}
${metaRow('Manager', escapeHtml(report.managerName || 'N/A'))}
${metaRow('Status', statusLabel)}
${metaRow('Reference', docRef)}
</table>
2026-03-13 16:16:59 -05:00
2026-07-03 10:35:42 -05:00
${sectionHeading('1. Planned Tasks')}
2026-03-13 16:16:59 -05:00
${plannedTasks.length > 0 ? `
2026-07-03 10:35:42 -05:00
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
2026-03-13 16:16:59 -05:00
<tr>
<th style="${headerStyle} width: 45%;">Description</th>
<th style="${headerStyle} width: 20%;">Estimate</th>
<th style="${headerStyle} width: 35%;">Notes</th>
</tr>
2026-07-03 10:09:25 -05:00
${plannedTasks.map((t, i) => `
2026-03-13 16:16:59 -05:00
<tr>
2026-07-03 10:09:25 -05:00
<td style="${cellStyle}${rowBg(i)}">${escapeHtml(t.description)}</td>
<td style="${cellStyle}${rowBg(i)} color: ${ACCENT};">${escapeHtml(t.timeEstimate || '-')}</td>
<td style="${cellStyle}${rowBg(i)} color: ${ACCENT};">${escapeHtml(t.notes || '-')}</td>
2026-03-13 16:16:59 -05:00
</tr>
`).join('')}
</table>
2026-07-03 10:35:42 -05:00
` : emptyNote('No planned tasks for today.')}
2026-03-13 16:16:59 -05:00
2026-07-03 10:35:42 -05:00
${sectionHeading('2. Completed Tasks')}
2026-03-13 16:16:59 -05:00
${completedTasks.length > 0 ? `
2026-07-03 10:35:42 -05:00
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
2026-03-13 16:16:59 -05:00
<tr>
<th style="${headerStyle} width: 40%;">Description</th>
<th style="${headerStyle} width: 20%;">Status</th>
<th style="${headerStyle} width: 40%;">Work Link</th>
</tr>
2026-07-03 10:09:25 -05:00
${completedTasks.map((t, i) => {
const link = safeLink(t.link);
return `
2026-03-13 16:16:59 -05:00
<tr>
2026-07-03 10:09:25 -05:00
<td style="${cellStyle}${rowBg(i)}">${escapeHtml(t.description)}</td>
2026-07-03 10:35:42 -05:00
<td style="${cellStyle}${rowBg(i)} font-weight: 600; color: ${GOLD_DARK};">${escapeHtml(t.status || 'Done')}</td>
2026-07-03 10:09:25 -05:00
<td style="${cellStyle}${rowBg(i)}">${link ? `<a href="${escapeHtml(link)}" style="color: ${GOLD_DARK}; text-decoration: none;">${escapeHtml(link)}</a>` : '-'}</td>
2026-03-13 16:16:59 -05:00
</tr>
`;
}).join('')}
2026-03-13 16:16:59 -05:00
</table>
2026-07-03 10:35:42 -05:00
` : emptyNote('No completed tasks reported today.')}
2026-07-03 10:09:25 -05:00
2026-07-03 10:35:42 -05:00
${goldRule}
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 10pt; color: ${SHADE_DARK}; margin: 0 0 2px 0;">Prepared by ${escapeHtml(report.user.name)}</p>
<p style="font-size: 9pt; color: ${ACCENT}; margin: 0 0 10px 0;">Message Point Media &nbsp;&middot;&nbsp; ${docRef} &nbsp;&middot;&nbsp; Generated by the WFH Daily Report app</p>
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 9.5pt; letter-spacing: 0.5px; color: ${GOLD_DARK}; margin: 0 0 2px 0;">Born to Innovate. Built to Last.</p>
<p style="font-size: 8.5pt; font-style: italic; color: ${ACCENT}; margin: 0;">Compelling&hellip; Affordable&hellip; Dynamic&hellip; Messaging&hellip; It&rsquo;s What We Do!</p>
2026-03-13 16:16:59 -05:00
</body>
</html>
`;
2026-03-12 17:09:22 -05:00
}