2026-07-01 19:43:36 -05:00
|
|
|
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';
|
2026-07-01 19:43:36 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 19:43:36 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 19:43:36 -05:00
|
|
|
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 });
|
|
|
|
|
|
2026-07-01 19:43:36 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 19:43:36 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 19:43:36 -05:00
|
|
|
function escapeHtml(value: unknown): string {
|
|
|
|
|
return String(value ?? '')
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.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) {
|
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' });
|
2026-07-01 19:43:36 -05:00
|
|
|
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:09:25 -05:00
|
|
|
// MPM brand palette
|
|
|
|
|
const GOLD_DARK = '#998643'; // titles on light backgrounds
|
|
|
|
|
const GOLD_MID = '#DCBB4F'; // accent lines, dividers
|
|
|
|
|
const SHADE_DARK = '#232022'; // body text / table header background
|
|
|
|
|
const SHADE_LIGHT = '#F5F1EC'; // section panels / table header text
|
|
|
|
|
const OFF_WHITE = '#FAF7F2'; // alternating table rows
|
|
|
|
|
const ACCENT = '#849698'; // secondary / muted text
|
|
|
|
|
|
|
|
|
|
const bodyFont = "'Open Sans', Arial, sans-serif";
|
|
|
|
|
const headingFont = "'Montserrat', 'Open Sans', Arial, sans-serif";
|
|
|
|
|
|
|
|
|
|
const cellStyle = `padding: 10px; border-bottom: 1px solid #E7E0D5; font-family: ${bodyFont}; font-size: 11pt; color: ${SHADE_DARK};`;
|
|
|
|
|
const headerStyle = `padding: 12px 10px; background-color: ${SHADE_DARK}; font-family: ${headingFont}; font-size: 11pt; font-weight: 600; text-align: left; color: ${SHADE_LIGHT};`;
|
|
|
|
|
|
|
|
|
|
// Embed the logo inline so it survives conversion without a reachable host.
|
|
|
|
|
const logoDataUri = getLogoDataUri();
|
|
|
|
|
const logoHtml = logoDataUri
|
|
|
|
|
? `<img src="${logoDataUri}" alt="Message Point Media" style="height: 44px; margin-bottom: 16px;" />`
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
const rowBg = (i: number) => (i % 2 === 1 ? ` background-color: ${OFF_WHITE};` : '');
|
2026-03-13 16:16:59 -05:00
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<html>
|
2026-07-03 10:09:25 -05:00
|
|
|
<body style="font-family: ${bodyFont}; color: ${SHADE_DARK}; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px;">
|
|
|
|
|
${logoHtml}
|
|
|
|
|
<h1 style="color: ${GOLD_DARK}; border-bottom: 3px solid ${GOLD_MID}; padding-bottom: 10px; font-family: ${headingFont}; font-weight: 700; margin-bottom: 20px;">WFH Daily Report</h1>
|
|
|
|
|
|
|
|
|
|
<div style="background-color: ${SHADE_LIGHT}; padding: 20px; border-left: 4px solid ${GOLD_MID}; border-radius: 4px; margin-bottom: 30px; font-family: ${bodyFont};">
|
2026-03-13 16:16:59 -05:00
|
|
|
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Date:</strong> ${dateStr}</p>
|
2026-07-01 19:43:36 -05:00
|
|
|
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Employee:</strong> ${escapeHtml(report.user.name)}</p>
|
|
|
|
|
<p style="margin: 0; font-size: 11pt;"><strong>Manager:</strong> ${escapeHtml(report.managerName || 'N/A')}</p>
|
2026-03-13 16:16:59 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-03 10:09:25 -05:00
|
|
|
<h2 style="color: ${GOLD_DARK}; margin-top: 30px; margin-bottom: 15px; font-family: ${headingFont}; font-weight: 600;">Planned Tasks</h2>
|
2026-03-13 16:16:59 -05:00
|
|
|
${plannedTasks.length > 0 ? `
|
|
|
|
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
|
|
|
|
|
<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:09:25 -05:00
|
|
|
` : `<p style="font-style: italic; color: ${ACCENT}; font-family: ${bodyFont}; margin-bottom: 30px;">No planned tasks for today.</p>`}
|
2026-03-13 16:16:59 -05:00
|
|
|
|
2026-07-03 10:09:25 -05:00
|
|
|
<h2 style="color: ${GOLD_DARK}; margin-top: 30px; margin-bottom: 15px; font-family: ${headingFont}; font-weight: 600;">Completed Tasks</h2>
|
2026-03-13 16:16:59 -05:00
|
|
|
${completedTasks.length > 0 ? `
|
|
|
|
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
|
|
|
|
|
<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) => {
|
2026-07-01 19:43:36 -05:00
|
|
|
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>
|
|
|
|
|
<td style="${cellStyle}${rowBg(i)} font-weight: bold; color: ${GOLD_DARK};">${escapeHtml(t.status || 'Done')}</td>
|
|
|
|
|
<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>
|
2026-07-01 19:43:36 -05:00
|
|
|
`;
|
|
|
|
|
}).join('')}
|
2026-03-13 16:16:59 -05:00
|
|
|
</table>
|
2026-07-03 10:09:25 -05:00
|
|
|
` : `<p style="font-style: italic; color: ${ACCENT}; font-family: ${bodyFont}; margin-bottom: 30px;">No completed tasks reported today.</p>`}
|
|
|
|
|
|
|
|
|
|
<div style="margin-top: 50px; font-size: 9pt; color: ${ACCENT}; text-align: center; border-top: 1px solid ${GOLD_MID}; padding-top: 20px; font-family: ${bodyFont};">
|
|
|
|
|
<p style="margin: 0 0 4px 0; font-family: ${headingFont}; font-weight: 600; color: ${GOLD_DARK}; letter-spacing: 0.5px;">Born to Innovate. Built to Last.</p>
|
|
|
|
|
<p style="margin: 0;">Message Point Media · Generated automatically by the WFH Daily Report app</p>
|
2026-03-13 16:16:59 -05:00
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`;
|
2026-03-12 17:09:22 -05:00
|
|
|
}
|