import { Hono } from 'hono'; import { serveStatic } from 'hono/bun'; import fs from 'fs'; // @ts-ignore Bun project may not have dotenv types configured import { config } from 'dotenv'; config(); const app = new Hono(); // 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); } }; // 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); } }); // --- 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; if (!headerToken && !envToken) { console.warn('[getGraphToken] No token available in header or environment'); } 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) => { const encoded = `u!${b64Url(link)}`; 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 }; }; 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) => { if (!category) return items; 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 items.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 { const token = getGraphToken(c); if (!token) { console.error('[job-files] No Graph token available (not in header or env)'); return c.json({ error: 'Graph access token required. Please sign in again.' }, 401); } 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); 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); } const filtered = filterByCategory(items, category); 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; 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(); return c.json({ error: `Graph ${res.status}: ${text}` }, res.status); } 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); } }); // 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) 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); }); const PORT = Number(process.env.PORT || 3000); logLine('logs/server.log', `Starting frontend server on port ${PORT}`); // 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, };