Initial production setup with dual-token auth

This commit is contained in:
2026-01-01 16:44:11 +00:00
commit 7c3d62d4a9
38 changed files with 6299 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
/**
* Application Constants
*/
export const APP_NAME = 'Job Info';
export const APP_VERSION = '2.0.0';
/** HTTP Status codes */
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
} as const;
/** Error messages */
export const ERROR_MESSAGES = {
UNAUTHORIZED: 'Authentication required',
TOKEN_EXPIRED: 'Token has expired',
TOKEN_INVALID: 'Invalid token',
TOKEN_REFRESH_FAILED: 'Failed to refresh token',
USER_NOT_FOUND: 'User not found',
JOB_NOT_FOUND: 'Job not found',
CACHE_ERROR: 'Cache operation failed',
POCKETBASE_ERROR: 'PocketBase operation failed',
GRAPH_API_ERROR: 'Microsoft Graph API error',
INTERNAL_ERROR: 'Internal server error',
} as const;
/** Success messages */
export const SUCCESS_MESSAGES = {
LOGIN_SUCCESS: 'Login successful',
LOGOUT_SUCCESS: 'Logout successful',
TOKEN_REFRESHED: 'Token refreshed successfully',
} as const;
+111
View File
@@ -0,0 +1,111 @@
/**
* Environment Configuration
*/
import { config } from 'dotenv';
// Load .env file
config();
interface EnvConfig {
PORT: number;
NODE_ENV: 'development' | 'production' | 'test';
ALLOWED_ORIGINS: string[];
// Redis/Valkey
REDIS_HOST: string;
REDIS_PORT: number;
REDIS_PASSWORD: string;
// PocketBase
POCKETBASE_URL: string;
PB_COLLECTION: string;
PB_AUTH_COLLECTION: string;
POCKETBASE_ADMIN_EMAIL: string;
POCKETBASE_ADMIN_PASSWORD: string;
// Microsoft OAuth
MICROSOFT_CLIENT_ID: string;
MICROSOFT_CLIENT_SECRET: string;
MICROSOFT_TENANT: string;
MICROSOFT_REDIRECT_URI: string;
MS_GRAPH_TOKEN: string;
// Microsoft Graph / SharePoint
DRIVE_ID: string;
ITEM_ID: string;
EXCEL_TABLE_NAME: string;
// Logging
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
LOG_FILE: string;
ERROR_LOG_FILE: string;
// Token settings
TOKEN_REFRESH_INTERVAL_MS: number;
TOKEN_EXPIRY_BUFFER_MS: number;
}
function getEnv<T extends string | number>(key: string, defaultValue: T): T {
const value = process.env[key];
if (value === undefined) return defaultValue;
if (typeof defaultValue === 'number') {
return parseInt(value, 10) as T;
}
return value as T;
}
function getEnvArray(key: string, defaultValue: string[]): string[] {
const value = process.env[key];
if (!value) return defaultValue;
return value.split(',').map(s => s.trim());
}
export const env: EnvConfig = {
PORT: getEnv('PORT', 3005),
NODE_ENV: getEnv('NODE_ENV', 'development') as EnvConfig['NODE_ENV'],
ALLOWED_ORIGINS: getEnvArray('ALLOWED_ORIGINS', ['http://localhost:3005', 'http://localhost:5173']),
REDIS_HOST: getEnv('REDIS_HOST', 'localhost'),
REDIS_PORT: getEnv('REDIS_PORT', 6379),
REDIS_PASSWORD: getEnv('REDIS_PASSWORD', ''),
POCKETBASE_URL: getEnv('POCKETBASE_URL', 'https://pocketbase.ccllc.pro'),
PB_COLLECTION: getEnv('PB_COLLECTION', 'Job_Info_Prod'),
PB_AUTH_COLLECTION: getEnv('PB_AUTH_COLLECTION', 'Users'),
POCKETBASE_ADMIN_EMAIL: getEnv('POCKETBASE_ADMIN_EMAIL', ''),
POCKETBASE_ADMIN_PASSWORD: getEnv('POCKETBASE_ADMIN_PASSWORD', ''),
MICROSOFT_CLIENT_ID: getEnv('MICROSOFT_CLIENT_ID', ''),
MICROSOFT_CLIENT_SECRET: getEnv('MICROSOFT_CLIENT_SECRET', ''),
MICROSOFT_TENANT: getEnv('MICROSOFT_TENANT', '3fd97ea7-b124-41f1-855f-52d8ac3b16c7'),
MICROSOFT_REDIRECT_URI: getEnv('MICROSOFT_REDIRECT_URI', 'https://pocketbase.ccllc.pro/api/oauth2-redirect'),
MS_GRAPH_TOKEN: getEnv('MS_GRAPH_TOKEN', ''),
DRIVE_ID: getEnv('DRIVE_ID', ''),
ITEM_ID: getEnv('ITEM_ID', ''),
EXCEL_TABLE_NAME: getEnv('EXCEL_TABLE_NAME', 'Job_List'),
LOG_LEVEL: getEnv('LOG_LEVEL', 'info') as EnvConfig['LOG_LEVEL'],
LOG_FILE: getEnv('LOG_FILE', 'logs/app.log'),
ERROR_LOG_FILE: getEnv('ERROR_LOG_FILE', 'logs/error.log'),
TOKEN_REFRESH_INTERVAL_MS: getEnv('TOKEN_REFRESH_INTERVAL_MS', 1800000),
TOKEN_EXPIRY_BUFFER_MS: getEnv('TOKEN_EXPIRY_BUFFER_MS', 900000),
};
export function validateEnv(): void {
const required = [
'POCKETBASE_URL',
'MICROSOFT_CLIENT_ID',
'MICROSOFT_CLIENT_SECRET',
'MICROSOFT_TENANT',
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.warn(`Warning: Missing environment variables: ${missing.join(', ')}`);
}
}
+514
View File
@@ -0,0 +1,514 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import fs from 'fs';
// @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();
// 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);
}
};
// 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 all jobs in a single cached request
app.get('/api/jobs-all', async (c) => {
const cacheKey = 'jobs:all';
const ttl = 1800; // 30 minutes cache
try {
// Try cache first for instant response
if (isRedisConnected()) {
const cached = await getCache(cacheKey);
if (cached) {
logLine('logs/server.log', `Cache HIT for ${cacheKey} (${cached.items?.length || 0} jobs)`);
// Return cached data immediately
setImmediate(async () => {
try {
await refreshJobsCache(cacheKey, ttl, c.req.header('Authorization') || '');
} catch (err) {}
});
// For ultra-fast display, send only the first 40 jobs in the initial response
const fastBatch = Array.isArray(cached.items) ? cached.items.slice(0, 40) : [];
return c.json({ ...cached, items: fastBatch, partial: cached.items && cached.items.length > 40 });
} else {
// No cache: return empty array instantly, trigger background fetch
setImmediate(async () => {
try {
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
if (isRedisConnected()) {
await setCache(cacheKey, result, ttl);
logLine('logs/server.log', `Cached ${cacheKey} with ${result.items.length} jobs for ${ttl}s`);
}
} catch (err) {
logLine('logs/error.log', `jobs-all background fetch error: ${(err as Error)?.message || String(err)}`);
}
});
return c.json({ items: [], partial: true });
}
}
// Redis not connected: fallback to direct fetch (blocking)
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
return c.json(result);
} catch (err) {
logLine('logs/error.log', `jobs-all endpoint error: ${(err as Error)?.message || String(err)}`);
return c.json({ error: 'Failed to fetch jobs' }, 500);
}
});
// Helper function to fetch all jobs from PocketBase
async function fetchAllJobsFromPocketBase(authHeader: string): Promise<any> {
const allItems: any[] = [];
let page = 1;
const perPage = 500;
while (true) {
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
const response = await fetch(pbUrl, {
headers: authHeader ? { Authorization: authHeader } : {}
});
if (!response.ok) {
throw new Error(`PocketBase returned ${response.status}`);
}
const data = await response.json();
const items = data.items || [];
if (items.length === 0) break;
allItems.push(...items);
if (allItems.length >= (data.totalItems || 0)) break;
page++;
}
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/public' }));
// 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 (Redis version) on port ${PORT}`);
// Warm up cache on startup
(async () => {
try {
if (isRedisConnected()) {
logLine('logs/server.log', 'Warming up jobs cache...');
const result = await fetchAllJobsFromPocketBase('');
await setCache('jobs:all', result, 1800);
logLine('logs/server.log', `✓ Cache warmed with ${result.items.length} jobs`);
}
} catch (err) {
logLine('logs/error.log', `Cache warmup failed: ${(err as Error)?.message || String(err)}`);
}
})();
// Background refresh every 5 minutes to keep cache hot
setInterval(async () => {
try {
if (isRedisConnected()) {
await refreshJobsCache('jobs:all', 1800, '');
}
} 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,
};
+90
View File
@@ -0,0 +1,90 @@
/**
* Auth Middleware - Token validation
*/
import type { Context, Next } from 'hono';
import { pocketbaseService } from '../services/pocketbaseService';
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
/**
* Validate auth token and attach user to context
*/
export async function authMiddleware(c: Context, next: Next): Promise<Response | void> {
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json(
{ error: ERROR_MESSAGES.UNAUTHORIZED },
HTTP_STATUS.UNAUTHORIZED
);
}
const token = authHeader.slice(7); // Remove 'Bearer '
try {
// Validate token with PocketBase
const user = await pocketbaseService.authenticateWithToken(token);
if (!user) {
return c.json(
{ error: ERROR_MESSAGES.TOKEN_INVALID },
HTTP_STATUS.UNAUTHORIZED
);
}
// Attach user info to context
c.set('userId', user.id);
c.set('user', user);
c.set('token', token);
await next();
} catch (err) {
console.error('[AuthMiddleware] Token validation failed:', err);
return c.json(
{ error: ERROR_MESSAGES.TOKEN_INVALID },
HTTP_STATUS.UNAUTHORIZED
);
}
}
/**
* Optional auth - doesn't fail if no token, but attaches user if present
*/
export async function optionalAuthMiddleware(c: Context, next: Next): Promise<Response | void> {
const authHeader = c.req.header('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
try {
const user = await pocketbaseService.authenticateWithToken(token);
if (user) {
c.set('userId', user.id);
c.set('user', user);
c.set('token', token);
}
} catch {
// Silently continue without auth
}
}
await next();
}
/**
* Get user ID from context (for use after auth middleware)
*/
export function getUserId(c: Context): string {
const userId = c.get('userId');
if (!userId) {
throw new Error('User ID not found in context - auth middleware not applied?');
}
return userId as string;
}
/**
* Get user from context
*/
export function getUser(c: Context): unknown {
return c.get('user');
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Error Handler Middleware
*/
import type { Context } from 'hono';
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
import { env } from '../config/env';
/** Custom application error */
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
this.name = 'AppError';
}
}
/** Not found error */
export class NotFoundError extends AppError {
constructor(resource: string = 'Resource') {
super(HTTP_STATUS.NOT_FOUND, `${resource} not found`, 'NOT_FOUND');
}
}
/** Unauthorized error */
export class UnauthorizedError extends AppError {
constructor(message: string = ERROR_MESSAGES.UNAUTHORIZED) {
super(HTTP_STATUS.UNAUTHORIZED, message, 'UNAUTHORIZED');
}
}
/** Bad request error */
export class BadRequestError extends AppError {
constructor(message: string) {
super(HTTP_STATUS.BAD_REQUEST, message, 'BAD_REQUEST');
}
}
/**
* Global error handler for Hono onError
*/
export function errorHandler(err: Error, c: Context): Response {
console.error('[ErrorHandler]', err);
// Handle known app errors
if (err instanceof AppError) {
return c.json(
{
error: err.message,
code: err.code,
},
err.statusCode as 400 | 401 | 403 | 404 | 500
);
}
// Handle unknown errors
const message = env.NODE_ENV === 'production'
? ERROR_MESSAGES.INTERNAL_ERROR
: err.message;
return c.json(
{
error: message,
code: 'INTERNAL_ERROR',
},
HTTP_STATUS.INTERNAL_SERVER_ERROR
);
}
/**
* Not found handler (for unmatched routes)
*/
export function notFoundHandler(c: Context): Response {
return c.json(
{
error: 'Endpoint not found',
code: 'NOT_FOUND',
},
HTTP_STATUS.NOT_FOUND
);
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Logger Middleware - Request logging
*/
import type { Context, Next } from 'hono';
import { env } from '../config/env';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/**
* Get current log level from env
*/
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[env.LOG_LEVEL];
}
/**
* Format log message
*/
function formatLog(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
const timestamp = new Date().toISOString();
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
}
/**
* Logger utility
*/
export const logger = {
debug(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('debug')) {
console.log(formatLog('debug', message, meta));
}
},
info(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('info')) {
console.log(formatLog('info', message, meta));
}
},
warn(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('warn')) {
console.warn(formatLog('warn', message, meta));
}
},
error(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('error')) {
console.error(formatLog('error', message, meta));
}
},
};
/**
* Request logging middleware
*/
export async function loggerMiddleware(c: Context, next: Next): Promise<void> {
const method = c.req.method;
const path = c.req.path;
// Skip logging for health check endpoints (UptimeKuma polling)
const skipLogging = path === '/version' || path === '/api/health';
const start = Date.now();
const requestId = crypto.randomUUID().slice(0, 8);
// Attach request ID to context
c.set('requestId', requestId);
if (!skipLogging) {
logger.info(`${method} ${path}`, { requestId });
}
await next();
if (!skipLogging) {
const duration = Date.now() - start;
const status = c.res.status;
const logFn = status >= 500 ? logger.error : status >= 400 ? logger.warn : logger.info;
logFn(`${method} ${path} ${status} ${duration}ms`, { requestId });
}
}
+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;
+107
View File
@@ -0,0 +1,107 @@
/**
* Auth Routes - Login, logout, token refresh
*/
import { Hono } from 'hono';
import { tokenService } from '../services/tokenService';
import { authMiddleware, getUserId } from '../middleware/auth';
import { BadRequestError } from '../middleware/errorHandler';
import type { LoginCallbackRequest, TokenType } from '@shared/types/auth';
import { HTTP_STATUS } from '../config/constants';
const auth = new Hono();
/**
* POST /api/auth/login-callback
* Called after OAuth flow to store tokens in cache
*/
auth.post('/login-callback', async (c) => {
const body = await c.req.json<LoginCallbackRequest>();
if (!body.pbToken || !body.graphToken || !body.user) {
throw new BadRequestError('Missing required token data');
}
// Store tokens in cache
await tokenService.storeTokens(
body.user.id,
body.pbToken,
body.graphToken,
body.refreshTokenPb ?? '',
body.refreshTokenGraph ?? ''
);
// Start background refresh loop
tokenService.startRefreshLoop(body.user.id);
const expiresAt = Date.now() + 3600 * 1000; // 1 hour
return c.json({
success: true,
user: body.user,
expiresAt_pb: expiresAt,
expiresAt_graph: expiresAt,
});
});
/**
* POST /api/auth/logout
* Clear tokens and stop refresh loop
*/
auth.post('/logout', authMiddleware, async (c) => {
const userId = getUserId(c);
await tokenService.clearUserTokens(userId);
return c.json({ success: true });
});
/**
* GET /api/refresh-token
* Get fresh token from cache or refresh
*/
auth.get('/refresh-token', authMiddleware, async (c) => {
const userId = getUserId(c);
const type = c.req.query('type') as TokenType | undefined;
if (!type || !['pb', 'graph'].includes(type)) {
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
}
const result = await tokenService.getToken(userId, type);
if (!result) {
return c.json(
{ error: 'Token not available' },
HTTP_STATUS.UNAUTHORIZED
);
}
return c.json(result);
});
/**
* POST /api/refresh-token
* Alternative POST endpoint for token refresh
*/
auth.post('/refresh-token', authMiddleware, async (c) => {
const userId = getUserId(c);
const body = await c.req.json<{ type: TokenType }>();
if (!body.type || !['pb', 'graph'].includes(body.type)) {
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
}
const result = await tokenService.getToken(userId, body.type);
if (!result) {
return c.json(
{ error: 'Token not available' },
HTTP_STATUS.UNAUTHORIZED
);
}
return c.json(result);
});
export default auth;
+124
View File
@@ -0,0 +1,124 @@
/**
* Files Routes - SharePoint file operations
*
* Note: Most file operations go through /api/jobs/:id/files
* This route provides additional file utilities
*/
import { Hono } from 'hono';
import { graphService } from '../services/graphService';
import { tokenService } from '../services/tokenService';
import { pocketbaseService } from '../services/pocketbaseService';
import { authMiddleware, getUserId } from '../middleware/auth';
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
const files = new Hono();
// All file routes require authentication
files.use('/*', authMiddleware);
/**
* GET /api/files
* List files in a job's folder - requires jobId query param
*/
files.get('/', async (c) => {
const userId = getUserId(c);
const jobId = c.req.query('jobId');
if (!jobId) {
throw new BadRequestError('jobId query parameter required');
}
// Get job to find site/drive info
const job = await pocketbaseService.getJob(jobId);
if (!job) {
throw new NotFoundError('Job not found');
}
if (!job.site_id || !job.drive_id) {
return c.json({ files: [] });
}
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
throw new BadRequestError('Graph token not available');
}
const result = await graphService.getFiles(
tokenResult.token,
job.site_id,
job.drive_id,
job.folder_path
);
return c.json({ files: result.files });
});
/**
* GET /api/files/download-url
* Get download URL for a file - requires jobId and fileId
*/
files.get('/download-url', async (c) => {
const userId = getUserId(c);
const jobId = c.req.query('jobId');
const fileId = c.req.query('fileId');
if (!jobId || !fileId) {
throw new BadRequestError('jobId and fileId query parameters required');
}
// Get job to find site/drive info
const job = await pocketbaseService.getJob(jobId);
if (!job || !job.site_id || !job.drive_id) {
throw new NotFoundError('Job not found or not configured for files');
}
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
throw new BadRequestError('Graph token not available');
}
const url = await graphService.getFileDownloadUrl(
tokenResult.token,
job.site_id,
job.drive_id,
fileId
);
if (!url) {
throw new NotFoundError('File not found');
}
return c.json({ url });
});
/**
* GET /api/files/search
* Search files in a job's SharePoint site
*/
files.get('/search', async (c) => {
const userId = getUserId(c);
const jobId = c.req.query('jobId');
const query = c.req.query('q');
if (!jobId || !query) {
throw new BadRequestError('jobId and q query parameters required');
}
// Get job to find site info
const job = await pocketbaseService.getJob(jobId);
if (!job || !job.site_id) {
throw new NotFoundError('Job not found or not configured for files');
}
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
throw new BadRequestError('Graph token not available');
}
const results = await graphService.searchFiles(tokenResult.token, job.site_id, query);
return c.json({ files: results });
});
export default files;
+63
View File
@@ -0,0 +1,63 @@
/**
* Health Routes - Server health check
*/
import { Hono } from 'hono';
import { cacheService } from '../services/cacheService';
const health = new Hono();
/**
* GET /api/health
* Basic health check
*/
health.get('/', (c) => {
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});
/**
* GET /api/health/ready
* Readiness check (includes cache connection)
*/
health.get('/ready', async (c) => {
const checks: Record<string, boolean> = {
server: true,
cache: false,
};
// Check cache connection
try {
await cacheService.get('health-check');
checks.cache = true;
} catch {
checks.cache = false;
}
const allHealthy = Object.values(checks).every(Boolean);
return c.json(
{
status: allHealthy ? 'ready' : 'degraded',
checks,
timestamp: new Date().toISOString(),
},
allHealthy ? 200 : 503
);
});
/**
* GET /api/health/live
* Liveness check (just confirms server is running)
*/
health.get('/live', (c) => {
return c.json({
status: 'alive',
timestamp: new Date().toISOString(),
});
});
export default health;
+20
View File
@@ -0,0 +1,20 @@
/**
* Routes Index - Register all routes
*/
import { Hono } from 'hono';
import auth from './auth';
import jobs from './jobs';
import files from './files';
import health from './health';
export function registerRoutes(app: Hono): void {
// Health routes (no /api prefix for k8s probes)
app.route('/health', health);
app.route('/api/health', health);
// API routes
app.route('/api/auth', auth);
app.route('/api/jobs', jobs);
app.route('/api/files', files);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Jobs Routes - Job listing and details
*/
import { Hono } from 'hono';
import { jobsService } from '../services/jobsService';
import { authMiddleware, getUserId } from '../middleware/auth';
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
const jobs = new Hono();
// All job routes require authentication
jobs.use('/*', authMiddleware);
/**
* GET /api/jobs
* List all jobs for the user
*/
jobs.get('/', async (c) => {
const page = parseInt(c.req.query('page') || '1');
const limit = parseInt(c.req.query('limit') || '50');
const result = await jobsService.getJobs({ page, perPage: limit });
return c.json(result);
});
/**
* GET /api/jobs/search
* Search jobs by query
*/
jobs.get('/search', async (c) => {
const query = c.req.query('q');
if (!query) {
throw new BadRequestError('Search query required');
}
const jobs = await jobsService.searchJobs(query);
return c.json({ jobs, page: 1, perPage: jobs.length, totalItems: jobs.length, totalPages: 1 });
});
/**
* GET /api/jobs/:id
* Get single job with all details
*/
jobs.get('/:id', async (c) => {
const userId = getUserId(c);
const jobId = c.req.param('id');
const result = await jobsService.getJobDetail(jobId, userId);
if (!result) {
throw new NotFoundError('Job not found');
}
return c.json(result);
});
/**
* GET /api/jobs/:id/files
* Get files for a job from SharePoint
*/
jobs.get('/:id/files', async (c) => {
const userId = getUserId(c);
const jobId = c.req.param('id');
const result = await jobsService.getJobFiles(jobId, userId);
return c.json({ files: result?.files ?? [] });
});
/**
* POST /api/jobs/:id/notes
* Create a sticky note on a job
*/
jobs.post('/:id/notes', async (c) => {
const userId = getUserId(c);
const jobId = c.req.param('id');
const body = await c.req.json<{ content: string; color?: string }>();
if (!body.content) {
throw new BadRequestError('Note content required');
}
const note = await jobsService.createNote(
jobId,
body.content,
userId,
{ color: body.color as 'yellow' | 'blue' | 'green' | 'pink' | 'purple' | undefined }
);
return c.json({ note });
});
/**
* PUT /api/jobs/:id/notes/:noteId
* Update a sticky note
*/
jobs.put('/:id/notes/:noteId', async (c) => {
const noteId = c.req.param('noteId');
const body = await c.req.json<{ content?: string; color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple' }>();
const note = await jobsService.updateNote(noteId, body);
return c.json({ note });
});
/**
* DELETE /api/jobs/:id/notes/:noteId
* Delete a sticky note
*/
jobs.delete('/:id/notes/:noteId', async (c) => {
const noteId = c.req.param('noteId');
await jobsService.deleteNote(noteId);
return c.json({ success: true });
});
export default jobs;
+181
View File
@@ -0,0 +1,181 @@
/**
* Cache Service - Valkey/Redis operations
*/
import Redis from 'ioredis';
import { env } from '../config/env';
class CacheService {
private client: Redis | null = null;
private isConnected = false;
/**
* Initialize Redis connection
*/
async connect(): Promise<void> {
if (this.client && this.isConnected) return;
this.client = new Redis({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
password: env.REDIS_PASSWORD || undefined,
retryStrategy: (times) => {
if (times > 3) {
console.error('[Cache] Max retry attempts reached');
return null;
}
return Math.min(times * 200, 2000);
},
maxRetriesPerRequest: 3,
});
this.client.on('connect', () => {
this.isConnected = true;
console.log('[Cache] Connected to Redis/Valkey');
});
this.client.on('error', (err) => {
console.error('[Cache] Redis error:', err.message);
this.isConnected = false;
});
this.client.on('close', () => {
this.isConnected = false;
console.log('[Cache] Redis connection closed');
});
}
/**
* Get value from cache
*/
async get<T>(key: string): Promise<T | null> {
if (!this.client) {
console.warn('[Cache] Not connected, returning null');
return null;
}
try {
const value = await this.client.get(key);
if (!value) return null;
return JSON.parse(value) as T;
} catch (err) {
console.error(`[Cache] Get error for key ${key}:`, err);
return null;
}
}
/**
* Set value in cache with optional TTL
*/
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<boolean> {
if (!this.client) {
console.warn('[Cache] Not connected, skipping set');
return false;
}
try {
const serialized = JSON.stringify(value);
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, serialized);
} else {
await this.client.set(key, serialized);
}
return true;
} catch (err) {
console.error(`[Cache] Set error for key ${key}:`, err);
return false;
}
}
/**
* Delete key from cache
*/
async del(key: string): Promise<boolean> {
if (!this.client) return false;
try {
await this.client.del(key);
return true;
} catch (err) {
console.error(`[Cache] Delete error for key ${key}:`, err);
return false;
}
}
/**
* Delete keys matching pattern
*/
async delPattern(pattern: string): Promise<number> {
if (!this.client) return 0;
try {
const keys = await this.client.keys(pattern);
if (keys.length === 0) return 0;
return await this.client.del(...keys);
} catch (err) {
console.error(`[Cache] Delete pattern error for ${pattern}:`, err);
return 0;
}
}
/**
* Check if connected
*/
get connected(): boolean {
return this.isConnected;
}
/**
* Add to set
*/
async sadd(key: string, member: string): Promise<boolean> {
if (!this.client) return false;
try {
await this.client.sadd(key, member);
return true;
} catch (err) {
console.error(`[Cache] SADD error for key ${key}:`, err);
return false;
}
}
/**
* Remove from set
*/
async srem(key: string, member: string): Promise<boolean> {
if (!this.client) return false;
try {
await this.client.srem(key, member);
return true;
} catch (err) {
console.error(`[Cache] SREM error for key ${key}:`, err);
return false;
}
}
/**
* Get all members of set
*/
async smembers(key: string): Promise<string[]> {
if (!this.client) return [];
try {
return await this.client.smembers(key);
} catch (err) {
console.error(`[Cache] SMEMBERS error for key ${key}:`, err);
return [];
}
}
/**
* Disconnect from Redis
*/
async disconnect(): Promise<void> {
if (this.client) {
await this.client.quit();
this.client = null;
this.isConnected = false;
}
}
}
export const cacheService = new CacheService();
+226
View File
@@ -0,0 +1,226 @@
/**
* Graph Service - Microsoft Graph API integration
*/
import { env } from '../config/env';
import type { JobFile } from '@shared/types/job';
interface GraphTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}
interface GraphDriveItem {
id: string;
name: string;
size: number;
file?: {
mimeType: string;
};
webUrl: string;
'@microsoft.graph.downloadUrl'?: string;
createdDateTime: string;
lastModifiedDateTime: string;
createdBy?: {
user: {
displayName: string;
email: string;
};
};
lastModifiedBy?: {
user: {
displayName: string;
email: string;
};
};
}
interface GraphListResponse {
value: GraphDriveItem[];
'@odata.nextLink'?: string;
}
class GraphService {
private readonly tokenEndpoint: string;
private readonly graphBaseUrl = 'https://graph.microsoft.com/v1.0';
constructor() {
this.tokenEndpoint = `https://login.microsoftonline.com/${env.MICROSOFT_TENANT}/oauth2/v2.0/token`;
}
/**
* Refresh access token using refresh token
*/
async refreshToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
const params = new URLSearchParams({
client_id: env.MICROSOFT_CLIENT_ID,
client_secret: env.MICROSOFT_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: 'offline_access Files.Read Sites.Read.All User.Read',
});
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const error = await response.text();
console.error('[Graph] Token refresh failed:', error);
throw new Error(`Token refresh failed: ${response.status}`);
}
const data: GraphTokenResponse = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
}
/**
* Get files from a SharePoint drive folder
*/
async getFiles(
accessToken: string,
siteId: string,
driveId: string,
folderPath?: string
): Promise<{ files: JobFile[]; nextLink?: string }> {
let endpoint: string;
if (folderPath) {
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root:/${folderPath}:/children`;
} else {
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root/children`;
}
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.text();
console.error('[Graph] Get files failed:', error);
throw new Error(`Get files failed: ${response.status}`);
}
const data: GraphListResponse = await response.json();
const files: JobFile[] = data.value
.filter(item => item.file) // Only files, not folders
.map(item => ({
id: item.id,
name: item.name,
size: item.size,
mimeType: item.file?.mimeType ?? 'application/octet-stream',
webUrl: item.webUrl,
downloadUrl: item['@microsoft.graph.downloadUrl'],
createdDateTime: item.createdDateTime,
lastModifiedDateTime: item.lastModifiedDateTime,
createdBy: item.createdBy,
lastModifiedBy: item.lastModifiedBy,
}));
return {
files,
nextLink: data['@odata.nextLink'],
};
}
/**
* Get file content/download URL
*/
async getFileDownloadUrl(
accessToken: string,
siteId: string,
driveId: string,
itemId: string
): Promise<string | null> {
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/items/${itemId}`;
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error('[Graph] Get download URL failed:', response.status);
return null;
}
const data: GraphDriveItem = await response.json();
return data['@microsoft.graph.downloadUrl'] ?? null;
}
/**
* Get user profile
*/
async getUserProfile(accessToken: string): Promise<{ displayName: string; mail: string } | null> {
const endpoint = `${this.graphBaseUrl}/me`;
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error('[Graph] Get user profile failed:', response.status);
return null;
}
return response.json();
}
/**
* Search for files in SharePoint
*/
async searchFiles(
accessToken: string,
siteId: string,
query: string
): Promise<JobFile[]> {
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drive/root/search(q='${encodeURIComponent(query)}')`;
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error('[Graph] Search files failed:', response.status);
return [];
}
const data: GraphListResponse = await response.json();
return data.value
.filter(item => item.file)
.map(item => ({
id: item.id,
name: item.name,
size: item.size,
mimeType: item.file?.mimeType ?? 'application/octet-stream',
webUrl: item.webUrl,
downloadUrl: item['@microsoft.graph.downloadUrl'],
createdDateTime: item.createdDateTime,
lastModifiedDateTime: item.lastModifiedDateTime,
createdBy: item.createdBy,
lastModifiedBy: item.lastModifiedBy,
}));
}
}
export const graphService = new GraphService();
+165
View File
@@ -0,0 +1,165 @@
/**
* Jobs Service - Business logic for jobs
*/
import { pocketbaseService } from './pocketbaseService';
import { graphService } from './graphService';
import { cacheService } from './cacheService';
import { tokenService } from './tokenService';
import { CACHE_KEYS } from '@shared/constants';
import type { Job, JobFile, StickyNote, JobListResponse, JobDetailResponse, JobFilesResponse } from '@shared/types/job';
/** Cache TTL for job files (5 minutes) */
const FILES_CACHE_TTL = 300;
class JobsService {
/**
* Get list of all jobs
*/
async getJobs(options?: {
page?: number;
perPage?: number;
filter?: string;
sort?: string;
}): Promise<JobListResponse> {
const result = await pocketbaseService.getJobs(options);
return {
jobs: result.items,
page: options?.page ?? 1,
perPage: options?.perPage ?? 50,
totalItems: result.totalItems,
totalPages: result.totalPages,
};
}
/**
* Get job detail with files and notes
*/
async getJobDetail(jobId: string, userId: string): Promise<JobDetailResponse | null> {
// Get job from PocketBase
const job = await pocketbaseService.getJob(jobId);
if (!job) return null;
// Get notes from PocketBase
const notes = await pocketbaseService.getJobNotes(jobId);
// Get files from SharePoint (if job has site/drive configured)
let files: JobFile[] = [];
if (job.site_id && job.drive_id) {
const filesResult = await this.getJobFiles(jobId, userId);
files = filesResult?.files ?? [];
}
return { job, files, notes };
}
/**
* Get files for a job from SharePoint
*/
async getJobFiles(jobId: string, userId: string): Promise<JobFilesResponse | null> {
// Try cache first
const cached = await cacheService.get<JobFilesResponse>(CACHE_KEYS.JOB_FILES(jobId));
if (cached) {
return { ...cached, cached: true };
}
// Get job to find site/drive info
const job = await pocketbaseService.getJob(jobId);
if (!job || !job.site_id || !job.drive_id) {
return { jobId, files: [], cached: false };
}
// Get Graph token for user
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
console.error('[JobsService] No Graph token available for user', userId);
return { jobId, files: [], cached: false };
}
try {
// Fetch files from SharePoint
const result = await graphService.getFiles(
tokenResult.token,
job.site_id,
job.drive_id,
job.folder_path
);
const response: JobFilesResponse = {
jobId,
files: result.files,
nextLink: result.nextLink,
cached: false,
};
// Cache the result
await cacheService.set(CACHE_KEYS.JOB_FILES(jobId), response, FILES_CACHE_TTL);
return response;
} catch (err) {
console.error('[JobsService] Failed to get files for job', jobId, err);
return { jobId, files: [], cached: false };
}
}
/**
* Search jobs by name or number
*/
async searchJobs(query: string): Promise<Job[]> {
const result = await pocketbaseService.getJobs({
filter: `job_name ~ "${query}" || job_number ~ "${query}"`,
perPage: 20,
});
return result.items;
}
/**
* Get job notes
*/
async getJobNotes(jobId: string): Promise<StickyNote[]> {
return pocketbaseService.getJobNotes(jobId);
}
/**
* Create a note for a job
*/
async createNote(
jobId: string,
content: string,
userId: string,
options?: { color?: StickyNote['color']; position_x?: number; position_y?: number }
): Promise<StickyNote | null> {
return pocketbaseService.createNote({
job_id: jobId,
content,
color: options?.color ?? 'yellow',
position_x: options?.position_x ?? 0,
position_y: options?.position_y ?? 0,
created_by: userId,
});
}
/**
* Update a note
*/
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
return pocketbaseService.updateNote(noteId, data);
}
/**
* Delete a note
*/
async deleteNote(noteId: string): Promise<boolean> {
return pocketbaseService.deleteNote(noteId);
}
/**
* Invalidate file cache for a job
*/
async invalidateFileCache(jobId: string): Promise<void> {
await cacheService.del(CACHE_KEYS.JOB_FILES(jobId));
}
}
export const jobsService = new JobsService();
+177
View File
@@ -0,0 +1,177 @@
/**
* PocketBase Service - PocketBase integration
*/
import PocketBase from 'pocketbase';
import { env } from '../config/env';
import type { PocketBaseUser } from '@shared/types/auth';
import type { Job, StickyNote } from '@shared/types/job';
class PocketBaseService {
private client: PocketBase;
constructor() {
this.client = new PocketBase(env.POCKETBASE_URL);
}
/**
* Get PocketBase client instance
*/
get pb(): PocketBase {
return this.client;
}
/**
* Authenticate with token
*/
async authenticateWithToken(token: string): Promise<PocketBaseUser | null> {
try {
this.client.authStore.save(token, null);
const authData = await this.client.collection('users').authRefresh();
return authData.record as unknown as PocketBaseUser;
} catch (err) {
console.error('[PocketBase] Auth with token failed:', err);
return null;
}
}
/**
* Refresh token using refresh token
*/
async refreshToken(refreshToken: string): Promise<{ token: string; refreshToken: string }> {
try {
// PocketBase SDK handles refresh internally
// We need to set the token first and then refresh
this.client.authStore.save(refreshToken, null);
const authData = await this.client.collection('users').authRefresh();
return {
token: authData.token,
refreshToken: authData.token, // PocketBase uses same token for refresh
};
} catch (err) {
console.error('[PocketBase] Token refresh failed:', err);
throw err;
}
}
/**
* Get user by ID
*/
async getUser(userId: string): Promise<PocketBaseUser | null> {
try {
const record = await this.client.collection('users').getOne(userId);
return record as unknown as PocketBaseUser;
} catch (err) {
console.error('[PocketBase] Get user failed:', err);
return null;
}
}
/**
* Get all jobs (with optional filters)
*/
async getJobs(options?: {
page?: number;
perPage?: number;
filter?: string;
sort?: string;
}): Promise<{ items: Job[]; totalItems: number; totalPages: number }> {
try {
const result = await this.client.collection('jobs').getList(
options?.page ?? 1,
options?.perPage ?? 50,
{
filter: options?.filter,
sort: options?.sort ?? '-created',
}
);
return {
items: result.items as unknown as Job[],
totalItems: result.totalItems,
totalPages: result.totalPages,
};
} catch (err) {
console.error('[PocketBase] Get jobs failed:', err);
throw err;
}
}
/**
* Get single job by ID
*/
async getJob(jobId: string): Promise<Job | null> {
try {
const record = await this.client.collection('jobs').getOne(jobId);
return record as unknown as Job;
} catch (err) {
console.error('[PocketBase] Get job failed:', err);
return null;
}
}
/**
* Get notes for a job
*/
async getJobNotes(jobId: string): Promise<StickyNote[]> {
try {
const result = await this.client.collection('sticky_notes').getFullList({
filter: `job_id = "${jobId}"`,
sort: '-created',
});
return result as unknown as StickyNote[];
} catch (err) {
console.error('[PocketBase] Get job notes failed:', err);
return [];
}
}
/**
* Create a sticky note
*/
async createNote(note: Omit<StickyNote, 'id' | 'created' | 'updated'>): Promise<StickyNote | null> {
try {
const record = await this.client.collection('sticky_notes').create(note);
return record as unknown as StickyNote;
} catch (err) {
console.error('[PocketBase] Create note failed:', err);
return null;
}
}
/**
* Update a sticky note
*/
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
try {
const record = await this.client.collection('sticky_notes').update(noteId, data);
return record as unknown as StickyNote;
} catch (err) {
console.error('[PocketBase] Update note failed:', err);
return null;
}
}
/**
* Delete a sticky note
*/
async deleteNote(noteId: string): Promise<boolean> {
try {
await this.client.collection('sticky_notes').delete(noteId);
return true;
} catch (err) {
console.error('[PocketBase] Delete note failed:', err);
return false;
}
}
/**
* Clear auth state
*/
clearAuth(): void {
this.client.authStore.clear();
}
}
export const pocketbaseService = new PocketBaseService();
+271
View File
@@ -0,0 +1,271 @@
/**
* Token Service - Token refresh and management
*/
import { cacheService } from './cacheService';
import { pocketbaseService } from './pocketbaseService';
import { graphService } from './graphService';
import { CACHE_KEYS, TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS, TOKEN_REFRESH_INTERVAL_MS } from '@shared/constants';
import type { CachedToken, TokenType, TokenRefreshResponse } from '@shared/types/auth';
import { env } from '../config/env';
class TokenService {
private refreshLoops = new Map<string, Timer>();
private activeUsers = new Set<string>();
/**
* Check if token is expiring soon (within buffer)
*/
isExpiring(expiresAt: number): boolean {
return expiresAt - Date.now() < env.TOKEN_EXPIRY_BUFFER_MS;
}
/**
* Store tokens in cache after login
*/
async storeTokens(
userId: string,
pbToken: string,
graphToken: string,
refreshPb: string,
refreshGraph: string
): Promise<void> {
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
// Store PocketBase token
await cacheService.set<CachedToken>(
CACHE_KEYS.TOKEN_PB(userId),
{
token: pbToken,
refresh_token: refreshPb,
expiresAt: expiresAt,
userId: userId,
},
TOKEN_TTL_SECONDS
);
// Store Graph token
await cacheService.set<CachedToken>(
CACHE_KEYS.TOKEN_GRAPH(userId),
{
token: graphToken,
refresh_token: refreshGraph,
expiresAt: expiresAt,
userId: userId,
},
TOKEN_TTL_SECONDS
);
// Store refresh tokens with longer TTL
await cacheService.set(
CACHE_KEYS.REFRESH_PB(userId),
refreshPb,
REFRESH_TOKEN_TTL_SECONDS
);
await cacheService.set(
CACHE_KEYS.REFRESH_GRAPH(userId),
refreshGraph,
REFRESH_TOKEN_TTL_SECONDS
);
// Track active user
this.activeUsers.add(userId);
await cacheService.sadd(CACHE_KEYS.ACTIVE_USERS, userId);
console.log(`[TokenService] Tokens stored for user ${userId}`);
}
/**
* Get fresh token for user (from cache or refresh)
*/
async getToken(userId: string, type: TokenType): Promise<TokenRefreshResponse | null> {
const cacheKey = type === 'pb'
? CACHE_KEYS.TOKEN_PB(userId)
: CACHE_KEYS.TOKEN_GRAPH(userId);
// Try to get from cache
const cached = await cacheService.get<CachedToken>(cacheKey);
if (cached && !this.isExpiring(cached.expiresAt)) {
return {
token: cached.token,
expiresAt: cached.expiresAt,
source: 'cache',
type,
};
}
// Need to refresh
const refreshed = await this.refreshToken(userId, type);
if (refreshed) {
return {
token: refreshed.token,
expiresAt: refreshed.expiresAt,
source: 'refreshed',
type,
};
}
// Return stale token if available (better than nothing for field workers)
if (cached) {
console.warn(`[TokenService] Returning stale ${type} token for user ${userId}`);
return {
token: cached.token,
expiresAt: cached.expiresAt,
source: 'cache',
type,
};
}
return null;
}
/**
* Refresh a specific token type
*/
async refreshToken(userId: string, type: TokenType): Promise<CachedToken | null> {
const refreshKey = type === 'pb'
? CACHE_KEYS.REFRESH_PB(userId)
: CACHE_KEYS.REFRESH_GRAPH(userId);
const refreshToken = await cacheService.get<string>(refreshKey);
if (!refreshToken) {
console.error(`[TokenService] No refresh token for ${type} user ${userId}`);
return null;
}
try {
let newToken: string;
let newRefreshToken: string;
if (type === 'pb') {
const result = await pocketbaseService.refreshToken(refreshToken);
newToken = result.token;
newRefreshToken = result.refreshToken;
} else {
const result = await graphService.refreshToken(refreshToken);
newToken = result.accessToken;
newRefreshToken = result.refreshToken;
}
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
const cachedToken: CachedToken = {
token: newToken,
refresh_token: newRefreshToken,
expiresAt: expiresAt,
userId: userId,
};
// Update cache
const cacheKey = type === 'pb'
? CACHE_KEYS.TOKEN_PB(userId)
: CACHE_KEYS.TOKEN_GRAPH(userId);
await cacheService.set(cacheKey, cachedToken, TOKEN_TTL_SECONDS);
await cacheService.set(refreshKey, newRefreshToken, REFRESH_TOKEN_TTL_SECONDS);
console.log(`[TokenService] Refreshed ${type} token for user ${userId}`);
return cachedToken;
} catch (err) {
console.error(`[TokenService] Failed to refresh ${type} token for user ${userId}:`, err);
return null;
}
}
/**
* Start background refresh loop for a user
*/
startRefreshLoop(userId: string): void {
if (this.refreshLoops.has(userId)) {
console.log(`[TokenService] Refresh loop already running for ${userId}`);
return;
}
const loop = setInterval(async () => {
await this.refreshUserTokens(userId);
}, TOKEN_REFRESH_INTERVAL_MS);
this.refreshLoops.set(userId, loop);
this.activeUsers.add(userId);
console.log(`[TokenService] Started refresh loop for user ${userId}`);
}
/**
* Stop background refresh loop for a user
*/
stopRefreshLoop(userId: string): void {
const loop = this.refreshLoops.get(userId);
if (loop) {
clearInterval(loop);
this.refreshLoops.delete(userId);
this.activeUsers.delete(userId);
console.log(`[TokenService] Stopped refresh loop for user ${userId}`);
}
}
/**
* Refresh all tokens for a user (called by background loop)
*/
private async refreshUserTokens(userId: string): Promise<void> {
console.log(`[TokenService] Background refresh for user ${userId}`);
// Check PocketBase token
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
if (pbToken && this.isExpiring(pbToken.expiresAt)) {
await this.refreshToken(userId, 'pb');
}
// Check Graph token
const graphToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_GRAPH(userId));
if (graphToken && this.isExpiring(graphToken.expiresAt)) {
await this.refreshToken(userId, 'graph');
}
}
/**
* Clear all tokens for a user (logout)
*/
async clearUserTokens(userId: string): Promise<void> {
this.stopRefreshLoop(userId);
await Promise.all([
cacheService.del(CACHE_KEYS.TOKEN_PB(userId)),
cacheService.del(CACHE_KEYS.TOKEN_GRAPH(userId)),
cacheService.del(CACHE_KEYS.REFRESH_PB(userId)),
cacheService.del(CACHE_KEYS.REFRESH_GRAPH(userId)),
]);
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
console.log(`[TokenService] Cleared all tokens for user ${userId}`);
}
/**
* Restore refresh loops for active users (on server restart)
*/
async restoreRefreshLoops(): Promise<void> {
const activeUsers = await cacheService.smembers(CACHE_KEYS.ACTIVE_USERS);
for (const userId of activeUsers) {
// Check if user still has valid tokens
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
if (pbToken) {
this.startRefreshLoop(userId);
} else {
// Remove stale user
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
}
}
console.log(`[TokenService] Restored ${this.refreshLoops.size} refresh loops`);
}
/**
* Get active user count
*/
get activeUserCount(): number {
return this.activeUsers.size;
}
}
export const tokenService = new TokenService();