821fafd990
- Add order field to all columns in DEFAULT_SETTINGS (1-47) - Settings page: reorder columns via numeric input or up/down arrows, autosave after 400ms - Estimator table: render columns in order specified by order field, refresh on settings updates - Normalize column orders on load and realtime updates with fallback for missing order - Add logging to trace column order changes through settings and estimator pages - Reset button reinitializes all settings to defaults with proper column ordering - Table reload prevents concurrent requests to avoid PocketBase auto-cancellation
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { serveStatic } from 'hono/bun';
|
|
import { cors } from 'hono/cors';
|
|
import PocketBase from 'pocketbase';
|
|
import { ConfidentialClientApplication } from '@azure/msal-node';
|
|
|
|
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<string> {
|
|
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('estimatortable.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('/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<string, any> = {};
|
|
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<string, { count: number; types: Set<string> }> = {};
|
|
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);
|
|
}
|
|
});
|
|
|
|
// 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<string, any> = {};
|
|
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<string, { count: number; types: Set<string> }> = {};
|
|
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<string>();
|
|
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);
|
|
|
|
// Keep concise startup pointers
|
|
console.log(`📊 Estimator Table: http://localhost:${PORT}/`);
|
|
console.log(`⚙️ Settings: http://localhost:${PORT}/appsettings.html`);
|
|
|
|
export default {
|
|
port: PORT,
|
|
hostname: '0.0.0.0',
|
|
fetch: app.fetch,
|
|
};
|