13 Commits

Author SHA1 Message Date
aewing d7cb428ed7 v1.0.0-beta3a: make Job_Name and Job_Address optional, allow blank phone, add Timothy Cardoza to Office_Rep, comment out Job_Size_Guess fields 2026-01-08 14:56:11 +00:00
aewing 2546eb30fe Fields are working now as expected. need to remove job size gues and guess source and move schedule confidence to beside start date, but this gets us back on the right path. 2026-01-06 14:29:47 +00:00
aewing 701b27cf3f Update port to 3020 2025-12-31 03:44:28 +00:00
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 2287 additions and 26 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 3020) 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.
+6 -2
View File
@@ -1,5 +1,9 @@
# Job Creation Form with Excel Sync
> Current Version: v1.0.0-beta3a
See CHANGELOG.md for release notes.
**Unified system** that combines job creation form with immediate Excel synchronization.
## Architecture
@@ -44,7 +48,7 @@ ITEM_ID=01SPNXLDQRICHB63BFUNGKZ3GE6RL7LGBT
EXCEL_TABLE=/drives/.../tables/Test_Table
# Server
PORT=4000
PORT=3020
```
## Installation
@@ -63,7 +67,7 @@ Start the server:
bun run dev
```
Server runs at: http://localhost:4000
Server runs at: http://localhost:3020
## Key Features
+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
};
+319 -14
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Job Creation Form</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@@ -60,10 +62,11 @@
<div class="grid grid-cols-1 gap-6 mb-6">
<div class="flex flex-col">
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2">
Job Name <span class="text-red-600">*</span>
Job Name
</label>
<input type="text" id="Job_Name" name="Job_Name" required placeholder="Enter job name" autocomplete="off"
<input type="text" id="Job_Name" name="Job_Name" placeholder="Enter job name" autocomplete="off"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<p class="text-xs text-gray-500 mt-1">Optional. Provide if known.</p>
</div>
</div>
@@ -162,8 +165,9 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col">
<label for="Phone_Number" class="block text-sm font-medium text-gray-700 mb-2">Phone Number</label>
<input type="tel" id="Phone_Number" name="Phone_Number" placeholder="Enter phone number" autocomplete="tel"
<input type="tel" id="Phone_Number" name="Phone_Number" placeholder="(###) ###-####" autocomplete="tel"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<p class="text-xs text-gray-500 mt-1">Optional. Format: (###) ###-####. Extension can go in EXT.</p>
</div>
<div class="flex flex-col">
@@ -173,11 +177,51 @@
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col">
<label for="EXT" class="block text-sm font-medium text-gray-700 mb-2">EXT (optional)</label>
<input type="text" id="EXT" name="EXT" placeholder="Extension" autocomplete="off"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
</div>
<div class="flex flex-col">
<label for="Fax" class="block text-sm font-medium text-gray-700 mb-2">Fax (optional)</label>
<input type="text" id="Fax" name="Fax" placeholder="Fax number" autocomplete="off"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
</div>
</div>
<div class="grid grid-cols-1 gap-6 mb-6">
<div class="flex flex-col">
<label for="Contact_Notes" class="block text-sm font-medium text-gray-700 mb-2">Contact Notes (optional)</label>
<textarea id="Contact_Notes" name="Contact_Notes" rows="2" placeholder="Add extra contact details or instructions"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"></textarea>
</div>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="flex flex-col">
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label>
<input type="text" id="Job_Address" name="Job_Address" placeholder="Enter job address" autocomplete="street-address"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<div class="flex gap-2">
<input type="text" id="Job_Address" name="Job_Address" placeholder="e.g., 123 Main St, Los Angeles, CA 90012 or 34.0522, -118.2437" autocomplete="street-address"
class="flex-1 px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<button type="button" id="openMapPicker" class="px-4 py-2.5 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2 whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Pick on Map
</button>
</div>
<p class="text-xs text-gray-500 mt-1">Examples: <strong>123 Main St, Los Angeles, CA</strong> or coordinates: <strong>34.0522, -118.2437</strong>. Click "Pick on Map" to select location visually.</p>
</div>
</div>
<div class="grid grid-cols-1 gap-6 mb-6">
<div class="flex flex-col">
<label for="Address_Notes" class="block text-sm font-medium text-gray-700 mb-2">Address Notes (required if address not precise)</label>
<textarea id="Address_Notes" name="Address_Notes" rows="2" placeholder="Add directions or location notes if the address is incomplete"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"></textarea>
</div>
</div>
</div>
@@ -196,6 +240,7 @@
<option value="" disabled selected>Select office rep</option>
<option value="Beth Cardoza">Beth Cardoza</option>
<option value="Steve Brewer">Steve Brewer</option>
<option value="Timothy Cardoza">Timothy Cardoza</option>
</select>
</div>
@@ -234,8 +279,9 @@
<!-- Start Date (moved to top) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col">
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2">Start Date</label>
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Estimated date work will start.">Start Date</label>
<input type="text" id="Start_Date" name="Start_Date" class="datepicker w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" placeholder="Select start date" autocomplete="off">
<p class="text-xs text-gray-500 mt-1">Estimated date work will start.</p>
</div>
</div>
@@ -263,19 +309,48 @@
<!-- Due Date and Time (now third) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col">
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-2">Due Date</label>
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Date estimate is due to client.">Due Date</label>
<input type="text" id="Due_Date" name="Due_Date" class="datepicker w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" placeholder="Select due date" autocomplete="off">
<p class="text-xs text-gray-500 mt-1">Date estimate is due to client.</p>
</div>
<div class="flex flex-col">
<label for="Due_Time" class="block text-sm font-medium text-gray-700 mb-2">Due Time</label>
<input type="text" id="Due_Time" name="Due_Time" placeholder="Enter time" autocomplete="off"
<input type="text" id="Due_Time" name="Due_Time" list="due-time-options" placeholder="Select or type" autocomplete="off"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<datalist id="due-time-options">
<option value="5:00 AM"></option>
<option value="5:30 AM"></option>
<option value="6:00 AM"></option>
<option value="6:30 AM"></option>
<option value="7:00 AM"></option>
<option value="7:30 AM"></option>
<option value="8:00 AM"></option>
<option value="8:30 AM"></option>
<option value="9:00 AM"></option>
<option value="9:30 AM"></option>
<option value="10:00 AM"></option>
<option value="10:30 AM"></option>
<option value="11:00 AM"></option>
<option value="11:30 AM"></option>
<option value="12:00 PM"></option>
<option value="12:30 PM"></option>
<option value="1:00 PM"></option>
<option value="1:30 PM"></option>
<option value="2:00 PM"></option>
<option value="2:30 PM"></option>
<option value="3:00 PM"></option>
<option value="3:30 PM"></option>
<option value="4:00 PM"></option>
<option value="4:30 PM"></option>
<option value="5:00 PM"></option>
</datalist>
<p class="text-xs text-gray-500 mt-1">Choose a time or type a custom value.</p>
</div>
</div>
<!-- Due Date Source (now last) -->
<div class="grid grid-cols-1 gap-6">
<div class="grid grid-cols-1 gap-6 mb-6">
<div class="flex flex-col">
<label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label>
<div class="flex flex-wrap gap-4">
@@ -294,6 +369,15 @@
</div>
</div>
</div>
<!-- Any other information -->
<div class="grid grid-cols-1 gap-6">
<div class="flex flex-col">
<label for="Notes" class="block text-sm font-medium text-gray-700 mb-2">Any other information we need to know? (optional)</label>
<textarea id="Notes" name="Notes" rows="3" placeholder="Add any additional details, special instructions, or notes"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"></textarea>
</div>
</div>
</div>
@@ -320,6 +404,25 @@
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p>
</div>
</div>
<!-- FUTURE: Job Size Guess and Guess Source
To be enabled in future: allow sq ft, linear ft, and/or dollar amount
Job_Size_Guess: Basic financial cost/value of the job
Job_Size_Guess_Source: Who provided the guess (Estimator, Rick, Project Manager, etc.)
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6">
<div class="flex flex-col">
<label for="Job_Size_Guess" class="block text-sm font-medium text-gray-700 mb-2" title="Basic financial cost/value of the job. Example: $25,000.00 (just a guess).">Job Size Guess</label>
<input type="text" id="Job_Size_Guess" name="Job_Size_Guess" placeholder="$0,000.00 (guess)"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<p class="text-xs text-gray-500 mt-1">Enter a rough value (e.g., $25,000.00). Will auto-format in USD.</p>
</div>
<div class="flex flex-col">
<label for="Job_Size_Guess_Source" class="block text-sm font-medium text-gray-700 mb-2" title="Who provided this guess? Estimator, Rick, Project Manager, etc.">Guess Source</label>
<input type="text" id="Job_Size_Guess_Source" name="Job_Size_Guess_Source" placeholder="Estimator, Rick, PM, etc."
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
</div>
</div>
-->
</div>
<button type="submit" id="submitBtn" disabled
@@ -331,6 +434,37 @@
</form>
</div>
<!-- Map Picker Modal -->
<div id="mapPickerModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-800">Select Location on Map</h2>
<button type="button" id="closeMapPicker" class="text-gray-500 hover:text-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 relative">
<div id="map" class="w-full h-full" style="min-height: 500px;"></div>
</div>
<div class="p-4 border-t border-gray-200 flex items-center justify-between gap-4">
<div class="flex-1">
<p class="text-sm text-gray-600">Click anywhere on the map to drop a pin. Selected coordinates will be added to Job Address.</p>
<p id="selectedCoords" class="text-sm font-medium text-primary mt-1"></p>
</div>
<div class="flex gap-2">
<button type="button" id="cancelMapPicker" class="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">
Cancel
</button>
<button type="button" id="confirmMapPicker" class="px-4 py-2 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity" disabled>
Use Coordinates
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script>
@@ -402,12 +536,11 @@
// Function to check if required fields are filled
function checkRequiredFields() {
const jobName = document.getElementById('Job_Name').value.trim();
const officeRep = document.getElementById('Office_Rep').value;
const taxExempt = document.querySelector('input[name="Tax_Exempt"]:checked');
const submitBtn = document.getElementById('submitBtn');
if (jobName && officeRep && taxExempt) {
if (officeRep && taxExempt) {
submitBtn.disabled = false;
} else {
submitBtn.disabled = true;
@@ -457,9 +590,74 @@
}
// Add event listeners to required fields
document.getElementById('Job_Name').addEventListener('input', checkRequiredFields);
document.getElementById('Office_Rep').addEventListener('change', checkRequiredFields);
// Phone number field: validate and format on blur
document.getElementById('Phone_Number').addEventListener('blur', (e) => {
const phoneRaw = (e.target.value || '').toString().trim();
if (!phoneRaw) return; // Allow empty
const isUnknown = phoneRaw.toLowerCase() === 'unknown';
if (isUnknown) {
e.target.classList.remove('border-red-500', 'bg-red-50');
e.target.title = '';
e.target.value = 'Unknown';
return;
}
const digits = phoneRaw.replace(/\D/g, '').slice(0, 10);
if (digits.length === 10) {
const formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
e.target.value = formatted;
e.target.classList.remove('border-red-500', 'bg-red-50');
e.target.title = `Phone formatted as: ${formatted}`;
} else {
e.target.classList.add('border-red-500', 'bg-red-50');
e.target.title = `Phone must be 10 digits (found ${digits.length}) or enter "Unknown".`;
}
});
// Fax field: validate and format on blur (same as phone)
document.getElementById('Fax').addEventListener('blur', (e) => {
const faxRaw = (e.target.value || '').toString().trim();
if (!faxRaw) return; // Allow empty (optional field)
const isUnknown = faxRaw.toLowerCase() === 'unknown';
if (isUnknown) {
e.target.classList.remove('border-red-500', 'bg-red-50');
e.target.title = '';
e.target.value = 'Unknown';
return;
}
const digits = faxRaw.replace(/\D/g, '').slice(0, 10);
if (digits.length === 10) {
const formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
e.target.value = formatted;
e.target.classList.remove('border-red-500', 'bg-red-50');
e.target.title = `Fax formatted as: ${formatted}`;
} else {
e.target.classList.add('border-red-500', 'bg-red-50');
e.target.title = `Fax must be 10 digits (found ${digits.length}) or enter "Unknown".`;
}
});
// No validation on Job Address blur (optional)
document.getElementById('Office_Rep').addEventListener('change', updateSelectPlaceholders);
// FUTURE: Job Size Guess formatter (when field is re-enabled)
// const jobSizeInput = document.getElementById('Job_Size_Guess');
// if (jobSizeInput) {
// jobSizeInput.addEventListener('blur', (e) => {
// const raw = (e.target.value || '').toString().trim();
// if (!raw) return;
// const cleaned = raw.replace(/[^0-9.]/g, '').replace(/(\..*?)\./g, '$1');
// const num = parseFloat(cleaned);
// if (isNaN(num)) return;
// e.target.value = num.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
// });
// }
document.querySelectorAll('input[name="Tax_Exempt"]').forEach(radio => {
radio.addEventListener('change', () => {
checkRequiredFields();
@@ -495,6 +693,24 @@
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
// Enforce Job_Type and Job_Division selection
const jobType = (data.Job_Type || '').toString().trim();
const jobDivision = (data.Job_Division || '').toString().trim();
if (!jobType || !jobDivision) {
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
message.textContent = 'Please select both Job Type and Job Division.';
message.classList.remove('hidden');
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Job';
return;
}
// FUTURE: Phone number format coercion
// When re-enabled, enforce strict format: (###) ###-#### or "Unknown" (or blank to allow)
// Currently: Phone is optional, no validation on submit
const phoneRaw = (data.Phone_Number || '').toString().trim();
data.Phone_Number = phoneRaw; // Accept as-is if provided, or empty if blank
// Add user's PocketBase token
data.pbToken = pb.authStore.token;
@@ -518,7 +734,12 @@
if (data.Start_Date) {
data.Start_Date = formatDateToShort(data.Start_Date);
}
// Address and Job Name: optional, no validation
data.Job_Address = (data.Job_Address || '').toString().trim();
data.Address_Notes = (data.Address_Notes || '').toString().trim();
data.Job_Name = (data.Job_Name || '').toString().trim();
// Convert Tax_Exempt to boolean
if (data.Tax_Exempt) {
data.Tax_Exempt = data.Tax_Exempt === 'true';
@@ -553,11 +774,95 @@
submitBtn.textContent = 'Submit Job';
}
});
// Map Picker Functionality
let map = null;
let marker = null;
let selectedLatLng = null;
document.getElementById('openMapPicker').addEventListener('click', () => {
const modal = document.getElementById('mapPickerModal');
modal.classList.remove('hidden');
// Initialize map if not already done
if (!map) {
// Default to 1836 North Deffer Drive, Nixa, MO 65714
map = L.map('map').setView([37.0415, -93.2985], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Handle map clicks
map.on('click', (e) => {
const { lat, lng } = e.latlng;
// Remove existing marker if any
if (marker) {
map.removeLayer(marker);
}
// Add new marker
marker = L.marker([lat, lng]).addTo(map);
// Store coordinates
selectedLatLng = { lat, lng };
// Update display
document.getElementById('selectedCoords').textContent = `Selected: ${lat.toFixed(6)}, ${lng.toFixed(6)}`;
document.getElementById('confirmMapPicker').disabled = false;
});
}
// Invalidate size to ensure map renders correctly in modal
setTimeout(() => {
map.invalidateSize();
// If Job_Address already has coordinates, center on them
const currentAddr = document.getElementById('Job_Address').value.trim();
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
if (coordPattern.test(currentAddr)) {
const [lat, lng] = currentAddr.split(',').map(s => parseFloat(s.trim()));
if (!isNaN(lat) && !isNaN(lng)) {
map.setView([lat, lng], 15);
if (marker) map.removeLayer(marker);
marker = L.marker([lat, lng]).addTo(map);
selectedLatLng = { lat, lng };
document.getElementById('selectedCoords').textContent = `Selected: ${lat.toFixed(6)}, ${lng.toFixed(6)}`;
document.getElementById('confirmMapPicker').disabled = false;
}
}
}, 100);
});
document.getElementById('closeMapPicker').addEventListener('click', () => {
document.getElementById('mapPickerModal').classList.add('hidden');
});
document.getElementById('cancelMapPicker').addEventListener('click', () => {
document.getElementById('mapPickerModal').classList.add('hidden');
});
document.getElementById('confirmMapPicker').addEventListener('click', () => {
if (selectedLatLng) {
const coordString = `${selectedLatLng.lat.toFixed(6)}, ${selectedLatLng.lng.toFixed(6)}`;
document.getElementById('Job_Address').value = coordString;
// Trigger blur validation
const addressInput = document.getElementById('Job_Address');
addressInput.classList.remove('border-red-500', 'bg-red-50');
addressInput.classList.add('border-green-500', 'bg-green-50');
addressInput.title = 'Valid coordinates format';
document.getElementById('mapPickerModal').classList.add('hidden');
}
});
</script>
<!-- Version Info -->
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
v1.0.0-beta2
v1.0.0-beta3a
</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": {
+625 -9
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();
@@ -317,7 +518,7 @@ async function addToExcel(record: any, pbId: string) {
// Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client)
if (colLower === 'job_full_name') {
return calculateJobFullName(record);
return '=CONCATENATE([@[Job_Name]]," ",[@[Job_Address]]," ",[@[Company_Client]])';
}
// Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days)
@@ -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;
@@ -440,6 +693,21 @@ app.post('/api/submit', async (c) => {
if (dueIso) data.Due_Date = dueIso;
if (startIso) data.Start_Date = startIso;
// Address and Job_Name are optional; trim but do not validate
data.Job_Address = (data.Job_Address || '').toString().trim();
data.Address_Notes = (data.Address_Notes || '').toString().trim();
data.Job_Name = (data.Job_Name || '').toString().trim();
// Enforce Job_Type and Job_Division selection
const jobType = (data.Job_Type || '').toString().trim();
const jobDivision = (data.Job_Division || '').toString().trim();
if (!jobType || !jobDivision) {
return c.json({
success: false,
message: 'Please select both Job Type and Job Division.'
}, 400);
}
// Get all existing job numbers and find the max
const existingJobs = await pb.collection(process.env.PB_COLLECTION!).getFullList({
fields: 'Job_Number',
@@ -465,9 +733,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 +770,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,7 +877,254 @@ app.get('/api/health', (c) => {
});
});
const PORT = Number(process.env.PORT || 4000);
// 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 || 3020);
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'})`);
}
}