From 7b4f58a8078c50a9c047c606c3dff953a0df3660 Mon Sep 17 00:00:00 2001 From: aewing Date: Sat, 20 Dec 2025 18:51:01 +0000 Subject: [PATCH] v1.0.0-beta3: Teams notifications, Excel formula preservation --- CHANGELOG.md | 13 + .../job-folder-integration.js | 32 +- logs/dev.out | 7 + logs/server.out | 44 +++ package.json | 2 +- server.ts | 335 ++++++++++++++---- 6 files changed, 348 insertions(+), 85 deletions(-) create mode 100644 logs/dev.out create mode 100644 logs/server.out diff --git a/CHANGELOG.md b/CHANGELOG.md index 872cc0b..b963614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## v1.0.0-beta3 - 2025-12-20 + +- Teams Notifications + - Fixed webhook 400 error by adding `contentUrl: null` to Adaptive Card attachment payload. + - Reformatted card from FactSet to TextBlocks with markdown bold labels (`**Label:** value`) and `spacing: 'None'` for compact rows. + - Added timestamp to notification card. + - Removed folder button/actions from card - notifications now show job info only. + - Removed unused `buildMessageCard` function and simplified `buildAdaptiveCard`/`notifyTeamsChannel` signatures. +- Excel Sync + - Fixed Active formula being overwritten: `updateExcelFolderLink` now uses Graph API `cell(row,column)` method to update only the Job_Folder_Link cell, preserving formulas in other columns. +- Versioning + - Bumped `package.json` to `1.0.0-beta3`. + ## v1.0.0-beta2 - 2025-12-19 - UI diff --git a/extracted-graph-logic/job-folder-integration.js b/extracted-graph-logic/job-folder-integration.js index db62532..7f66b04 100644 --- a/extracted-graph-logic/job-folder-integration.js +++ b/extracted-graph-logic/job-folder-integration.js @@ -3,7 +3,7 @@ // Uses the same Graph API token from the server to avoid re-authentication const { DRIVE_ID, PARENT_ITEM_ID } = require('./config'); -const { Document, Packer, Paragraph, HeadingLevel, convertInchesToTwip } = require('docx'); +const { Document, Packer, Paragraph, HeadingLevel, TextRun, convertInchesToTwip } = require('docx'); /** * Creates a manager info document template @@ -45,27 +45,45 @@ async function createManagerInfoDocument(jobNumber, jobName, record) { spacing: { before: 200, after: 100 } }), new Paragraph({ - text: `Job Name: ${jobName}`, + children: [ + new TextRun({ text: 'Job Name: ' }), + new TextRun({ text: jobName, underline: {} }) + ], spacing: { after: 50 } }), new Paragraph({ - text: `Job Address: ${jobAddress}`, + children: [ + new TextRun({ text: 'Job Address: ' }), + new TextRun({ text: jobAddress, underline: {} }) + ], spacing: { after: 50 } }), new Paragraph({ - text: `Job Division: ${jobDivision}`, + children: [ + new TextRun({ text: 'Job Division: ' }), + new TextRun({ text: jobDivision, underline: {} }) + ], spacing: { after: 50 } }), new Paragraph({ - text: `Customer/Client Name: ${companyClient}`, + children: [ + new TextRun({ text: 'Customer/Client Name: ' }), + new TextRun({ text: companyClient, underline: {} }) + ], spacing: { after: 50 } }), new Paragraph({ - text: `Job Type: ${jobType}`, + children: [ + new TextRun({ text: 'Job Type: ' }), + new TextRun({ text: jobType, underline: {} }) + ], spacing: { after: 50 } }), new Paragraph({ - text: `Job Status: ${jobStatus}`, + children: [ + new TextRun({ text: 'Job Status: ' }), + new TextRun({ text: jobStatus, underline: {} }) + ], spacing: { after: 200 } }), diff --git a/logs/dev.out b/logs/dev.out new file mode 100644 index 0000000..8bea263 --- /dev/null +++ b/logs/dev.out @@ -0,0 +1,7 @@ +nohup: ignoring input +$ bun run server.ts +[dotenv@17.2.3] injecting env (13) from ../secrets/.env -- tip: 🔄 add secrets lifecycle management: https://dotenvx.com/ops +Job Creation with Excel Sync server running at http://localhost:4000 +Started development server: http://localhost:4000 +✓ Table cache initialized with 47 columns +error: script "dev" was terminated by signal SIGTERM (Polite quit request) diff --git a/logs/server.out b/logs/server.out new file mode 100644 index 0000000..8834dc4 --- /dev/null +++ b/logs/server.out @@ -0,0 +1,44 @@ +nohup: ignoring input +[dotenv@17.2.3] injecting env (13) from ../secrets/.env -- tip: 📡 add observability to secrets: https://dotenvx.com/ops +Job Creation with Excel Sync server running at http://localhost:4000 +Started development server: http://localhost:4000 +✓ Table cache initialized with 47 columns +✓ Created PocketBase record with Job_Number: 4764, ID: 99oq6cdykiuoy2y +✓ Updated PB with calculated fields (Full Name, QB, Voxer, Codes) +✓ Synced to Excel (pb_id: 99oq6cdykiuoy2y) +✓ Excel row created +[Post-Record Integration] Processing new job record: { + jobNumber: "4764", + jobName: "Again", + recordId: "99oq6cdykiuoy2y", +} +[Post-Record Integration] Calculated job full name: Again - jlk - abs +[Job Folder Integration] Creating main folder: 4764 - Again - jlk - abs +[Job Folder Integration] Create folder response: { + status: 201, + folderId: "01SPNXLDT4VWRLN7EOKVCJBKYAUJD5WAZL", + folderName: "4764 - Again - jlk - abs", +} +[Job Folder Integration] Creating subfolder: Managers Info +[Job Folder Integration] Create subfolder response: { + status: 201, + subfolderId: "01SPNXLDWATR7EERSJTJAIF2BT3V5NBKDM", +} +[Job Folder Integration] Creating and uploading Manager Info document +[Job Folder Integration] ✓ Manager Info document uploaded successfully +[Job Folder Integration] Creating share link for main folder +[Job Folder Integration] Share link response: { + status: 201, + hasLink: true, +} +[Job Folder Integration] ✓ Successfully created folder structure and share link +[Job Folder Integration] Share link: https://czflex.sharepoint.com/:f:/s/Team/IgB8raK2_I5VRJCrAKJH2wMrAbVvY7DZ4ctPRR_40vi5mJs +[Post-Record Integration] ✓ Folder creation successful +[Post-Record Integration] Share link: https://czflex.sharepoint.com/:f:/s/Team/IgB8raK2_I5VRJCrAKJH2wMrAbVvY7DZ4ctPRR_40vi5mJs +✓ Folder created and share link captured: https://czflex.sharepoint.com/:f:/s/Team/IgB8raK2_I5VRJCrAKJH2wMrAbVvY7DZ4ctPRR_40vi5mJs +📁 Open folder: https://czflex.sharepoint.com/:f:/s/Team/IgB8raK2_I5VRJCrAKJH2wMrAbVvY7DZ4ctPRR_40vi5mJs +✓ Updated Excel Job_Folder_Link for pb_id: 99oq6cdykiuoy2y +✓ Updated PB with folder link, due date counter (static), and active (boolean) +[Teams Webhook] Sending notification for Job 4764 +⚠️ Teams notification failed: 400 Bad Request + Response: Summary or Text is required. diff --git a/package.json b/package.json index d24eb52..69fe7b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "job-creation-with-excel-sync", - "version": "1.0.0-beta2", + "version": "1.0.0-beta3", "description": "Unified job creation form with PocketBase and Excel sync", "type": "module", "scripts": { diff --git a/server.ts b/server.ts index d0dda6b..ee775bc 100644 --- a/server.ts +++ b/server.ts @@ -54,81 +54,101 @@ async function logError(context: string, error: any, additionalData?: any) { // 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 || ''; +function toIsoDateOnly(val: any): string { + if (!val) return ''; + const d = new Date(val); + if (isNaN(d.getTime())) return ''; + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} - // Valid Adaptive Card (Teams flowbot expects type: AdaptiveCard) +function buildPowerAutomateExcelItem(record: any) { 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 } - ] : [] + '@odata.context': '', + '@odata.etag': '', + ItemInternalId: record.id || '', + 'Job_Number': record.Job_Number || record.job_number || '', + 'Job_Name': record.Job_Name || record.job_name || '', + 'Job Address': record.Job_Address || record.job_address || '', + 'Job Type': record.Job_Type || record.job_type || '', + 'Job Status': record.Job_Status || record.job_status || '', + 'Job Division': record.Job_Division || record.job_division || '', + 'Estimator': record.Estimator || record.estimator || '', + 'Office Rep': record.Office_Rep || record.office_rep || '', + 'Manager': record.Manager || record.manager || '', + 'Company/Client': record.Company_Client || record.company_client || '', + 'Contact Person': record.Contact_Person || record.contact_person || '', + 'Phone Number': record.Phone_Number || record.phone_number || '', + 'Email': record.Email || record.email || '', + 'Due Date': toIsoDateOnly(record.Due_Date || record.due_date || ''), + 'Due Date Source': record.Due_Date_Source || record.due_date_source || '', + 'Due Time': record.Due_Time || record.due_time || '', + 'Start Date': toIsoDateOnly(record.Start_Date || record.start_date || '') }; } -async function notifyTeamsChannel(jobData: any, jobFolderLink: string) { - const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL; +function buildAdaptiveCard(jobData: any) { + const jobName = jobData.Job_Name || ''; + const jobNumber = jobData.Job_Number || ''; + const jobAddress = jobData.Job_Address || ''; + const jobDivision = jobData.Job_Division || ''; + + // Format timestamp for the card + const now = new Date(); + const timestamp = now.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + return { + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.4', + body: [ + { + type: 'Container', + style: 'emphasis', + items: [ + { type: 'TextBlock', text: 'New Job!', weight: 'Bolder', size: 'Large', spacing: 'None' }, + { type: 'TextBlock', text: `**Number:** ${jobNumber || 'N/A'}`, spacing: 'Small' }, + { type: 'TextBlock', text: `**Name:** ${jobName || 'N/A'}`, spacing: 'None' }, + { type: 'TextBlock', text: `**Address:** ${jobAddress || 'N/A'}`, spacing: 'None' }, + { type: 'TextBlock', text: `**Division:** ${jobDivision || 'N/A'}`, spacing: 'None' }, + { type: 'TextBlock', text: timestamp, size: 'Small', isSubtle: true, spacing: 'Small' } + ] + } + ] + }; +} + +async function notifyTeamsChannel(jobData: any) { + const webhook = process.env.TEAMS_WEBHOOK_URL; - // Skip if webhook not configured if (!webhook) { - console.log('⚠️ Power Automate webhook not configured (POWER_AUTOMATE_WEBHOOK_URL missing)'); + console.log('⚠️ TEAMS_WEBHOOK_URL not configured'); 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 adaptiveCard = buildAdaptiveCard(jobData); 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 + type: 'message', + attachments: [ + { + contentType: 'application/vnd.microsoft.card.adaptive', + contentUrl: null, + content: adaptiveCard + } + ] }; - console.log(`[Power Automate] Sending webhook for Job ${jobNumber}:`, JSON.stringify(payload, null, 2)); + console.log(`[Teams Webhook] Sending notification for Job ${jobData.Job_Number || ''}:`, JSON.stringify(payload, null, 2)); const response = await fetch(webhook, { method: 'POST', @@ -140,14 +160,13 @@ async function notifyTeamsChannel(jobData: any, jobFolderLink: string) { if (!response.ok) { const text = await response.text(); - console.warn(`⚠️ Power Automate notification failed: ${response.status} ${response.statusText}`); + console.warn(`⚠️ Teams notification failed: ${response.status} ${response.statusText}`); console.warn(` Response: ${text}`); } else { - console.log(`✓ Power Automate notification sent for Job ${jobNumber} (${response.status})`); + console.log(`✓ Teams notification sent for Job ${jobData.Job_Number || ''} (${response.status})`); } } catch (error: any) { - console.error('⚠️ Failed to send Power Automate notification:', error.message); - // Don't fail the job creation if notification fails + console.error('⚠️ Failed to send Teams notification:', error.message); } } @@ -526,16 +545,15 @@ async function updateExcelFolderLink(pbId: string, link: string) { 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})`) + // Update only the Job_Folder_Link cell to preserve formulas in other columns (e.g., Active) + // Use the table row's range and cell() method to target just the one cell + // This avoids hardcoding worksheet names and works through the table structure + await client.api(`${excelTablePath}/rows/itemAt(index=${target.index})/range/cell(row=0,column=${linkColIndex})`) .patch({ - values: [currentValues] + values: [[link || '']] }); - console.log('✓ Updated Excel Job_Folder_Link for pb_id:', pbId); + console.log('✓ Updated Excel Job_Folder_Link cell for pb_id:', pbId); } // API endpoint to submit job @@ -702,11 +720,9 @@ app.post('/api/submit', async (c) => { } // Send Teams notification (fire and forget, don't block response) - if (jobFolderLink) { - notifyTeamsChannel(record, jobFolderLink).catch(err => { - console.error('⚠️ Teams notification error:', err.message); - }); - } + notifyTeamsChannel(record).catch(err => { + console.error('⚠️ Teams notification error:', err.message); + }); return c.json({ success: true, @@ -737,6 +753,171 @@ app.get('/api/health', (c) => { }); }); +// Test route: sends an exact sample Adaptive Card payload (no envelope) +app.post('/api/test-notification', async (c) => { + const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL; + if (!webhook) { + return c.json({ success: false, message: 'Webhook not configured' }, 400); + } + + const payload = { + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.4', + body: [ + { type: 'TextBlock', text: 'New Job!', weight: 'Bolder', size: 'Large' }, + { type: 'TextBlock', text: '4766 - Teams Card v1.4', wrap: true }, + { + type: 'FactSet', + facts: [ + { title: 'Number', value: '4766' }, + { title: 'Name', value: 'Teams Card v1.4' }, + { title: 'Address', value: 'Blue eye' }, + { title: 'Client', value: 'ClientCo' }, + { title: 'Division', value: 'C#' }, + { title: 'Type', value: 'PWEX' }, + { title: 'Contact', value: 'Wyatt Gann - (417) 429-1417 x117' } + ] + } + ], + actions: [ + { + type: 'Action.OpenUrl', + title: 'Open Folder', + url: 'https://czflex.sharepoint.com/:f:/s/Team/IgCD37kQLFb6Q7BsoWjFb5rlAWkqMkqyX2zhoQpRMh9hUiI' + } + ] + }; + + const resp = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const ok = resp.ok; + const text = await resp.text(); + return c.json({ success: ok, status: resp.status, body: text }); +}); + +// Test route: sends the same sample card wrapped in a Teams message envelope +app.post('/api/test-notification-envelope', async (c) => { + const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL; + if (!webhook) { + return c.json({ success: false, message: 'Webhook not configured' }, 400); + } + + const card = { + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.4', + body: [ + { type: 'TextBlock', text: 'New Job!', weight: 'Bolder', size: 'Large' }, + { type: 'TextBlock', text: '4766 - Teams Card v1.4', wrap: true }, + { + type: 'FactSet', + facts: [ + { title: 'Number', value: '4766' }, + { title: 'Name', value: 'Teams Card v1.4' }, + { title: 'Address', value: 'Blue eye' }, + { title: 'Client', value: 'ClientCo' }, + { title: 'Division', value: 'C#' }, + { title: 'Type', value: 'PWEX' }, + { title: 'Contact', value: 'Wyatt Gann - (417) 429-1417 x117' } + ] + } + ], + actions: [ + { + type: 'Action.OpenUrl', + title: 'Open Folder', + url: 'https://czflex.sharepoint.com/:f:/s/Team/IgCD37kQLFb6Q7BsoWjFb5rlAWkqMkqyX2zhoQpRMh9hUiI' + } + ] + }; + + const payload = { + type: 'message', + text: 'New Job!', + attachments: [ + { + contentType: 'application/vnd.microsoft.card.adaptive', + content: card + } + ] + }; + + const resp = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const ok = resp.ok; + const text = await resp.text(); + return c.json({ success: ok, status: resp.status, body: text }); +}); + +// Test route: post exact sample card directly to Teams Incoming Webhook (no envelope) +app.post('/api/test-teams-webhook', async (c) => { + const webhook = process.env.TEAMS_WEBHOOK_URL; + if (!webhook) { + return c.json({ success: false, message: 'TEAMS_WEBHOOK_URL not configured' }, 400); + } + + // Format timestamp for the card + const now = new Date(); + const timestamp = now.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + const adaptiveCard = { + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.4', + body: [ + { + type: 'Container', + style: 'emphasis', + items: [ + { type: 'TextBlock', text: 'New Job!', weight: 'Bolder', size: 'Large', spacing: 'None' }, + { type: 'TextBlock', text: '**Number:** 4738', spacing: 'Small' }, + { type: 'TextBlock', text: '**Name:** New VO-AG Facility & Early Childhood Addition', spacing: 'None' }, + { type: 'TextBlock', text: '**Address:** Blue eye', spacing: 'None' }, + { type: 'TextBlock', text: '**Division:** C#', spacing: 'None' }, + { type: 'TextBlock', text: timestamp, size: 'Small', isSubtle: true, spacing: 'Small' } + ] + } + ] + }; + + const payload = { + type: 'message', + attachments: [ + { + contentType: 'application/vnd.microsoft.card.adaptive', + contentUrl: null, + content: adaptiveCard + } + ] + }; + + const resp = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const ok = resp.ok; + const text = await resp.text(); + return c.json({ success: ok, status: resp.status, body: text }); +}); + const PORT = Number(process.env.PORT || 4000); console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`);