4 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
4 changed files with 133 additions and 30 deletions
+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_
+23 -2
View File
@@ -1,8 +1,29 @@
import { config } from 'dotenv';
import { Client } from '@microsoft/microsoft-graph-client';
config({ path: '/home/admin/secrets/.env' });
// 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`;
-1
View File
@@ -9,7 +9,6 @@
},
"dependencies": {
"@microsoft/microsoft-graph-client": "^3.0.7",
"dotenv": "^17.2.3",
"hono": "^4.10.8",
"pocketbase": "^0.26.5"
},
+68 -27
View File
@@ -23,20 +23,30 @@ 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(' 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();
@@ -436,29 +446,46 @@ async function createPlannerTask({
// Task title: "Disposition New Job Folder for [Job_Number] [Job_Full_Name]"
const title = `Disposition New Job Folder for ${jobNumber} ${jobFullName}`;
// Create the Planner task
const groupId = process.env.PLANNER_GROUP_ID;
const planId = process.env.PLANNER_PLAN_ID;
const bucketId = process.env.PLANNER_BUCKET_ID;
// 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: PLANNER_GROUP_ID, PLANNER_PLAN_ID, or PLANNER_BUCKET_ID');
}
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: ' !',
planId,
bucketId,
title,
assignments: {
[assigneeId]: {
'@odata.type': 'microsoft.graph.plannerAssignment',
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 detailsEtag = taskDetails['@odata.etag'];
console.log(`[Planner] Task created ${newTask.id}, fetched details ETag: ${detailsEtag}`);
@@ -467,8 +494,22 @@ async function createPlannerTask({
.header('If-Match', detailsEtag)
.patch({
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}`);
return { success: true, taskId: newTask.id, title };
@@ -751,8 +792,8 @@ app.post('/api/submit', async (c) => {
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;
if (!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) {
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).'
@@ -771,7 +812,7 @@ app.post('/api/submit', async (c) => {
}
// Get all existing job numbers and find the max
const existingJobs = await pb.collection(pbCollection).getFullList({
const existingJobs = await pb.collection(activeConfig.pbCollection).getFullList({
fields: 'Job_Number',
sort: '-created',
});
@@ -792,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(pbCollection).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
@@ -802,7 +843,7 @@ app.post('/api/submit', async (c) => {
const voxerLinkVal = calculateVoxerLink(record);
const jobCodesVal = calculateJobCodes(record);
await pb.collection(pbCollection).update(record.id, {
await pb.collection(activeConfig.pbCollection).update(record.id, {
Job_Full_Name: fullName,
Job_QB_Link: qbLink,
Voxer_Link: voxerLinkVal,
@@ -872,7 +913,7 @@ app.post('/api/submit', async (c) => {
try {
const dueStatic = calculateDueDateCounter(record);
const activeStatic = calculateActive(record);
await pb.collection(pbCollection).update(record.id, {
await pb.collection(activeConfig.pbCollection).update(record.id, {
Job_Folder_Link: jobFolderLink || '',
Due_Date_Counter: dueStatic,
Active: activeStatic