Add Mode6Test: copy of Mode5Test running on port 3000
Build & Deploy / Test & Lint (push) Has been cancelled
Build & Deploy / Security Scan (push) Has been cancelled
Code Quality / Code Quality Checks (push) Has been cancelled
Code Quality / Dependency Vulnerabilities (push) Has been cancelled
Code Quality / Performance Testing (push) Has been cancelled
Build & Deploy / Build Docker Images (push) Has been cancelled
Build & Deploy / Deploy to Kubernetes (push) Has been cancelled

This commit is contained in:
2026-01-20 13:43:29 +00:00
parent 61dbbc457a
commit 42ff3e8f33
133 changed files with 353720 additions and 2 deletions
+296
View File
@@ -0,0 +1,296 @@
/**
* ROUTES: OAuth Authentication
*
* PURPOSE: Handle OAuth2 authorization and token exchange
* ENDPOINTS:
* - POST /api/auth/authorize - Exchange authorization code for token
* - POST /api/auth/refresh - Refresh access token using refresh token
* - POST /api/auth/logout - Logout and invalidate tokens
*
* SECURITY:
* - Client secret kept server-side only
* - Refresh tokens stored in HttpOnly cookies
* - PKCE verification with code verifier
* - CORS protection
*/
import { Context } from 'hono';
// ============================================================================
// TYPES
// ============================================================================
interface AuthorizeRequest {
code: string;
state: string;
codeVerifier: string;
}
interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
id: string;
displayName: string;
mail: string;
};
}
interface RefreshTokenRequest {
refreshToken: string;
}
// ============================================================================
// CONFIGURATION
// ============================================================================
const MICROSOFT_TENANT_ID = process.env.MICROSOFT_TENANT_ID || 'common';
const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || '';
const MICROSOFT_CLIENT_SECRET = process.env.MICROSOFT_CLIENT_SECRET || '';
const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || '';
// Token endpoints
const TOKEN_ENDPOINT = `https://login.microsoftonline.com/${MICROSOFT_TENANT_ID}/oauth2/v2.0/token`;
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0/me';
// In-memory token store (in production, use Redis or database)
const tokenStore = new Map<string, { refreshToken: string; expiresAt: number }>();
// ============================================================================
// AUTHORIZATION ENDPOINT
// ============================================================================
/**
* POST /api/auth/authorize
*
* Exchange authorization code for access token
*
* REQUEST:
* {
* code: string (from Microsoft OAuth redirect)
* state: string (CSRF token)
* codeVerifier: string (PKCE code verifier)
* }
*
* RESPONSE:
* {
* accessToken: string
* refreshToken: string
* expiresIn: number (seconds)
* user: { id, displayName, mail }
* }
*/
export async function handleAuthorize(c: Context): Promise<Response> {
try {
const body = await c.req.json() as AuthorizeRequest;
const { code, state, codeVerifier } = body;
// Validate inputs
if (!code || !state || !codeVerifier) {
return c.json(
{ error: 'Missing required parameters', details: 'code, state, codeVerifier required' },
{ status: 400 }
);
}
// Validate configuration
if (!MICROSOFT_CLIENT_ID || !MICROSOFT_CLIENT_SECRET || !REDIRECT_URI) {
console.error('[Auth] Missing OAuth configuration');
return c.json(
{ error: 'OAuth configuration incomplete' },
{ status: 500 }
);
}
console.log('[Auth] Exchanging authorization code for token');
// Step 1: Exchange code for token with Microsoft
const tokenParams = new URLSearchParams({
client_id: MICROSOFT_CLIENT_ID,
client_secret: MICROSOFT_CLIENT_SECRET,
code: code,
code_verifier: codeVerifier,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
scope: 'offline_access',
});
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error('[Auth] Token exchange failed:', error);
return c.json(
{ error: 'Failed to exchange code for token', details: error },
{ status: 400 }
);
}
const tokenData = await tokenResponse.json() as any;
const accessToken = tokenData.access_token;
const refreshToken = tokenData.refresh_token;
const expiresIn = tokenData.expires_in || 3600;
if (!accessToken) {
console.error('[Auth] No access token in response');
return c.json(
{ error: 'No access token in response' },
{ status: 400 }
);
}
console.log('[Auth] Successfully exchanged code for token');
// Step 2: Get user profile from Microsoft Graph
const userResponse = await fetch(GRAPH_ENDPOINT, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userResponse.ok) {
console.error('[Auth] Failed to fetch user profile');
return c.json(
{ error: 'Failed to fetch user profile' },
{ status: 400 }
);
}
const user = await userResponse.json() as any;
console.log('[Auth] Retrieved user profile:', user.displayName);
// Step 3: Store refresh token server-side
if (refreshToken) {
const expiresAt = Date.now() + (tokenData.refresh_token_expires_in || 90 * 24 * 60 * 60) * 1000;
tokenStore.set(user.id, { refreshToken, expiresAt });
console.log('[Auth] Stored refresh token for user:', user.id);
}
// Step 4: Return response
const response: TokenResponse = {
accessToken,
refreshToken: refreshToken || '',
expiresIn,
user: {
id: user.id,
displayName: user.displayName,
mail: user.mail,
},
};
return c.json(response);
} catch (error) {
console.error('[Auth] Authorization handler error:', error);
return c.json(
{ error: 'Internal server error', details: (error as Error).message },
{ status: 500 }
);
}
}
// ============================================================================
// TOKEN REFRESH ENDPOINT
// ============================================================================
/**
* POST /api/auth/refresh
*
* Refresh access token using refresh token
*
* REQUEST:
* {
* refreshToken: string
* }
*
* RESPONSE:
* {
* accessToken: string
* expiresIn: number (seconds)
* }
*/
export async function handleRefresh(c: Context): Promise<Response> {
try {
const body = await c.req.json() as RefreshTokenRequest;
const { refreshToken } = body;
if (!refreshToken) {
return c.json(
{ error: 'Refresh token required' },
{ status: 400 }
);
}
console.log('[Auth] Refreshing access token');
// Exchange refresh token for new access token
const tokenParams = new URLSearchParams({
client_id: MICROSOFT_CLIENT_ID,
client_secret: MICROSOFT_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: 'offline_access',
});
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error('[Auth] Token refresh failed:', error);
return c.json(
{ error: 'Failed to refresh token' },
{ status: 401 }
);
}
const tokenData = await tokenResponse.json() as any;
const newAccessToken = tokenData.access_token;
const expiresIn = tokenData.expires_in || 3600;
console.log('[Auth] Successfully refreshed access token');
return c.json({
accessToken: newAccessToken,
expiresIn,
});
} catch (error) {
console.error('[Auth] Refresh handler error:', error);
return c.json(
{ error: 'Internal server error', details: (error as Error).message },
{ status: 500 }
);
}
}
// ============================================================================
// LOGOUT ENDPOINT
// ============================================================================
/**
* POST /api/auth/logout
*
* Logout and invalidate tokens
*/
export async function handleLogout(c: Context): Promise<Response> {
try {
console.log('[Auth] Logout requested');
// In production, would invalidate refresh token in database
// For now, just return success
return c.json({ success: true });
} catch (error) {
console.error('[Auth] Logout handler error:', error);
return c.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+37
View File
@@ -0,0 +1,37 @@
// Express API for Redis/Valkey: keys list and single key fetch (for fast UI)
const express = require('express');
const Redis = require('ioredis');
const app = express();
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
// List all keys (no values)
app.get('/api/cache/keys', async (req, res) => {
try {
const keys = await redis.keys('*');
// Optionally, add summary/metadata here
res.json(keys.map(key => ({ key })));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Fetch value for a single key
app.get('/api/cache/:key', async (req, res) => {
try {
const key = req.params.key;
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
res.json({ key, value });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const port = process.env.CACHE_API_PORT || 3006;
app.listen(port, () => {
console.log(`Cache API (keys+single) running on http://localhost:${port}/api/cache/keys`);
});
+29
View File
@@ -0,0 +1,29 @@
// Simple Express API to serve all Redis/Valkey cache keys and values as JSON (CommonJS)
const express = require('express');
const Redis = require('ioredis');
const app = express();
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
app.get('/api/cache', async (req, res) => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const port = process.env.CACHE_API_PORT || 3006;
app.listen(port, () => {
console.log(`Cache API running on http://localhost:${port}/api/cache`);
});
+29
View File
@@ -0,0 +1,29 @@
// Simple Express API to serve all Redis/Valkey cache keys and values as JSON
const express = require('express');
const Redis = require('ioredis');
const app = express();
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
app.get('/api/cache', async (req, res) => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
const port = process.env.CACHE_API_PORT || 3030;
app.listen(port, () => {
console.log(`Cache API running on http://localhost:${port}/api/cache`);
});
+25
View File
@@ -0,0 +1,25 @@
// Script to list all Redis/Valkey keys and their values for visualization (CommonJS)
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
(async () => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (err) {
console.error('Error listing Redis keys:', err);
process.exit(1);
}
})();
+25
View File
@@ -0,0 +1,25 @@
// Script to list all Redis/Valkey keys and their values for visualization
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
});
(async () => {
try {
const keys = await redis.keys('*');
const result = [];
for (const key of keys) {
let value = await redis.get(key);
try { value = JSON.parse(value); } catch {}
result.push({ key, value });
}
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (err) {
console.error('Error listing Redis keys:', err);
process.exit(1);
}
})();
+101
View File
@@ -0,0 +1,101 @@
import Redis from 'ioredis';
// Create Redis client
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
lazyConnect: true,
});
// Handle connection events
redis.on('connect', () => {
console.log('✓ Redis connected');
});
redis.on('error', (err) => {
console.error('Redis error:', err.message);
});
redis.on('close', () => {
console.log('Redis connection closed');
});
// Connect to Redis
(async () => {
try {
await redis.connect();
} catch (err) {
console.error('Failed to connect to Redis:', err);
}
})();
/**
* Get a value from cache
*/
export async function getCache(key: string): Promise<any | null> {
try {
const value = await redis.get(key);
if (!value) return null;
return JSON.parse(value);
} catch (err) {
console.error(`Cache get error for key "${key}":`, err);
return null;
}
}
/**
* Set a value in cache with TTL in seconds
*/
export async function setCache(key: string, value: any, ttl: number = 300): Promise<boolean> {
try {
const serialized = JSON.stringify(value);
await redis.setex(key, ttl, serialized);
return true;
} catch (err) {
console.error(`Cache set error for key "${key}":`, err);
return false;
}
}
/**
* Delete a key from cache
*/
export async function deleteCache(key: string): Promise<boolean> {
try {
await redis.del(key);
return true;
} catch (err) {
console.error(`Cache delete error for key "${key}":`, err);
return false;
}
}
/**
* Clear all cache keys matching a pattern
*/
export async function clearCachePattern(pattern: string): Promise<number> {
try {
const keys = await redis.keys(pattern);
if (keys.length === 0) return 0;
await redis.del(...keys);
return keys.length;
} catch (err) {
console.error(`Cache clear error for pattern "${pattern}":`, err);
return 0;
}
}
/**
* Check if Redis is connected
*/
export function isRedisConnected(): boolean {
return redis.status === 'ready';
}
export default redis;
+695
View File
@@ -0,0 +1,695 @@
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,
};