import { Hono } from 'hono'; import { serveStatic } from 'hono/bun'; import fs from 'fs'; import path from 'path'; // @ts-ignore Bun project may not have dotenv types configured import { config } from 'dotenv'; import { getCache, setCache, isRedisConnected } from './redis-cache'; config(); const app = new Hono(); const JOBS_CACHE_FILE = path.join(import.meta.dir, '../jobs-cache.json'); // In-memory cache for fast access let cachedJobs: any = null; let lastCacheTime = 0; let isFetching = false; let partialJobs: any[] = []; // Minimal log helpers const logLine = (path: string, line: string) => { try { fs.appendFileSync(path, `${new Date().toISOString()} ${line}\n`); } catch (err) { console.error('Failed to write log:', err); } }; // Cache helper functions const saveCacheFile = async (data: any): Promise => { try { await Bun.write(JOBS_CACHE_FILE, JSON.stringify(data, null, 2)); cachedJobs = data; lastCacheTime = Date.now(); } catch (err) { logLine('logs/error.log', `Failed to save cache file: ${err}`); } }; // Fast load: returns first 30 items immediately, full cache lazily const loadCacheFileFast = async (): Promise => { try { const file = Bun.file(JOBS_CACHE_FILE); if (!(await file.exists())) return null; // Read as text and parse (we're already async, so it's fine) const text = await file.text(); const data = JSON.parse(text); cachedJobs = data; lastCacheTime = Date.now(); return data; } catch (err) { logLine('logs/error.log', `Failed to load cache file: ${err}`); return null; } }; const loadCacheFile = async (): Promise => { try { const file = Bun.file(JOBS_CACHE_FILE); if (await file.exists()) { const data = JSON.parse(await file.text()); cachedJobs = data; lastCacheTime = Date.now(); return data; } } catch (err) { logLine('logs/error.log', `Failed to load cache file: ${err}`); } return null; }; // Version endpoint sourced from package.json app.get('/version', (c) => { try { const pkgRaw = fs.readFileSync('./package.json', 'utf-8'); const pkg = JSON.parse(pkgRaw); return c.json({ version: pkg.version || '0.0.0' }); } catch (err) { logLine('logs/error.log', `version endpoint error: ${(err as Error)?.message || String(err)}`); return c.json({ version: '0.0.0' }, 200); } }); // Jobs endpoint with Redis caching app.get('/api/jobs', async (c) => { const page = parseInt(c.req.query('page') || '1'); const perPage = parseInt(c.req.query('perPage') || '50'); const sort = c.req.query('sort') || '-Job_Number'; const cacheKey = `jobs:page:${page}:perPage:${perPage}:sort:${sort}`; const ttl = 300; // 5 minutes cache try { // Try to get from cache first if (isRedisConnected()) { const cached = await getCache(cacheKey); if (cached) { logLine('logs/server.log', `Cache HIT for ${cacheKey}`); return c.json(cached); } logLine('logs/server.log', `Cache MISS for ${cacheKey}`); } // Fetch from PocketBase const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=${sort}`; const authHeader = c.req.header('Authorization') || ''; const response = await fetch(pbUrl, { headers: authHeader ? { Authorization: authHeader } : {} }); if (!response.ok) { throw new Error(`PocketBase returned ${response.status}`); } const data = await response.json(); // Cache the result if (isRedisConnected()) { await setCache(cacheKey, data, ttl); logLine('logs/server.log', `Cached ${cacheKey} for ${ttl}s`); } return c.json(data); } catch (err) { logLine('logs/error.log', `jobs endpoint error: ${(err as Error)?.message || String(err)}`); return c.json({ error: 'Failed to fetch jobs' }, 500); } }); // Fast endpoint: returns jobs with pagination app.get('/api/jobs-all', async (c) => { try { // Get page and perPage from query params, default to first page with 100 items const page = parseInt(c.req.query('page') || '1'); const perPage = parseInt(c.req.query('perPage') || '100'); // Return in-memory cache if available if (cachedJobs && cachedJobs.items && Array.isArray(cachedJobs.items)) { const allJobs = cachedJobs.items; const totalItems = allJobs.length; const totalPages = Math.ceil(totalItems / perPage); const startIdx = (page - 1) * perPage; const endIdx = startIdx + perPage; const pageJobs = allJobs.slice(startIdx, endIdx); // Refresh in background if older than 5 minutes if (Date.now() - lastCacheTime > 5 * 60 * 1000) { fetchAllJobsProgressively(c.req.header('Authorization') || '').catch(err => { logLine('logs/error.log', `Background refresh error: ${err}`); }); } return c.json({ page, perPage: pageJobs.length, totalItems, totalPages, items: pageJobs }); } // Try to load from disk if not in memory const diskCache = await loadCacheFile(); if (diskCache && diskCache.items && Array.isArray(diskCache.items)) { const allJobs = diskCache.items; const totalItems = allJobs.length; const totalPages = Math.ceil(totalItems / perPage); const startIdx = (page - 1) * perPage; const endIdx = startIdx + perPage; const pageJobs = allJobs.slice(startIdx, endIdx); // Refresh in background fetchAllJobsProgressively(c.req.header('Authorization') || '').catch(err => { logLine('logs/error.log', `Background refresh error: ${err}`); }); return c.json({ page, perPage: pageJobs.length, totalItems, totalPages, items: pageJobs }); } // No cache: fetch first page from PocketBase if (!isFetching) { isFetching = true; fetchAllJobsProgressively(c.req.header('Authorization') || '').catch(err => { logLine('logs/error.log', `Progressive fetch error: ${err}`); isFetching = false; }); } // Return first batch immediately try { const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=1&perPage=100&sort=-Job_Number`; const response = await fetch(pbUrl, { headers: c.req.header('Authorization') ? { Authorization: c.req.header('Authorization')! } : {} }); if (response.ok) { const data = await response.json(); const items = data.items || []; return c.json({ page: 1, perPage: items.length, totalItems: items.length, totalPages: 1, items: items }); } } catch (err) { logLine('logs/error.log', `Failed to get first page: ${err}`); } return c.json({ error: 'No cache available and initial fetch failed' }, 503); } catch(err) { logLine('logs/error.log', `jobs-all endpoint error: ${err}`); return c.json({ error: 'Failed to fetch jobs' }, 500); } }); // Progressive fetch helper: fetches all jobs in background, updating cache as it goes async function fetchAllJobsProgressively(authHeader: string): Promise { try { const perPage = 500; // Get first page to determine total const firstUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=1&perPage=${perPage}&sort=-Job_Number`; const firstResponse = await fetch(firstUrl, { headers: authHeader ? { Authorization: authHeader } : {} }); if (!firstResponse.ok) { throw new Error(`PocketBase returned ${firstResponse.status}`); } const firstData = await firstResponse.json(); const totalItems = firstData.totalItems || 0; const totalPages = Math.ceil(totalItems / perPage); const allItems = [...(firstData.items || [])]; partialJobs = allItems; // Fetch remaining pages in parallel if (totalPages > 1) { const pagePromises = []; for (let page = 2; page <= totalPages; page++) { const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`; pagePromises.push( fetch(pbUrl, { headers: authHeader ? { Authorization: authHeader } : {} }).then(r => r.json()) ); } const results = await Promise.all(pagePromises); for (const result of results) { allItems.push(...(result.items || [])); } } // Save final result to cache file const cacheData = { page: 1, perPage: allItems.length, totalItems: allItems.length, totalPages: 1, items: allItems }; await saveCacheFile(cacheData); isFetching = false; logLine('logs/server.log', `✓ Progressive fetch complete: ${allItems.length} jobs cached`); } catch (err) { logLine('logs/error.log', `Progressive fetch failed: ${err}`); isFetching = false; } } // Helper function to fetch all jobs from PocketBase async function fetchAllJobsFromPocketBase(authHeader: string): Promise { const perPage = 500; // First, get total count const firstUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=1&perPage=${perPage}&sort=-Job_Number`; const firstResponse = await fetch(firstUrl, { headers: authHeader ? { Authorization: authHeader } : {} }); if (!firstResponse.ok) { throw new Error(`PocketBase returned ${firstResponse.status}`); } const firstData = await firstResponse.json(); const totalItems = firstData.totalItems || 0; const totalPages = Math.ceil(totalItems / perPage); // Fetch all pages in parallel const allItems = [...(firstData.items || [])]; if (totalPages > 1) { const pagePromises = []; for (let page = 2; page <= totalPages; page++) { const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`; pagePromises.push( fetch(pbUrl, { headers: authHeader ? { Authorization: authHeader } : {} }).then(r => r.json()) ); } const results = await Promise.all(pagePromises); for (const result of results) { allItems.push(...(result.items || [])); } } return { page: 1, perPage: allItems.length, totalItems: allItems.length, totalPages: 1, items: allItems }; } // Helper function to refresh jobs cache in background async function refreshJobsCache(cacheKey: string, ttl: number, authHeader: string): Promise { try { const result = await fetchAllJobsFromPocketBase(authHeader); await setCache(cacheKey, result, ttl); logLine('logs/server.log', `Background refresh: cached ${cacheKey} with ${result.items.length} jobs`); } catch (err) { logLine('logs/error.log', `Background refresh error: ${(err as Error)?.message || String(err)}`); } } // --- Microsoft Graph helpers --- const getGraphToken = (c?: any) => { const headerToken = c?.req?.header ? c.req.header('x-graph-token') : ''; const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || ''; const token = headerToken || envToken; console.log('[getGraphToken] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE'); console.log('[getGraphToken] Env token:', envToken ? 'SET' : 'NOT SET'); console.log('[getGraphToken] Using:', token ? token.substring(0, 20) + '...' : 'NONE'); return token; }; const b64Url = (input: string) => Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); type DriveItem = { id: string; name: string; webUrl?: string; size?: number; lastModifiedDateTime?: string; file?: { mimeType?: string }; folder?: { childCount?: number }; parentReference?: { path?: string; driveId?: string }; }; const fetchJson = async (url: string, token: string) => { const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', }, }); if (!res.ok) { const text = await res.text(); const err: any = new Error(`Graph ${res.status}: ${text}`); err.status = res.status; throw err; } return res.json(); }; const resolveShareLink = async (link: string, token: string) => { // Decode the link in case it comes pre-encoded from the database const decodedLink = decodeURIComponent(link); console.log('[resolveShareLink] Decoded link:', decodedLink); // Check if this is a short sharing link (/:f:/ or /:b:/) or a direct document library URL if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) { // Short sharing link - use shares API const encoded = `u!${b64Url(decodedLink)}`; console.log('[resolveShareLink] Using shares API, encoded:', encoded); const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token); return { driveId: data.parentReference?.driveId as string, itemId: data.id as string }; } else { // Direct document library URL - parse it and use drive API // Example: https://czflex.sharepoint.com/sites/Team/Shared Documents/Forms/AllItems.aspx?RootFolder=/sites/Team/Shared Documents/General/... const url = new URL(decodedLink); const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/); if (!pathMatch) throw new Error('Invalid SharePoint URL'); const hostname = pathMatch[1]; // e.g., 'czflex' const sitePath = url.pathname.split('/Shared')[0]; // e.g., /sites/Team const rootFolderParam = url.searchParams.get('RootFolder'); if (!rootFolderParam) throw new Error('RootFolder parameter missing'); // Extract the folder path relative to the document library // RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/... // We need: General/Operations [Server]/... const folderPath = rootFolderParam.split('/Shared Documents/')[1]; if (!folderPath) throw new Error('Could not parse folder path'); console.log('[resolveShareLink] Hostname:', hostname); console.log('[resolveShareLink] Site path:', sitePath); console.log('[resolveShareLink] Folder path:', folderPath); // Get the site ID first using hostname:sitePath format const siteData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${hostname}.sharepoint.com:${sitePath}`, token); const siteId = siteData.id; // Get the default document library drive const driveData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, token); const driveId = driveData.id; // Get the folder item by path const itemData = await fetchJson( `https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${folderPath}`, token ); console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id); return { driveId, itemId: itemData.id }; } }; const listChildrenRecursive = async ( driveId: string, itemId: string, depth = 0, maxDepth = 5, token: string, ): Promise => { const out: DriveItem[] = []; const data = await fetchJson(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/children`, token); for (const child of data.value || []) { out.push(child as DriveItem); if (child.folder && depth < maxDepth) { const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token); out.push(...nested); } } return out; }; const searchWithinFolder = async (driveId: string, itemId: string, query: string, token: string): Promise => { const data = await fetchJson( `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/search(q='${encodeURIComponent(query)}')`, token, ); return (data.value || []) as DriveItem[]; }; const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolean = true) => { // First filter: show PDF files and Word documents (which will be converted to PDF) let filtered = items; if (pdfOnly) { filtered = items.filter((i) => { if (i.folder) return false; const name = i.name.toLowerCase(); const mimeType = i.file?.mimeType?.toLowerCase() || ''; // Include PDFs, Word documents, and images return name.endsWith('.pdf') || mimeType.includes('pdf') || name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') || name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') || name.endsWith('.webp') || mimeType.includes('image'); }); } // Second filter: category filtering (if specified) if (!category) return filtered; const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w)); const lc = (s: string) => (s || '').toLowerCase(); const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow']; const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout']; return filtered.filter((i) => { const n = lc(i.name); if (category === 'contracts') return nameHas(n, contractWords); if (category === 'plans') return nameHas(n, planWords); return true; }); }; // List/search job files via Graph using a shared folder link app.get('/api/job-files', async (c) => { try { console.log('[job-files] Request received'); const token = getGraphToken(c); if (!token) { console.log('[job-files] No token found'); return c.json({ error: 'GRAPH_TOKEN missing' }, 500); } console.log('[job-files] Token found:', token.substring(0, 20) + '...'); const link = c.req.query('link'); const q = c.req.query('q') || ''; const category = c.req.query('category') || ''; if (!link) return c.json({ error: 'link required' }, 400); console.log('[job-files] Link:', link); const { driveId, itemId } = await resolveShareLink(link, token); let items: DriveItem[] = []; if (q) { items = await searchWithinFolder(driveId, itemId, q, token); } else { // Walk subfolders up to depth 5 items = await listChildrenRecursive(driveId, itemId, 0, 5, token); } // Filter to show only PDFs by default (can be disabled with pdfOnly=false query param) const pdfOnly = c.req.query('pdfOnly') !== 'false'; const filtered = filterByCategory(items, category, pdfOnly); console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`); const mapped = filtered.map((i) => ({ id: i.id, name: i.name, url: i.webUrl, driveId: i.parentReference?.driveId || driveId, size: i.size, modified: i.lastModifiedDateTime, contentType: i.file?.mimeType, isFolder: Boolean(i.folder), path: i.parentReference?.path || '', })); return c.json({ items: mapped, total: mapped.length, source: q ? 'search' : 'walk', driveId }); } catch (err) { const message = (err as Error)?.message || String(err); const status = (err as any)?.status || 500; console.error('[job-files] ERROR:', message); logLine('logs/error.log', `/api/job-files error: ${message}`); return c.json({ error: message }, status); } }); // Stream file content via Graph to avoid CSP issues for inline preview app.get('/api/job-file-content', async (c) => { try { const token = getGraphToken(c); if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500); const driveId = c.req.query('driveId'); const itemId = c.req.query('itemId'); if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400); const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) { const text = await res.text(); logLine('logs/error.log', `/api/job-file-content failed: ${res.status} - ${text}`); return c.json({ error: `Graph ${res.status}: ${text}` }, 502); } const headers: Record = {}; const ct = res.headers.get('content-type'); const cd = res.headers.get('content-disposition'); if (ct) headers['Content-Type'] = ct; if (cd) headers['Content-Disposition'] = cd; return new Response(res.body, { status: 200, headers }); } catch (err) { const message = (err as Error)?.message || String(err); const status = (err as any)?.status || 500; logLine('logs/error.log', `/api/job-file-content error: ${message}`); return c.json({ error: message }, status); } }); // Convert Word documents to PDF for display app.get('/api/job-file-pdf', async (c) => { try { const token = getGraphToken(c); if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500); const driveId = c.req.query('driveId'); const itemId = c.req.query('itemId'); if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400); // Use Graph API to convert to PDF format const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) { const text = await res.text(); logLine('logs/error.log', `/api/job-file-pdf conversion failed: ${res.status} - ${text}`); return c.json({ error: `PDF conversion failed: ${res.status}` }, 502); } const headers: Record = { 'Content-Type': 'application/pdf', }; const cd = res.headers.get('content-disposition'); if (cd) { // Replace .docx/.doc extension with .pdf in filename headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf'); } logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`); return new Response(res.body, { status: 200, headers }); } catch (err) { const message = (err as Error)?.message || String(err); const status = (err as any)?.status || 500; logLine('logs/error.log', `/api/job-file-pdf error: ${message}`); return c.json({ error: message }, status); } }); // Mutation logging endpoint app.post('/log-change', async (c) => { try { const body = await c.req.json(); const { action = 'unknown', user = 'unknown', target = '', detail = '' } = body || {}; const payload = JSON.stringify({ action, user, target, detail }); logLine('logs/changes.log', payload); return c.json({ status: 'ok' }); } catch (err) { logLine('logs/error.log', `log-change error: ${(err as Error)?.message || String(err)}`); return c.text('Bad Request', 400); } }); // Serve static files from the frontend directory (must come after API routes) // Add cache headers for static assets app.use('/assets/*', async (c, next) => { await next(); // Cache assets for 1 year c.header('Cache-Control', 'public, max-age=31536000, immutable'); }); app.use('/*', serveStatic({ root: './frontend' })); // Error handler app.onError((err, c) => { logLine('logs/error.log', `Unhandled error: ${err?.message || err}`); return c.text('Internal Server Error', 500); }); // Default to 3005 for test instance; can be overridden by Environment=PORT const PORT = Number(process.env.PORT || 3005); logLine('logs/server.log', `Starting frontend server on port ${PORT}`); // Load cache on startup (async () => { try { const cached = await loadCacheFile(); if (cached) { logLine('logs/server.log', `✓ Loaded cache from disk: ${cached.items?.length || 0} jobs`); } else { logLine('logs/server.log', 'No cache file found, will fetch from PocketBase on first request'); } } catch (err) { logLine('logs/error.log', `Startup cache load error: ${(err as Error)?.message || String(err)}`); } })(); // Immediately load cache synchronously if it exists (for fastest first request) (async () => { try { await loadCacheFile(); } catch (err) { // Silent fail } })(); // Background refresh every 5 minutes to keep cache fresh setInterval(async () => { try { const result = await fetchAllJobsFromPocketBase(''); await saveCacheFile(result); logLine('logs/server.log', `✓ Background refresh: updated cache with ${result.items?.length || 0} jobs`); } catch (err) { logLine('logs/error.log', `Periodic refresh error: ${(err as Error)?.message || String(err)}`); } }, 5 * 60 * 1000); // Every 5 minutes // Graceful shutdown logging const shutdown = (signal: string) => { logLine('logs/server.log', `Shutting down due to ${signal}`); process.exit(0); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); export default { port: PORT, fetch: app.fetch, };