Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4628eb9a1 | |||
| 6f02194c23 | |||
| cc52c11bf6 | |||
| 827f32a3d9 | |||
| ca486a6196 | |||
| d8cc287330 | |||
| 2546eb30fe | |||
| 701b27cf3f | |||
| 5d9abb4cae | |||
| 65fb9a52d9 | |||
| 513b3ca76f | |||
| 611be70362 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Session Notes
|
||||
|
||||
## 2026-01-07
|
||||
|
||||
### Context
|
||||
- Picked up the Job-Form project
|
||||
- Stack: TypeScript server, HTML frontend with TailwindCSS
|
||||
- Current version: v1.0.0-beta4
|
||||
|
||||
### Project Summary
|
||||
- **Purpose**: Job creation form with Excel sync and PocketBase backend
|
||||
- **Features**:
|
||||
- Form submission → PocketBase record → Excel sync
|
||||
- Teams channel notifications (Adaptive Cards)
|
||||
- Teams message management UI
|
||||
|
||||
### Known Issues / Limitations
|
||||
- Teams message deletion not supported via Graph API (HTTP 405) — platform limitation
|
||||
- Workaround: Delete messages directly in Teams client
|
||||
|
||||
### Today's Work
|
||||
- [x] Migrate to full Bun-native stack (removed dotenv, use Bun file APIs)
|
||||
- [x] Fix Planner task creation to use form's due date with proper UTC handling
|
||||
- [x] Remove hardcoded test values — now uses activeConfig for all environments
|
||||
- [x] Make assignee configurable via `PLANNER_ASSIGNEE_ID` env var
|
||||
|
||||
### PROD Mode Checklist
|
||||
Before switching `MODE = 'PROD'`, ensure `/home/admin/secrets/.env` contains:
|
||||
- `PB_COLLECTION` - PocketBase collection name (e.g., `Job_Info_Prod`)
|
||||
- `EXCEL_TABLE_NAME` - Excel table name (e.g., `Job_List`)
|
||||
- `PLANNER_GROUP_ID` - Microsoft Planner group ID
|
||||
- `PLANNER_PLAN_ID` - Microsoft Planner plan ID
|
||||
- `PLANNER_BUCKET_ID` - Microsoft Planner bucket ID
|
||||
- `PLANNER_ASSIGNEE_ID` - User ID for Planner task assignee (e.g., `1a6e9d10-138a-43e4-a09e-1f50463148c9`)
|
||||
- `PLANNER_ASSIGNEE_NAME` - Display name for assignee (e.g., `Alex Ewing`)
|
||||
- All standard Azure AD + PocketBase creds (CLIENT_ID, CLIENT_SECRET, etc.)
|
||||
|
||||
**No hardcoded test values remain in the codebase.**
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-01-07_
|
||||
@@ -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.
|
||||
+456
-42
@@ -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 = {
|
||||
@@ -62,8 +64,9 @@
|
||||
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Job Name <span class="text-red-600">*</span>
|
||||
</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" required placeholder="Enter job name" autocomplete="off" value="unk"
|
||||
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">Use a descriptive name (no symbols-only, no N/A).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +96,7 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="jobTypeFX" name="Job_Type" value="FX" class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<input type="radio" id="jobTypeFX" name="Job_Type" value="FX" class="w-4 h-4 text-primary focus:ring-primary" checked autocomplete="off">
|
||||
<span class="text-sm">FX</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -129,7 +132,7 @@
|
||||
<span class="text-sm">C#</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="divisionR" name="Job_Division" value="R#" class="w-4 h-4 text-primary focus:ring-primary">
|
||||
<input type="radio" id="divisionR" name="Job_Division" value="R#" class="w-4 h-4 text-primary focus:ring-primary" checked>
|
||||
<span class="text-sm">R#</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -148,13 +151,13 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<label for="Company_Client" class="block text-sm font-medium text-gray-700 mb-2">Company / Client</label>
|
||||
<input type="text" id="Company_Client" name="Company_Client" placeholder="Enter company or client name" autocomplete="organization"
|
||||
<input type="text" id="Company_Client" name="Company_Client" placeholder="Enter company or client name" autocomplete="organization" value="Tester"
|
||||
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="Contact_Person" class="block text-sm font-medium text-gray-700 mb-2">Contact Person</label>
|
||||
<input type="text" id="Contact_Person" name="Contact_Person" placeholder="Enter contact person" autocomplete="name"
|
||||
<input type="text" id="Contact_Person" name="Contact_Person" placeholder="Enter contact person" autocomplete="name" value="Tester"
|
||||
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>
|
||||
@@ -162,22 +165,63 @@
|
||||
<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" value="(111) 111-1111"
|
||||
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">Use format (###) ###-####. Extension can go in EXT.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label for="Email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input type="email" id="Email" name="Email" placeholder="Enter email address" autocomplete="email"
|
||||
<input type="email" id="Email" name="Email" placeholder="Enter email address" autocomplete="email" value="test@test"
|
||||
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 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" value="123"
|
||||
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" value="(222) 222-2222"
|
||||
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">testing</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" value="123 Main St. Los Angeles, CA"
|
||||
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>
|
||||
@@ -193,8 +237,8 @@
|
||||
</label>
|
||||
<select id="Office_Rep" name="Office_Rep" required 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 bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select office rep</option>
|
||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
||||
<option value="" disabled>Select office rep</option>
|
||||
<option value="Beth Cardoza" selected>Beth Cardoza</option>
|
||||
<option value="Steve Brewer">Steve Brewer</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -203,8 +247,8 @@
|
||||
<label for="Estimator" class="block text-sm font-medium text-gray-700 mb-2">Estimator</label>
|
||||
<select id="Estimator" name="Estimator" required 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 bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select estimator</option>
|
||||
<option value="Beth Cardoza">Beth Cardoza</option>
|
||||
<option value="" disabled>Select estimator</option>
|
||||
<option value="Beth Cardoza" selected>Beth Cardoza</option>
|
||||
<option value="Steve Brewer">Steve Brewer</option>
|
||||
<option value="Timothy Cardoza">Timothy Cardoza</option>
|
||||
</select>
|
||||
@@ -216,9 +260,9 @@
|
||||
<label for="Project_Manager" class="block text-sm font-medium text-gray-700 mb-2">Project Manager</label>
|
||||
<select id="Project_Manager" name="Project_Manager" required 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 bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select project manager</option>
|
||||
<option value="" disabled>Select project manager</option>
|
||||
<option value="Dave">Dave</option>
|
||||
<option value="Rick">Rick</option>
|
||||
<option value="Rick" selected>Rick</option>
|
||||
<option value="Eddie">Eddie</option>
|
||||
<option value="Steve">Steve</option>
|
||||
<option value="Timothy">Timothy</option>
|
||||
@@ -234,8 +278,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>
|
||||
<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">
|
||||
<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" value="1/1/26">
|
||||
<p class="text-xs text-gray-500 mt-1">Estimated date work will start.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -245,10 +290,10 @@
|
||||
<label for="Schedule_Confidence" class="block text-sm font-medium text-gray-700 mb-2">Schedule Confidence</label>
|
||||
<select id="Schedule_Confidence" name="Schedule_Confidence" required 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 bg-white text-gray-400" data-placeholder="true">
|
||||
<option value="" disabled selected>Select confidence level</option>
|
||||
<option value="" disabled>Select confidence level</option>
|
||||
<option value="1 (Total Guess)">1 (Total Guess)</option>
|
||||
<option value="2 (Guess)">2 (Guess)</option>
|
||||
<option value="3 (Start Date)">3 (Start Date)</option>
|
||||
<option value="3 (Start Date)" selected>3 (Start Date)</option>
|
||||
<option value="4 (Client Verbal Guess)">4 (Client Verbal Guess)</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
@@ -263,24 +308,53 @@
|
||||
<!-- 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>
|
||||
<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">
|
||||
<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" value="1/6/26">
|
||||
<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" value="1:00 PM"
|
||||
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">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="sourceRequested" name="Due_Date_Source" value="Requested" class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<input type="radio" id="sourceRequested" name="Due_Date_Source" value="Requested" class="w-4 h-4 text-primary focus:ring-primary" checked autocomplete="off">
|
||||
<span class="text-sm">Requested</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
@@ -294,6 +368,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">testing notes</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -308,18 +391,32 @@
|
||||
Tax Exempt <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="taxExemptFalse" name="Tax_Exempt" value="false" required class="w-4 h-4 text-primary focus:ring-primary" checked autocomplete="off">
|
||||
<span class="text-sm">No</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="taxExemptTrue" name="Tax_Exempt" value="true" required class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<span class="text-sm">Yes</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" id="taxExemptFalse" name="Tax_Exempt" value="false" required class="w-4 h-4 text-primary focus:ring-primary" autocomplete="off">
|
||||
<span class="text-sm">No</span>
|
||||
</label>
|
||||
</div>
|
||||
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)" value="1"
|
||||
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." value="me"
|
||||
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 +428,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>
|
||||
@@ -459,7 +587,120 @@
|
||||
// Add event listeners to required fields
|
||||
document.getElementById('Job_Name').addEventListener('input', checkRequiredFields);
|
||||
document.getElementById('Office_Rep').addEventListener('change', checkRequiredFields);
|
||||
|
||||
// Job name field: validate on blur for 4-char minimum
|
||||
document.getElementById('Job_Name').addEventListener('blur', (e) => {
|
||||
const jobName = (e.target.value || '').toString().trim();
|
||||
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
|
||||
const allowedSpecials = ['unk', 'unknown'];
|
||||
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
||||
const hasLetters = /[A-Za-z]/.test(jobName);
|
||||
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
||||
const isAllowedSpecial = allowedSpecials.includes(jobName.toLowerCase());
|
||||
const isLongEnough = jobName.length >= 4;
|
||||
|
||||
if (jobName.length > 0 && ((!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) && !isAllowedSpecial)) {
|
||||
e.target.classList.add('border-red-500', 'bg-red-50');
|
||||
e.target.title = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A, but UNK/UNKNOWN allowed).';
|
||||
} else {
|
||||
e.target.classList.remove('border-red-500', 'bg-red-50');
|
||||
e.target.title = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 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".`;
|
||||
}
|
||||
});
|
||||
|
||||
// Job Address field: validate address or coordinates on blur
|
||||
document.getElementById('Job_Address').addEventListener('blur', (e) => {
|
||||
const addr = (e.target.value || '').toString().trim();
|
||||
if (!addr) {
|
||||
e.target.classList.remove('border-red-500', 'bg-red-50', 'border-green-500', 'bg-green-50');
|
||||
e.target.title = '';
|
||||
return; // Empty is allowed, will check Address_Notes on submit
|
||||
}
|
||||
|
||||
// Check if it looks like coordinates: pattern "lat, lon" or "lat,lon"
|
||||
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
|
||||
const isCoordinates = coordPattern.test(addr);
|
||||
|
||||
// Check if it looks like a real address: has digit(s) and letter(s), at least 5 chars
|
||||
const hasDigit = /\d/.test(addr);
|
||||
const hasAlpha = /[A-Za-z]/.test(addr);
|
||||
const isAddress = addr.length >= 5 && hasDigit && hasAlpha;
|
||||
|
||||
if (isCoordinates || isAddress) {
|
||||
e.target.classList.remove('border-red-500', 'bg-red-50');
|
||||
e.target.classList.add('border-green-500', 'bg-green-50');
|
||||
e.target.title = isCoordinates ? 'Valid coordinates format' : 'Valid address format';
|
||||
} else {
|
||||
e.target.classList.remove('border-green-500', 'bg-green-50');
|
||||
e.target.classList.add('border-red-500', 'bg-red-50');
|
||||
e.target.title = 'Address must be like "123 Main St, City, State" or coordinates like "34.0522, -118.2437". Add directions in Address Notes if needed.';
|
||||
}
|
||||
});
|
||||
document.getElementById('Office_Rep').addEventListener('change', updateSelectPlaceholders);
|
||||
|
||||
// Job Size Guess: format as USD on blur
|
||||
const jobSizeInput = document.getElementById('Job_Size_Guess');
|
||||
if (jobSizeInput) {
|
||||
jobSizeInput.addEventListener('blur', (e) => {
|
||||
const raw = (e.target.value || '').toString().trim();
|
||||
if (!raw) return;
|
||||
// Keep only digits and one decimal point
|
||||
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 +736,36 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Enforce phone format (###) ###-####; reject otherwise
|
||||
const phoneRaw = (data.Phone_Number || '').toString();
|
||||
const isUnknown = phoneRaw.toLowerCase() === 'unknown';
|
||||
if (!isUnknown) {
|
||||
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)}`;
|
||||
data.Phone_Number = formatted;
|
||||
} else {
|
||||
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
|
||||
message.textContent = 'Phone number must be 10 digits or "Unknown". Add extensions in EXT field.';
|
||||
message.classList.remove('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Submit Job';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add user's PocketBase token
|
||||
data.pbToken = pb.authStore.token;
|
||||
@@ -518,14 +789,58 @@
|
||||
if (data.Start_Date) {
|
||||
data.Start_Date = formatDateToShort(data.Start_Date);
|
||||
}
|
||||
|
||||
// Address validation: require plausible address, coordinates, OR Address_Notes
|
||||
const addr = (data.Job_Address || '').toString().trim();
|
||||
const addrNotes = (data.Address_Notes || '').toString().trim();
|
||||
|
||||
// Check if it looks like coordinates: pattern "lat, lon" or "lat,lon"
|
||||
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
|
||||
const isCoordinates = coordPattern.test(addr);
|
||||
|
||||
// Check if it looks like a real address: has digit(s) and letter(s), at least 5 chars
|
||||
const hasDigit = /\d/.test(addr);
|
||||
const hasAlpha = /[A-Za-z]/.test(addr);
|
||||
const isAddress = addr.length >= 5 && hasDigit && hasAlpha;
|
||||
|
||||
const addressLooksValid = isCoordinates || isAddress;
|
||||
if (!addressLooksValid && addrNotes.length < 5) {
|
||||
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
|
||||
message.textContent = 'Provide a real address (e.g., 123 Main St, City, State), coordinates (e.g., 34.0522, -118.2437), or enter directions in Address Notes.';
|
||||
message.classList.remove('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Submit Job';
|
||||
return;
|
||||
}
|
||||
data.Job_Address = addr;
|
||||
data.Address_Notes = addrNotes;
|
||||
|
||||
// Job name validation: must be at least 4 characters, contain letters, not symbols-only, not "n/a" (but allow "unk"/"unknown")
|
||||
const jobName = (data.Job_Name || '').toString().trim();
|
||||
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
|
||||
const allowedSpecials = ['unk', 'unknown'];
|
||||
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
||||
const hasLetters = /[A-Za-z]/.test(jobName);
|
||||
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
||||
const isAllowedSpecial = allowedSpecials.includes(jobName.toLowerCase());
|
||||
const isLongEnough = jobName.length >= 4;
|
||||
if ((!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) && !isAllowedSpecial) {
|
||||
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
|
||||
message.textContent = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A, but UNK/UNKNOWN allowed).';
|
||||
message.classList.remove('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Submit Job';
|
||||
return;
|
||||
}
|
||||
data.Job_Name = jobName;
|
||||
|
||||
// Convert Tax_Exempt to boolean
|
||||
if (data.Tax_Exempt) {
|
||||
data.Tax_Exempt = data.Tax_Exempt === 'true';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
const response = await fetch('http://localhost:3020/api/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -536,15 +851,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,14 +868,122 @@
|
||||
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
|
||||
<div class="fixed bottom-4 right-4 text-xs text-gray-400 flex items-center gap-2">
|
||||
<span id="env-badge" class="hidden px-1.5 py-0.5 rounded-sm font-semibold uppercase tracking-wider text-[10px]"></span>
|
||||
<span id="version-text">v1.0.0-beta3</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Fetch environment info
|
||||
async function updateEnvBadge() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const badge = document.getElementById('env-badge');
|
||||
|
||||
if (data.mode === 'PROD') {
|
||||
badge.textContent = 'PROD';
|
||||
badge.className = 'px-1.5 py-0.5 rounded-sm font-bold uppercase tracking-wider text-[10px] bg-green-600 text-white';
|
||||
badge.classList.remove('hidden');
|
||||
} else if (data.mode === 'TEST') {
|
||||
badge.textContent = 'TEST';
|
||||
badge.className = 'px-1.5 py-0.5 rounded-sm font-bold uppercase tracking-wider text-[10px] bg-amber-500 text-white';
|
||||
badge.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
}
|
||||
}
|
||||
updateEnvBadge();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,74 @@
|
||||
|
||||
import { Client } from '@microsoft/microsoft-graph-client';
|
||||
|
||||
// Bun-native env loader
|
||||
async function loadEnv(filePath: string) {
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
console.warn(`⚠ Env file not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
const text = await file.text();
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await loadEnv('/home/admin/secrets/.env');
|
||||
|
||||
async function getGraphToken() {
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`;
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', process.env.CLIENT_ID!);
|
||||
params.append('client_secret', process.env.CLIENT_SECRET!);
|
||||
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,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function inspectTables() {
|
||||
const token = await getGraphToken();
|
||||
const client = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, token);
|
||||
},
|
||||
});
|
||||
|
||||
const driveId = process.env.DRIVE_ID;
|
||||
const itemId = process.env.ITEM_ID;
|
||||
const baseUrl = `/drives/${driveId}/items/${itemId}/workbook/tables`;
|
||||
|
||||
console.log(`Inspecting tables in file: ${itemId}`);
|
||||
|
||||
try {
|
||||
const res = await client.api(baseUrl).get();
|
||||
const tables = res.value;
|
||||
|
||||
for (const table of tables) {
|
||||
console.log(`\nTable: ${table.name}`);
|
||||
const cols = await client.api(`${baseUrl}/${table.name}/columns`).get();
|
||||
console.log(`- Column count: ${cols.value.length}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
inspectTables();
|
||||
@@ -9,7 +9,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"pocketbase": "^0.26.5"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { 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';
|
||||
@@ -12,7 +14,50 @@ import { processNewJobRecord } from './extracted-graph-logic/post-record-integra
|
||||
// Load secrets from absolute path (not in project directory)
|
||||
config({ path: '/home/admin/secrets/.env' });
|
||||
|
||||
// ============================================================================
|
||||
// ENVIRONMENT SETTINGS (Toggle between TEST and PROD here)
|
||||
// ============================================================================
|
||||
const MODE = 'TEST'; // CHANGE THIS TO 'PROD' TO GO LIVE
|
||||
|
||||
const CONFIG = {
|
||||
TEST: {
|
||||
pbCollection: 'Job_Info_TestEnv',
|
||||
excelTable: 'Test_Table',
|
||||
plannerGroupId: 'e45f2188-dc65-42d6-b7b0-d0a873e07472',
|
||||
plannerPlanId: '87nMEU5OqUqro1xLjY58-2UAD4uj',
|
||||
plannerBucketId: '9VuH0OQEa0GQsCHtPgUCFmUACHVF',
|
||||
description: '🧪 TEST ENVIRONMENT'
|
||||
},
|
||||
PROD: {
|
||||
pbCollection: process.env.PB_COLLECTION || 'Job_Info_Prod',
|
||||
excelTable: process.env.EXCEL_TABLE_NAME || 'Job_List',
|
||||
plannerGroupId: process.env.PLANNER_GROUP_ID || 'e45f2188-dc65-42d6-b7b0-d0a873e07472',
|
||||
plannerPlanId: process.env.PLANNER_PLAN_ID || '87nMEU5OqUqro1xLjY58-2UAD4uj',
|
||||
plannerBucketId: process.env.PLANNER_BUCKET_ID || '9VuH0OQEa0GQsCHtPgUCFmUACHVF',
|
||||
description: '🚀 PRODUCTION ENVIRONMENT'
|
||||
}
|
||||
};
|
||||
|
||||
const activeConfig = MODE === 'PROD' ? CONFIG.PROD : CONFIG.TEST;
|
||||
console.log('\n' + '*'.repeat(60));
|
||||
console.log(' RUNNING IN: ' + activeConfig.description);
|
||||
console.log(' PocketBase: ' + activeConfig.pbCollection);
|
||||
console.log(' Excel Table: ' + activeConfig.excelTable);
|
||||
console.log(' Planner Plan: ' + activeConfig.plannerPlanId);
|
||||
console.log(' Planner Bucket: ' + activeConfig.plannerBucketId);
|
||||
console.log(' Planner Plan: ' + activeConfig.plannerPlanId);
|
||||
console.log(' Planner Bucket: ' + activeConfig.plannerBucketId);
|
||||
console.log('*'.repeat(60) + '\n');
|
||||
|
||||
const app = new Hono();
|
||||
// Environment config endpoint
|
||||
app.get('/api/config', (c) => {
|
||||
return c.json({
|
||||
mode: MODE,
|
||||
description: activeConfig.description
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// ERROR LOGGING
|
||||
@@ -248,13 +293,7 @@ let tableCache: {
|
||||
// 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 Test_Table.
|
||||
function resolveTableName(): string {
|
||||
const nameFromSimpleEnv = process.env.EXCEL_TABLE_NAME?.trim();
|
||||
if (nameFromSimpleEnv) return nameFromSimpleEnv;
|
||||
|
||||
const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim();
|
||||
if (nameFromPath) return nameFromPath;
|
||||
|
||||
return 'Test_Table';
|
||||
return activeConfig.excelTable;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -272,7 +311,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 +414,115 @@ 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 = activeConfig.plannerGroupId;
|
||||
const planId = activeConfig.plannerPlanId;
|
||||
const bucketId = activeConfig.plannerBucketId;
|
||||
|
||||
if (!groupId || !planId || !bucketId) {
|
||||
throw new Error('Missing Planner configuration in activeConfig');
|
||||
}
|
||||
|
||||
const newTask = await client.api('/planner/tasks').post({
|
||||
planId,
|
||||
bucketId,
|
||||
title,
|
||||
assignments: {
|
||||
[assigneeId]: {
|
||||
'@odata.type': 'microsoft.graph.plannerAssignment',
|
||||
orderHint: ' !',
|
||||
},
|
||||
},
|
||||
...(dueDateObj ? { dueDateTime: dueDateObj } : {}),
|
||||
});
|
||||
|
||||
console.log(`✓ Planner task created in Plan ID: ${planId}`);
|
||||
|
||||
console.log(`✓ Planner task created in Plan ID: ${planId}`);
|
||||
|
||||
// Build checklist object with unique IDs
|
||||
const checklist: Record<string, { '@odata.type': string; title: string; isChecked: boolean; orderHint: string }> = {};
|
||||
const checklistItems = ['Create Voxer Chat', 'Create Quickbooks', 'Upload plans'];
|
||||
checklistItems.forEach((item, index) => {
|
||||
const itemId = `checklist_${Date.now()}_${index}`;
|
||||
checklist[itemId] = {
|
||||
'@odata.type': 'microsoft.graph.plannerChecklistItem',
|
||||
title: item,
|
||||
isChecked: false,
|
||||
orderHint: ` ${index}!`,
|
||||
};
|
||||
});
|
||||
|
||||
// Set the Notes/Description AND checklist via task details
|
||||
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.',
|
||||
checklist: checklist,
|
||||
});
|
||||
console.log(`[Planner] Successfully patched description and added ${checklistItems.length} checklist items`);
|
||||
|
||||
// Add "plum" label (category6) to task
|
||||
const taskForLabel = await client.api(`/planner/tasks/${newTask.id}`).get();
|
||||
const taskEtag = taskForLabel['@odata.etag'];
|
||||
await client
|
||||
.api(`/planner/tasks/${newTask.id}`)
|
||||
.header('If-Match', taskEtag)
|
||||
.patch({
|
||||
appliedCategories: {
|
||||
category6: true,
|
||||
},
|
||||
});
|
||||
console.log(`[Planner] Added plum label to task`);
|
||||
|
||||
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();
|
||||
@@ -541,8 +689,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)
|
||||
@@ -558,6 +717,7 @@ async function updateExcelFolderLink(pbId: string, link: string) {
|
||||
|
||||
// API endpoint to submit job
|
||||
app.post('/api/submit', async (c) => {
|
||||
const pbCollection = activeConfig.pbCollection;
|
||||
let data: any;
|
||||
try {
|
||||
data = await c.req.json();
|
||||
@@ -603,8 +763,56 @@ app.post('/api/submit', async (c) => {
|
||||
if (dueIso) data.Due_Date = dueIso;
|
||||
if (startIso) data.Start_Date = startIso;
|
||||
|
||||
// Address validation: require plausible address, coordinates, OR Address_Notes provided
|
||||
const addr = (data.Job_Address || '').toString().trim();
|
||||
const addrNotes = (data.Address_Notes || '').toString().trim();
|
||||
|
||||
// Check if it looks like coordinates: pattern "lat, lon" or "lat,lon"
|
||||
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
|
||||
const isCoordinates = coordPattern.test(addr);
|
||||
|
||||
// Check if it looks like a real address: has digit(s) and letter(s), at least 5 chars
|
||||
const hasDigit = /\d/.test(addr);
|
||||
const hasAlpha = /[A-Za-z]/.test(addr);
|
||||
const isAddress = addr.length >= 5 && hasDigit && hasAlpha;
|
||||
|
||||
const addressLooksValid = isCoordinates || isAddress;
|
||||
if (!addressLooksValid && addrNotes.length < 5) {
|
||||
return c.json({
|
||||
success: false,
|
||||
message: 'Provide a real address (e.g., 123 Main St, City, State), coordinates (e.g., 34.0522, -118.2437), or enter directions in Address Notes.'
|
||||
}, 400);
|
||||
}
|
||||
data.Job_Address = addr;
|
||||
data.Address_Notes = addrNotes;
|
||||
|
||||
// Job name validation: must be at least 4 characters, contain letters, not symbols-only, not "n/a"
|
||||
const jobName = (data.Job_Name || '').toString().trim();
|
||||
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
|
||||
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
|
||||
const hasLetters = /[A-Za-z]/.test(jobName);
|
||||
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
|
||||
const isLongEnough = jobName.length >= 4 || jobName.toUpperCase() === "UNK";
|
||||
if (!isLongEnough || (!hasLetters && jobName.toUpperCase() !== "UNK") || onlySymbols || isInvalidLiteral) {
|
||||
return c.json({
|
||||
success: false,
|
||||
message: 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).'
|
||||
}, 400);
|
||||
}
|
||||
data.Job_Name = jobName;
|
||||
|
||||
// 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({
|
||||
const existingJobs = await pb.collection(activeConfig.pbCollection).getFullList({
|
||||
fields: 'Job_Number',
|
||||
sort: '-created',
|
||||
});
|
||||
@@ -625,7 +833,7 @@ app.post('/api/submit', async (c) => {
|
||||
delete data.pbToken;
|
||||
|
||||
// Create the record in PocketBase as the authenticated user
|
||||
const record = await pb.collection(process.env.PB_COLLECTION!).create(data);
|
||||
const record = await pb.collection(activeConfig.pbCollection).create(data);
|
||||
console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`);
|
||||
|
||||
// Persist calculated fields to PocketBase immediately after creation
|
||||
@@ -635,7 +843,7 @@ app.post('/api/submit', async (c) => {
|
||||
const voxerLinkVal = calculateVoxerLink(record);
|
||||
const jobCodesVal = calculateJobCodes(record);
|
||||
|
||||
await pb.collection(process.env.PB_COLLECTION!).update(record.id, {
|
||||
await pb.collection(activeConfig.pbCollection).update(record.id, {
|
||||
Job_Full_Name: fullName,
|
||||
Job_QB_Link: qbLink,
|
||||
Voxer_Link: voxerLinkVal,
|
||||
@@ -705,7 +913,7 @@ app.post('/api/submit', async (c) => {
|
||||
try {
|
||||
const dueStatic = calculateDueDateCounter(record);
|
||||
const activeStatic = calculateActive(record);
|
||||
await pb.collection(process.env.PB_COLLECTION!).update(record.id, {
|
||||
await pb.collection(activeConfig.pbCollection).update(record.id, {
|
||||
Job_Folder_Link: jobFolderLink || '',
|
||||
Due_Date_Counter: dueStatic,
|
||||
Active: activeStatic
|
||||
@@ -719,6 +927,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 +980,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 +1227,10 @@ 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);
|
||||
// Serve static files from frontend directory
|
||||
app.use('/*', serveStatic({ root: './frontend' }));
|
||||
|
||||
const PORT = Number(process.env.PORT || 3020);
|
||||
|
||||
console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`);
|
||||
|
||||
|
||||
@@ -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'})`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user