6 Commits

Author SHA1 Message Date
aewing d4628eb9a1 feat: migrate to Bun-native, fix Planner task dates, remove hardcoded test values
- Remove dotenv dependency, implement lightweight Bun-native env loader
- Replace Node.js fs/path with Bun file APIs (Bun.file, Bun.write)
- Fix Planner task creation to use form's due date with proper UTC handling
- Make table name, Planner assignee configurable via activeConfig and env vars
- Remove all hardcoded test values for PROD readiness
- Update NOTES.md with PROD mode checklist
2026-01-07 13:40:23 +00:00
aewing 6f02194c23 Add checklist items and plum label to Planner tasks
- Add 3 checklist items to new Planner tasks:
  1. Create Voxer Chat
  2. Create Quickbooks
  3. Upload plans
- Add plum label (category6) to tasks
- Checklist and description patched in single API call
- Label added via separate PATCH with proper ETag handling
2026-01-07 07:37:48 +00:00
aewing cc52c11bf6 feat: implement environment-aware Planner configuration and UNK job name support 2026-01-07 07:02:16 +00:00
aewing 827f32a3d9 Fix middleware order, pbCollection scope, and add UNK support 2026-01-07 06:39:24 +00:00
aewing ca486a6196 chore: switch to TEST mode and add environment indicator 2026-01-07 06:12:34 +00:00
aewing d8cc287330 feat: Add TEST/PROD environment toggle and improve UNK validation 2026-01-07 05:50:43 +00:00
7 changed files with 447 additions and 106 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ All notable changes to this project will be documented in this file.
- Ops - Ops
- Systemd service now uses `EnvironmentFile` for production env vars; fixed `EnvironmentFile` typo and validated permissions. - Systemd service now uses `EnvironmentFile` for production env vars; fixed `EnvironmentFile` typo and validated permissions.
- Switched service restart policy to `Restart=always` for resilience. - Switched service restart policy to `Restart=always` for resilience.
- Confirmed service listens on `PORT` (default 3020) and added guidance for restarts after env changes. - Confirmed service listens on `PORT` (default 4000) and added guidance for restarts after env changes.
- Excel Sync - Excel Sync
- Addressed column mismatch errors by refreshing Excel table column cache on service restart; sync confirmed working. - Addressed column mismatch errors by refreshing Excel table column cache on service restart; sync confirmed working.
- Versioning - Versioning
+42
View File
@@ -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_
+3 -3
View File
@@ -1,6 +1,6 @@
# Job Creation Form with Excel Sync # Job Creation Form with Excel Sync
> Current Version: v1.0.0-beta3a > Current Version: v1.0.0-beta2
See CHANGELOG.md for release notes. See CHANGELOG.md for release notes.
@@ -48,7 +48,7 @@ ITEM_ID=01SPNXLDQRICHB63BFUNGKZ3GE6RL7LGBT
EXCEL_TABLE=/drives/.../tables/Test_Table EXCEL_TABLE=/drives/.../tables/Test_Table
# Server # Server
PORT=3020 PORT=4000
``` ```
## Installation ## Installation
@@ -67,7 +67,7 @@ Start the server:
bun run dev bun run dev
``` ```
Server runs at: http://localhost:3020 Server runs at: http://localhost:4000
## Key Features ## Key Features
+186 -66
View File
@@ -62,11 +62,11 @@
<div class="grid grid-cols-1 gap-6 mb-6"> <div class="grid grid-cols-1 gap-6 mb-6">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2"> <label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2">
Job Name Job Name <span class="text-red-600">*</span>
</label> </label>
<input type="text" id="Job_Name" name="Job_Name" 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"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<p class="text-xs text-gray-500 mt-1">Optional. Provide if known.</p> <p class="text-xs text-gray-500 mt-1">Use a descriptive name (no symbols-only, no N/A).</p>
</div> </div>
</div> </div>
@@ -96,7 +96,7 @@
<label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label> <label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer"> <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> <span class="text-sm">FX</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -132,7 +132,7 @@
<span class="text-sm">C#</span> <span class="text-sm">C#</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <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> <span class="text-sm">R#</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -151,13 +151,13 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Company_Client" class="block text-sm font-medium text-gray-700 mb-2">Company / Client</label> <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"> 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="flex flex-col"> <div class="flex flex-col">
<label for="Contact_Person" class="block text-sm font-medium text-gray-700 mb-2">Contact Person</label> <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"> 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> </div>
@@ -165,14 +165,14 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Phone_Number" class="block text-sm font-medium text-gray-700 mb-2">Phone Number</label> <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="(###) ###-####" 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"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<p class="text-xs text-gray-500 mt-1">Optional. Format: (###) ###-####. Extension can go in EXT.</p> <p class="text-xs text-gray-500 mt-1">Use format (###) ###-####. Extension can go in EXT.</p>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Email" class="block text-sm font-medium text-gray-700 mb-2">Email</label> <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"> 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> </div>
@@ -180,13 +180,13 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="EXT" class="block text-sm font-medium text-gray-700 mb-2">EXT (optional)</label> <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" <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"> 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="flex flex-col"> <div class="flex flex-col">
<label for="Fax" class="block text-sm font-medium text-gray-700 mb-2">Fax (optional)</label> <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" <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"> 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> </div>
@@ -195,7 +195,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Contact_Notes" class="block text-sm font-medium text-gray-700 mb-2">Contact Notes (optional)</label> <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" <textarea id="Contact_Notes" name="Contact_Notes" rows="2" placeholder="Add extra contact details or instructions"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"></textarea> 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> </div>
@@ -203,7 +203,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label> <label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label>
<div class="flex gap-2"> <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" <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"> 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"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -237,10 +237,9 @@
</label> </label>
<select id="Office_Rep" name="Office_Rep" required autocomplete="off" <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"> 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="" disabled>Select office rep</option>
<option value="Beth Cardoza">Beth Cardoza</option> <option value="Beth Cardoza" selected>Beth Cardoza</option>
<option value="Steve Brewer">Steve Brewer</option> <option value="Steve Brewer">Steve Brewer</option>
<option value="Timothy Cardoza">Timothy Cardoza</option>
</select> </select>
</div> </div>
@@ -248,8 +247,8 @@
<label for="Estimator" class="block text-sm font-medium text-gray-700 mb-2">Estimator</label> <label for="Estimator" class="block text-sm font-medium text-gray-700 mb-2">Estimator</label>
<select id="Estimator" name="Estimator" required autocomplete="off" <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"> 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="" disabled>Select estimator</option>
<option value="Beth Cardoza">Beth Cardoza</option> <option value="Beth Cardoza" selected>Beth Cardoza</option>
<option value="Steve Brewer">Steve Brewer</option> <option value="Steve Brewer">Steve Brewer</option>
<option value="Timothy Cardoza">Timothy Cardoza</option> <option value="Timothy Cardoza">Timothy Cardoza</option>
</select> </select>
@@ -261,9 +260,9 @@
<label for="Project_Manager" class="block text-sm font-medium text-gray-700 mb-2">Project Manager</label> <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" <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"> 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="Dave">Dave</option>
<option value="Rick">Rick</option> <option value="Rick" selected>Rick</option>
<option value="Eddie">Eddie</option> <option value="Eddie">Eddie</option>
<option value="Steve">Steve</option> <option value="Steve">Steve</option>
<option value="Timothy">Timothy</option> <option value="Timothy">Timothy</option>
@@ -280,7 +279,7 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Estimated date work will start.">Start Date</label> <label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Estimated date work will start.">Start Date</label>
<input type="text" id="Start_Date" name="Start_Date" class="datepicker w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" placeholder="Select start date" autocomplete="off"> <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> <p class="text-xs text-gray-500 mt-1">Estimated date work will start.</p>
</div> </div>
</div> </div>
@@ -291,10 +290,10 @@
<label for="Schedule_Confidence" class="block text-sm font-medium text-gray-700 mb-2">Schedule Confidence</label> <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" <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"> 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="1 (Total Guess)">1 (Total Guess)</option>
<option value="2 (Guess)">2 (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="4 (Client Verbal Guess)">4 (Client Verbal Guess)</option>
<option value="5">5</option> <option value="5">5</option>
<option value="6">6</option> <option value="6">6</option>
@@ -310,13 +309,13 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col"> <div class="flex flex-col">
<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> <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"> <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> <p class="text-xs text-gray-500 mt-1">Date estimate is due to client.</p>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Due_Time" class="block text-sm font-medium text-gray-700 mb-2">Due Time</label> <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" list="due-time-options" placeholder="Select or type" 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"> 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"> <datalist id="due-time-options">
<option value="5:00 AM"></option> <option value="5:00 AM"></option>
@@ -355,7 +354,7 @@
<label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label> <label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer"> <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> <span class="text-sm">Requested</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -375,7 +374,7 @@
<div class="flex flex-col"> <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> <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" <textarea id="Notes" name="Notes" rows="3" placeholder="Add any additional details, special instructions, or notes"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"></textarea> 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> </div>
</div> </div>
@@ -393,36 +392,31 @@
</label> </label>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer"> <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"> <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">Yes</span> <span class="text-sm">No</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <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"> <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">No</span> <span class="text-sm">Yes</span>
</label> </label>
</div> </div>
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p> <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> </div>
<!-- FUTURE: Job Size Guess and Guess Source
To be enabled in future: allow sq ft, linear ft, and/or dollar amount
Job_Size_Guess: Basic financial cost/value of the job
Job_Size_Guess_Source: Who provided the guess (Estimator, Rick, Project Manager, etc.)
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6">
<div class="flex flex-col"> <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> <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)" <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"> 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> <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>
<div class="flex flex-col"> <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> <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." <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"> 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> </div>
-->
</div> </div>
<button type="submit" id="submitBtn" disabled <button type="submit" id="submitBtn" disabled
@@ -536,11 +530,12 @@
// Function to check if required fields are filled // Function to check if required fields are filled
function checkRequiredFields() { function checkRequiredFields() {
const jobName = document.getElementById('Job_Name').value.trim();
const officeRep = document.getElementById('Office_Rep').value; const officeRep = document.getElementById('Office_Rep').value;
const taxExempt = document.querySelector('input[name="Tax_Exempt"]:checked'); const taxExempt = document.querySelector('input[name="Tax_Exempt"]:checked');
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
if (officeRep && taxExempt) { if (jobName && officeRep && taxExempt) {
submitBtn.disabled = false; submitBtn.disabled = false;
} else { } else {
submitBtn.disabled = true; submitBtn.disabled = true;
@@ -590,8 +585,29 @@
} }
// Add event listeners to required fields // Add event listeners to required fields
document.getElementById('Job_Name').addEventListener('input', checkRequiredFields);
document.getElementById('Office_Rep').addEventListener('change', 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 // Phone number field: validate and format on blur
document.getElementById('Phone_Number').addEventListener('blur', (e) => { document.getElementById('Phone_Number').addEventListener('blur', (e) => {
const phoneRaw = (e.target.value || '').toString().trim(); const phoneRaw = (e.target.value || '').toString().trim();
@@ -642,22 +658,49 @@
} }
}); });
// No validation on Job Address blur (optional) // 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); document.getElementById('Office_Rep').addEventListener('change', updateSelectPlaceholders);
// FUTURE: Job Size Guess formatter (when field is re-enabled) // Job Size Guess: format as USD on blur
// const jobSizeInput = document.getElementById('Job_Size_Guess'); const jobSizeInput = document.getElementById('Job_Size_Guess');
// if (jobSizeInput) { if (jobSizeInput) {
// jobSizeInput.addEventListener('blur', (e) => { jobSizeInput.addEventListener('blur', (e) => {
// const raw = (e.target.value || '').toString().trim(); const raw = (e.target.value || '').toString().trim();
// if (!raw) return; if (!raw) return;
// const cleaned = raw.replace(/[^0-9.]/g, '').replace(/(\..*?)\./g, '$1'); // Keep only digits and one decimal point
// const num = parseFloat(cleaned); const cleaned = raw.replace(/[^0-9.]/g, '').replace(/(\..*?)\./g, '$1');
// if (isNaN(num)) return; const num = parseFloat(cleaned);
// e.target.value = num.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }); 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 => { document.querySelectorAll('input[name="Tax_Exempt"]').forEach(radio => {
radio.addEventListener('change', () => { radio.addEventListener('change', () => {
checkRequiredFields(); checkRequiredFields();
@@ -706,11 +749,23 @@
return; return;
} }
// FUTURE: Phone number format coercion // Enforce phone format (###) ###-####; reject otherwise
// When re-enabled, enforce strict format: (###) ###-#### or "Unknown" (or blank to allow) const phoneRaw = (data.Phone_Number || '').toString();
// Currently: Phone is optional, no validation on submit const isUnknown = phoneRaw.toLowerCase() === 'unknown';
const phoneRaw = (data.Phone_Number || '').toString().trim(); if (!isUnknown) {
data.Phone_Number = phoneRaw; // Accept as-is if provided, or empty if blank 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 // Add user's PocketBase token
data.pbToken = pb.authStore.token; data.pbToken = pb.authStore.token;
@@ -735,10 +790,49 @@
data.Start_Date = formatDateToShort(data.Start_Date); data.Start_Date = formatDateToShort(data.Start_Date);
} }
// Address and Job Name: optional, no validation // Address validation: require plausible address, coordinates, OR Address_Notes
data.Job_Address = (data.Job_Address || '').toString().trim(); const addr = (data.Job_Address || '').toString().trim();
data.Address_Notes = (data.Address_Notes || '').toString().trim(); const addrNotes = (data.Address_Notes || '').toString().trim();
data.Job_Name = (data.Job_Name || '').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 // Convert Tax_Exempt to boolean
if (data.Tax_Exempt) { if (data.Tax_Exempt) {
@@ -746,7 +840,7 @@
} }
try { try {
const response = await fetch('/api/submit', { const response = await fetch('http://localhost:3020/api/submit', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -861,9 +955,35 @@
</script> </script>
<!-- Version Info --> <!-- Version Info -->
<div class="fixed bottom-4 right-4 text-xs text-gray-400"> <div class="fixed bottom-4 right-4 text-xs text-gray-400 flex items-center gap-2">
v1.0.0-beta3a <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> </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> </body>
</html> </html>
+74
View File
@@ -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();
-1
View File
@@ -9,7 +9,6 @@
}, },
"dependencies": { "dependencies": {
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"dotenv": "^17.2.3",
"hono": "^4.10.8", "hono": "^4.10.8",
"pocketbase": "^0.26.5" "pocketbase": "^0.26.5"
}, },
+141 -35
View File
@@ -1,4 +1,5 @@
import { config } from 'dotenv'; import { config } from 'dotenv';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { serveStatic } from 'hono/bun'; import { serveStatic } from 'hono/bun';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
@@ -13,7 +14,50 @@ import { processNewJobRecord } from './extracted-graph-logic/post-record-integra
// Load secrets from absolute path (not in project directory) // Load secrets from absolute path (not in project directory)
config({ path: '/home/admin/secrets/.env' }); 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(); const app = new Hono();
// Environment config endpoint
app.get('/api/config', (c) => {
return c.json({
mode: MODE,
description: activeConfig.description
});
});
// ============================================================================ // ============================================================================
// ERROR LOGGING // ERROR LOGGING
@@ -249,13 +293,7 @@ let tableCache: {
// Resolve the Excel table name from env; prefers EXCEL_TABLE_NAME (just the table's name), // 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. // and falls back to parsing EXCEL_TABLE if provided as a full path, else default to Test_Table.
function resolveTableName(): string { function resolveTableName(): string {
const nameFromSimpleEnv = process.env.EXCEL_TABLE_NAME?.trim(); return activeConfig.excelTable;
if (nameFromSimpleEnv) return nameFromSimpleEnv;
const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim();
if (nameFromPath) return nameFromPath;
return 'Test_Table';
} }
// ============================================================================ // ============================================================================
@@ -273,7 +311,7 @@ function calculateJobFullName(record: any): string {
const companyClient = record.Company_Client || record.company_client || ''; const companyClient = record.Company_Client || record.company_client || '';
const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim()); const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim());
return parts.map(p => String(p).trim()).join(' ').trim(); return parts.map(p => String(p).trim()).join(' - ').trim();
} }
/** /**
@@ -408,29 +446,46 @@ async function createPlannerTask({
// Task title: "Disposition New Job Folder for [Job_Number] [Job_Full_Name]" // Task title: "Disposition New Job Folder for [Job_Number] [Job_Full_Name]"
const title = `Disposition New Job Folder for ${jobNumber} ${jobFullName}`; const title = `Disposition New Job Folder for ${jobNumber} ${jobFullName}`;
// Create the Planner task // Create the Planner task
const groupId = process.env.PLANNER_GROUP_ID; const groupId = activeConfig.plannerGroupId;
const planId = process.env.PLANNER_PLAN_ID; const planId = activeConfig.plannerPlanId;
const bucketId = process.env.PLANNER_BUCKET_ID; const bucketId = activeConfig.plannerBucketId;
if (!groupId || !planId || !bucketId) { if (!groupId || !planId || !bucketId) {
throw new Error('Missing Planner configuration: PLANNER_GROUP_ID, PLANNER_PLAN_ID, or PLANNER_BUCKET_ID'); throw new Error('Missing Planner configuration in activeConfig');
} }
const newTask = await client.api('/planner/tasks').post({ const newTask = await client.api('/planner/tasks').post({
planId, planId,
bucketId, bucketId,
title, title,
assignments: { assignments: {
[assigneeId]: { [assigneeId]: {
'@odata.type': 'microsoft.graph.plannerAssignment', '@odata.type': 'microsoft.graph.plannerAssignment',
orderHint: ' !', orderHint: ' !',
},
}, },
}, ...(dueDateObj ? { dueDateTime: dueDateObj } : {}),
...(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 via task details (match the working test script) // Set the Notes/Description AND checklist via task details
const taskDetails = await client.api(`/planner/tasks/${newTask.id}/details`).get(); const taskDetails = await client.api(`/planner/tasks/${newTask.id}/details`).get();
const detailsEtag = taskDetails['@odata.etag']; const detailsEtag = taskDetails['@odata.etag'];
console.log(`[Planner] Task created ${newTask.id}, fetched details ETag: ${detailsEtag}`); console.log(`[Planner] Task created ${newTask.id}, fetched details ETag: ${detailsEtag}`);
@@ -439,8 +494,22 @@ async function createPlannerTask({
.header('If-Match', detailsEtag) .header('If-Match', detailsEtag)
.patch({ .patch({
description: 'Move the new folder to the correct Job Pages folder.', description: 'Move the new folder to the correct Job Pages folder.',
checklist: checklist,
}); });
console.log(`[Planner] Successfully patched description`); 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}`); console.log(`✓ Planner task created: "${title}" assigned to ${assigneeName}`);
return { success: true, taskId: newTask.id, title }; return { success: true, taskId: newTask.id, title };
@@ -518,7 +587,7 @@ async function addToExcel(record: any, pbId: string) {
// Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client) // Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client)
if (colLower === 'job_full_name') { if (colLower === 'job_full_name') {
return '=CONCATENATE([@[Job_Name]]," ",[@[Job_Address]]," ",[@[Company_Client]])'; return calculateJobFullName(record);
} }
// Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days) // Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days)
@@ -648,6 +717,7 @@ async function updateExcelFolderLink(pbId: string, link: string) {
// API endpoint to submit job // API endpoint to submit job
app.post('/api/submit', async (c) => { app.post('/api/submit', async (c) => {
const pbCollection = activeConfig.pbCollection;
let data: any; let data: any;
try { try {
data = await c.req.json(); data = await c.req.json();
@@ -693,10 +763,43 @@ app.post('/api/submit', async (c) => {
if (dueIso) data.Due_Date = dueIso; if (dueIso) data.Due_Date = dueIso;
if (startIso) data.Start_Date = startIso; if (startIso) data.Start_Date = startIso;
// Address and Job_Name are optional; trim but do not validate // Address validation: require plausible address, coordinates, OR Address_Notes provided
data.Job_Address = (data.Job_Address || '').toString().trim(); const addr = (data.Job_Address || '').toString().trim();
data.Address_Notes = (data.Address_Notes || '').toString().trim(); const addrNotes = (data.Address_Notes || '').toString().trim();
data.Job_Name = (data.Job_Name || '').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 // Enforce Job_Type and Job_Division selection
const jobType = (data.Job_Type || '').toString().trim(); const jobType = (data.Job_Type || '').toString().trim();
@@ -709,7 +812,7 @@ app.post('/api/submit', async (c) => {
} }
// Get all existing job numbers and find the max // 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', fields: 'Job_Number',
sort: '-created', sort: '-created',
}); });
@@ -730,7 +833,7 @@ app.post('/api/submit', async (c) => {
delete data.pbToken; delete data.pbToken;
// Create the record in PocketBase as the authenticated user // 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}`); console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`);
// Persist calculated fields to PocketBase immediately after creation // Persist calculated fields to PocketBase immediately after creation
@@ -740,7 +843,7 @@ app.post('/api/submit', async (c) => {
const voxerLinkVal = calculateVoxerLink(record); const voxerLinkVal = calculateVoxerLink(record);
const jobCodesVal = calculateJobCodes(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_Full_Name: fullName,
Job_QB_Link: qbLink, Job_QB_Link: qbLink,
Voxer_Link: voxerLinkVal, Voxer_Link: voxerLinkVal,
@@ -810,7 +913,7 @@ app.post('/api/submit', async (c) => {
try { try {
const dueStatic = calculateDueDateCounter(record); const dueStatic = calculateDueDateCounter(record);
const activeStatic = calculateActive(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 || '', Job_Folder_Link: jobFolderLink || '',
Due_Date_Counter: dueStatic, Due_Date_Counter: dueStatic,
Active: activeStatic Active: activeStatic
@@ -1124,6 +1227,9 @@ app.post('/api/test-teams-webhook', async (c) => {
return c.json({ success: ok, status: resp.status, body: text }); return c.json({ success: ok, status: resp.status, body: text });
}); });
// Serve static files from frontend directory
app.use('/*', serveStatic({ root: './frontend' }));
const PORT = Number(process.env.PORT || 3020); const PORT = Number(process.env.PORT || 3020);
console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`); console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`);