diff --git a/Job-List.code-workspace b/Job-List.code-workspace new file mode 100644 index 0000000..e9b1f76 --- /dev/null +++ b/Job-List.code-workspace @@ -0,0 +1,6 @@ +{ + "folders": [ + { "path": "/home/admin/Job-List" } + ], + "settings": {} +} diff --git a/backend/server.ts b/backend/server.ts index 16df9e4..325d279 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -29,13 +29,167 @@ app.get('/version', (c) => { } }); -// Serve static files from the frontend directory -app.use('/*', serveStatic({ root: './frontend' })); +// --- 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; +}; -// Error handler -app.onError((err, c) => { - logLine('logs/error.log', `Unhandled error: ${err?.message || err}`); - return c.text('Internal Server Error', 500); +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(); + throw new Error(`Graph ${res.status}: ${text}`); + } + 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 { + 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); + } + + 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); + logLine('logs/error.log', `/api/job-files error: ${message}`); + return c.json({ error: message }, 500); + } +}); + +// 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); + logLine('logs/error.log', `/api/job-file-content error: ${message}`); + return c.json({ error: message }, 500); + } }); // Mutation logging endpoint @@ -52,7 +206,16 @@ app.post('/log-change', async (c) => { } }); -const PORT = Number(process.env.PORT || 3000); +// 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 || 3003); logLine('logs/server.log', `Starting frontend server on port ${PORT}`); diff --git a/frontend/index.html b/frontend/index.html index 1b88d04..b74e74a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@ + @@ -132,6 +133,61 @@ + + +