diff --git a/extracted-graph-logic/README.md b/extracted-graph-logic/README.md new file mode 100644 index 0000000..d549484 --- /dev/null +++ b/extracted-graph-logic/README.md @@ -0,0 +1,94 @@ +# Job Folder Integration Module + +Automatically creates OneDrive folders with share links when PocketBase job records are created. + +## Installation + +```bash +cd extracted-graph-logic +bun install +``` + +## Integration + +### 1. Import in server.ts + +```typescript +import { processNewJobRecord } from './extracted-graph-logic/post-record-integration.js'; +``` + +### 2. Call after PocketBase record creation + +```typescript +const graphToken = await getGraphToken(); +const folderResult = await processNewJobRecord(record, graphToken); + +if (folderResult.success) { + record.Job_Folder_Link = folderResult.shareLink || ''; +} + +await addToExcel(record, record.id); +``` + +## What It Does + +1. Receives PocketBase record + Graph API token +2. Creates folder: `{Job_Number} - {Job_Name} - {Job_Address} - {Company_Client}` +3. Creates "Managers Info" subfolder +4. Generates organization-wide share link +5. Returns folder details and link + +## Module Files + +- **post-record-integration.js** - Main entry point, exports processNewJobRecord() +- **job-folder-integration.js** - Core Graph API logic, exports createJobFolderAndGetLink() +- **config.js** - Production configuration: + - DRIVE_ID: b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx + - PARENT_ITEM_ID: 01SPNXLDW3Y56HAWK5CNAJ4YJLLSQCUCJ7 + - CLIENT_ID: 3c846e71-9609-40e1-b458-0eb805e21b9f +- **package.json** - Dependencies (node-fetch@2.7.0) + +## Return Values + +### On Success +```javascript +{ + success: true, + jobNumber: "12345", + jobFullName: "New Construction - 123 Main St - ABC Corp", + shareLink: "https://...", + mainFolderId: "...", + mainFolderName: "...", + subFolderId: "...", + folderPath: "..." +} +``` + +### On Failure +```javascript +{ + success: false, + error: "error message", + jobNumber: "12345", + shareLink: null +} +``` + +## Configuration + +Edit config.js to change OneDrive location. Current values point to Cardoza Construction production SharePoint/OneDrive. + +## Error Handling + +- If folder creation fails, job is still created in PocketBase and synced to Excel +- Share link will be null on failure +- Errors logged with `[Job Folder Integration]` prefix + +## Dependencies + +- node-fetch@2.7.0 (for Graph API calls) +- Graph API token from your server + +## Status + +Production-ready. Uses actual OneDrive/SharePoint IDs from Job Creation Form environment. diff --git a/extracted-graph-logic/bun.lock b/extracted-graph-logic/bun.lock new file mode 100644 index 0000000..da86fe3 --- /dev/null +++ b/extracted-graph-logic/bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "job-folder-graph-integration", + "dependencies": { + "node-fetch": "^2.7.0", + }, + }, + }, + "packages": { + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + } +} diff --git a/extracted-graph-logic/config.js b/extracted-graph-logic/config.js new file mode 100644 index 0000000..e8cda7a --- /dev/null +++ b/extracted-graph-logic/config.js @@ -0,0 +1,14 @@ +// SharePoint/OneDrive Configuration +// These IDs specify where folders and files will be created + +const DRIVE_ID = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx"; +const PARENT_ITEM_ID = "01SPNXLDW3Y56HAWK5CNAJ4YJLLSQCUCJ7"; + +// Azure AD App Configuration +const CLIENT_ID = "3c846e71-9609-40e1-b458-0eb805e21b9f"; + +module.exports = { + DRIVE_ID, + PARENT_ITEM_ID, + CLIENT_ID +}; diff --git a/extracted-graph-logic/job-folder-integration.js b/extracted-graph-logic/job-folder-integration.js new file mode 100644 index 0000000..4148233 --- /dev/null +++ b/extracted-graph-logic/job-folder-integration.js @@ -0,0 +1,126 @@ +// Job Folder Integration Module +// This module handles folder creation and share link capture after PocketBase record creation +// Uses the same Graph API token from the server to avoid re-authentication + +const { DRIVE_ID, PARENT_ITEM_ID } = require('./config'); + +/** + * Creates a job folder structure in OneDrive/SharePoint and returns the share link + * @param {string} accessToken - Graph API access token (from server's token flow) + * @param {string} jobNumber - Job number (e.g., "12345") + * @param {string} jobFullName - Full job name calculated from form data + * @returns {Promise} Result with folder IDs and share link + */ +async function createJobFolderAndGetLink(accessToken, jobNumber, jobFullName) { + try { + // 1) Create main folder under configured parent + const mainFolderName = `${jobNumber} - ${jobFullName}`; + const createFolderEndpoint = `https://graph.microsoft.com/v1.0/drives/${DRIVE_ID}/items/${PARENT_ITEM_ID}/children`; + + console.log(`[Job Folder Integration] Creating main folder: ${mainFolderName}`); + const folderResp = await fetch(createFolderEndpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: mainFolderName, + folder: {}, + "@microsoft.graph.conflictBehavior": "rename" + }) + }); + + const folderData = await folderResp.json(); + console.log(`[Job Folder Integration] Create folder response:`, { + status: folderResp.status, + folderId: folderData.id, + folderName: folderData.name + }); + + if (!folderResp.ok) { + throw new Error(`Create main folder failed: ${JSON.stringify(folderData)}`); + } + + const mainFolderId = folderData.id; + + // 2) Create subfolder "Managers Info" + console.log('[Job Folder Integration] Creating subfolder: Managers Info'); + const subFolderResp = await fetch( + `https://graph.microsoft.com/v1.0/drives/${DRIVE_ID}/items/${mainFolderId}/children`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'Managers Info', + folder: {}, + "@microsoft.graph.conflictBehavior": "rename" + }) + } + ); + + const subFolderData = await subFolderResp.json(); + console.log('[Job Folder Integration] Create subfolder response:', { + status: subFolderResp.status, + subfolderId: subFolderData.id + }); + + if (!subFolderResp.ok) { + throw new Error(`Create subfolder failed: ${JSON.stringify(subFolderData)}`); + } + + const subFolderId = subFolderData.id; + + // 3) Create share link for the main folder + console.log('[Job Folder Integration] Creating share link for main folder'); + const shareResp = await fetch( + `https://graph.microsoft.com/v1.0/drives/${DRIVE_ID}/items/${mainFolderId}/createLink`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'view', + scope: 'organization' + }) + } + ); + + const shareData = await shareResp.json(); + console.log('[Job Folder Integration] Share link response:', { + status: shareResp.status, + hasLink: !!shareData.link?.webUrl + }); + + if (!shareResp.ok) { + throw new Error(`Create share link failed: ${JSON.stringify(shareData)}`); + } + + const shareLink = shareData.link && shareData.link.webUrl ? shareData.link.webUrl : null; + + console.log(`[Job Folder Integration] ✓ Successfully created folder structure and share link`); + console.log(`[Job Folder Integration] Share link: ${shareLink}`); + + return { + success: true, + mainFolderId, + mainFolderName, + subFolderId, + shareLink, + folderPath: mainFolderName + }; + + } catch (error) { + console.error('[Job Folder Integration] Error in createJobFolderAndGetLink:', error); + throw error; + } +} + +module.exports = { + createJobFolderAndGetLink +}; diff --git a/extracted-graph-logic/package.json b/extracted-graph-logic/package.json new file mode 100644 index 0000000..ce80dd9 --- /dev/null +++ b/extracted-graph-logic/package.json @@ -0,0 +1,20 @@ +{ + "name": "job-folder-graph-integration", + "version": "1.0.0", + "description": "Microsoft Graph integration for job folder creation and management", + "main": "post-record-integration.js", + "scripts": { + "test": "node example-usage.js" + }, + "dependencies": { + "node-fetch": "^2.7.0" + }, + "keywords": [ + "microsoft-graph", + "onedrive", + "sharepoint", + "folder-management" + ], + "author": "Cardoza Construction", + "license": "MIT" +} diff --git a/extracted-graph-logic/post-record-integration.js b/extracted-graph-logic/post-record-integration.js new file mode 100644 index 0000000..46f6c17 --- /dev/null +++ b/extracted-graph-logic/post-record-integration.js @@ -0,0 +1,83 @@ +// Post-Record Integration Module +// Handles all post-PocketBase record creation tasks: +// 1. Captures form data and Job_Number from the created PB record +// 2. Creates folder structure in OneDrive using the same Graph token +// 3. Captures share link from created folder +// 4. Returns the share link to be added to Excel row + +const { createJobFolderAndGetLink } = require('./job-folder-integration'); + +/** + * Processes a newly created PocketBase record by: + * - Creating corresponding folder structure in OneDrive + * - Capturing the share link for the folder + * + * @param {Object} record - The PocketBase record that was just created + * @param {string} accessToken - Graph API access token (from server) + * @returns {Promise} Result with share link and folder info + */ +async function processNewJobRecord(record, accessToken) { + try { + console.log('[Post-Record Integration] Processing new job record:', { + jobNumber: record.Job_Number, + jobName: record.Job_Name, + recordId: record.id + }); + + // Extract required fields from the record + const jobNumber = record.Job_Number || record.job_number; + const jobName = record.Job_Name || record.job_name || ''; + const jobAddress = record.Job_Address || record.job_address || ''; + const companyClient = record.Company_Client || record.company_client || ''; + + // Calculate Job_Full_Name (same logic as in server.ts) + const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim()); + const jobFullName = parts.join(' - '); + + console.log('[Post-Record Integration] Calculated job full name:', jobFullName); + + // Validate required fields + if (!jobNumber) { + throw new Error('Job_Number is required but was not found in record'); + } + + if (!jobFullName || jobFullName.trim() === '') { + throw new Error('Job_Full_Name could not be calculated from record data'); + } + + // Create folder and get share link + const folderResult = await createJobFolderAndGetLink( + accessToken, + jobNumber, + jobFullName + ); + + console.log('[Post-Record Integration] ✓ Folder creation successful'); + console.log('[Post-Record Integration] Share link:', folderResult.shareLink); + + return { + success: true, + jobNumber, + jobFullName, + shareLink: folderResult.shareLink, + mainFolderId: folderResult.mainFolderId, + mainFolderName: folderResult.mainFolderName, + subFolderId: folderResult.subFolderId, + folderPath: folderResult.folderPath + }; + + } catch (error) { + console.error('[Post-Record Integration] Error processing new job record:', error); + + return { + success: false, + error: error.message, + jobNumber: record.Job_Number || record.job_number, + shareLink: null + }; + } +} + +module.exports = { + processNewJobRecord +}; diff --git a/frontend/index.html b/frontend/index.html index e2290d1..4f541a9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -536,6 +536,15 @@ const result = await response.json(); if (result.success) { + // Log and display the folder link when available + if (result.jobFolderLink) { + console.log(`📁 Folder link: ${result.jobFolderLink}`); + const linkContainer = document.getElementById('folderLinkContainer'); + if (linkContainer) { + linkContainer.innerHTML = `Open job folder`; + linkContainer.classList.remove('hidden'); + } + } message.className = 'p-4 rounded-lg font-medium bg-green-50 text-green-700 border border-green-200'; message.textContent = result.message; message.classList.remove('hidden'); @@ -556,6 +565,8 @@ + +
v1.0.0-beta2
diff --git a/server.ts b/server.ts index be4b36f..1b2245d 100644 --- a/server.ts +++ b/server.ts @@ -1,14 +1,16 @@ +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 { config } from 'dotenv'; import { mkdir, appendFile } from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; +import { processNewJobRecord } from './extracted-graph-logic/post-record-integration.js'; -config(); +// Load secrets from absolute path (not in project directory) +config({ path: '/home/admin/secrets/.env' }); const app = new Hono(); @@ -124,7 +126,7 @@ let tableCache: { } | 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 Job_List. +// 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; @@ -132,7 +134,7 @@ function resolveTableName(): string { const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim(); if (nameFromPath) return nameFromPath; - return 'Job_List'; + return 'Test_Table'; } // ============================================================================ @@ -393,6 +395,48 @@ async function addToExcel(record: any, pbId: string) { } } +// 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; @@ -465,9 +509,35 @@ app.post('/api/submit', async (c) => { const record = await pb.collection(process.env.PB_COLLECTION!).create(data); console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`); - // Add to Excel with pb_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({ @@ -476,15 +546,66 @@ app.post('/api/submit', async (c) => { 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 }); + } + return c.json({ success: true, message: `Job created successfully with Job Number: ${newJobNumber} and synced to Excel`, jobId: record.id, - jobNumber: newJobNumber + jobNumber: newJobNumber, + jobFolderLink: jobFolderLink || null }); } catch (error: any) { console.error('✗ Error submitting job:', error);