696 lines
24 KiB
TypeScript
696 lines
24 KiB
TypeScript
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<void> => {
|
|
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<any | null> => {
|
|
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<any | null> => {
|
|
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<void> {
|
|
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<any> {
|
|
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<void> {
|
|
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<DriveItem[]> => {
|
|
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<DriveItem[]> => {
|
|
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<string, string> = {};
|
|
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<string, string> = {
|
|
'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,
|
|
};
|