From 5a4b279071a7e2aec5f2bfe866b9a423cde12793 Mon Sep 17 00:00:00 2001 From: aewing Date: Mon, 15 Dec 2025 15:44:24 -0600 Subject: [PATCH] Init --- .gitignore | 30 +++ README.md | 135 ++++++++++- frontend/index.html | 560 ++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 384 ++++++++++++++++++++++++++++++ package.json | 20 ++ server.ts | 523 +++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 22 ++ 7 files changed, 1673 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 frontend/index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e98eaa7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ + +# Environment variables +.env +.env.local +.env.*.local + +# Build outputs +dist/ +build/ +*.log + +# OS files +.DS_Store +Thumbs.db + +# Editor directories +.vscode/ +.idea/ + +# Backup files +*.backup +*.bak +*.old + +# Lock files (optional - include if using different package managers) +# package-lock.json +# yarn.lock +# bun.lockb diff --git a/README.md b/README.md index 2043f1e..5773a67 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,135 @@ -# Job-Form +# Job Creation Form with Excel Sync +**Unified system** that combines job creation form with immediate Excel synchronization. + +## Architecture + +This system merges: +- **JobSubmissionFlow**: User form with auto-incrementing Job_Number +- **SyncToExcelFromPb**: Microsoft Graph API Excel sync logic + +### Flow + +1. User submits job form +2. Server authenticates with PocketBase (agent credentials) +3. Auto-calculates `Job_Number = max(existing) + 1` +4. Creates record in PocketBase `Job_Info_Prod` +5. Captures `record.id` from creation response +6. Acquires Microsoft Graph API token +7. Gets Excel table columns +8. Maps PocketBase fields to Excel columns (case-insensitive, only existing columns) +9. Adds `pb_id = record.id` to Excel row +10. POSTs new row to Excel `Test_Table` + +## Configuration + +All credentials are in `.env` (copied from existing systems): + +```bash +# Azure AD +CLIENT_ID=3c846e71-9609-40e1-b458-0eb805e21b9f +CLIENT_SECRET=7aD8Q~d5K~_PzQv6KqDdrEnmyXHE60eVDpbcnaK_ +TENANT_ID=3fd97ea7-b124-41f1-855f-52d8ac3b16c7 + +# PocketBase Agent +PB_DB=https://pocketbase.ccllc.pro +PB_AGENT_EMAIL=pbserviceupdate@cardoza.construction +PB_AGENT_PASSWORD=gJFjYx5y0_BSMgX +PB_COLLECTION=Job_Info_Prod +PB_AUTH_COLLECTION=Users + +# Excel +DRIVE_ID=b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx +ITEM_ID=01SPNXLDQRICHB63BFUNGKZ3GE6RL7LGBT +EXCEL_TABLE=/drives/.../tables/Test_Table + +# Server +PORT=4000 +``` + +## Installation + +**Use npm install** (not bun install) to avoid Windows/OneDrive symlink issues: + +```bash +npm install +``` + +## Usage + +Start the server: + +```bash +bun run dev +``` + +Server runs at: http://localhost:4000 + +## Key Features + +### Auto-Increment Job Numbers +- Queries all existing `Job_Number` values +- Calculates `max + 1` for new job +- No manual input required + +### Excel Column Mapping +- **Case-insensitive**: Matches `Job_Number` to `job_number`, `Company_Client` to `company_client`, etc. +- **Only existing columns**: Ignores PocketBase fields not in Excel table +- **Special mapping**: `record.id` → Excel column `pb_id` + +### Error Handling +- If Excel sync fails, PocketBase record is still created +- Returns warning with partial success +- Logs Excel error details + +## Endpoints + +- `POST /api/submit` - Submit job form (creates in PB + Excel) +- `GET /api/health` - Health check + +## Technical Details + +### Dependencies +- **Hono**: Web framework +- **PocketBase SDK**: Database client +- **@microsoft/microsoft-graph-client**: Excel API +- **dotenv**: Environment config + +### Authentication +- **PocketBase**: Service account (`pbserviceupdate@cardoza.construction`) +- **Microsoft Graph**: OAuth2 client credentials flow + +### Excel Operations +- Table path: `EXCEL_TABLE` from .env +- Operation: `POST /rows` with `{ values: [[...]] }` +- Row format: 2D array matching column order + +## Differences from Other Systems + +### vs. SyncToExcelFromPb +- **No batch sync**: Creates one record at a time +- **No duplicate detection**: Always adds new row +- **No change comparison**: Simple create operation +- **No collision warnings**: Assumes Job_Number is unique + +### vs. JobSubmissionFlow +- **Excel sync included**: Adds to Excel immediately after PB creation +- **pb_id captured**: Stores PocketBase record.id in Excel +- **Graph token acquisition**: Handles Microsoft authentication +- **Column-aware**: Only maps fields that exist in Excel table + +## Troubleshooting + +### Bun Package Resolution Error +**Symptom**: `Cannot find package 'hono'` +**Solution**: Use `npm install` instead of `bun install` + +### Excel Sync Fails +- Check `EXCEL_TABLE` path in .env +- Verify Azure app has Files.ReadWrite.All permission +- Check Excel table name matches exactly (case-sensitive in Graph API) + +### PocketBase Auth Fails +- Verify `PB_AGENT_EMAIL` and `PB_AGENT_PASSWORD` are correct +- Check agent account exists in `Users` collection +- Ensure `PB_DB` URL is accessible diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1035744 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,560 @@ + + + + + + Job Creation Form + + + + + + +
+
+

Cardoza Construction

+

Job Creation Portal

+
+ +
+

Sign in with your Microsoft account to access the Job Creation Form

+ +
+ + +
+ + + + + + + + + +
+ v1.0.0-beta1 +
+ + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f08a06c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,384 @@ +{ + "name": "job-creation-with-excel-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "job-creation-with-excel-sync", + "version": "1.0.0", + "dependencies": { + "@microsoft/microsoft-graph-client": "^3.0.7", + "dotenv": "^17.2.3", + "hono": "^4.10.8", + "pocketbase": "^0.26.5" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "latest", + "autoprefixer": "^10.4.22", + "postcss": "^8.5.6" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@microsoft/microsoft-graph-client": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz", + "integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependenciesMeta": { + "@azure/identity": { + "optional": true + }, + "@azure/msal-browser": { + "optional": true + }, + "buffer": { + "optional": true + }, + "stream-browserify": { + "optional": true + } + } + }, + "node_modules/@types/bun": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.4.tgz", + "integrity": "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.4" + } + }, + "node_modules/@types/node": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.1.tgz", + "integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", + "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bun-types": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.4.tgz", + "integrity": "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/hono": { + "version": "4.10.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.8.tgz", + "integrity": "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pocketbase": { + "version": "0.26.5", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.5.tgz", + "integrity": "sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e791944 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "job-creation-with-excel-sync", + "version": "1.0.0", + "description": "Unified job creation form with PocketBase and Excel sync", + "type": "module", + "scripts": { + "dev": "bun run server.ts", + "start": "bun run server.ts" + }, + "dependencies": { + "@microsoft/microsoft-graph-client": "^3.0.7", + "dotenv": "^17.2.3", + "hono": "^4.10.8", + "pocketbase": "^0.26.5" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "latest" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..be4b36f --- /dev/null +++ b/server.ts @@ -0,0 +1,523 @@ +import { Hono } from 'hono'; +import { serveStatic } from 'hono/bun'; +import { cors } from 'hono/cors'; +import PocketBase from 'pocketbase'; +import { Client } from '@microsoft/microsoft-graph-client'; +import { config } from 'dotenv'; +import { mkdir, appendFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; + +config(); + +const app = new Hono(); + +// ============================================================================ +// ERROR LOGGING +// ============================================================================ + +const LOG_DIR = path.join(process.cwd(), 'logs'); +const ERROR_LOG_PATH = path.join(LOG_DIR, 'error.log'); + +// Ensure logs directory exists +if (!existsSync(LOG_DIR)) { + await mkdir(LOG_DIR, { recursive: true }); +} + +async function logError(context: string, error: any, additionalData?: any) { + const timestamp = new Date().toISOString(); + const logEntry = { + timestamp, + context, + error: { + message: error?.message || String(error), + stack: error?.stack, + status: error?.status || error?.statusCode, + }, + data: additionalData + }; + + const logLine = `${timestamp} [ERROR] ${context}: ${JSON.stringify(logEntry)}\n`; + + try { + await appendFile(ERROR_LOG_PATH, logLine); + console.error(`✗ [${context}] ${error?.message || error}`); + } catch (writeError) { + console.error('Failed to write to error log:', writeError); + console.error('Original error:', error); + } +} + +// Enable CORS +app.use('/*', cors()); + +// Serve static files from frontend directory +app.use('/*', serveStatic({ root: './frontend' })); + +// PocketBase client +const pb = new PocketBase(process.env.PB_DB || 'https://pocketbase.ccllc.pro'); + +// (Removed) formula seeding logic: we rely on Excel's table calculated columns defined in the workbook + +// Microsoft Graph authentication +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, + }); + + if (!response.ok) { + const text = await response.text(); + const error = new Error(`Graph token request failed: ${response.status} ${response.statusText} - ${text}`); + await logError('Graph API Token', error, { endpoint: tokenEndpoint }); + throw error; + } + + type TokenResponse = { access_token: string }; + const data = await response.json() as TokenResponse; + if (!data || typeof data.access_token !== 'string' || !data.access_token) { + const error = new Error('Graph token response missing access_token'); + await logError('Graph API Token', error, { response: data }); + throw error; + } + return data.access_token; +} + +// Create Microsoft Graph client +async function getGraphClient() { + const token = await getGraphToken(); + + return Client.init({ + authProvider: (done) => { + done(null, token); + }, + }); +} + +// Use user's PocketBase token (passed from authenticated frontend) +function setUserPocketBaseAuth(token: string) { + pb.authStore.save(token); +} + +// Get Excel table column names +async function getExcelColumns(client: any, excelTablePath: string) { + const colsResp = await client.api(`${excelTablePath}/columns`).get(); + const columns = colsResp?.value || []; + const columnNames: string[] = columns.map((c: any) => c.name); + return columnNames; +} + +// Cache for table structure (queried once, reused for all submissions) +let tableCache: { + columnNames: string[]; +} | null = null; + +// Resolve the Excel table name from env; prefers EXCEL_TABLE_NAME (just the table's name), +// and falls back to parsing EXCEL_TABLE if provided as a full path, else default to Job_List. +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 'Job_List'; +} + +// ============================================================================ +// CALCULATED COLUMN FUNCTIONS (server-side formula equivalents) +// ============================================================================ +// These replace Excel table formulas with TypeScript calculations on entry +// Ensures consistent, calculated values added to each new row + +/** + * Job_Full_Name = CONCAT([@[Job_Name]]," - ",[@[Job_Address]]," - ",[@[Company_Client]]) + */ +function calculateJobFullName(record: any): string { + const jobName = record.Job_Name || record.job_name || ''; + const jobAddress = record.Job_Address || record.job_address || ''; + const companyClient = record.Company_Client || record.company_client || ''; + + const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim()); + return parts.join(' - '); +} + +/** + * Due_Date_Counter = IF([@[Job_Status]]="Estimating",IF(ISNUMBER(SEARCH("ASAP",[@[Due_Date]])),"ASAP",IF([@[Due_Date]] c.name); + + tableCache = { columnNames }; + console.log(`✓ Table cache initialized with ${columnNames.length} columns`); + } catch (error: any) { + console.error('✗ Failed to initialize table cache:', error); + await logError('Table Cache Initialization', error, { + driveId: process.env.DRIVE_ID, + itemId: process.env.ITEM_ID + }); + throw error; + } +} + +// Add record to Excel (only columns that exist in table) +async function addToExcel(record: any, pbId: string) { + const client = await getGraphClient(); + const tableName = resolveTableName(); + const excelTablePath = `/drives/${process.env.DRIVE_ID}/items/${process.env.ITEM_ID}/workbook/tables/${tableName}`; + + // Initialize cache if not already done + if (!tableCache) { + await initializeTableCache(); + } + + const { columnNames } = tableCache!; + + try { + // Build row values in column order + // Helper: format PocketBase UTC/ISO dates to MM/dd/yyyy for Excel display + function formatPbDateToShort(val: any): string { + if (!val) return ''; + const d = new Date(val); + if (isNaN(d.getTime())) return ''; + // Use UTC accessors to preserve date (don't convert to local time) + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const yyyy = String(d.getUTCFullYear()); + return `${mm}/${dd}/${yyyy}`; + } + + const rowValues = columnNames.map((col) => { + const colLower = col.toLowerCase(); + + // Special case: pb_id column gets the record.id + if (colLower === 'pb_id') { + return pbId || ''; + } + + // ======================================================================== + // CALCULATED COLUMNS: compute values server-side on entry + // ======================================================================== + + // Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client) + if (colLower === 'job_full_name') { + return calculateJobFullName(record); + } + + // Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days) + if (colLower === 'due_date_counter') { + // Send Excel formula so the table auto-calculates + return '=IF([@[Job_Status]]<>"Estimating","N/A",IF(ISNUMBER(SEARCH("ASAP",[@[Due_Date]])),"ASAP",IF([@[Due_Date]] k.toLowerCase() === colLower); + if (matchingKey) { + val = record[matchingKey]; + } + } + + // Format known date columns from PB to Excel-friendly short dates + if (['due_date','start_date','submission_date'].includes(colLower)) { + return formatPbDateToShort(val); + } + + return val == null ? '' : val; + }); + + // Add row to Excel table at index 0 (first position after headers) + await client.api(`${excelTablePath}/rows`).post({ + index: 0, + values: [rowValues] + }); + + console.log(`✓ Synced to Excel (pb_id: ${pbId})`); + + // DISABLED: formula copy causing issues - skip for now + // Previous logic was attempting to copy formulas which may corrupt or duplicate rows + + return { success: true }; + } catch (error: any) { + console.error('✗ Failed to add record to Excel:', error); + await logError('Excel Sync', error, { + pbId, + jobNumber: record.Job_Number || record.job_number, + driveId: process.env.DRIVE_ID, + itemId: process.env.ITEM_ID + }); + throw error; + } +} + +// API endpoint to submit job +app.post('/api/submit', async (c) => { + let data: any; + try { + data = await c.req.json(); + const userToken = data.pbToken; + + if (!userToken) { + return c.json({ + success: false, + message: 'User authentication required. Please log in first.' + }, 401); + } + + // Use user's PocketBase token + setUserPocketBaseAuth(userToken); + + // Convert picker values to UTC ISO strings before PocketBase create + function toIsoUtc(val: any): string | undefined { + if (!val) return undefined; + // If already ISO-like, try Date parsing + let d = new Date(val); + if (isNaN(d.getTime())) { + // Try m/d/Y + const m = /^\s*(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s*$/.exec(String(val)); + if (m) { + const month = Number(m[1]); + const day = Number(m[2]); + const year = Number(m[3].length === 2 ? `20${m[3]}` : m[3]); + // Assume local midnight, then convert to UTC + d = new Date(year, month - 1, day, 0, 0, 0); + } else { + return undefined; + } + } + // Normalize to UTC midnight (strip time if present) + const utc = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0)); + return utc.toISOString(); + } + + const dueDateRaw = data.Due_Date ?? data.due_date ?? data.DueDate ?? data.dueDate; + const startDateRaw = data.Start_Date ?? data.start_date ?? data.StartDate ?? data.startDate; + const dueIso = toIsoUtc(dueDateRaw); + const startIso = toIsoUtc(startDateRaw); + if (dueIso) data.Due_Date = dueIso; + if (startIso) data.Start_Date = startIso; + + // Get all existing job numbers and find the max + const existingJobs = await pb.collection(process.env.PB_COLLECTION!).getFullList({ + fields: 'Job_Number', + sort: '-created', + }); + + let maxJobNumber = 0; + existingJobs.forEach((job: any) => { + const jobNum = parseInt(job.Job_Number, 10); + if (!isNaN(jobNum) && jobNum > maxJobNumber) { + maxJobNumber = jobNum; + } + }); + + // Create new job number as string + const newJobNumber = String(maxJobNumber + 1); + data.Job_Number = newJobNumber; + + // Remove pbToken from data before creating record + delete data.pbToken; + + // Create the record in PocketBase as the authenticated user + const record = await pb.collection(process.env.PB_COLLECTION!).create(data); + console.log(`✓ Created PocketBase record with Job_Number: ${newJobNumber}, ID: ${record.id}`); + + // Add to Excel with pb_id = record.id + try { + await addToExcel(record, record.id); + } catch (excelError: any) { + console.error('⚠️ Excel sync failed, but PocketBase record was created:', excelError); + return c.json({ + success: true, + warning: 'Job created in PocketBase but Excel sync failed', + message: `Job created successfully with Job Number: ${newJobNumber}`, + jobId: record.id, + jobNumber: newJobNumber, + excelError: excelError.message + }); + } + + return c.json({ + success: true, + message: `Job created successfully with Job Number: ${newJobNumber} and synced to Excel`, + jobId: record.id, + jobNumber: newJobNumber + }); + } catch (error: any) { + console.error('✗ Error submitting job:', error); + await logError('Job Submission', error, { + jobName: data?.Job_Name, + userId: pb.authStore.record?.id + }); + return c.json({ + success: false, + message: error.message || 'Failed to create job' + }, 400); + } +}); + +// Health check endpoint +app.get('/api/health', (c) => { + return c.json({ + status: 'ok', + service: 'job-creation-with-excel-sync', + timestamp: new Date().toISOString() + }); +}); + +const PORT = Number(process.env.PORT || 4000); + +console.log(`Job Creation with Excel Sync server running at http://localhost:${PORT}`); + +// Initialize table cache only (non-blocking) +initializeTableCache().catch((error) => { + console.error('✗ Failed to initialize table cache:', error); +}); + +export default { + port: PORT, + fetch: app.fetch, +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6f0efe0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun-types", "node", "bun"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "moduleDetection": "force", + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "esModuleInterop": true, + "resolveJsonModule": true + } +}