7 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
7 changed files with 1164 additions and 47 deletions
+1 -14
View File
@@ -2,19 +2,6 @@
All notable changes to this project will be documented in this file.
## v1.0.0-beta3 - 2025-12-20
- Teams Notifications
- Fixed webhook 400 error by adding `contentUrl: null` to Adaptive Card attachment payload.
- Reformatted card from FactSet to TextBlocks with markdown bold labels (`**Label:** value`) and `spacing: 'None'` for compact rows.
- Added timestamp to notification card.
- Removed folder button/actions from card - notifications now show job info only.
- Removed unused `buildMessageCard` function and simplified `buildAdaptiveCard`/`notifyTeamsChannel` signatures.
- Excel Sync
- Fixed Active formula being overwritten: `updateExcelFolderLink` now uses Graph API `cell(row,column)` method to update only the Job_Folder_Link cell, preserving formulas in other columns.
- Versioning
- Bumped `package.json` to `1.0.0-beta3`.
## v1.0.0-beta2 - 2025-12-19
- UI
@@ -24,7 +11,7 @@ All notable changes to this project will be documented in this file.
- Ops
- Systemd service now uses `EnvironmentFile` for production env vars; fixed `EnvironmentFile` typo and validated permissions.
- Switched service restart policy to `Restart=always` for resilience.
- Confirmed service listens on `PORT` (default 4000) and added guidance for restarts after env changes.
- 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
+3 -3
View File
@@ -1,6 +1,6 @@
# Job Creation Form with Excel Sync
> Current Version: v1.0.0-beta2
> Current Version: v1.0.0-beta3a
See CHANGELOG.md for release notes.
@@ -48,7 +48,7 @@ ITEM_ID=01SPNXLDQRICHB63BFUNGKZ3GE6RL7LGBT
EXCEL_TABLE=/drives/.../tables/Test_Table
# Server
PORT=4000
PORT=3020
```
## Installation
@@ -67,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.
+319 -25
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';
@@ -536,15 +757,6 @@
const result = await response.json();
if (result.success) {
// Log and display the folder link when available
if (result.jobFolderLink) {
console.log(`📁 Folder link: ${result.jobFolderLink}`);
const linkContainer = document.getElementById('folderLinkContainer');
if (linkContainer) {
linkContainer.innerHTML = `<a href="${result.jobFolderLink}" target="_blank" rel="noopener" class="text-blue-600 underline">Open job folder</a>`;
linkContainer.classList.remove('hidden');
}
}
message.className = 'p-4 rounded-lg font-medium bg-green-50 text-green-700 border border-green-200';
message.textContent = result.message;
message.classList.remove('hidden');
@@ -562,13 +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 -->
<!-- Folder link container -->
<div id="folderLinkContainer" class="fixed bottom-16 right-4 text-sm text-blue-600 hidden"></div>
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
v1.0.0-beta2
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>
+211 -5
View File
@@ -4,6 +4,7 @@ import { serveStatic } from 'hono/bun';
import { cors } from 'hono/cors';
import PocketBase from 'pocketbase';
import { Client } from '@microsoft/microsoft-graph-client';
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';
@@ -272,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();
}
/**
@@ -375,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();
@@ -439,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)
@@ -541,8 +620,19 @@ async function updateExcelFolderLink(pbId: string, link: string) {
return vals && vals[pbIdColIndex] === pbId;
});
if (!target) {
console.warn('⚠️ Excel row not found for pb_id:', pbId);
return;
// 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)
@@ -603,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',
@@ -719,6 +824,25 @@ app.post('/api/submit', async (c) => {
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);
@@ -753,6 +877,88 @@ app.get('/api/health', (c) => {
});
});
// List channels in a Team (to obtain Channel IDs)
app.get('/api/teams/:teamId/channels', async (c) => {
const teamId = c.req.param('teamId');
try {
const authHeader = c.req.header('authorization') || c.req.header('Authorization');
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const channels = bearer
? await listTeamChannelsWithToken(teamId, bearer)
: await listTeamChannels(teamId);
return c.json({ success: true, teamId, channels });
} catch (err: any) {
console.error('⚠️ Failed to list channels:', err);
await logError('Graph List Channels', err, { teamId });
return c.json({ success: false, message: err.message || 'Failed to list channels' }, 500);
}
});
// List messages in a channel (to obtain message IDs)
app.get('/api/teams/:teamId/channels/:channelId/messages', async (c) => {
const teamId = c.req.param('teamId');
const channelId = c.req.param('channelId');
const top = Number(c.req.query('top') || '50');
try {
const authHeader = c.req.header('authorization') || c.req.header('Authorization');
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const messages = bearer
? await listChannelMessagesWithToken(teamId, channelId, Math.min(top, 50), bearer)
: await listChannelMessages(teamId, channelId, Math.min(top, 50));
return c.json({ success: true, teamId, channelId, count: messages.length, messages });
} catch (err: any) {
console.error('⚠️ Failed to list messages:', err);
await logError('Graph List Messages', err, { teamId, channelId });
return c.json({ success: false, message: err.message || 'Failed to list messages' }, 500);
}
});
// Delete a message in a channel (requires appropriate Graph permissions)
app.delete('/api/teams/:teamId/channels/:channelId/messages/:messageId', async (c) => {
const teamId = c.req.param('teamId');
const channelId = c.req.param('channelId');
const messageId = c.req.param('messageId');
try {
const authHeader = c.req.header('authorization') || c.req.header('Authorization');
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const tokenType = bearer ? 'Bearer token provided' : 'Using app credentials';
console.log(`🗑️ DELETE request: ${tokenType} | Team: ${teamId} | Channel: ${channelId} | Message: ${messageId}`);
if (bearer) {
await deleteChannelMessageWithToken(teamId, channelId, messageId, bearer);
} else {
await deleteChannelMessage(teamId, channelId, messageId);
}
console.log(`✅ Successfully deleted message: ${messageId}`);
return c.json({ success: true, teamId, channelId, messageId });
} catch (err: any) {
const errMsg = err?.message || JSON.stringify(err);
const errStatus = err?.status || err?.statusCode;
// Check if it's the Teams API limitation error
const isTeamsLimitation = errMsg?.includes('Teams API limitation') || errMsg?.includes('not supported');
console.error(`${isTeamsLimitation ? '⚠️' : '❌'} Failed to delete message: ${errMsg} | Status: ${errStatus}`);
if (isTeamsLimitation) {
console.log(`📝 Note: This is a Microsoft Teams platform limitation, not an application error`);
}
await logError('Graph Delete Message', err, {
teamId,
channelId,
messageId,
error: errMsg,
status: errStatus,
isTeamsLimitation
});
return c.json({
success: false,
message: errMsg || 'Failed to delete message',
isTeamsLimitation: isTeamsLimitation || false
}, 500);
}
});
// Test route: sends an exact sample Adaptive Card payload (no envelope)
app.post('/api/test-notification', async (c) => {
const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL;
@@ -918,7 +1124,7 @@ app.post('/api/test-teams-webhook', async (c) => {
return c.json({ success: ok, status: resp.status, body: text });
});
const PORT = Number(process.env.PORT || 4000);
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'})`);
}
}