340b7e0818
- Added dotenv package to load environment variables from absolute path - Secrets now loaded automatically on app startup, no manual sourcing needed - Secrets file secured with chmod 600, outside project directory - Enables persistent secrets across service restarts - Tested: Job submission flow working end-to-end with all steps completing
645 lines
23 KiB
TypeScript
645 lines
23 KiB
TypeScript
import { config } from 'dotenv';
|
|
import { Hono } from 'hono';
|
|
import { serveStatic } from 'hono/bun';
|
|
import { cors } from 'hono/cors';
|
|
import PocketBase from 'pocketbase';
|
|
import { Client } from '@microsoft/microsoft-graph-client';
|
|
import { mkdir, appendFile } from 'fs/promises';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import { processNewJobRecord } from './extracted-graph-logic/post-record-integration.js';
|
|
|
|
// Load secrets from absolute path (not in project directory)
|
|
config({ path: '/home/admin/secrets/.env' });
|
|
|
|
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 Test_Table.
|
|
function resolveTableName(): string {
|
|
const nameFromSimpleEnv = process.env.EXCEL_TABLE_NAME?.trim();
|
|
if (nameFromSimpleEnv) return nameFromSimpleEnv;
|
|
|
|
const nameFromPath = process.env.EXCEL_TABLE?.split('/tables/').pop()?.trim();
|
|
if (nameFromPath) return nameFromPath;
|
|
|
|
return 'Test_Table';
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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]]<TODAY(),"Passed due",IF([@[Due_Date]]=TODAY(),"Due today",([@[Due_Date]]-TODAY())&IF([@[Due_Date]]-TODAY()=1," day"," days")))),"N/A")
|
|
*/
|
|
function calculateDueDateCounter(record: any): string {
|
|
const jobStatus = record.Job_Status || record.job_status || '';
|
|
const dueDateStr = record.Due_Date || record.due_date || '';
|
|
|
|
if (jobStatus !== 'Estimating') {
|
|
return 'N/A';
|
|
}
|
|
|
|
if (!dueDateStr) {
|
|
return 'N/A';
|
|
}
|
|
|
|
// Check if "ASAP" appears in the date string
|
|
if (String(dueDateStr).toUpperCase().includes('ASAP')) {
|
|
return 'ASAP';
|
|
}
|
|
|
|
// Parse the date (could be ISO or MM/dd/yyyy)
|
|
let dueDate = new Date(dueDateStr);
|
|
if (isNaN(dueDate.getTime())) {
|
|
return 'N/A';
|
|
}
|
|
|
|
const today = new Date();
|
|
// Reset time portions to midnight for fair comparison
|
|
today.setHours(0, 0, 0, 0);
|
|
dueDate.setHours(0, 0, 0, 0);
|
|
|
|
const timeDiff = dueDate.getTime() - today.getTime();
|
|
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
|
|
|
|
if (daysDiff < 0) {
|
|
return 'Passed due';
|
|
} else if (daysDiff === 0) {
|
|
return 'Due today';
|
|
} else {
|
|
const dayLabel = daysDiff === 1 ? ' day' : ' days';
|
|
return `${daysDiff}${dayLabel}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Job_QB_Link = [@[Job_Division]]&[@[Job_Number]]&[@[Job_Type]]&" - "&[@[Job_Name]]
|
|
*/
|
|
function calculateJobQbLink(record: any): string {
|
|
const jobDivision = record.Job_Division || record.job_division || '';
|
|
const jobNumber = record.Job_Number || record.job_number || '';
|
|
const jobType = record.Job_Type || record.job_type || '';
|
|
const jobName = record.Job_Name || record.job_name || '';
|
|
|
|
return `${jobDivision}${jobNumber}${jobType} - ${jobName}`;
|
|
}
|
|
|
|
/**
|
|
* Voxer_Link = [@[Job_Division]]&[@[Job_Number]]&[@[Job_Type]]&" - "&[@[Job_Name]]&" - "&[@[Job_Address]]&" - "&[@[Company_Client]]&" - "&[@[Contact_Person]]&" - "&[@[Phone_Number]]
|
|
*/
|
|
function calculateVoxerLink(record: any): string {
|
|
const jobDivision = record.Job_Division || record.job_division || '';
|
|
const jobNumber = record.Job_Number || record.job_number || '';
|
|
const jobType = record.Job_Type || record.job_type || '';
|
|
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 contactPerson = record.Contact_Person || record.contact_person || '';
|
|
const phoneNumber = record.Phone_Number || record.phone_number || '';
|
|
|
|
return `${jobDivision}${jobNumber}${jobType} - ${jobName} - ${jobAddress} - ${companyClient} - ${contactPerson} - ${phoneNumber}`;
|
|
}
|
|
|
|
/**
|
|
* Job_Codes = [@[Job_Number]]&" - "&[@[Job_Name]]
|
|
*/
|
|
function calculateJobCodes(record: any): string {
|
|
const jobNumber = record.Job_Number || record.job_number || '';
|
|
const jobName = record.Job_Name || record.job_name || '';
|
|
|
|
return `${jobNumber} - ${jobName}`;
|
|
}
|
|
|
|
/**
|
|
* Active = IF(OR([@[Job_Status]]="Estimating",[@[Job_Status]]="Est Sent",[@[Job_Status]]="Est Hold",[@[Job_Status]]="Follow Up",[@[Job_Status]]="Awarded",[@[Job_Status]]="In Progress",[@[Job_Status]]="Billed In Progress"),TRUE,FALSE)
|
|
*/
|
|
function calculateActive(record: any): boolean {
|
|
const jobStatus = record.Job_Status || record.job_status || '';
|
|
const activeStatuses = [
|
|
'Estimating',
|
|
'Est Sent',
|
|
'Est Hold',
|
|
'Follow Up',
|
|
'Awarded',
|
|
'In Progress',
|
|
'Billed In Progress'
|
|
];
|
|
|
|
return activeStatuses.includes(jobStatus);
|
|
}
|
|
|
|
// Initialize table cache on startup
|
|
async function initializeTableCache() {
|
|
const client = await getGraphClient();
|
|
const tableName = resolveTableName();
|
|
const excelTablePath = `/drives/${process.env.DRIVE_ID}/items/${process.env.ITEM_ID}/workbook/tables/${tableName}`;
|
|
|
|
try {
|
|
const colsResp = await client.api(`${excelTablePath}/columns`).get();
|
|
const columns = colsResp?.value || [];
|
|
const columnNames: string[] = columns.map((c: any) => 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]]<TODAY(),"Passed due",IF([@[Due_Date]]=TODAY(),"Due today",[@[Due_Date]]-TODAY()&IF([@[Due_Date]]-TODAY()=1," day"," days")))))';
|
|
}
|
|
|
|
// Job_QB_Link: Job_Division + Job_Number + Job_Type + " - " + Job_Name
|
|
if (colLower === 'job_qb_link') {
|
|
return calculateJobQbLink(record);
|
|
}
|
|
|
|
// Voxer_Link: Long concatenation with division, number, type, name, address, client, contact, phone
|
|
if (colLower === 'voxer_link') {
|
|
return calculateVoxerLink(record);
|
|
}
|
|
|
|
// Job_Codes: Job_Number + " - " + Job_Name
|
|
if (colLower === 'job_codes') {
|
|
return calculateJobCodes(record);
|
|
}
|
|
|
|
// Active: boolean based on Job_Status membership in active statuses list
|
|
if (colLower === 'active') {
|
|
// Send Excel formula so the table auto-calculates
|
|
return '=OR([@[Job_Status]]="Estimating",[@[Job_Status]]="Est Sent",[@[Job_Status]]="Est Hold",[@[Job_Status]]="Follow Up",[@[Job_Status]]="Awarded",[@[Job_Status]]="In Progress",[@[Job_Status]]="Billed In Progress")';
|
|
}
|
|
|
|
// ========================================================================
|
|
// REGULAR COLUMNS: format dates, fall back to direct value
|
|
// ========================================================================
|
|
|
|
// Try exact match first, then case-insensitive
|
|
let val = record[col];
|
|
if (val === undefined) {
|
|
const recordKeys = Object.keys(record);
|
|
const matchingKey = recordKeys.find(k => 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;
|
|
}
|
|
}
|
|
|
|
// Update the Excel row's Job_Folder_Link for the given pb_id
|
|
async function updateExcelFolderLink(pbId: string, link: 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!;
|
|
const lower = columnNames.map((n) => n.toLowerCase());
|
|
const linkColIndex = lower.indexOf('job_folder_link');
|
|
const pbIdColIndex = lower.indexOf('pb_id');
|
|
if (linkColIndex === -1 || pbIdColIndex === -1) {
|
|
console.warn('⚠️ Excel table missing Job_Folder_Link or pb_id column');
|
|
return;
|
|
}
|
|
|
|
const rowsResp = await client.api(`${excelTablePath}/rows`).get();
|
|
const rows = rowsResp?.value || [];
|
|
const target = rows.find((row: any) => {
|
|
const vals = row?.values?.[0];
|
|
return vals && vals[pbIdColIndex] === pbId;
|
|
});
|
|
if (!target) {
|
|
console.warn('⚠️ Excel row not found for pb_id:', pbId);
|
|
return;
|
|
}
|
|
|
|
// Update using table row itemAt - update the full row with link populated
|
|
const currentValues = [...target.values[0]]; // Copy array
|
|
currentValues[linkColIndex] = link || '';
|
|
|
|
await client.api(`${excelTablePath}/rows/itemAt(index=${target.index})`)
|
|
.patch({
|
|
values: [currentValues]
|
|
});
|
|
|
|
console.log('✓ Updated Excel Job_Folder_Link for pb_id:', pbId);
|
|
}
|
|
|
|
// 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}`);
|
|
|
|
// Persist calculated fields to PocketBase immediately after creation
|
|
try {
|
|
const fullName = calculateJobFullName(record);
|
|
const qbLink = calculateJobQbLink(record);
|
|
const voxerLinkVal = calculateVoxerLink(record);
|
|
const jobCodesVal = calculateJobCodes(record);
|
|
|
|
await pb.collection(process.env.PB_COLLECTION!).update(record.id, {
|
|
Job_Full_Name: fullName,
|
|
Job_QB_Link: qbLink,
|
|
Voxer_Link: voxerLinkVal,
|
|
Job_Codes: jobCodesVal
|
|
});
|
|
|
|
// Keep local record in sync for Excel add
|
|
(record as any).Job_Full_Name = fullName;
|
|
(record as any).Job_QB_Link = qbLink;
|
|
(record as any).Voxer_Link = voxerLinkVal;
|
|
(record as any).Job_Codes = jobCodesVal;
|
|
console.log('✓ Updated PB with calculated fields (Full Name, QB, Voxer, Codes)');
|
|
} catch (pbUpdateErr: any) {
|
|
console.error('⚠️ Failed to update PB calculated fields:', pbUpdateErr);
|
|
await logError('PocketBase Calculated Fields Update', pbUpdateErr, { recordId: record.id });
|
|
}
|
|
|
|
// Add to Excel first with pb_id = record.id (dynamic formulas for Due_Date_Counter, Active)
|
|
try {
|
|
await addToExcel(record, record.id);
|
|
console.log('✓ Excel row created');
|
|
} 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,
|
|
jobFolderLink: null,
|
|
excelError: excelError.message
|
|
});
|
|
}
|
|
|
|
// After Excel is added, create folder structure and capture share link
|
|
let jobFolderLink = '';
|
|
try {
|
|
const graphToken = await getGraphToken();
|
|
const folderResult = await processNewJobRecord(record, graphToken) as any;
|
|
|
|
if (folderResult.success) {
|
|
jobFolderLink = folderResult.shareLink || '';
|
|
console.log(`✓ Folder created and share link captured: ${jobFolderLink}`);
|
|
console.log(`📁 Open folder: ${jobFolderLink}`);
|
|
} else {
|
|
console.warn(`⚠️ Folder creation failed: ${folderResult.error}`);
|
|
}
|
|
} catch (folderError: any) {
|
|
console.error('⚠️ Folder creation failed, continuing without folder link:', folderError);
|
|
await logError('Folder Creation', folderError, {
|
|
jobNumber: newJobNumber,
|
|
recordId: record.id
|
|
});
|
|
}
|
|
|
|
// Update Excel row with Job_Folder_Link (if available)
|
|
if (jobFolderLink) {
|
|
try {
|
|
await updateExcelFolderLink(record.id, jobFolderLink);
|
|
} catch (excelUpdateErr: any) {
|
|
console.error('⚠️ Failed to update Excel with folder link:', excelUpdateErr);
|
|
await logError('Excel Update Folder Link', excelUpdateErr, { recordId: record.id, jobFolderLink });
|
|
}
|
|
}
|
|
|
|
// Persist static values back to PocketBase: Job_Folder_Link, Due_Date_Counter, Active
|
|
try {
|
|
const dueStatic = calculateDueDateCounter(record);
|
|
const activeStatic = calculateActive(record);
|
|
await pb.collection(process.env.PB_COLLECTION!).update(record.id, {
|
|
Job_Folder_Link: jobFolderLink || '',
|
|
Due_Date_Counter: dueStatic,
|
|
Active: activeStatic
|
|
});
|
|
(record as any).Job_Folder_Link = jobFolderLink || '';
|
|
(record as any).Due_Date_Counter = dueStatic;
|
|
(record as any).Active = activeStatic;
|
|
console.log('✓ Updated PB with folder link, due date counter (static), and active (boolean)');
|
|
} catch (pbFinalizeErr: any) {
|
|
console.error('⚠️ Failed to update PB with final static values:', pbFinalizeErr);
|
|
await logError('PocketBase Final Update', pbFinalizeErr, { recordId: record.id, jobFolderLink });
|
|
}
|
|
|
|
return c.json({
|
|
success: true,
|
|
message: `Job created successfully with Job Number: ${newJobNumber} and synced to Excel`,
|
|
jobId: record.id,
|
|
jobNumber: newJobNumber,
|
|
jobFolderLink: jobFolderLink || null
|
|
});
|
|
} 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,
|
|
};
|