Initial production setup with dual-token auth
This commit is contained in:
@@ -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;
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user