v1.0.0-alpha4: Add Redis/Valkey caching and search with principal agent auth for global settings

This commit is contained in:
2026-01-01 23:58:00 +00:00
parent f98ff8fcec
commit 71e5070e6c
10 changed files with 1012 additions and 6 deletions
+175
View File
@@ -4,6 +4,7 @@ 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' });
@@ -75,6 +76,11 @@ app.post('/api/submit', async (c) => {
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) {
@@ -119,6 +125,32 @@ app.post('/api/admin/login', async (c) => {
}
});
// 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 {
@@ -468,11 +500,154 @@ app.get('/api/export/schema', async (c) => {
}
});
// 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,