Init
This commit is contained in:
@@ -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]]<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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user