v1.0.0-beta3: Teams notifications, Excel formula preservation

This commit is contained in:
2025-12-20 18:51:01 +00:00
parent def6f323df
commit 7b4f58a807
6 changed files with 348 additions and 85 deletions
+258 -77
View File
@@ -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}`);