Files
Job-List/server.ts
T
aewing 9ca7d2e5c2 Add email integration feature
- Add /api/emails/unread endpoint to fetch unread emails from sales@cardoza.construction
- Add 'Review Unread Sales Emails' button in filter buttons section
- Implement draggable modal window to display emails
- Show unread count badge on email button
- Click emails to open in Outlook web
- Add permission error handling for Azure AD Mail.Read permission
- Position modal with high z-index to ensure visibility
2026-01-02 06:07:51 +00:00

728 lines
26 KiB
TypeScript

import { config } from 'dotenv';
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 { getCached, setCached, deleteCached, getCachedSearchResults, cacheSearchResults, invalidateSearchCache } from './redis';
// Load secrets from absolute path (not in project directory)
config({ path: '/home/admin/secrets/.env' });
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 });
});
// Email endpoint - fetch unread emails from sales@cardoza.construction
app.get('/api/emails/unread', async (c) => {
try {
const pbToken = c.req.header('X-PB-Token');
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);
}
// Get Graph API token
const graphToken = await getGraphToken();
// Fetch unread emails from sales@cardoza.construction mailbox
const mailboxEmail = 'sales@cardoza.construction';
const graphUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mailboxEmail)}/messages?$filter=isRead eq false&$select=id,subject,from,receivedDateTime,bodyPreview,webLink&$orderby=receivedDateTime desc&$top=50`;
const response = await fetch(graphUrl, {
headers: {
'Authorization': `Bearer ${graphToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('graph_api_error', { status: response.status, error: errorText });
// Provide helpful error message
if (response.status === 403) {
return c.json({
success: false,
message: 'Permission denied. The application needs Mail.Read permission in Azure AD to access the sales mailbox.',
hint: 'Ask your Azure AD administrator to grant "Mail.Read" application permission to this app.',
error: errorText
}, 403);
}
return c.json({ success: false, message: 'Failed to fetch emails', error: errorText }, response.status);
}
const data = await response.json();
const emails = data.value || [];
return c.json({
success: true,
emails: emails.map((email: any) => ({
id: email.id,
subject: email.subject || '(No Subject)',
from: email.from?.emailAddress?.name || email.from?.emailAddress?.address || 'Unknown',
fromEmail: email.from?.emailAddress?.address || '',
receivedDateTime: email.receivedDateTime,
bodyPreview: email.bodyPreview || '',
webLink: email.webLink || ''
}))
});
} catch (err) {
console.error('email_fetch_error', { message: (err as Error)?.message, stack: (err as Error)?.stack });
return c.json({ success: false, message: 'Internal error: ' + (err as Error)?.message }, 500);
}
});
// 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()
});
// Invalidate cache after update
const collectionName = process.env.PB_COLLECTION || 'Job_Info_TestEnv';
await deleteCached(`jobs:*`);
await invalidateSearchCache();
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 regular user
const authData = await pb.collection('users').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 });
} catch (err) {
console.error('admin_login_error', { message: (err as Error)?.message });
return c.json({ message: 'Login failed' }, 401);
}
});
// Get environment variable for principal agent (admin only)
app.get('/api/admin/env', async (c) => {
try {
const key = c.req.query('key');
if (!key) {
return c.json({ message: 'Missing key parameter' }, 400);
}
// Only allow specific agent credentials
const allowedKeys = ['PB_AGENT_EMAIL', 'PB_AGENT_PASSWORD'];
if (!allowedKeys.includes(key)) {
return c.json({ message: 'Unauthorized key' }, 403);
}
const value = process.env[key];
if (!value) {
return c.json({ message: 'Key not found' }, 404);
}
return c.json({ ok: true, value });
} catch (err) {
console.error('env_fetch_error', { message: (err as Error)?.message });
return c.json({ message: 'Internal error' }, 500);
}
});
// 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);
}
});
// Get all jobs with Redis caching
// Note: Caches the full unfiltered dataset - all users share this cache
// Filtering/sorting/column preferences are handled client-side per user
app.get('/api/jobs', async (c) => {
try {
const pbToken = c.req.header('X-PocketBase-Token');
if (!pbToken) {
return c.json({ success: false, message: 'Missing authentication token' }, 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);
}
const collectionName = process.env.PB_COLLECTION || 'Job_Info_TestEnv';
// Shared cache key for all users - full dataset, no filters applied
const cacheKey = `jobs:all:${collectionName}`;
// Try to get from cache first
const cached = await getCached<any[]>(cacheKey);
if (cached) {
console.log('Jobs fetched from cache');
return c.json({ success: true, data: cached, cached: true });
}
// Fetch from PocketBase if not in cache
const records = await pb.collection(collectionName).getFullList({
sort: 'Job_Number',
});
// Cache for 5 minutes (300 seconds)
await setCached(cacheKey, records, { ttl: 300 });
console.log('Jobs fetched from PocketBase and cached');
return c.json({ success: true, data: records, cached: false });
} catch (err) {
console.error('jobs_fetch_error', { message: (err as Error)?.message });
return c.json({ success: false, message: 'Failed to fetch jobs' }, 500);
}
});
// Search jobs with Redis caching
app.get('/api/jobs/search', async (c) => {
try {
const pbToken = c.req.header('X-PocketBase-Token');
if (!pbToken) {
return c.json({ success: false, message: 'Missing authentication token' }, 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);
}
const url = new URL(c.req.url);
const query = url.searchParams.get('q') || '';
if (!query.trim()) {
return c.json({ success: false, message: 'Search query is required' }, 400);
}
// Check cache first
const cached = await getCachedSearchResults(query);
if (cached) {
console.log('Search results fetched from cache');
return c.json({ success: true, data: cached, query, cached: true });
}
const collectionName = process.env.PB_COLLECTION || 'Job_Info_TestEnv';
// Build search filter for PocketBase
// Search across key fields: Job_Number, Job_Full_Name, Job_Name, Company_Client, Contact_Person, Estimator
const searchTerm = query.trim();
const filter = [
`Job_Number ~ "${searchTerm}"`,
`Job_Full_Name ~ "${searchTerm}"`,
`Job_Name ~ "${searchTerm}"`,
`Company_Client ~ "${searchTerm}"`,
`Contact_Person ~ "${searchTerm}"`,
`Estimator ~ "${searchTerm}"`,
`Job_Status ~ "${searchTerm}"`,
`Project_Manager ~ "${searchTerm}"`,
`Notes ~ "${searchTerm}"`
].join(' || ');
const results = await pb.collection(collectionName).getFullList({
filter,
sort: 'Job_Number',
});
// Cache search results for 3 minutes (180 seconds)
await cacheSearchResults(query, results, { ttl: 180 });
console.log('Search results fetched from PocketBase and cached', { query, count: results.length });
return c.json({ success: true, data: results, query, cached: false });
} catch (err) {
console.error('search_error', { message: (err as Error)?.message });
return c.json({ success: false, message: 'Search failed' }, 500);
}
});
// Clear cache endpoint (optional - for admin use)
app.post('/api/cache/clear', async (c) => {
try {
const pbToken = c.req.header('X-PocketBase-Token');
if (!pbToken) {
return c.json({ success: false, message: 'Missing authentication token' }, 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);
}
const collectionName = process.env.PB_COLLECTION || 'Job_Info_TestEnv';
// Clear all job caches
await deleteCached(`jobs:*`);
await invalidateSearchCache();
console.log('Cache cleared successfully');
return c.json({ success: true, message: 'Cache cleared successfully' });
} catch (err) {
console.error('cache_clear_error', { message: (err as Error)?.message });
return c.json({ success: false, message: 'Failed to clear cache' }, 500);
}
});
const PORT = Number(process.env.PORT || 3025);
// Keep concise startup pointers
console.log(`📊 Estimator Table: http://localhost:${PORT}/`);
console.log(`⚙️ Settings: http://localhost:${PORT}/appsettings.html`);
console.log(`🔍 Search API: http://localhost:${PORT}/api/jobs/search?q=<query>`);
console.log(`💾 Redis caching enabled`);
export default {
port: PORT,
hostname: '0.0.0.0',
fetch: app.fetch,
idleTimeout: 30,
};