import { config } from 'dotenv'; import { Hono } from 'hono'; import { serveStatic } from 'hono/bun'; import { cors } from 'hono/cors'; import PocketBase from 'pocketbase'; import { Client } from '@microsoft/microsoft-graph-client'; import { mkdir, appendFile } from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; import { processNewJobRecord } from './extracted-graph-logic/post-record-integration.js'; // Load secrets from absolute path (not in project directory) config({ path: '/home/admin/secrets/.env' }); const app = new Hono(); // ============================================================================ // ERROR LOGGING // ============================================================================ const LOG_DIR = path.join(process.cwd(), 'logs'); const ERROR_LOG_PATH = path.join(LOG_DIR, 'error.log'); // Ensure logs directory exists if (!existsSync(LOG_DIR)) { await mkdir(LOG_DIR, { recursive: true }); } async function logError(context: string, error: any, additionalData?: any) { const timestamp = new Date().toISOString(); const logEntry = { timestamp, context, error: { message: error?.message || String(error), stack: error?.stack, status: error?.status || error?.statusCode, }, data: additionalData }; const logLine = `${timestamp} [ERROR] ${context}: ${JSON.stringify(logEntry)}\n`; try { await appendFile(ERROR_LOG_PATH, logLine); console.error(`✗ [${context}] ${error?.message || error}`); } catch (writeError) { console.error('Failed to write to error log:', writeError); console.error('Original error:', error); } } // ============================================================================ // TEAMS WEBHOOK NOTIFICATION (Power Automate) // ============================================================================ function buildAdaptiveCard(jobData: any, jobFolderLink: string) { const jobName = jobData.Job_Name || ''; const jobNumber = jobData.Job_Number || ''; const jobAddress = jobData.Job_Address || ''; const companyClient = jobData.Company_Client || ''; const jobDivision = jobData.Job_Division || ''; const jobType = jobData.Job_Type || ''; const contactPerson = jobData.Contact_Person || ''; const phoneNumber = jobData.Phone_Number || ''; // Valid Adaptive Card (Teams flowbot expects type: AdaptiveCard) return { type: 'AdaptiveCard', $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', version: '1.5', body: [ { type: 'TextBlock', text: 'New Job!', weight: 'Bolder', size: 'Large' }, { type: 'TextBlock', text: `${jobNumber} - ${jobName}`, wrap: true }, { type: 'FactSet', facts: [ { title: 'Number', value: jobNumber || 'N/A' }, { title: 'Name', value: jobName || 'N/A' }, { title: 'Address', value: jobAddress || 'N/A' }, { title: 'Client', value: companyClient || 'N/A' }, { title: 'Division', value: jobDivision || 'N/A' }, { title: 'Type', value: jobType || 'N/A' }, { title: 'Contact', value: (contactPerson && phoneNumber) ? `${contactPerson} - ${phoneNumber}` : (contactPerson || 'N/A') } ] } ], actions: jobFolderLink ? [ { type: 'Action.OpenUrl', title: 'Open Folder', url: jobFolderLink } ] : [] }; } async function notifyTeamsChannel(jobData: any, jobFolderLink: string) { const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL; // Skip if webhook not configured if (!webhook) { console.log('⚠️ Power Automate webhook not configured (POWER_AUTOMATE_WEBHOOK_URL missing)'); return; } try { const jobName = jobData.Job_Name || ''; const jobNumber = jobData.Job_Number || ''; const jobAddress = jobData.Job_Address || ''; const companyClient = jobData.Company_Client || ''; const jobDivision = jobData.Job_Division || ''; const jobType = jobData.Job_Type || ''; const contactPerson = jobData.Contact_Person || ''; const phoneNumber = jobData.Phone_Number || ''; // Build Adaptive Card and payload for Power Automate const adaptiveCard = buildAdaptiveCard(jobData, jobFolderLink); const payload = { // raw fields, in case the flow maps them directly jobNumber, jobName, jobAddress, companyClient, jobDivision, jobType, contactPerson, phoneNumber, jobFolderLink, // Provide card in common parameter names used by PA templates card: JSON.stringify(adaptiveCard), adaptiveCard }; console.log(`[Power Automate] Sending webhook for Job ${jobNumber}:`, JSON.stringify(payload, null, 2)); const response = await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const text = await response.text(); console.warn(`⚠️ Power Automate notification failed: ${response.status} ${response.statusText}`); console.warn(` Response: ${text}`); } else { console.log(`✓ Power Automate notification sent for Job ${jobNumber} (${response.status})`); } } catch (error: any) { console.error('⚠️ Failed to send Power Automate notification:', error.message); // Don't fail the job creation if notification fails } } // Enable CORS app.use('/*', cors()); // Serve static files from frontend directory app.use('/*', serveStatic({ root: './frontend' })); // PocketBase client const pb = new PocketBase(process.env.PB_DB || 'https://pocketbase.ccllc.pro'); // (Removed) formula seeding logic: we rely on Excel's table calculated columns defined in the workbook // Microsoft Graph authentication async function getGraphToken() { const tokenEndpoint = `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`; const params = new URLSearchParams(); params.append('client_id', process.env.CLIENT_ID!); params.append('client_secret', process.env.CLIENT_SECRET!); params.append('scope', 'https://graph.microsoft.com/.default'); params.append('grant_type', 'client_credentials'); const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: params, }); if (!response.ok) { const text = await response.text(); const error = new Error(`Graph token request failed: ${response.status} ${response.statusText} - ${text}`); await logError('Graph API Token', error, { endpoint: tokenEndpoint }); throw error; } type TokenResponse = { access_token: string }; const data = await response.json() as TokenResponse; if (!data || typeof data.access_token !== 'string' || !data.access_token) { const error = new Error('Graph token response missing access_token'); await logError('Graph API Token', error, { response: data }); throw error; } return data.access_token; } // Create Microsoft Graph client async function getGraphClient() { const token = await getGraphToken(); return Client.init({ authProvider: (done) => { done(null, token); }, }); } // Use user's PocketBase token (passed from authenticated frontend) function setUserPocketBaseAuth(token: string) { pb.authStore.save(token); } // Get Excel table column names async function getExcelColumns(client: any, excelTablePath: string) { const colsResp = await client.api(`${excelTablePath}/columns`).get(); const columns = colsResp?.value || []; const columnNames: string[] = columns.map((c: any) => c.name); return columnNames; } // Cache for table structure (queried once, reused for all submissions) let tableCache: { columnNames: string[]; } | null = null; // Resolve the Excel table name from env; prefers EXCEL_TABLE_NAME (just the table's name), // and falls back to parsing EXCEL_TABLE if provided as a full path, else default to Test_Table. function resolveTableName(): string { const nameFromSimpleEnv = process.env.EXCEL_TABLE_NAME?.trim(); if (nameFromSimpleEnv) return nameFromSimpleEnv; const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim(); if (nameFromPath) return nameFromPath; return 'Test_Table'; } // ============================================================================ // CALCULATED COLUMN FUNCTIONS (server-side formula equivalents) // ============================================================================ // These replace Excel table formulas with TypeScript calculations on entry // Ensures consistent, calculated values added to each new row /** * Job_Full_Name = CONCAT([@[Job_Name]]," - ",[@[Job_Address]]," - ",[@[Company_Client]]) */ function calculateJobFullName(record: any): string { const jobName = record.Job_Name || record.job_name || ''; const jobAddress = record.Job_Address || record.job_address || ''; const companyClient = record.Company_Client || record.company_client || ''; const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim()); return parts.join(' - '); } /** * Due_Date_Counter = IF([@[Job_Status]]="Estimating",IF(ISNUMBER(SEARCH("ASAP",[@[Due_Date]])),"ASAP",IF([@[Due_Date]] c.name); tableCache = { columnNames }; console.log(`✓ Table cache initialized with ${columnNames.length} columns`); } catch (error: any) { console.error('✗ Failed to initialize table cache:', error); await logError('Table Cache Initialization', error, { driveId: process.env.DRIVE_ID, itemId: process.env.ITEM_ID }); throw error; } } // Add record to Excel (only columns that exist in table) async function addToExcel(record: any, pbId: string) { const client = await getGraphClient(); const tableName = resolveTableName(); const excelTablePath = `/drives/${process.env.DRIVE_ID}/items/${process.env.ITEM_ID}/workbook/tables/${tableName}`; // Initialize cache if not already done if (!tableCache) { await initializeTableCache(); } const { columnNames } = tableCache!; try { // Build row values in column order // Helper: format PocketBase UTC/ISO dates to MM/dd/yyyy for Excel display function formatPbDateToShort(val: any): string { if (!val) return ''; const d = new Date(val); if (isNaN(d.getTime())) return ''; // Use UTC accessors to preserve date (don't convert to local time) const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); const dd = String(d.getUTCDate()).padStart(2, '0'); const yyyy = String(d.getUTCFullYear()); return `${mm}/${dd}/${yyyy}`; } const rowValues = columnNames.map((col) => { const colLower = col.toLowerCase(); // Special case: pb_id column gets the record.id if (colLower === 'pb_id') { return pbId || ''; } // ======================================================================== // CALCULATED COLUMNS: compute values server-side on entry // ======================================================================== // Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client) if (colLower === 'job_full_name') { return calculateJobFullName(record); } // Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days) if (colLower === 'due_date_counter') { // Send Excel formula so the table auto-calculates return '=IF([@[Job_Status]]<>"Estimating","N/A",IF(ISNUMBER(SEARCH("ASAP",[@[Due_Date]])),"ASAP",IF([@[Due_Date]] k.toLowerCase() === colLower); if (matchingKey) { val = record[matchingKey]; } } // Format known date columns from PB to Excel-friendly short dates if (['due_date','start_date','submission_date'].includes(colLower)) { return formatPbDateToShort(val); } return val == null ? '' : val; }); // Add row to Excel table at index 0 (first position after headers) await client.api(`${excelTablePath}/rows`).post({ index: 0, values: [rowValues] }); console.log(`✓ Synced to Excel (pb_id: ${pbId})`); // DISABLED: formula copy causing issues - skip for now // Previous logic was attempting to copy formulas which may corrupt or duplicate rows return { success: true }; } catch (error: any) { console.error('✗ Failed to add record to Excel:', error); await logError('Excel Sync', error, { pbId, jobNumber: record.Job_Number || record.job_number, driveId: process.env.DRIVE_ID, itemId: process.env.ITEM_ID }); throw error; } } // Update the Excel row's Job_Folder_Link for the given pb_id async function updateExcelFolderLink(pbId: string, link: string) { const client = await getGraphClient(); const tableName = resolveTableName(); const excelTablePath = `/drives/${process.env.DRIVE_ID}/items/${process.env.ITEM_ID}/workbook/tables/${tableName}`; // Initialize cache if not already done if (!tableCache) { await initializeTableCache(); } const { columnNames } = tableCache!; const lower = columnNames.map((n) => n.toLowerCase()); const linkColIndex = lower.indexOf('job_folder_link'); const pbIdColIndex = lower.indexOf('pb_id'); if (linkColIndex === -1 || pbIdColIndex === -1) { console.warn('⚠️ Excel table missing Job_Folder_Link or pb_id column'); return; } const rowsResp = await client.api(`${excelTablePath}/rows`).get(); const rows = rowsResp?.value || []; const target = rows.find((row: any) => { const vals = row?.values?.[0]; return vals && vals[pbIdColIndex] === pbId; }); if (!target) { console.warn('⚠️ Excel row not found for pb_id:', pbId); return; } // Update using table row itemAt - update the full row with link populated const currentValues = [...target.values[0]]; // Copy array currentValues[linkColIndex] = link || ''; await client.api(`${excelTablePath}/rows/itemAt(index=${target.index})`) .patch({ values: [currentValues] }); console.log('✓ Updated Excel Job_Folder_Link for pb_id:', pbId); } // API endpoint to submit job app.post('/api/submit', async (c) => { let data: any; try { data = await c.req.json(); const userToken = data.pbToken; if (!userToken) { return c.json({ success: false, message: 'User authentication required. Please log in first.' }, 401); } // Use user's PocketBase token setUserPocketBaseAuth(userToken); // Convert picker values to UTC ISO strings before PocketBase create function toIsoUtc(val: any): string | undefined { if (!val) return undefined; // If already ISO-like, try Date parsing let d = new Date(val); if (isNaN(d.getTime())) { // Try m/d/Y const m = /^\s*(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s*$/.exec(String(val)); if (m) { const month = Number(m[1]); const day = Number(m[2]); const year = Number(m[3].length === 2 ? `20${m[3]}` : m[3]); // Assume local midnight, then convert to UTC d = new Date(year, month - 1, day, 0, 0, 0); } else { return undefined; } } // Normalize to UTC midnight (strip time if present) const utc = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0)); return utc.toISOString(); } const dueDateRaw = data.Due_Date ?? data.due_date ?? data.DueDate ?? data.dueDate; const startDateRaw = data.Start_Date ?? data.start_date ?? data.StartDate ?? data.startDate; const dueIso = toIsoUtc(dueDateRaw); const startIso = toIsoUtc(startDateRaw); if (dueIso) data.Due_Date = dueIso; if (startIso) data.Start_Date = startIso; // Get all existing job numbers and find the max const existingJobs = await pb.collection(process.env.PB_COLLECTION!).getFullList({ fields: 'Job_Number', sort: '-created', }); let maxJobNumber = 0; existingJobs.forEach((job: any) => { const jobNum = parseInt(job.Job_Number, 10); if (!isNaN(jobNum) && jobNum > maxJobNumber) { maxJobNumber = jobNum; } }); // Create new job number as string const newJobNumber = String(maxJobNumber + 1); data.Job_Number = newJobNumber; // Remove pbToken from data before creating record delete data.pbToken; // Create the record in PocketBase as the authenticated user const record = await pb.collection(process.env.PB_COLLECTION!).create(data); console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`); // Persist calculated fields to PocketBase immediately after creation try { const fullName = calculateJobFullName(record); const qbLink = calculateJobQbLink(record); const voxerLinkVal = calculateVoxerLink(record); const jobCodesVal = calculateJobCodes(record); await pb.collection(process.env.PB_COLLECTION!).update(record.id, { Job_Full_Name: fullName, Job_QB_Link: qbLink, Voxer_Link: voxerLinkVal, Job_Codes: jobCodesVal }); // Keep local record in sync for Excel add (record as any).Job_Full_Name = fullName; (record as any).Job_QB_Link = qbLink; (record as any).Voxer_Link = voxerLinkVal; (record as any).Job_Codes = jobCodesVal; console.log('✓ Updated PB with calculated fields (Full Name, QB, Voxer, Codes)'); } catch (pbUpdateErr: any) { console.error('⚠️ Failed to update PB calculated fields:', pbUpdateErr); await logError('PocketBase Calculated Fields Update', pbUpdateErr, { recordId: record.id }); } // Add to Excel first with pb_id = record.id (dynamic formulas for Due_Date_Counter, Active) try { await addToExcel(record, record.id); console.log('✓ Excel row created'); } catch (excelError: any) { console.error('⚠️ Excel sync failed, but PocketBase record was created:', excelError); return c.json({ success: true, warning: 'Job created in PocketBase but Excel sync failed', message: `Job created successfully with Job Number: ${newJobNumber}`, jobId: record.id, jobNumber: newJobNumber, jobFolderLink: null, excelError: excelError.message }); } // After Excel is added, create folder structure and capture share link let jobFolderLink = ''; try { const graphToken = await getGraphToken(); const folderResult = await processNewJobRecord(record, graphToken) as any; if (folderResult.success) { jobFolderLink = folderResult.shareLink || ''; console.log(`✓ Folder created and share link captured: ${jobFolderLink}`); console.log(`📁 Open folder: ${jobFolderLink}`); } else { console.warn(`⚠️ Folder creation failed: ${folderResult.error}`); } } catch (folderError: any) { console.error('⚠️ Folder creation failed, continuing without folder link:', folderError); await logError('Folder Creation', folderError, { jobNumber: newJobNumber, recordId: record.id }); } // Update Excel row with Job_Folder_Link (if available) if (jobFolderLink) { try { await updateExcelFolderLink(record.id, jobFolderLink); } catch (excelUpdateErr: any) { console.error('⚠️ Failed to update Excel with folder link:', excelUpdateErr); await logError('Excel Update Folder Link', excelUpdateErr, { recordId: record.id, jobFolderLink }); } } // Persist static values back to PocketBase: Job_Folder_Link, Due_Date_Counter, Active try { const dueStatic = calculateDueDateCounter(record); const activeStatic = calculateActive(record); await pb.collection(process.env.PB_COLLECTION!).update(record.id, { Job_Folder_Link: jobFolderLink || '', Due_Date_Counter: dueStatic, Active: activeStatic }); (record as any).Job_Folder_Link = jobFolderLink || ''; (record as any).Due_Date_Counter = dueStatic; (record as any).Active = activeStatic; console.log('✓ Updated PB with folder link, due date counter (static), and active (boolean)'); } catch (pbFinalizeErr: any) { console.error('⚠️ Failed to update PB with final static values:', pbFinalizeErr); await logError('PocketBase Final Update', pbFinalizeErr, { recordId: record.id, jobFolderLink }); } // Send Teams notification (fire and forget, don't block response) if (jobFolderLink) { notifyTeamsChannel(record, jobFolderLink).catch(err => { console.error('⚠️ Teams notification error:', err.message); }); } return c.json({ success: true, message: `Job created successfully with Job Number: ${newJobNumber} and synced to Excel`, jobId: record.id, jobNumber: newJobNumber, jobFolderLink: jobFolderLink || null }); } catch (error: any) { console.error('✗ Error submitting job:', error); await logError('Job Submission', error, { jobName: data?.Job_Name, userId: pb.authStore.record?.id }); return c.json({ success: false, message: error.message || 'Failed to create job' }, 400); } }); // Health check endpoint app.get('/api/health', (c) => { return c.json({ status: 'ok', service: 'job-creation-with-excel-sync', timestamp: new Date().toISOString() }); }); const PORT = Number(process.env.PORT || 4000); console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`); // Initialize table cache only (non-blocking) initializeTableCache().catch((error) => { console.error('✗ Failed to initialize table cache:', error); }); export default { port: PORT, fetch: app.fetch, };