import { Hono } from 'hono'; import { serveStatic } from 'hono/bun'; import { cors } from 'hono/cors'; import PocketBase from 'pocketbase'; import { ConfidentialClientApplication } from '@azure/msal-node'; import { readFileSync } from 'fs'; import { join } from 'path'; // Load env from Estimator/.env const envPath = join(import.meta.dir, 'Estimator', '.env'); try { const envContent = readFileSync(envPath, 'utf-8'); envContent.split('\n').forEach(line => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); if (key && valueParts.length > 0) { process.env[key.trim()] = valueParts.join('=').trim(); } } }); console.log('āœ“ Loaded env from Estimator/.env'); } catch (e) { console.warn('⚠ Could not load Estimator/.env:', (e as Error).message); } const app = new Hono(); // Enable CORS app.use('/*', cors({ origin: '*', credentials: true })); // PocketBase client (fixed URL per request) const pb = new PocketBase('https://pocketbase.ccllc.pro'); // Use user's PocketBase token function setUserPocketBaseAuth(token: string) { pb.authStore.save(token, null); } // MSAL config for Graph token (client credentials) const msalConfig = { auth: { clientId: process.env.CLIENT_ID || '', authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`, clientSecret: process.env.CLIENT_SECRET || '' } }; const cca = new ConfidentialClientApplication(msalConfig); async function getGraphToken(): Promise { const result = await cca.acquireTokenByClientCredential({ scopes: ['https://graph.microsoft.com/.default'] }); if (!result || !result.accessToken) { throw new Error('Failed to acquire Graph token'); } return result.accessToken; } // Health check app.get('/health', (c) => { return c.json({ ok: true, pbDB: process.env.PB_DB }); }); // Submit endpoint app.post('/api/submit', async (c) => { try { const body = await c.req.json(); const pbToken = body.pbToken; if (!pbToken) { return c.json({ success: false, message: 'Missing pbToken' }, 401); } // Validate user token setUserPocketBaseAuth(pbToken); try { await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authRefresh(); } catch (e) { console.error('auth_validation_failed', { message: (e as Error)?.message }); return c.json({ success: false, message: 'Invalid token' }, 401); } // Store in PocketBase collection const record = await pb.collection(process.env.PB_COLLECTION || 'Job_Info_TestEnv').create({ ...body, submittedBy: pb.authStore.model?.email || 'unknown', submittedAt: new Date().toISOString() }); console.log('submission_success', { recordId: record.id }); return c.json({ success: true, message: 'Feedback submitted successfully', recordId: record.id }); } catch (err) { console.error('submit_error', { message: (err as Error)?.message }); return c.json({ success: false, message: 'Internal error' }, 500); } }); // Serve static files app.get('/', async (c) => { const html = await Bun.file('index.html').text(); return c.html(html); }); app.get('/estimatortable.html', async (c) => { const html = await Bun.file('estimatortable.html').text(); return c.html(html); }); app.get('/admin.html', async (c) => { const html = await Bun.file('admin.html').text(); return c.html(html); }); app.get('/appsettings.html', async (c) => { const html = await Bun.file('appsettings.html').text(); return c.html(html); }); // Admin login endpoint app.post('/api/admin/login', async (c) => { try { const { email, password } = await c.req.json(); if (!email || !password) { return c.json({ message: 'Missing email or password' }, 400); } // Authenticate as PocketBase superuser via `_superusers` collection const authData = await pb.collection('_superusers').authWithPassword(email, password); if (!authData || !authData.token) { return c.json({ message: 'Invalid credentials' }, 401); } return c.json({ ok: true, token: authData.token, email: authData.record?.email, role: 'superuser' }); } catch (err) { console.error('admin_login_error', { message: (err as Error)?.message }); return c.json({ message: 'Login failed' }, 401); } }); // Get collection schema (requires admin token) app.get('/api/schema', async (c) => { try { const authHeader = c.req.header('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return c.json({ message: 'Missing authorization token' }, 401); } const token = authHeader.slice(7); setUserPocketBaseAuth(token); // Verify superuser token; `_superusers` can fetch schema try { await pb.collection('_superusers').authRefresh(); } catch (e) { return c.json({ message: 'Invalid or non-superuser token' }, 401); } const url = new URL(c.req.url); const requestedName = url.searchParams.get('name'); const collectionName = requestedName || process.env.PB_COLLECTION || 'Job_Info_TestEnv'; const pbDb = process.env.PB_DB || 'https://pocketbase.ccllc.pro'; // Try SDK first let schema: any[] = []; let meta: Record = {}; try { const collection = await pb.collections.getOne(collectionName); schema = Array.isArray(collection?.schema) ? collection.schema : []; meta = { name: collection?.name, type: collection?.type, listRule: collection?.listRule, viewRule: collection?.viewRule, createRule: collection?.createRule, updateRule: collection?.updateRule, deleteRule: collection?.deleteRule, }; } catch (e) { // ignore and try REST fallback } if (!schema.length) { // Fallback to REST API for robustness const resp = await fetch(`${pbDb}/api/collections/${encodeURIComponent(collectionName)}`, { headers: { Authorization: `Bearer ${token}` } }); if (resp.ok) { const data = await resp.json(); schema = Array.isArray(data?.schema) ? data.schema : []; meta = { name: data?.name, type: data?.type, listRule: data?.listRule, viewRule: data?.viewRule, createRule: data?.createRule, updateRule: data?.updateRule, deleteRule: data?.deleteRule, }; } } if (!schema.length) { // As a last resort, try to derive field names and generate schema from sample records try { const perPage = 25; const list = await pb.collection(collectionName).getList(1, perPage); const items = list?.items || []; if (items.length) { const systemKeys = new Set(['id','created','updated','collectionId','collectionName','expand']); const keyStats: Record }> = {}; for (const it of items) { for (const k of Object.keys(it)) { if (systemKeys.has(k)) continue; const v = (it as any)[k]; const t = Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v); if (!keyStats[k]) keyStats[k] = { count: 0, types: new Set() }; if (v !== undefined && v !== null) keyStats[k].count += 1; keyStats[k].types.add(t); } } const generatedSchema = Object.entries(keyStats).map(([name, stat]) => ({ name, type: Array.from(stat.types).join('|'), required: stat.count === items.length })); return c.json({ ok: true, schema: [], meta, generatedSchema, message: 'Generated schema from sample records.' }); } } catch {} return c.json({ ok: true, schema: [], meta, message: 'No fields found. Check collection name or permissions.' }); } return c.json({ ok: true, schema, meta }); } catch (err) { console.error('schema_fetch_error', { message: (err as Error)?.message }); return c.json({ message: 'Internal error' }, 500); } }); // List all collections (requires superuser token) app.get('/api/collections', async (c) => { try { const authHeader = c.req.header('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return c.json({ message: 'Missing authorization token' }, 401); } const token = authHeader.slice(7); setUserPocketBaseAuth(token); try { await pb.collection('_superusers').authRefresh(); } catch (e) { return c.json({ message: 'Invalid or non-superuser token' }, 401); } const list = await pb.collections.getFullList(); const items = list.map((c) => ({ id: (c as any).id, name: (c as any).name, type: (c as any).type, schemaCount: Array.isArray((c as any).schema) ? (c as any).schema.length : 0, })); return c.json({ ok: true, items }); } catch (err) { console.error('collections_list_error', { message: (err as Error)?.message }); return c.json({ message: 'Internal error' }, 500); } }); // Get a single field definition and sample values (requires superuser token) app.get('/api/field', async (c) => { try { const authHeader = c.req.header('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return c.json({ message: 'Missing authorization token' }, 401); } const token = authHeader.slice(7); setUserPocketBaseAuth(token); // Verify superuser try { await pb.collection('_superusers').authRefresh(); } catch (e) { return c.json({ message: 'Invalid or non-superuser token' }, 401); } const url = new URL(c.req.url); const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv'; const fieldNameParam = url.searchParams.get('field') || 'job_number'; const lc = (s: string) => s.toLowerCase(); const candidates = Array.from(new Set([ fieldNameParam, lc(fieldNameParam), fieldNameParam.toUpperCase(), fieldNameParam.replace(/[\s-]/g, '_'), lc(fieldNameParam.replace(/[\s-]/g, '_')), // naive camelCase variants fieldNameParam.replace(/[_\s-]([a-z])/g, (_, ch) => ch.toUpperCase()), lc(fieldNameParam.replace(/[_\s-]([a-z])/g, (_, ch) => ch.toUpperCase())) ])); let definition: any = null; let derived = false; let values: any[] = []; let totalItems = 0; let resolvedField: string | null = null; // Try getting field definition via SDK try { const collection = await pb.collections.getOne(collectionName); if (Array.isArray(collection?.schema)) { definition = collection.schema.find((f: any) => candidates.some((cand) => lc(f?.name || '') === lc(cand))) || null; if (definition) resolvedField = definition.name; } } catch {} // If no explicit schema, derive from a sample record if (!definition) { try { const list = await pb.collection(collectionName).getList(1, 1); const sample = list?.items?.[0]; if (sample) { const sampleKeys = Object.keys(sample); const matchKey = sampleKeys.find(k => candidates.some(c => lc(k) === lc(c))); if (matchKey) { derived = true; resolvedField = matchKey; } } } catch {} } // Collect up to 10 values for preview try { const list = await pb.collection(collectionName).getList(1, 1); totalItems = list?.totalItems ?? 0; } catch {} try { const list = await pb.collection(collectionName).getList(1, 20); const items = list?.items || []; values = items.map((it: any) => { if (resolvedField && Object.prototype.hasOwnProperty.call(it, resolvedField)) return it[resolvedField]; // last attempt: case-insensitive per record const itKeys = Object.keys(it); const k = itKeys.find(k => candidates.some(c => lc(k) === lc(c))); return k ? it[k] : undefined; }).filter((v: any) => v !== undefined && v !== null); } catch {} const nonNullCount = values.length; return c.json({ ok: true, collection: collectionName, field: fieldNameParam, resolvedField, definition, derived, values, count: nonNullCount, totalItems }); } catch (err) { console.error('field_fetch_error', { message: (err as Error)?.message }); return c.json({ message: 'Internal error' }, 500); } }); // Get sample records for a collection (requires superuser token) app.get('/api/records', async (c) => { try { const authHeader = c.req.header('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return c.json({ message: 'Missing authorization token' }, 401); } const token = authHeader.slice(7); setUserPocketBaseAuth(token); try { await pb.collection('_superusers').authRefresh(); } catch (e) { return c.json({ message: 'Invalid or non-superuser token' }, 401); } const url = new URL(c.req.url); const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv'; const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('perPage') || 10))); const firstPage = await pb.collection(collectionName).getList(1, perPage); const items = firstPage?.items || []; return c.json({ ok: true, collection: collectionName, totalItems: firstPage?.totalItems ?? 0, items }); } catch (err) { console.error('records_fetch_error', { message: (err as Error)?.message }); return c.json({ message: 'Internal error' }, 500); } }); // Serve admin.html app.get('/admin', async (c) => { const html = await Bun.file(new URL('./admin.html', import.meta.url)).text(); return c.html(html); }); // Export collection schema and headers (requires superuser token) app.get('/api/export/schema', async (c) => { try { const authHeader = c.req.header('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return c.json({ message: 'Missing authorization token' }, 401); } const token = authHeader.slice(7); setUserPocketBaseAuth(token); try { await pb.collection('_superusers').authRefresh(); } catch (e) { return c.json({ message: 'Invalid or non-superuser token' }, 401); } const url = new URL(c.req.url); const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv'; let schema: any[] = []; let meta: Record = {}; let generatedSchema: any[] = []; // Fetch explicit schema via SDK try { const collection = await pb.collections.getOne(collectionName); schema = Array.isArray(collection?.schema) ? collection.schema : []; meta = { id: (collection as any)?.id, name: (collection as any)?.name, type: (collection as any)?.type, listRule: (collection as any)?.listRule, viewRule: (collection as any)?.viewRule, createRule: (collection as any)?.createRule, updateRule: (collection as any)?.updateRule, deleteRule: (collection as any)?.deleteRule, }; } catch {} // If no explicit schema, generate from records if (!schema.length) { try { const perPage = 100; const list = await pb.collection(collectionName).getList(1, perPage); const items = list?.items || []; if (items.length) { const systemKeys = new Set(['id','created','updated','collectionId','collectionName','expand']); const keyStats: Record }> = {}; for (const it of items) { for (const k of Object.keys(it)) { if (systemKeys.has(k)) continue; const v = (it as any)[k]; const t = Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v); if (!keyStats[k]) keyStats[k] = { count: 0, types: new Set() }; if (v !== undefined && v !== null) keyStats[k].count += 1; keyStats[k].types.add(t); } } generatedSchema = Object.entries(keyStats).map(([name, stat]) => ({ name, type: Array.from(stat.types).join('|'), required: stat.count === items.length, occurrences: stat.count, sampleSize: items.length })); } } catch {} } // Collect all unique headers (field names) const headers = new Set(); try { const perPage = 100; const list = await pb.collection(collectionName).getList(1, perPage); const items = list?.items || []; for (const it of items) { for (const k of Object.keys(it)) { if (!['id','created','updated','collectionId','collectionName','expand'].includes(k)) { headers.add(k); } } } } catch {} const exportData = { exportedAt: new Date().toISOString(), collection: collectionName, meta, schema: schema.length ? schema : generatedSchema, schemaType: schema.length ? 'explicit' : 'generated', headers: Array.from(headers).sort(), }; c.header('Content-Type', 'application/json'); c.header('Content-Disposition', `attachment; filename="${collectionName}-schema-${new Date().toISOString().split('T')[0]}.json"`); return c.json(exportData); } catch (err) { console.error('export_schema_error', { message: (err as Error)?.message }); return c.json({ message: 'Internal error' }, 500); } }); const PORT = Number(process.env.PORT || 6500); console.log(`\nšŸš€ Server running on port ${PORT}`); console.log(`\nšŸ“Š Estimator Table: \x1b[36mhttp://localhost:${PORT}/estimatortable.html\x1b[0m`); console.log(`šŸ“ Idea & Feedback Form: \x1b[36mhttp://localhost:${PORT}/index.html\x1b[0m`); console.log(`šŸ‘¤ Admin Panel: \x1b[36mhttp://localhost:${PORT}/admin.html\x1b[0m\n`); export default { port: PORT, hostname: '0.0.0.0', fetch: app.fetch, };