Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b4f58a807 | |||
| def6f323df | |||
| b8bd3debe0 | |||
| 9c73773c19 | |||
| 340b7e0818 | |||
| 2e7940befa |
@@ -0,0 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
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
|
||||
- Reordered "Schedule & Dates": Start Date → Schedule Confidence → Due Date/Due Time → Due Date Source.
|
||||
- Moved "Company & Contact Information" directly under "Basic Information".
|
||||
- Updated footer version text to v1.0.0-beta2.
|
||||
- Ops
|
||||
- Systemd service now uses `EnvironmentFile` for production env vars; fixed `EnvironmentFile` typo and validated permissions.
|
||||
- Switched service restart policy to `Restart=always` for resilience.
|
||||
- Confirmed service listens on `PORT` (default 4000) and added guidance for restarts after env changes.
|
||||
- Excel Sync
|
||||
- Addressed column mismatch errors by refreshing Excel table column cache on service restart; sync confirmed working.
|
||||
- Versioning
|
||||
- Bumped `package.json` to `1.0.0-beta2` and created git tag `v1.0.0-beta2`.
|
||||
|
||||
## v1.0.0-beta1 - 2025-12-15
|
||||
|
||||
- Initial beta with form submission, PocketBase create, and Excel table sync.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Job Creation Form with Excel Sync
|
||||
|
||||
> Current Version: v1.0.0-beta2
|
||||
|
||||
See CHANGELOG.md for release notes.
|
||||
|
||||
**Unified system** that combines job creation form with immediate Excel synchronization.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "job-folder-graph-integration",
|
||||
"dependencies": {
|
||||
"docx": "^8.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"docx": ["docx@8.6.0", "", { "dependencies": { "@types/node": "^20.3.1", "jszip": "^3.10.1", "nanoid": "^5.0.4", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-JEzPozEsuGIyUkEqdGlCv/b1avYeXjR4PjwiLdPwRKdsI9spBCq4WP0QcGYfIANpgYdJSphw4IT8M/a9dpnpvQ=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||
|
||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
// 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');
|
||||
const { Document, Packer, Paragraph, HeadingLevel, TextRun, convertInchesToTwip } = require('docx');
|
||||
|
||||
/**
|
||||
* Creates a manager info document template
|
||||
* @param {string} jobNumber - Job number
|
||||
* @param {string} jobName - Job name
|
||||
* @param {Object} record - Full job record with all data
|
||||
* @returns {Promise<Buffer>} Word document buffer
|
||||
*/
|
||||
async function createManagerInfoDocument(jobNumber, jobName, record) {
|
||||
// Extract fields from record, with fallbacks
|
||||
const jobAddress = record.Job_Address || record.job_address || '';
|
||||
const companyClient = record.Company_Client || record.company_client || '';
|
||||
const jobType = record.Job_Type || record.job_type || '';
|
||||
const jobDivision = record.Job_Division || record.job_division || '';
|
||||
const contactPerson = record.Contact_Person || record.contact_person || '';
|
||||
const phoneNumber = record.Phone_Number || record.phone_number || '';
|
||||
const jobStatus = record.Job_Status || record.job_status || '';
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{
|
||||
properties: {},
|
||||
children: [
|
||||
// Header: Job # and Name
|
||||
new Paragraph({
|
||||
text: `Job # ${jobNumber}`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { after: 0 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `Name: ${jobName}`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { after: 200 }
|
||||
}),
|
||||
|
||||
// Project Info section
|
||||
new Paragraph({
|
||||
text: 'Project Info',
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: 'Job Name: ' }),
|
||||
new TextRun({ text: jobName, underline: {} })
|
||||
],
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: 'Job Address: ' }),
|
||||
new TextRun({ text: jobAddress, underline: {} })
|
||||
],
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: 'Job Division: ' }),
|
||||
new TextRun({ text: jobDivision, underline: {} })
|
||||
],
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: 'Customer/Client Name: ' }),
|
||||
new TextRun({ text: companyClient, underline: {} })
|
||||
],
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: 'Job Type: ' }),
|
||||
new TextRun({ text: jobType, underline: {} })
|
||||
],
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: 'Job Status: ' }),
|
||||
new TextRun({ text: jobStatus, underline: {} })
|
||||
],
|
||||
spacing: { after: 200 }
|
||||
}),
|
||||
|
||||
// Estimates Information section
|
||||
new Paragraph({
|
||||
text: 'Estimates Information',
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Scope: _________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Main Vendors: _________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Estimate Cost: _________________________________',
|
||||
spacing: { after: 200 }
|
||||
}),
|
||||
|
||||
// Schedule section
|
||||
new Paragraph({
|
||||
text: 'Schedule',
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Expected Start Date: _________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Actual Start Date: _________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Estimated End Date: _________________________________',
|
||||
spacing: { after: 200 }
|
||||
}),
|
||||
|
||||
// Main Contacts section
|
||||
new Paragraph({
|
||||
text: 'Main Contacts',
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Project Manager (Name / Phone / Email):',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `${contactPerson}${phoneNumber ? ' / ' + phoneNumber : ''}`,
|
||||
spacing: { after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Superintendent (Name / Phone / Email):',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Assistant/Coordinator (Name / Phone / Email):',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Billing Manager (Name / Phone / Email):',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 200 }
|
||||
}),
|
||||
|
||||
// Job Notes & Key Info section
|
||||
new Paragraph({
|
||||
text: 'Job Notes & Key Info',
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 50 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: '_________________________________________________________________',
|
||||
spacing: { after: 50 }
|
||||
})
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
return await Packer.toBuffer(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {Object} record - Full job record with all data
|
||||
* @returns {Promise<Object>} Result with folder IDs and share link
|
||||
*/
|
||||
async function createJobFolderAndGetLink(accessToken, jobNumber, jobFullName, record) {
|
||||
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 and upload Manager Info document to Managers Info subfolder
|
||||
console.log('[Job Folder Integration] Creating and uploading Manager Info document');
|
||||
try {
|
||||
const docBuffer = await createManagerInfoDocument(jobNumber, jobFullName, record);
|
||||
const uploadResp = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/drives/${DRIVE_ID}/items/${subFolderId}:/${jobNumber} - Manager Info.docx:/content`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
},
|
||||
body: docBuffer
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadResp.ok) {
|
||||
const errorData = await uploadResp.text();
|
||||
console.warn('[Job Folder Integration] Failed to upload Manager Info document:', {
|
||||
status: uploadResp.status,
|
||||
error: errorData
|
||||
});
|
||||
} else {
|
||||
console.log('[Job Folder Integration] ✓ Manager Info document uploaded successfully');
|
||||
}
|
||||
} catch (docError) {
|
||||
console.warn('[Job Folder Integration] Error creating/uploading Manager Info document:', docError);
|
||||
}
|
||||
|
||||
// 4) 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,
|
||||
createManagerInfoDocument
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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",
|
||||
"docx": "^8.5.0"
|
||||
},
|
||||
"keywords": [
|
||||
"microsoft-graph",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"folder-management"
|
||||
],
|
||||
"author": "Cardoza Construction",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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<Object>} 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,
|
||||
record
|
||||
);
|
||||
|
||||
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
|
||||
};
|
||||
@@ -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 = `<a href="${result.jobFolderLink}" target="_blank" rel="noopener" class="text-blue-600 underline">Open job folder</a>`;
|
||||
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 @@
|
||||
</script>
|
||||
|
||||
<!-- Version Info -->
|
||||
<!-- Folder link container -->
|
||||
<div id="folderLinkContainer" class="fixed bottom-16 right-4 text-sm text-blue-600 hidden"></div>
|
||||
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
|
||||
v1.0.0-beta2
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -48,6 +50,126 @@ async function logError(context: string, error: any, additionalData?: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEAMS WEBHOOK NOTIFICATION (Power Automate)
|
||||
// ============================================================================
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function buildPowerAutomateExcelItem(record: any) {
|
||||
return {
|
||||
'@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 || '')
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!webhook) {
|
||||
console.log('⚠️ TEAMS_WEBHOOK_URL not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const adaptiveCard = buildAdaptiveCard(jobData);
|
||||
const payload = {
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: adaptiveCard
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
console.log(`[Teams Webhook] Sending notification for Job ${jobData.Job_Number || ''}:`, 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(`⚠️ Teams notification failed: ${response.status} ${response.statusText}`);
|
||||
console.warn(` Response: ${text}`);
|
||||
} else {
|
||||
console.log(`✓ Teams notification sent for Job ${jobData.Job_Number || ''} (${response.status})`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('⚠️ Failed to send Teams notification:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable CORS
|
||||
app.use('/*', cors());
|
||||
|
||||
@@ -124,7 +246,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 +254,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 +515,47 @@ 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 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: [[link || '']]
|
||||
});
|
||||
|
||||
console.log('✓ Updated Excel Job_Folder_Link cell for pb_id:', pbId);
|
||||
}
|
||||
|
||||
// API endpoint to submit job
|
||||
app.post('/api/submit', async (c) => {
|
||||
let data: any;
|
||||
@@ -465,9 +628,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 +665,71 @@ 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 });
|
||||
}
|
||||
|
||||
// Send Teams notification (fire and forget, don't block response)
|
||||
notifyTeamsChannel(record).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
|
||||
jobNumber: newJobNumber,
|
||||
jobFolderLink: jobFolderLink || null
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('✗ Error submitting job:', error);
|
||||
@@ -508,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}`);
|
||||
|
||||
Reference in New Issue
Block a user