diff --git a/.env.example b/.env.example deleted file mode 100644 index 5690d66..0000000 --- a/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# Server -PORT=3005 -NODE_ENV=development - -# Redis/Valkey -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= - -# PocketBase -POCKETBASE_URL=https://pocketbase.ccllc.pro -POCKETBASE_ADMIN_EMAIL= -POCKETBASE_ADMIN_PASSWORD= - -# Microsoft OAuth -MICROSOFT_CLIENT_ID= -MICROSOFT_CLIENT_SECRET= -MICROSOFT_TENANT=common -MICROSOFT_REDIRECT_URI=http://localhost:3005/api/auth/callback - -# Logging -LOG_LEVEL=info -LOG_FILE=logs/app.log -ERROR_LOG_FILE=logs/error.log - -# Token Settings -TOKEN_REFRESH_INTERVAL_MS=1800000 -TOKEN_EXPIRY_BUFFER_MS=900000 diff --git a/CHANGELOG_SESSION.md b/CHANGELOG_SESSION.md deleted file mode 100644 index 1b16517..0000000 --- a/CHANGELOG_SESSION.md +++ /dev/null @@ -1,76 +0,0 @@ -# Session Changelog - January 1, 2026 - -## Production Setup Session - -### What Was Done - -1. **Backend Setup** - - Copied working `server.ts` from Job-Info-Test - - Added `redis-cache.ts` for Valkey/Redis caching - - Fixed static file path to serve from `frontend/public` - - Fast job loading: returns first 40 jobs instantly, then full list on poll - -2. **Frontend Setup** - - Copied working frontend from Job-Info-Test to `frontend/public/` - - Simplified token validation at startup: - - Check PocketBase session valid - - Check Graph token in localStorage - - If either missing → redirect to signin - - Removed redundant token refresh loops - - Added localStorage storage for Graph token in signin.html - -3. **Authentication Flow** - - PocketBase OAuth with Microsoft provider - - Graph token stored in localStorage after signin - - Both tokens checked once at page load - - No repeated token validation during app usage - -4. **Systemd Service** - - Created `/etc/systemd/system/job-info-prod.service` - - Runs on port 3005 - - Auto-restarts on failure - -5. **Git Repository** - - Initialized and pushed to: https://gitea.ccllc.pro/Cardoza_Construction/Job-Info-Prod.git - - Branch: main - -### Key Files - -| File | Purpose | -|------|---------| -| `backend/src/index.ts` | Main Hono server with all endpoints | -| `backend/src/redis-cache.ts` | Redis/Valkey cache helpers | -| `frontend/public/index.html` | Main app UI | -| `frontend/public/signin.html` | OAuth signin page (purple styling) | -| `frontend/public/auth.js` | PocketBase auth bootstrap | - -### Endpoints - -- `GET /` - Serve frontend -- `GET /version` - App version -- `GET /api/health` - Health check -- `GET /api/jobs-all` - All jobs (first 40 fast, then full) -- `GET /api/jobs` - Paginated jobs -- `GET /api/job-files` - SharePoint files via Graph API -- `GET /api/job-file-content` - File content/download -- `GET /api/job-file-pdf` - PDF proxy -- `POST /log-change` - Log changes - -### Configuration - -- Port: 3005 -- PocketBase: https://pocketbase.ccllc.pro -- Collection: pbc_1407612689 -- Redis/Valkey: localhost:6379 - -### Known Limitations - -- Graph tokens expire after ~60 minutes (Microsoft limitation) -- No automatic Graph token refresh (would require direct OAuth flow) -- User must re-signin when Graph token expires - -### Next Steps - -- Consider implementing direct Microsoft OAuth for refresh token support -- Add error boundaries for better UX on token expiry -- Performance monitoring diff --git a/README.md b/README.md deleted file mode 100644 index 4645a14..0000000 --- a/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# Job-Info-Prod - -Production-ready job information management system for field workers. - -## Features - -- **Dual-Token Authentication**: PocketBase + Microsoft Graph tokens -- **Always-Warm Cache**: Background refresh loop keeps tokens fresh -- **Offline-First**: Works without internet using cached data -- **Zero Re-Auth**: Users never need to login again after initial auth - -## Tech Stack - -- **Runtime**: Bun -- **Backend**: Hono -- **Auth**: PocketBase + Microsoft OAuth -- **Cache**: Redis/Valkey via ioredis -- **Frontend**: TypeScript + Tailwind CSS - -## Quick Start - -```bash -# Install dependencies -bun install - -# Copy environment file -cp .env.example .env - -# Edit .env with your configuration -nano .env - -# Start development server -bun run dev -``` - -## Environment Variables - -See `.env.example` for all required variables: - -- `PORT` - Server port (default: 3000) -- `POCKETBASE_URL` - PocketBase instance URL -- `MS_CLIENT_ID` - Microsoft OAuth client ID -- `MS_CLIENT_SECRET` - Microsoft OAuth client secret -- `MS_TENANT_ID` - Microsoft tenant ID -- `REDIS_HOST` - Redis/Valkey host -- `REDIS_PORT` - Redis/Valkey port - -## Architecture - -``` -┌─────────────────┐ ┌──────────────────┐ -│ Frontend │────▶│ Hono Backend │ -│ (TypeScript) │◀────│ │ -└─────────────────┘ └────────┬─────────┘ - │ - ┌────────────────────────┼────────────────────────┐ - ▼ ▼ ▼ -┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ PocketBase │ │ Redis/Valkey │ │ Microsoft Graph │ -│ (Auth+Data) │ │ (Token Cache) │ │ (SharePoint) │ -└───────────────┘ └─────────────────┘ └─────────────────┘ -``` - -## Token Refresh Flow - -1. User completes OAuth flow → tokens stored in localStorage + cache -2. Backend starts 30-min refresh loop per user -3. Frontend checks backend for fresh tokens every 25 min -4. If backend unreachable, frontend uses localStorage cache -5. Stale tokens returned as fallback (field workers can't wait) - -## Scripts - -- `bun run dev` - Start development server with watch -- `bun run start` - Start production server -- `bun run build:css` - Build Tailwind CSS -- `bun run typecheck` - Run TypeScript type checking -- `bun run test` - Run tests - -## Folder Structure - -``` -Job-Info-Prod/ -├── backend/ -│ └── src/ -│ ├── config/ # Environment and constants -│ ├── middleware/ # Auth, logging, error handling -│ ├── routes/ # API route handlers -│ ├── services/ # Business logic -│ └── index.ts # Server entry point -├── frontend/ -│ ├── public/ # Static HTML files -│ └── src/ -│ ├── auth/ # Auth client -│ ├── components/ # UI components -│ ├── services/ # API clients -│ ├── styles/ # Tailwind CSS -│ └── app.ts # Main app entry -└── shared/ - ├── constants/ # Shared constants - └── types/ # TypeScript types -``` - -## License - -MIT diff --git a/backend/src/config/constants.ts b/backend/src/config/constants.ts deleted file mode 100644 index 4ca8909..0000000 --- a/backend/src/config/constants.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts deleted file mode 100644 index c4c956e..0000000 --- a/backend/src/config/env.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 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(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(', ')}`); - } -} diff --git a/backend/src/index.ts b/backend/src/index.ts deleted file mode 100644 index 037c6ef..0000000 --- a/backend/src/index.ts +++ /dev/null @@ -1,905 +0,0 @@ -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(); - -// Service Principal token cache -let appOnlyToken: string | null = null; -let appOnlyTokenExpiry: number = 0; - -// Get app-only token using client credentials flow -async function getAppOnlyToken(): Promise { - try { - // Check if we have a valid cached token - if (appOnlyToken && appOnlyTokenExpiry > Date.now()) { - return appOnlyToken; - } - - const clientId = process.env.MICROSOFT_CLIENT_ID; - const clientSecret = process.env.MICROSOFT_CLIENT_SECRET; - const tenant = process.env.MICROSOFT_TENANT || 'common'; - - if (!clientId || !clientSecret) { - return null; // Service principal not configured - } - - const tokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`; - - const params = new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - scope: 'https://graph.microsoft.com/.default', - grant_type: 'client_credentials' - }); - - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString() - }); - - if (!response.ok) { - const error = await response.text(); - logLine('logs/error.log', `Failed to get app-only token: ${error}`); - return null; - } - - const data = await response.json() as any; - appOnlyToken = data.access_token; - // Set expiry to 55 minutes (token is valid for 60 minutes) - appOnlyTokenExpiry = Date.now() + (55 * 60 * 1000); - - logLine('logs/server.log', `✓ Got app-only token (expires in 55 minutes)`); - return appOnlyToken; - } catch (err) { - logLine('logs/error.log', `getAppOnlyToken error: ${(err as Error)?.message || String(err)}`); - return null; - } -} - -// 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) { - // Return first 100 jobs immediately for instant display, rest loads in background - const fastBatch = Array.isArray(cached.items) ? cached.items.slice(0, 100) : []; - const hasMore = cached.items && cached.items.length > 100; - - logLine('logs/server.log', `Cache HIT for ${cacheKey} - sending ${fastBatch.length} jobs (${hasMore ? 'partial' : 'complete'})`); - - // Refresh cache in background - setImmediate(async () => { - try { - await refreshJobsCache(cacheKey, ttl, c.req.header('Authorization') || ''); - } catch (err) {} - }); - - return c.json({ - page: 1, - perPage: fastBatch.length, - totalItems: cached.totalItems || fastBatch.length, - totalPages: 1, - items: fastBatch, - partial: hasMore - }); - } 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); - } -}); - -// Get remaining jobs after initial batch -app.get('/api/jobs-remaining', async (c) => { - const cacheKey = 'jobs:all'; - try { - if (isRedisConnected()) { - const cached = await getCache(cacheKey); - if (cached && cached.items && cached.items.length > 100) { - const remaining = cached.items.slice(100); - return c.json({ - items: remaining, - totalItems: cached.totalItems || cached.items.length - }); - } - } - return c.json({ items: [] }); - } catch (err) { - logLine('logs/error.log', `jobs-remaining error: ${(err as Error)?.message || String(err)}`); - return c.json({ error: 'Failed to fetch remaining jobs' }, 500); - } -}); - -// Helper function to fetch all jobs from PocketBase -async function fetchAllJobsFromPocketBase(authHeader: string): Promise { - 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 { - 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 => { - 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 => { - 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 link = c.req.query('link'); - if (!link) return c.json({ error: 'link required' }, 400); - console.log('[job-files] Link:', link); - - const q = c.req.query('q') || ''; - const category = c.req.query('category') || ''; - - // Check cache first (only for non-search, non-category requests) - if (!q && !category && isRedisConnected()) { - const cacheKey = `job-files:${link}`; - const cached = await getCache(cacheKey); - if (cached) { - console.log('[job-files] Cache HIT for', link); - // Apply filtering if requested - const pdfOnly = c.req.query('pdfOnly') !== 'false'; - const items = cached.items || []; - const filtered = pdfOnly - ? items.filter((i: any) => { - if (i.isFolder) return false; - const name = (i.name || '').toLowerCase(); - const mimeType = (i.mimeType || '').toLowerCase(); - 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'); - }) - : items; - - const mapped = filtered.map((i: any) => ({ - id: i.id, - name: i.name, - url: i.webUrl, - driveId: i.driveId, - size: i.size, - modified: i.lastModifiedDateTime, - contentType: i.mimeType, - isFolder: i.isFolder, - path: i.parentPath || '', - })); - - return c.json({ items: mapped, total: mapped.length, source: 'cache', driveId: cached.driveId }); - } - console.log('[job-files] Cache MISS for', link); - } - - // Not in cache or search/category request - fetch from Graph API - 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 { 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 = {}; - 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); - - // Check cache first - const cacheKey = `job-file-pdf:${driveId}:${itemId}`; - if (isRedisConnected()) { - const cached = await getCache(cacheKey); - if (cached && cached.data) { - logLine('logs/server.log', `PDF cache HIT: ${driveId}:${itemId}`); - const buffer = Buffer.from(cached.data, 'base64'); - const headers: Record = { - 'Content-Type': 'application/pdf', - }; - if (cached.filename) { - headers['Content-Disposition'] = `inline; filename="${cached.filename}"`; - } - return new Response(buffer, { status: 200, headers }); - } else { - logLine('logs/server.log', `PDF cache MISS: ${driveId}:${itemId}`); - } - } - - // 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 pdfBuffer = Buffer.from(await res.arrayBuffer()); - const headers: Record = { - 'Content-Type': 'application/pdf', - }; - const cd = res.headers.get('content-disposition'); - let filename = 'document.pdf'; - if (cd) { - // Replace .docx/.doc extension with .pdf in filename - headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf'); - const match = cd.match(/filename="?([^"]+)"?/); - if (match) filename = match[1].replace(/\.(docx?)/gi, '.pdf'); - } - - // Cache the converted PDF for 30 minutes - if (isRedisConnected()) { - await setCache(cacheKey, { - data: pdfBuffer.toString('base64'), - filename - }, 1800); - } - - logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`); - return new Response(pdfBuffer, { 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 - wait for Redis to be ready -(async () => { - // Wait for Redis to be ready (max 10 seconds) - let retries = 0; - while (!isRedisConnected() && retries < 20) { - await new Promise(resolve => setTimeout(resolve, 500)); - retries++; - } - - if (!isRedisConnected()) { - logLine('logs/error.log', 'Redis not connected after 10 seconds, skipping cache warmup'); - return; - } - - try { - 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} total jobs`); - - // Try to start file caching if service principal is configured - const token = await getAppOnlyToken(); - if (token) { - logLine('logs/server.log', 'Service principal authenticated, starting background file caching...'); - // Cache files for ALL jobs with folder links - startFileCaching(result.items, token); - } else { - logLine('logs/server.log', 'Service principal not configured, file caching will occur on-demand'); - } - } catch (err) { - logLine('logs/error.log', `Cache warmup failed: ${(err as Error)?.message || String(err)}`); - } -})(); - -// Background file caching state -let fileCachingActive = false; -let fileCachingIndex = 0; -let fileCachingJobs: any[] = []; -let fileCachingToken: string = ''; -let fileCachingTimer: NodeJS.Timeout | null = null; - -async function startFileCaching(jobs: any[], token: string) { - if (fileCachingActive) return; - - fileCachingJobs = jobs; - if (fileCachingJobs.length === 0) { - logLine('logs/server.log', 'No jobs to cache'); - return; - } - - fileCachingActive = true; - fileCachingIndex = 0; - fileCachingToken = token; - - logLine('logs/server.log', `Starting background file caching for ${fileCachingJobs.length} jobs...`); - cacheNextFileBatch(); -} - -async function cacheNextFileBatch() { - if (!fileCachingActive || !isRedisConnected()) return; - - const batchSize = 10; // Cache 10 jobs at a time for faster initial coverage - const maxJobs = fileCachingJobs.length; - - if (fileCachingIndex >= maxJobs) { - // Completed initial full cache of all jobs - console.log(`✓ Completed caching all ${maxJobs} jobs. Scheduling refresh in 10 minutes...`); - logLine('logs/server.log', `✓ Completed caching all ${maxJobs} jobs. Scheduling refresh in 10 minutes...`); - fileCachingIndex = 0; - - // After completing all jobs, wait 10 minutes before refreshing - fileCachingTimer = setTimeout(async () => { - try { - // Refresh the jobs cache first - await refreshJobsCache('jobs:all', 1800, ''); - - // Get updated job list from cache - const cached = await getCache('jobs:all'); - if (cached && cached.items) { - fileCachingJobs = cached.items; - console.log(`Starting refresh cycle: ${fileCachingJobs.length} jobs to update`); - logLine('logs/server.log', `Starting refresh cycle: ${fileCachingJobs.length} jobs to update`); - } - - // Refresh token if needed - const newToken = await getAppOnlyToken(); - if (newToken) { - fileCachingToken = newToken; - cacheNextFileBatch(); - } else { - console.error('Failed to refresh app-only token, stopping file caching'); - logLine('logs/error.log', 'Failed to refresh app-only token, stopping file caching'); - fileCachingActive = false; - } - } catch (err) { - console.error(`File cache cycle restart error: ${(err as Error)?.message}`); - logLine('logs/error.log', `File cache cycle restart error: ${(err as Error)?.message}`); - } - }, 10 * 60 * 1000); - return; - } - - // Get next batch of jobs to cache - const batch = fileCachingJobs.slice(fileCachingIndex, fileCachingIndex + batchSize); - const jobNumbers = batch.map(j => j.Job_Number).join(', '); - const batchMsg = `[Cache Batch] Processing jobs: ${jobNumbers} (${fileCachingIndex}/${maxJobs})`; - console.log(batchMsg); - logLine('logs/server.log', batchMsg); - - for (const job of batch) { - try { - await cacheJobFiles(job, fileCachingToken); - } catch (err) { - // Silently continue on errors - logLine('logs/error.log', `File cache error for job ${job.Job_Number}: ${(err as Error)?.message}`); - } - } - - fileCachingIndex += batchSize; - - // Continue with next batch after a short delay (1 second between batches for faster throughput) - fileCachingTimer = setTimeout(() => cacheNextFileBatch(), 1000); -} - -async function cacheJobFiles(job: any, token: string) { - const link = job.Job_Folder_Link; - if (!link) { - // Job has no folder link, just mark it as cached - const cacheKey = `job-files:${job.Job_Number}`; - await setCache(cacheKey, { items: [] }, 1800); - console.log(`✓ No folder for job ${job.Job_Number}`); - logLine('logs/server.log', `✓ No folder for job ${job.Job_Number}`); - return; - } - - const cacheKey = `job-files:${link}`; - const ttl = 1800; // 30 minutes - - try { - const { driveId, itemId } = await resolveShareLink(link, token); - const items = await listChildrenRecursive(driveId, itemId, 0, 5, token); - - const data = { - driveId, - items: items.map(item => ({ - id: item.id, - name: item.name, - webUrl: item.webUrl, - size: item.size, - lastModifiedDateTime: item.lastModifiedDateTime, - mimeType: item.file?.mimeType, - isFolder: !!item.folder, - parentPath: item.parentReference?.path, - driveId: item.parentReference?.driveId || driveId - })), - cachedAt: Date.now() - }; - - await setCache(cacheKey, data, ttl); - - // Pre-cache PDF conversions for Word/Excel documents AND native PDF files - const officeFiles = items.filter(item => { - const name = (item.name || '').toLowerCase(); - return name.endsWith('.docx') || name.endsWith('.doc') || name.endsWith('.xlsx') || name.endsWith('.xls'); - }); - - const pdfFiles = items.filter(item => { - const name = (item.name || '').toLowerCase(); - return name.endsWith('.pdf'); - }); - - let pdfsCached = 0; - - // Cache converted Office documents - for (const file of officeFiles) { - try { - const fileDriveId = file.parentReference?.driveId || driveId; - const pdfCacheKey = `job-file-pdf:${fileDriveId}:${file.id}`; - - // Check if already cached - const existing = await getCache(pdfCacheKey); - if (existing) { - pdfsCached++; - continue; - } - - // Convert and cache - const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${fileDriveId}/items/${file.id}/content?format=pdf`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (res.ok) { - const pdfBuffer = Buffer.from(await res.arrayBuffer()); - const cd = res.headers.get('content-disposition'); - let filename = file.name.replace(/\.(docx?|xlsx?)$/gi, '.pdf'); - if (cd) { - const match = cd.match(/filename="?([^"]+)"?/); - if (match) filename = match[1].replace(/\.(docx?|xlsx?)$/gi, '.pdf'); - } - - await setCache(pdfCacheKey, { - data: pdfBuffer.toString('base64'), - filename - }, ttl); - pdfsCached++; - } - } catch (err) { - // Silently skip PDF caching errors - } - } - - // Cache native PDF files - for (const file of pdfFiles) { - try { - const fileDriveId = file.parentReference?.driveId || driveId; - const pdfCacheKey = `job-file-pdf:${fileDriveId}:${file.id}`; - - // Check if already cached - const existing = await getCache(pdfCacheKey); - if (existing) { - pdfsCached++; - continue; - } - - // Fetch and cache native PDF - const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${fileDriveId}/items/${file.id}/content`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (res.ok) { - const pdfBuffer = Buffer.from(await res.arrayBuffer()); - await setCache(pdfCacheKey, { - data: pdfBuffer.toString('base64'), - filename: file.name - }, ttl); - pdfsCached++; - } - } catch (err) { - // Silently skip PDF caching errors - } - } - - const totalPDFs = officeFiles.length + pdfFiles.length; - const msg = `✓ Cached ${items.length} files${totalPDFs > 0 ? ` + ${pdfsCached}/${totalPDFs} PDFs` : ''} for job ${job.Job_Number}`; - console.log(msg); - logLine('logs/server.log', msg); - } catch (err: any) { - // Only log if not a token expiration issue - if (!err.message?.includes('401') && !err.message?.includes('token')) { - logLine('logs/error.log', `File cache skip for job ${job.Job_Number}: ${err.message}`); - } - } -} - -// Graceful shutdown logging -const shutdown = (signal: string) => { - logLine('logs/server.log', `Shutting down due to ${signal}`); - if (fileCachingTimer) clearTimeout(fileCachingTimer); - process.exit(0); -}; -process.on('SIGINT', () => shutdown('SIGINT')); -process.on('SIGTERM', () => shutdown('SIGTERM')); - -export default { - port: PORT, - fetch: app.fetch, -}; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts deleted file mode 100644 index f0d18f2..0000000 --- a/backend/src/middleware/auth.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 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 { - 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 { - 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'); -} diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts deleted file mode 100644 index 31869b5..0000000 --- a/backend/src/middleware/errorHandler.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 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 - ); -} diff --git a/backend/src/middleware/logger.ts b/backend/src/middleware/logger.ts deleted file mode 100644 index c0618d0..0000000 --- a/backend/src/middleware/logger.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * 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 = { - 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 { - 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): void { - if (shouldLog('debug')) { - console.log(formatLog('debug', message, meta)); - } - }, - - info(message: string, meta?: Record): void { - if (shouldLog('info')) { - console.log(formatLog('info', message, meta)); - } - }, - - warn(message: string, meta?: Record): void { - if (shouldLog('warn')) { - console.warn(formatLog('warn', message, meta)); - } - }, - - error(message: string, meta?: Record): void { - if (shouldLog('error')) { - console.error(formatLog('error', message, meta)); - } - }, -}; - -/** - * Request logging middleware - */ -export async function loggerMiddleware(c: Context, next: Next): Promise { - 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 }); - } -} diff --git a/backend/src/redis-cache.ts b/backend/src/redis-cache.ts deleted file mode 100644 index f6463a2..0000000 --- a/backend/src/redis-cache.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 { - 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 { - 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 { - 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 { - 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; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts deleted file mode 100644 index b32120a..0000000 --- a/backend/src/routes/auth.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * 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(); - - 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; diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts deleted file mode 100644 index cc77b07..0000000 --- a/backend/src/routes/files.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 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; diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts deleted file mode 100644 index fbee8b9..0000000 --- a/backend/src/routes/health.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * 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 = { - 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; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts deleted file mode 100644 index 34bb6a6..0000000 --- a/backend/src/routes/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * 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); -} diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts deleted file mode 100644 index 15d71f1..0000000 --- a/backend/src/routes/jobs.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * 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; diff --git a/backend/src/services/cacheService.ts b/backend/src/services/cacheService.ts deleted file mode 100644 index 7817f6f..0000000 --- a/backend/src/services/cacheService.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * 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 { - 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(key: string): Promise { - 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(key: string, value: T, ttlSeconds?: number): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - if (this.client) { - await this.client.quit(); - this.client = null; - this.isConnected = false; - } - } -} - -export const cacheService = new CacheService(); diff --git a/backend/src/services/graphService.ts b/backend/src/services/graphService.ts deleted file mode 100644 index 0d87004..0000000 --- a/backend/src/services/graphService.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * 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 { - 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 { - 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(); diff --git a/backend/src/services/jobsService.ts b/backend/src/services/jobsService.ts deleted file mode 100644 index f906989..0000000 --- a/backend/src/services/jobsService.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * 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 { - 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 { - // 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 { - // Try cache first - const cached = await cacheService.get(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 { - 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 { - 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 { - 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): Promise { - return pocketbaseService.updateNote(noteId, data); - } - - /** - * Delete a note - */ - async deleteNote(noteId: string): Promise { - return pocketbaseService.deleteNote(noteId); - } - - /** - * Invalidate file cache for a job - */ - async invalidateFileCache(jobId: string): Promise { - await cacheService.del(CACHE_KEYS.JOB_FILES(jobId)); - } -} - -export const jobsService = new JobsService(); diff --git a/backend/src/services/pocketbaseService.ts b/backend/src/services/pocketbaseService.ts deleted file mode 100644 index 9ee9e82..0000000 --- a/backend/src/services/pocketbaseService.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * 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 { - 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 { - 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 { - 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 { - 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): Promise { - 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): Promise { - 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 { - 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(); diff --git a/backend/src/services/tokenService.ts b/backend/src/services/tokenService.ts deleted file mode 100644 index 7ca82f9..0000000 --- a/backend/src/services/tokenService.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * 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(); - private activeUsers = new Set(); - - /** - * 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 { - const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000; - - // Store PocketBase token - await cacheService.set( - CACHE_KEYS.TOKEN_PB(userId), - { - token: pbToken, - refresh_token: refreshPb, - expiresAt: expiresAt, - userId: userId, - }, - TOKEN_TTL_SECONDS - ); - - // Store Graph token - await cacheService.set( - 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 { - const cacheKey = type === 'pb' - ? CACHE_KEYS.TOKEN_PB(userId) - : CACHE_KEYS.TOKEN_GRAPH(userId); - - // Try to get from cache - const cached = await cacheService.get(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 { - const refreshKey = type === 'pb' - ? CACHE_KEYS.REFRESH_PB(userId) - : CACHE_KEYS.REFRESH_GRAPH(userId); - - const refreshToken = await cacheService.get(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 { - console.log(`[TokenService] Background refresh for user ${userId}`); - - // Check PocketBase token - const pbToken = await cacheService.get(CACHE_KEYS.TOKEN_PB(userId)); - if (pbToken && this.isExpiring(pbToken.expiresAt)) { - await this.refreshToken(userId, 'pb'); - } - - // Check Graph token - const graphToken = await cacheService.get(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 { - 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 { - 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(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(); diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 7d09b06..0000000 --- a/bun.lock +++ /dev/null @@ -1,70 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "job-info-prod", - "dependencies": { - "dotenv": "^17.2.3", - "hono": "^4.10.8", - "ioredis": "^5.3.2", - "pocketbase": "^0.26.5", - }, - "devDependencies": { - "@types/bun": "latest", - "postcss": "^8.4.32", - "tailwindcss": "^4.1.18", - "typescript": "^5.3.3", - }, - }, - }, - "packages": { - "@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="], - - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], - - "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], - - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], - - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], - - "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], - - "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="], - - "ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="], - - "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], - - "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "pocketbase": ["pocketbase@0.26.5", "", {}, "sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ=="], - - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - - "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], - - "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], - - "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - } -} diff --git a/frontend/assets/CCwhiteApp.png b/frontend/assets/CCwhiteApp.png deleted file mode 100644 index e4ef4f1..0000000 Binary files a/frontend/assets/CCwhiteApp.png and /dev/null differ diff --git a/frontend/assets/DailyCheck.png b/frontend/assets/DailyCheck.png deleted file mode 100644 index 200eba4..0000000 Binary files a/frontend/assets/DailyCheck.png and /dev/null differ diff --git a/frontend/assets/PalmIsland.png b/frontend/assets/PalmIsland.png deleted file mode 100644 index e31ab51..0000000 Binary files a/frontend/assets/PalmIsland.png and /dev/null differ diff --git a/frontend/assets/lightbulb.jpg b/frontend/assets/lightbulb.jpg deleted file mode 100644 index f75425e..0000000 Binary files a/frontend/assets/lightbulb.jpg and /dev/null differ diff --git a/frontend/auth.js b/frontend/auth.js deleted file mode 100644 index 323c570..0000000 --- a/frontend/auth.js +++ /dev/null @@ -1,39 +0,0 @@ -// PocketBase auth bootstrap shared by pages -(function (global) { - const pb = new PocketBase('https://pocketbase.ccllc.pro'); - - function authHeaders() { - if (pb.authStore.isValid && pb.authStore.token) { - return { Authorization: `Bearer ${pb.authStore.token}` }; - } - return {}; - } - - function getDisplayName() { - const model = pb.authStore.model || {}; - return ( - model.display_name || - model.name || - model.email || - model.username || - 'Unknown' - ); - } - - function getEmail() { - const model = pb.authStore.model || {}; - // Prefer email, but fall back to username if email missing - return model.email || model.username || null; - } - - async function ensureAuth() { - if (pb.authStore.isValid) return true; - const path = new URL(window.location.href).pathname; - if (!path.endsWith('signin.html')) { - window.location.href = 'signin.html'; - } - return false; - } - - global.Auth = { pb, authHeaders, ensureAuth, getDisplayName, getEmail }; -})(window); diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 2b0e3ed..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,2965 +0,0 @@ - - - - - -Job Info — Vanilla JS - - - - - - - - - - - - - -
-
-
-
- CC -
-
- -
-

Job Info

- -
- - -
- - - - - -
-
- -
- - v1.0.0-beta2 -
-
-
- - - - - - - - - - diff --git a/frontend/output.css b/frontend/output.css deleted file mode 100644 index 971f4ad..0000000 --- a/frontend/output.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Generated Tailwind CSS - Basic version */ -/* This will be updated by tailwind CLI on dev */ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/frontend/public/assets/CCwhiteApp.png b/frontend/public/assets/CCwhiteApp.png deleted file mode 100644 index e4ef4f1..0000000 Binary files a/frontend/public/assets/CCwhiteApp.png and /dev/null differ diff --git a/frontend/public/assets/DailyCheck.png b/frontend/public/assets/DailyCheck.png deleted file mode 100644 index 200eba4..0000000 Binary files a/frontend/public/assets/DailyCheck.png and /dev/null differ diff --git a/frontend/public/assets/PalmIsland.png b/frontend/public/assets/PalmIsland.png deleted file mode 100644 index e31ab51..0000000 Binary files a/frontend/public/assets/PalmIsland.png and /dev/null differ diff --git a/frontend/public/assets/lightbulb.jpg b/frontend/public/assets/lightbulb.jpg deleted file mode 100644 index f75425e..0000000 Binary files a/frontend/public/assets/lightbulb.jpg and /dev/null differ diff --git a/frontend/public/auth.js b/frontend/public/auth.js deleted file mode 100644 index 7967cb8..0000000 --- a/frontend/public/auth.js +++ /dev/null @@ -1,181 +0,0 @@ -// PocketBase auth bootstrap shared by pages -(function (global) { - const pb = new PocketBase('https://pocketbase.ccllc.pro'); - const GRAPH_TOKEN_KEY = 'graphAccessToken'; - const USER_ID_KEY = 'userId'; - - function authHeaders() { - if (pb.authStore.isValid && pb.authStore.token) { - return { Authorization: `Bearer ${pb.authStore.token}` }; - } - return {}; - } - - function getDisplayName() { - const model = pb.authStore.model || {}; - return ( - model.display_name || - model.name || - model.email || - model.username || - 'Unknown' - ); - } - - function getEmail() { - const model = pb.authStore.model || {}; - return model.email || model.username || null; - } - - function getUserId() { - return pb.authStore.model?.id || localStorage.getItem(USER_ID_KEY) || null; - } - - // Get Graph token - first from localStorage, then try to refresh from backend - async function getGraphToken() { - // First check localStorage - let token = localStorage.getItem(GRAPH_TOKEN_KEY); - - // If we have a valid PB session but no graph token, try to get from backend - if (!token && pb.authStore.isValid) { - try { - const response = await fetch('/api/auth/refresh-token?type=graph', { - headers: authHeaders() - }); - if (response.ok) { - const data = await response.json(); - if (data.token) { - token = data.token; - localStorage.setItem(GRAPH_TOKEN_KEY, token); - console.log('[Auth] Got Graph token from backend cache'); - } - } - } catch (err) { - console.warn('[Auth] Could not get Graph token from backend:', err); - } - } - - return token; - } - - // Synchronous version for backwards compatibility - function getGraphTokenSync() { - return localStorage.getItem(GRAPH_TOKEN_KEY); - } - - async function ensureAuth() { - if (pb.authStore.isValid) { - // Try to refresh tokens from backend on page load - try { - await refreshTokensFromBackend(); - } catch (err) { - console.warn('[Auth] Token refresh failed:', err); - } - return true; - } - const path = new URL(window.location.href).pathname; - if (!path.endsWith('signin.html')) { - window.location.href = 'signin.html'; - } - return false; - } - - // Refresh tokens from backend cache - async function refreshTokensFromBackend() { - if (!pb.authStore.isValid) return; - - const userId = getUserId(); - if (!userId) return; - - try { - // Get fresh Graph token from backend - const response = await fetch('/api/auth/refresh-token?type=graph', { - headers: authHeaders() - }); - - if (response.ok) { - const data = await response.json(); - if (data.token) { - localStorage.setItem(GRAPH_TOKEN_KEY, data.token); - console.log('[Auth] Refreshed Graph token from backend, source:', data.source); - } - } else if (response.status === 401) { - // Token expired or invalid in backend, but we might still have a valid PB session - // Try to re-store our current tokens - console.log('[Auth] Backend token expired, attempting to re-store'); - await restoreTokensToBackend(); - } - } catch (err) { - console.warn('[Auth] Token refresh error:', err); - } - } - - // Re-store tokens to backend (in case backend cache was cleared) - async function restoreTokensToBackend() { - if (!pb.authStore.isValid) return; - - try { - // Refresh PocketBase auth to potentially get a new Graph token - const authData = await pb.collection('users').authRefresh(); - - const graphToken = extractGraphToken(authData) || localStorage.getItem(GRAPH_TOKEN_KEY); - - if (graphToken) { - const response = await fetch('/api/auth/login-callback', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...authHeaders() - }, - body: JSON.stringify({ - pbToken: pb.authStore.token, - graphToken: graphToken, - refreshTokenPb: '', - refreshTokenGraph: authData?.meta?.refreshToken || '', - user: { - id: pb.authStore.model?.id, - email: pb.authStore.model?.email, - name: pb.authStore.model?.name || pb.authStore.model?.display_name - } - }) - }); - - if (response.ok) { - console.log('[Auth] Re-stored tokens in backend'); - } - } - } catch (err) { - console.warn('[Auth] Failed to re-store tokens:', err); - } - } - - function extractGraphToken(authData) { - return ( - authData?.meta?.graphAccessToken || - authData?.meta?.graph_token || - authData?.meta?.graphToken || - authData?.meta?.accessToken || - authData?.meta?.access_token || - authData?.meta?.token || - authData?.meta?.rawToken || - authData?.meta?.authData?.access_token || - '' - ); - } - - global.Auth = { - pb, - authHeaders, - ensureAuth, - getDisplayName, - getEmail, - getUserId, - getGraphToken, - getGraphTokenSync, - refreshTokensFromBackend, - restoreTokensToBackend, - extractGraphToken, - GRAPH_TOKEN_KEY, - USER_ID_KEY - }; -})(window); diff --git a/frontend/public/index.html b/frontend/public/index.html deleted file mode 100644 index bb8f1d0..0000000 --- a/frontend/public/index.html +++ /dev/null @@ -1,2969 +0,0 @@ - - - - - -Job Info — Vanilla JS - - - - - - - - - - - - -
-
-
-
- CC -
-
- -
-

Job Info

- -
- - -
- - - - - -
-
- -
- - v1.0.0-beta2 -
-
-
- - - - - - - - - - diff --git a/frontend/public/output.css b/frontend/public/output.css deleted file mode 100644 index 971f4ad..0000000 --- a/frontend/public/output.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Generated Tailwind CSS - Basic version */ -/* This will be updated by tailwind CLI on dev */ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/frontend/public/signin.html b/frontend/public/signin.html deleted file mode 100644 index 737ede9..0000000 --- a/frontend/public/signin.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - Sign In - Job Info - - - - -
-

Job Info

-

Sign in with your Microsoft account

- - - - - - -
- - - - diff --git a/frontend/signin.html b/frontend/signin.html deleted file mode 100644 index a0c3792..0000000 --- a/frontend/signin.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - Sign In - - - - - -
-

Welcome

-

Sign in with your Microsoft account

- - - - - - -
- - - - diff --git a/images/apple.svg b/images/apple.svg deleted file mode 100644 index de4b0d1..0000000 --- a/images/apple.svg +++ /dev/null @@ -1 +0,0 @@ -Apple \ No newline at end of file diff --git a/images/google.svg b/images/google.svg deleted file mode 100644 index 2eaf915..0000000 --- a/images/google.svg +++ /dev/null @@ -1 +0,0 @@ -Google \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index aa1a96a..0000000 --- a/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "job-info-prod", - "version": "2.0.0", - "description": "Professional field management application with dual-token authentication", - "type": "module", - "scripts": { - "dev": "bun run --watch backend/src/index.ts", - "start": "bun run backend/src/index.ts", - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "dotenv": "^17.2.3", - "hono": "^4.10.8", - "ioredis": "^5.3.2", - "pocketbase": "^0.26.5" - }, - "devDependencies": { - "@types/bun": "latest", - "typescript": "^5.3.3" - } -} diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 2aa7205..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/shared/constants/index.ts b/shared/constants/index.ts deleted file mode 100644 index bf63984..0000000 --- a/shared/constants/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Shared Constants - */ - -/** Token refresh interval in milliseconds (30 minutes) */ -export const TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000; - -/** Buffer before token expiry to trigger refresh (15 minutes) */ -export const TOKEN_EXPIRY_BUFFER_MS = 15 * 60 * 1000; - -/** Token TTL in cache (1 hour) */ -export const TOKEN_TTL_SECONDS = 3600; - -/** Refresh token TTL in cache (30 days) */ -export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; - -/** API request timeout in milliseconds */ -export const API_TIMEOUT_MS = 30000; - -/** Cache key prefixes */ -export const CACHE_KEYS = { - TOKEN_PB: (userId: string) => `tokens:${userId}:pb`, - TOKEN_GRAPH: (userId: string) => `tokens:${userId}:graph`, - REFRESH_PB: (userId: string) => `tokens:${userId}:refresh_pb`, - REFRESH_GRAPH: (userId: string) => `tokens:${userId}:refresh_graph`, - JOB_FILES: (jobId: string) => `jobs:${jobId}:files`, - ACTIVE_USERS: 'active_users', -} as const; - -/** Local storage keys */ -export const STORAGE_KEYS = { - PB_TOKEN: 'job-info:pb_token', - GRAPH_TOKEN: 'job-info:graph_token', - USER: 'job-info:user', - EXPIRES_AT: 'job-info:expires_at', - LAST_SYNC: 'job-info:last_sync', - JOBS_CACHE: 'job-info:jobs', -} as const; - -/** API endpoints */ -export const API_ENDPOINTS = { - LOGIN_CALLBACK: '/api/auth/login-callback', - LOGOUT: '/api/auth/logout', - REFRESH_TOKEN: '/api/auth/refresh-token', - JOBS: '/api/jobs', - FILES: '/api/files', - HEALTH: '/api/health', -} as const; diff --git a/shared/types/auth.ts b/shared/types/auth.ts deleted file mode 100644 index 926e324..0000000 --- a/shared/types/auth.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Authentication Types - Shared between frontend and backend - */ - -/** Token types supported by the system */ -export type TokenType = 'pb' | 'graph'; - -/** Stored token data in cache */ -export interface CachedToken { - token: string; - refresh_token?: string; - expiresAt: number; - userId: string; - source?: 'cache' | 'refreshed'; -} - -/** Token response from refresh endpoint */ -export interface TokenRefreshResponse { - token: string; - expiresAt: number; - source: 'cache' | 'refreshed'; - type: TokenType; -} - -/** User session data from PocketBase */ -export interface PocketBaseUser { - id: string; - email: string; - name: string; - avatar?: string; - verified: boolean; - created: string; - updated: string; -} - -/** Alias for PocketBaseUser */ -export type User = PocketBaseUser; - -/** Authentication state for frontend */ -export interface AuthState { - isAuthenticated: boolean; - user?: PocketBaseUser | null; - pbToken?: string | null; - graphToken?: string | null; - expiresAt?: number; -} - -/** Login callback request body */ -export interface LoginCallbackRequest { - pbToken: string; - graphToken: string; - refreshTokenPb?: string; - refreshTokenGraph?: string; - user: PocketBaseUser; -} - -/** Login callback response */ -export interface LoginCallbackResponse { - success: boolean; - user: PocketBaseUser; - expiresAt_pb: number; - expiresAt_graph: number; -} - -/** Logout response */ -export interface LogoutResponse { - success: boolean; -} - -/** Token validation result */ -export interface TokenValidation { - valid: boolean; - expired: boolean; - expiresIn: number; - userId?: string; -} diff --git a/shared/types/job.ts b/shared/types/job.ts deleted file mode 100644 index cb87b4e..0000000 --- a/shared/types/job.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Job Types - Shared between frontend and backend - */ - -/** Job status */ -export type JobStatus = 'active' | 'completed' | 'pending' | 'cancelled' | 'Active' | 'Completed' | 'Pending' | 'On Hold'; - -/** Job record from PocketBase */ -export interface Job { - id: string; - job_number: string; - job_name: string; - client_name: string; - status: JobStatus; - site_id?: string; - drive_id?: string; - folder_path?: string; - created: string; - updated: string; - // Computed/alias properties for frontend convenience - jobNumber?: string; - customer?: string; - address?: string; - createdAt?: string; - updatedAt?: string; - notes?: StickyNote[]; -} - -/** Job file from SharePoint/Graph API */ -export interface JobFile { - id: string; - name: string; - size: number; - mimeType: string; - webUrl: string; - downloadUrl?: string; - createdDateTime: string; - lastModifiedDateTime: string; - createdBy?: { - user: { - displayName: string; - email: string; - }; - }; - lastModifiedBy?: { - user: { - displayName: string; - email: string; - }; - }; -} - -/** Job files response */ -export interface JobFilesResponse { - jobId: string; - files: JobFile[]; - nextLink?: string; - cached: boolean; -} - -/** Sticky note for a job */ -export interface StickyNote { - id: string; - job_id: string; - content: string; - color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple'; - position_x?: number; - position_y?: number; - created: string; - updated: string; - created_by: string; - // Alias for frontend - createdAt?: string; -} - -/** Job list response */ -export interface JobListResponse { - jobs: Job[]; - page: number; - perPage: number; - totalItems: number; - totalPages: number; -} - -/** Job detail response */ -export interface JobDetailResponse { - job: Job; - files: JobFile[]; - notes: StickyNote[]; -} diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 5d83d6e..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,30 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - './frontend/public/**/*.html', - './frontend/src/**/*.{js,ts}', - ], - theme: { - extend: { - colors: { - // Brand colors - primary: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', - }, - }, - fontFamily: { - sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], - }, - }, - }, - plugins: [], -}; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 3cab6ee..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": ".", - "baseUrl": ".", - "paths": { - "@backend/*": ["./backend/src/*"], - "@frontend/*": ["./frontend/src/*"], - "@shared/*": ["./shared/*"] - }, - "types": ["bun-types"] - }, - "include": [ - "backend/src/**/*.ts", - "frontend/src/**/*.ts", - "shared/**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -}