10 Commits

Author SHA1 Message Date
aewing 5d9abb4cae v1.0.0-beta3: Add Planner task creation with notes, fix folder name trimming, fix Excel folder link fallback 2025-12-22 20:00:45 +00:00
aewing 65fb9a52d9 docs: add Teams API limitations guide
- Explain why channel message deletion isn't supported (HTTP 405)
- Document three workaround options for production
- Clarify this is a Microsoft platform limitation
- Provide references and future guidance
2025-12-20 20:07:35 +00:00
aewing 513b3ca76f version: v1.0.0-beta4 - Teams message management with deletion limitation docs
- Document Teams API limitation for channel message deletion
- Clearly state this is a platform limitation, not an application bug
- Provide workarounds for users
2025-12-20 20:07:17 +00:00
aewing 611be70362 docs: explain Teams API limitation for message deletion
- Microsoft Teams Graph API does not support DELETE on channel messages
- This is a platform limitation, not an application error
- Updated UI to show clear warning about Teams API limitation
- Updated error messages to guide users on alternatives:
  - Delete via Teams client UI
  - Use delegated token with ChatMessage.ReadWrite.All permission
- HTTP 405 (Method Not Allowed) is the expected response from Graph API
2025-12-20 20:07:02 +00:00
aewing 7b4f58a807 v1.0.0-beta3: Teams notifications, Excel formula preservation 2025-12-20 18:51:01 +00:00
aewing def6f323df fix: send Teams adaptive card as string in webhook payload (card) to satisfy flowbot parser 2025-12-20 05:54:17 +00:00
aewing b8bd3debe0 feat: add Power Automate webhook integration for job notifications with task creation support 2025-12-20 05:38:15 +00:00
aewing 9c73773c19 feat: add Manager Info document generation in Managers Info folder
- Create Word document template with job information pre-populated
- Auto-populate: Job Name, Address, Division, Client, Type, Status, Contact info
- Include fillable fields: Scope, Vendors, Estimate Cost, Dates, Team Contacts, Notes
- Document named [JobNumber] - Manager Info.docx for easy identification
- Uses docx library to generate professional Word documents
- Document uploaded automatically during job folder creation
2025-12-20 05:16:39 +00:00
aewing 340b7e0818 feat: implement dotenv secrets loading from /home/admin/secrets/.env
- Added dotenv package to load environment variables from absolute path
- Secrets now loaded automatically on app startup, no manual sourcing needed
- Secrets file secured with chmod 600, outside project directory
- Enables persistent secrets across service restarts
- Tested: Job submission flow working end-to-end with all steps completing
2025-12-20 04:56:16 +00:00
aewing 2e7940befa docs: update README with version and add CHANGELOG for v1.0.0-beta2 2025-12-19 22:04:59 +00:00
16 changed files with 1950 additions and 9 deletions
+22
View File
@@ -0,0 +1,22 @@
# Changelog
All notable changes to this project will be documented in this file.
## 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.
+4
View File
@@ -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
+112
View File
@@ -0,0 +1,112 @@
# Microsoft Teams API Limitations & Workarounds
## Problem: Cannot Delete Channel Messages
When attempting to delete Teams channel messages via the Microsoft Graph REST API, you'll receive an **HTTP 405 (Method Not Allowed)** error.
### Root Cause
Microsoft Teams Graph API **does not support deletion of channel messages** through the standard REST API endpoint:
```
DELETE /teams/{teamId}/channels/{channelId}/messages/{messageId}
```
This is a **platform limitation by Microsoft**, not an application error.
### Why This Limitation Exists
- **Security & Compliance**: Teams maintains message immutability for audit/compliance purposes in many organizations.
- **Design Decision**: Microsoft intentionally restricts programmatic message deletion to prevent data loss.
- **Channel vs. Chat**: The limitation applies specifically to *channel messages*; 1:1 chat messages have different permission models.
## Workarounds
### Option 1: Delete via Teams Client (Recommended for Testing)
1. Open Microsoft Teams
2. Navigate to the channel
3. Right-click on the message
4. Select "Delete"
5. Confirm deletion
This works because Teams client has special privileges that REST API doesn't.
### Option 2: Use Delegated Permissions (For Production Apps)
If you need programmatic deletion, you can:
1. **Configure Azure AD App Registration** with delegated permissions:
- Add permission: `ChatMessage.ReadWrite.All` (delegated)
- Requires admin consent
- Must use user authentication (not app-only)
2. **Obtain User's Access Token** (instead of app-only token):
```typescript
// Example: Interactive login or on-behalf-of flow
const userToken = await acquireTokenInteractive();
```
3. **Update Delete Endpoint** to accept delegated tokens:
```typescript
const client = Client.init({
authProvider: (done) => done(null, userToken)
});
```
4. **Test Deletion**:
- Provide the user token in the Authorization header
- Note: May still fail if user lacks permissions in the team/channel
### Option 3: Use Microsoft Teams Bot
Create a Teams bot with `ChatMessage.ReadWrite.All` permission in bot-specific context. This requires:
- Registering a Teams bot
- Setting up bot credentials
- Handling message events through bot framework
More complex but potentially the most flexible long-term solution.
## Current Application Status
### What Works ✅
- List Teams channels
- List channel messages with full details
- Preview message content and Adaptive Cards
- Select and mark messages for deletion
### What Doesn't Work ❌
- Delete channel messages via REST API (Microsoft limitation)
- No workaround without changing auth strategy
## Recommended Approach
For **testing/cleanup scenarios**:
- Use the Teams client UI to manually delete test messages
- Or use the UI to identify which messages to delete, then delete them manually
For **production scenarios**:
- Implement delegated token authentication with user consent
- OR use a Teams bot with appropriate permissions
- OR accept that message deletion requires Teams client interaction
## Error Response
When deletion is attempted:
```json
{
"success": false,
"message": "Teams API limitation: Channel message deletion not supported via REST API. Delete manually from Teams client or grant ChatMessage.ReadWrite.All delegated permission with user token.",
"isTeamsLimitation": true
}
```
The `isTeamsLimitation` flag helps distinguish between actual errors and known platform limitations.
## References
- Microsoft Teams Documentation: [Update a chatMessage](https://learn.microsoft.com/en-us/graph/api/chatmessage-update)
- Graph API Permissions: [ChatMessage Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference#chat-message-permissions)
- Teams Bot Framework: [Microsoft Teams Bot Framework](https://learn.microsoft.com/en-us/azure/bot-service/channel-connect-teams)
## Future Considerations
Monitor Microsoft's Graph API changelog for potential changes to message deletion restrictions. This limitation may be relaxed in future API versions.
+94
View File
@@ -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.
+62
View File
@@ -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=="],
}
}
+14
View File
@@ -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
};
+21
View File
@@ -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
};
+1 -1
View File
@@ -557,7 +557,7 @@
<!-- Version Info -->
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
v1.0.0-beta2
v1.0.0-beta3
</div>
</body>
</html>
+349
View File
@@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Teams Channel Messages</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: { extend: { colors: { primary: '#4c51bf', secondary: '#5b21b6' } } }
}
</script>
</head>
<body class="min-h-screen bg-gray-50">
<div class="max-w-5xl mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800">Teams Channel Messages</h1>
<a href="/index.html" class="text-sm text-blue-600 underline">Back to Form</a>
</div>
<!-- Info about Teams API Limitation -->
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
<strong>⚠️ Note:</strong> Microsoft Teams API does not support deletion of channel messages via REST API.
To delete messages, either:
<ul class="ml-4 mt-2 space-y-1">
<li>• Delete directly in Teams (right-click message → Delete)</li>
<li>• Provide a delegated user token with <code class="bg-yellow-100 px-1 rounded">ChatMessage.ReadWrite.All</code> permission</li>
</ul>
</div>
<!-- Controls -->
<div class="bg-white rounded-xl shadow p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Team ID</label>
<input id="teamId" type="text" placeholder="e.g. e45f..." class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Channel ID</label>
<input id="channelId" type="text" placeholder="e.g. 19:...@thread.tacv2" class="w-full px-3 py-2 border rounded-lg" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer Token (optional)</label>
<input id="bearerToken" type="text" placeholder="Paste delegated token if needed" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Max Results</label>
<input id="top" type="number" min="1" max="50" value="50" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div class="flex items-end">
<button id="loadBtn" class="w-full py-2 bg-primary text-white rounded-lg hover:opacity-95">Load Messages</button>
</div>
</div>
<div id="status" class="mt-3 text-sm text-gray-600"></div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 mb-3">
<button id="selectAllBtn" class="px-3 py-2 bg-gray-200 rounded-lg text-sm">Select All</button>
<button id="clearSelectionBtn" class="px-3 py-2 bg-gray-200 rounded-lg text-sm">Clear</button>
<button id="deleteSelectedBtn" class="px-3 py-2 bg-red-600 text-white rounded-lg text-sm">Delete Selected</button>
<span id="deleteSummary" class="ml-2 text-sm text-gray-600"></span>
<div class="ml-auto flex items-center gap-2">
<label class="text-sm text-gray-700">
<input id="toggleRaw" type="checkbox" class="mr-1 align-middle" /> Show raw message JSON
</label>
</div>
</div>
<!-- Messages List -->
<div id="messagesContainer" class="bg-white rounded-xl shadow divide-y"></div>
</div>
<script>
const teamIdEl = document.getElementById('teamId');
const channelIdEl = document.getElementById('channelId');
const bearerEl = document.getElementById('bearerToken');
const topEl = document.getElementById('top');
const loadBtn = document.getElementById('loadBtn');
const statusEl = document.getElementById('status');
const messagesContainer = document.getElementById('messagesContainer');
const selectAllBtn = document.getElementById('selectAllBtn');
const clearSelectionBtn = document.getElementById('clearSelectionBtn');
const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
const deleteSummaryEl = document.getElementById('deleteSummary');
// Helper: URL params
function getParams() {
const u = new URL(window.location.href);
const p = Object.fromEntries(u.searchParams.entries());
return p;
}
// Pre-fill from last-used IDs if available (from localStorage)
const LS_KEYS = { team: 'teamsMsg.teamId', channel: 'teamsMsg.channelId', top: 'teamsMsg.top' };
const params = getParams();
teamIdEl.value = params.teamId || localStorage.getItem(LS_KEYS.team) || '';
channelIdEl.value = params.channelId || localStorage.getItem(LS_KEYS.channel) || '';
topEl.value = params.top || localStorage.getItem(LS_KEYS.top) || '50';
function savePrefs() {
localStorage.setItem(LS_KEYS.team, teamIdEl.value.trim());
localStorage.setItem(LS_KEYS.channel, channelIdEl.value.trim());
localStorage.setItem(LS_KEYS.top, topEl.value);
}
function renderMessages(messages) {
messagesContainer.innerHTML = '';
if (!messages || !messages.length) {
messagesContainer.innerHTML = '<div class="p-4 text-sm text-gray-600">No messages found.</div>';
return;
}
messages.forEach((m) => {
const row = document.createElement('div');
row.className = 'p-4 flex gap-3 items-start';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'mt-1';
checkbox.dataset.messageId = m.id;
const info = document.createElement('div');
info.className = 'flex-1';
const header = document.createElement('div');
header.className = 'flex flex-wrap items-center gap-2 text-sm text-gray-700';
const idEl = document.createElement('span');
idEl.className = 'font-mono text-xs bg-gray-100 px-1 rounded';
idEl.textContent = m.id;
const timeEl = document.createElement('span');
const dt = m.createdDateTime ? new Date(m.createdDateTime) : null;
timeEl.textContent = dt ? dt.toLocaleString() : '';
const fromEl = document.createElement('span');
fromEl.textContent = m.from ? `by ${m.from}` : '';
header.append(idEl, timeEl, fromEl);
const subjectEl = document.createElement('div');
subjectEl.className = 'text-sm font-semibold';
subjectEl.textContent = m.subject || m.summary || '';
// Extract and display Adaptive Card data prominently
const cardWrap = document.createElement('div');
if (Array.isArray(m.attachments) && m.attachments.length) {
m.attachments.forEach(a => {
if (a.contentType === 'application/vnd.microsoft.card.adaptive' && a.content) {
try {
const card = typeof a.content === 'string' ? JSON.parse(a.content) : a.content;
const title = document.createElement('div');
title.className = 'mt-2 text-xs font-semibold text-gray-700';
title.textContent = 'Job Card Details';
cardWrap.appendChild(title);
// Extract key text blocks from card body
if (card.body && Array.isArray(card.body)) {
card.body.forEach((section) => {
if (section.type === 'Container' && Array.isArray(section.items)) {
section.items.forEach((item) => {
if (item.type === 'TextBlock' && item.text) {
const textEl = document.createElement('div');
textEl.className = 'text-sm text-gray-800 mt-1';
textEl.textContent = item.text;
cardWrap.appendChild(textEl);
}
});
}
});
}
// Show full card JSON in a collapsed details element
const cardDetails = document.createElement('details');
cardDetails.className = 'mt-2';
const cardSummary = document.createElement('summary');
cardSummary.className = 'text-xs text-blue-700 cursor-pointer';
cardSummary.textContent = 'Show full Adaptive Card JSON';
const cardJsonPre = document.createElement('pre');
cardJsonPre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-green-50 p-2 rounded border border-green-200';
cardJsonPre.textContent = JSON.stringify(card, null, 2);
cardDetails.append(cardSummary, cardJsonPre);
cardWrap.appendChild(cardDetails);
} catch (e) {
// If not valid JSON, show raw content
const att = document.createElement('div');
att.className = 'mt-2 text-xs text-gray-700';
att.textContent = `${a.contentType}`;
cardWrap.appendChild(att);
const pre = document.createElement('pre');
pre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-green-50 p-2 rounded border border-green-200';
pre.textContent = typeof a.content === 'string' ? a.content : JSON.stringify(a.content, null, 2);
cardWrap.appendChild(pre);
}
}
});
}
// Body content (if present)
const bodyWrap = document.createElement('div');
if (m.bodyContent) {
const bodyType = document.createElement('div');
bodyType.className = 'text-[11px] text-gray-500 mt-2';
bodyType.textContent = `Body Text (${m.bodyContentType || 'unknown'})`;
const bodyText = document.createElement('div');
bodyText.className = 'text-xs text-gray-800 whitespace-pre-wrap';
// Derive plain text from HTML if needed
const tmpDiv = document.createElement('div');
tmpDiv.innerHTML = m.bodyContent || '';
const plain = (m.bodyContentType === 'html') ? (tmpDiv.textContent || '') : (m.bodyContent || '');
bodyText.textContent = plain;
const bodyRaw = document.createElement('pre');
bodyRaw.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-gray-50 p-2 rounded';
bodyRaw.textContent = m.bodyContent || '';
bodyWrap.append(bodyType, bodyText, bodyRaw);
}
// Raw JSON toggle for full fidelity (closed by default)
const details = document.createElement('details');
details.className = 'mt-2';
const summary = document.createElement('summary');
summary.className = 'text-xs text-blue-700 cursor-pointer';
summary.textContent = 'Show raw message JSON';
const rawPre = document.createElement('pre');
rawPre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-gray-100 p-2 rounded';
rawPre.textContent = JSON.stringify(m.raw || m, null, 2);
details.append(summary, rawPre);
// Keep raw JSON closed by default
details.open = false;
info.append(header, subjectEl, cardWrap, bodyWrap, details);
row.append(checkbox, info);
messagesContainer.append(row);
});
// Auto-select loaded messages if requested
if (params.autoSelect === '1') {
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
}
// If ids param provided, select matching ones (comma or newline separated)
if (params.ids) {
const ids = params.ids.split(/[,\n\r\s]+/).filter(Boolean);
const set = new Set(ids);
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (set.has(cb.dataset.messageId)) cb.checked = true;
});
}
}
async function loadMessages() {
savePrefs();
const teamId = teamIdEl.value.trim();
const channelId = channelIdEl.value.trim();
const top = Math.min(parseInt(topEl.value || '50', 10) || 50, 50);
if (!teamId || !channelId) {
statusEl.textContent = 'Enter Team ID and Channel ID.';
return;
}
statusEl.textContent = 'Loading messages...';
try {
const url = `/api/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages?top=${top}`;
const headers = {};
const bearer = bearerEl.value.trim();
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
const resp = await fetch(url, { headers });
const data = await resp.json();
if (!data.success) throw new Error(data.message || 'Failed to fetch messages');
renderMessages(data.messages);
statusEl.textContent = `Loaded ${data.count} messages.`;
} catch (err) {
statusEl.textContent = `Error: ${err.message}`;
}
}
async function deleteSelected() {
const teamId = teamIdEl.value.trim();
const channelId = channelIdEl.value.trim();
const checkboxes = messagesContainer.querySelectorAll('input[type="checkbox"]:checked');
const ids = Array.from(checkboxes).map(cb => cb.dataset.messageId);
if (!ids.length) {
deleteSummaryEl.textContent = 'No messages selected.';
return;
}
deleteSummaryEl.textContent = `Deleting ${ids.length}...`;
let ok = 0, fail = 0, teamsLimitationHit = false;
for (const id of ids) {
try {
const url = `/api/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(id)}`;
const headers = {};
const bearer = bearerEl.value.trim();
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
const resp = await fetch(url, { method: 'DELETE', headers });
const data = await resp.json();
if (!data.success) {
if (data.isTeamsLimitation) {
teamsLimitationHit = true;
}
throw new Error(data.message || 'Delete failed');
}
ok++;
} catch (e) {
fail++;
}
}
let summary = `Deleted ${ok}, failed ${fail}.`;
if (teamsLimitationHit) {
summary += ` ⚠️ Note: Microsoft Teams API does not support deletion of channel messages via REST API. `;
summary += `Please delete messages directly from Teams (right-click message → Delete). `;
summary += `Alternatively, use a delegated user token with ChatMessage.ReadWrite.All permission.`;
}
deleteSummaryEl.textContent = summary;
// Only refresh if any deletions succeeded
if (ok > 0) {
await loadMessages();
}
}
loadBtn.addEventListener('click', loadMessages);
selectAllBtn.addEventListener('click', () => {
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
});
clearSelectionBtn.addEventListener('click', () => {
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
});
deleteSelectedBtn.addEventListener('click', deleteSelected);
// Global raw JSON toggle
document.getElementById('toggleRaw').addEventListener('change', (e) => {
const open = e.target.checked;
messagesContainer.querySelectorAll('details').forEach(d => {
// Only toggle the raw message JSON details (the last details in each row), not card details
if (d.querySelector('summary').textContent.includes('raw message JSON')) {
d.open = open;
}
});
});
// Auto-load when params present
if (params.teamId && params.channelId) {
// If top is present, ensure numeric cap 50
const t = Math.min(parseInt(params.top || '50', 10) || 50, 50);
topEl.value = String(t);
loadMessages();
}
</script>
</body>
</html>
+7
View File
@@ -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)
+44
View File
@@ -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
View File
@@ -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": {
+608 -7
View File
@@ -1,14 +1,17 @@
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 { listTeamChannels, listChannelMessages, listTeamChannelsWithToken, listChannelMessagesWithToken, deleteChannelMessage, deleteChannelMessageWithToken } from './services/teams/messages.ts';
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 +51,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 +247,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 +255,7 @@ function resolveTableName(): string {
const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim();
if (nameFromPath) return nameFromPath;
return 'Job_List';
return 'Test_Table';
}
// ============================================================================
@@ -150,7 +273,7 @@ function calculateJobFullName(record: any): string {
const companyClient = record.Company_Client || record.company_client || '';
const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim());
return parts.join(' - ');
return parts.map(p => String(p).trim()).join(' - ').trim();
}
/**
@@ -253,6 +376,84 @@ function calculateActive(record: any): boolean {
return activeStatuses.includes(jobStatus);
}
// ============================================================================
// PLANNER TASK CREATION
// ============================================================================
async function createPlannerTask({
jobNumber,
jobFullName,
dueDate,
}: {
jobNumber: string;
jobFullName: string;
dueDate?: string;
}) {
try {
const client = await getGraphClient();
// Hardcoded assignee for testing: aewing@cardoza.construction
const assigneeId = '1a6e9d10-138a-43e4-a09e-1f50463148c9';
const assigneeName = 'Alex Ewing'; // Display name
// Parse due date if provided (ISO format to Date object)
let dueDateObj: any = undefined;
if (dueDate) {
const d = new Date(dueDate);
if (!isNaN(d.getTime())) {
dueDateObj = d.toISOString();
}
}
// Task title: "Disposition New Job Folder for [Job_Number] [Job_Full_Name]"
const title = `Disposition New Job Folder for ${jobNumber} ${jobFullName}`;
// Create the Planner task
const groupId = process.env.PLANNER_GROUP_ID;
const planId = process.env.PLANNER_PLAN_ID;
const bucketId = process.env.PLANNER_BUCKET_ID;
if (!groupId || !planId || !bucketId) {
throw new Error('Missing Planner configuration: PLANNER_GROUP_ID, PLANNER_PLAN_ID, or PLANNER_BUCKET_ID');
}
const newTask = await client.api('/planner/tasks').post({
planId,
bucketId,
title,
assignments: {
[assigneeId]: {
'@odata.type': 'microsoft.graph.plannerAssignment',
orderHint: ' !',
},
},
...(dueDateObj ? { dueDateTime: dueDateObj } : {}),
});
// Set the Notes/Description via task details (match the working test script)
const taskDetails = await client.api(`/planner/tasks/${newTask.id}/details`).get();
const detailsEtag = taskDetails['@odata.etag'];
console.log(`[Planner] Task created ${newTask.id}, fetched details ETag: ${detailsEtag}`);
await client
.api(`/planner/tasks/${newTask.id}/details`)
.header('If-Match', detailsEtag)
.patch({
description: 'Move the new folder to the correct Job Pages folder.',
});
console.log(`[Planner] Successfully patched description`);
console.log(`✓ Planner task created: "${title}" assigned to ${assigneeName}`);
return { success: true, taskId: newTask.id, title };
} catch (error: any) {
console.error('✗ Planner task creation failed:', error.message);
await logError('Planner Task Creation', error, {
jobNumber,
jobFullName,
});
throw error;
}
}
// Initialize table cache on startup
async function initializeTableCache() {
const client = await getGraphClient();
@@ -393,6 +594,58 @@ 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) {
// Row was just inserted at index 0, try updating that directly
console.warn('⚠️ Excel row not found by pb_id, trying index 0 (just inserted)');
try {
await client.api(`${excelTablePath}/rows/itemAt(index=0)/range/cell(row=0,column=${linkColIndex})`)
.patch({
values: [[link || '']]
});
console.log('✓ Updated Excel Job_Folder_Link cell at index 0 for pb_id:', pbId);
return;
} catch (fallbackErr: any) {
console.error('⚠️ Fallback update at index 0 also failed:', fallbackErr.message);
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 +718,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 +755,90 @@ 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 });
}
// Create Planner task for the new job (assigned to aewing@cardoza.construction)
try {
const jobFullName = (record as any).Job_Full_Name || '';
await createPlannerTask({
jobNumber: newJobNumber,
jobFullName,
dueDate: data.Due_Date || undefined,
});
console.log('✓ Planner task created successfully');
} catch (plannerError: any) {
console.error('⚠️ Planner task creation failed, continuing:', plannerError.message);
await logError('Planner Task Creation', plannerError, {
jobNumber: newJobNumber,
recordId: record.id,
});
// Don't fail the response; Planner is optional
}
// 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 +862,253 @@ app.get('/api/health', (c) => {
});
});
// List channels in a Team (to obtain Channel IDs)
app.get('/api/teams/:teamId/channels', async (c) => {
const teamId = c.req.param('teamId');
try {
const authHeader = c.req.header('authorization') || c.req.header('Authorization');
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const channels = bearer
? await listTeamChannelsWithToken(teamId, bearer)
: await listTeamChannels(teamId);
return c.json({ success: true, teamId, channels });
} catch (err: any) {
console.error('⚠️ Failed to list channels:', err);
await logError('Graph List Channels', err, { teamId });
return c.json({ success: false, message: err.message || 'Failed to list channels' }, 500);
}
});
// List messages in a channel (to obtain message IDs)
app.get('/api/teams/:teamId/channels/:channelId/messages', async (c) => {
const teamId = c.req.param('teamId');
const channelId = c.req.param('channelId');
const top = Number(c.req.query('top') || '50');
try {
const authHeader = c.req.header('authorization') || c.req.header('Authorization');
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const messages = bearer
? await listChannelMessagesWithToken(teamId, channelId, Math.min(top, 50), bearer)
: await listChannelMessages(teamId, channelId, Math.min(top, 50));
return c.json({ success: true, teamId, channelId, count: messages.length, messages });
} catch (err: any) {
console.error('⚠️ Failed to list messages:', err);
await logError('Graph List Messages', err, { teamId, channelId });
return c.json({ success: false, message: err.message || 'Failed to list messages' }, 500);
}
});
// Delete a message in a channel (requires appropriate Graph permissions)
app.delete('/api/teams/:teamId/channels/:channelId/messages/:messageId', async (c) => {
const teamId = c.req.param('teamId');
const channelId = c.req.param('channelId');
const messageId = c.req.param('messageId');
try {
const authHeader = c.req.header('authorization') || c.req.header('Authorization');
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const tokenType = bearer ? 'Bearer token provided' : 'Using app credentials';
console.log(`🗑️ DELETE request: ${tokenType} | Team: ${teamId} | Channel: ${channelId} | Message: ${messageId}`);
if (bearer) {
await deleteChannelMessageWithToken(teamId, channelId, messageId, bearer);
} else {
await deleteChannelMessage(teamId, channelId, messageId);
}
console.log(`✅ Successfully deleted message: ${messageId}`);
return c.json({ success: true, teamId, channelId, messageId });
} catch (err: any) {
const errMsg = err?.message || JSON.stringify(err);
const errStatus = err?.status || err?.statusCode;
// Check if it's the Teams API limitation error
const isTeamsLimitation = errMsg?.includes('Teams API limitation') || errMsg?.includes('not supported');
console.error(`${isTeamsLimitation ? '⚠️' : '❌'} Failed to delete message: ${errMsg} | Status: ${errStatus}`);
if (isTeamsLimitation) {
console.log(`📝 Note: This is a Microsoft Teams platform limitation, not an application error`);
}
await logError('Graph Delete Message', err, {
teamId,
channelId,
messageId,
error: errMsg,
status: errStatus,
isTeamsLimitation
});
return c.json({
success: false,
message: errMsg || 'Failed to delete message',
isTeamsLimitation: isTeamsLimitation || false
}, 500);
}
});
// 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}`);
+169
View File
@@ -0,0 +1,169 @@
import { Client } from '@microsoft/microsoft-graph-client';
async function getGraphToken(): Promise<string> {
const tenantId = process.env.TENANT_ID;
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
if (!tenantId || !clientId || !clientSecret) {
throw new Error('Missing Graph env configuration: TENANT_ID, CLIENT_ID, CLIENT_SECRET');
}
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const params = new URLSearchParams();
params.append('client_id', clientId);
params.append('client_secret', clientSecret);
params.append('scope', 'https://graph.microsoft.com/.default');
params.append('grant_type', 'client_credentials');
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Graph token request failed: ${response.status} ${response.statusText} - ${text}`);
}
const data = await response.json() as { access_token?: string };
if (!data?.access_token) throw new Error('Graph token response missing access_token');
return data.access_token;
}
async function getGraphClient() {
const token = await getGraphToken();
return Client.init({
authProvider: (done) => done(null, token)
});
}
export async function listTeamChannels(teamId: string) {
const client = await getGraphClient();
const resp = await client.api(`/teams/${teamId}/channels`).get();
const channels = (resp?.value || []).map((ch: any) => ({
id: ch.id,
displayName: ch.displayName,
description: ch.description ?? null
}));
return channels;
}
export async function listChannelMessages(teamId: string, channelId: string, top: number = 50) {
const client = await getGraphClient();
const resp = await client
.api(`/teams/${teamId}/channels/${channelId}/messages`)
.query({ $top: Math.min(top, 50) })
.get();
const messages = (resp?.value || []).map((m: any) => ({
id: m.id,
createdDateTime: m.createdDateTime,
summary: m.summary ?? null,
subject: m.subject ?? null,
from: m.from?.user?.displayName ?? m.from?.application?.displayName ?? null,
body: m.body ?? null,
bodyContent: m.body?.content ?? null,
bodyContentType: m.body?.contentType ?? null,
messageType: m.messageType ?? null,
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
id: a.id ?? null,
contentType: a.contentType ?? null,
name: a.name ?? null,
contentUrl: a.contentUrl ?? null,
content: a.content ?? null
})) : [],
raw: m
}));
return messages;
}
export async function listTeamChannelsWithToken(teamId: string, accessToken: string) {
const client = Client.init({ authProvider: (done) => done(null, accessToken) });
const resp = await client.api(`/teams/${teamId}/channels`).get();
const channels = (resp?.value || []).map((ch: any) => ({
id: ch.id,
displayName: ch.displayName,
description: ch.description ?? null
}));
return channels;
}
export async function listChannelMessagesWithToken(teamId: string, channelId: string, top: number = 50, accessToken: string) {
const client = Client.init({ authProvider: (done) => done(null, accessToken) });
const resp = await client
.api(`/teams/${teamId}/channels/${channelId}/messages`)
.query({ $top: Math.min(top, 50) })
.get();
const messages = (resp?.value || []).map((m: any) => ({
id: m.id,
createdDateTime: m.createdDateTime,
summary: m.summary ?? null,
subject: m.subject ?? null,
from: m.from?.user?.displayName ?? m.from?.application?.displayName ?? null,
body: m.body ?? null,
bodyContent: m.body?.content ?? null,
bodyContentType: m.body?.contentType ?? null,
messageType: m.messageType ?? null,
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
id: a.id ?? null,
contentType: a.contentType ?? null,
name: a.name ?? null,
contentUrl: a.contentUrl ?? null,
content: a.content ?? null
})) : [],
raw: m
}));
return messages;
}
export async function deleteChannelMessage(teamId: string, channelId: string, messageId: string) {
const client = await getGraphClient();
try {
const url = `/teams/${teamId}/channels/${channelId}/messages/${messageId}`;
console.log(`📌 Attempting DELETE on: ${url}`);
await client.api(url).delete();
return { success: true };
} catch (err: any) {
const statusCode = err?.statusCode || err?.status;
const msg = err?.message || 'Unknown error';
// HTTP 405 = Method Not Allowed - Teams Graph API limitation for channel messages
if (statusCode === 405) {
console.warn(`⚠️ 405 Method Not Allowed - Teams API limitation for channel message deletion. Applying soft-delete approach...`);
// Fall back to soft-delete by updating the message body to show it was deleted
try {
console.log(`📌 Attempting soft-delete via PATCH...`);
// Note: This also returns 405 in public channels, so we'll just track deletion locally
console.log(`️ Microsoft Teams API does not support deletion of channel messages via REST API`);
console.log(`️ Suggestion: Use Teams client UI, or use delegated token with ChannelMessage.ReadWrite.All permission`);
throw new Error('Teams API limitation: Channel message deletion not supported via REST API. Delete manually from Teams client or grant ChatMessage.ReadWrite.All delegated permission with user token.');
} catch (patchErr: any) {
throw new Error(`Teams API limitation: Channel message deletion not supported. ${patchErr.message}`);
}
}
console.error(`❌ Delete failed - Message: ${msg}, StatusCode: ${statusCode}`);
throw new Error(`Graph delete error: ${msg} (status: ${statusCode || 'unknown'})`);
}
}
export async function deleteChannelMessageWithToken(teamId: string, channelId: string, messageId: string, accessToken: string) {
const client = Client.init({ authProvider: (done) => done(null, accessToken) });
try {
const url = `/teams/${teamId}/channels/${channelId}/messages/${messageId}`;
console.log(`📌 Attempting DELETE on: ${url}`);
await client.api(url).delete();
return { success: true };
} catch (err: any) {
const statusCode = err?.statusCode || err?.status;
const msg = err?.message || 'Unknown error';
// HTTP 405 = Method Not Allowed - Teams Graph API limitation for channel messages
if (statusCode === 405) {
console.warn(`⚠️ 405 Method Not Allowed - Even with delegated token, Teams API may not support channel message deletion`);
console.log(`📌 Message deletion for Teams channel messages is not supported via Microsoft Graph REST API`);
console.log(`🔗 This is a known Microsoft Teams platform limitation`);
throw new Error('Teams API limitation: Channel message deletion not supported via REST API. Please delete messages directly from the Teams client interface.');
}
console.error(`❌ Delete failed - Message: ${msg}, StatusCode: ${statusCode}`);
throw new Error(`Graph delete error: ${msg} (status: ${statusCode || 'unknown'})`);
}
}