commit 7c3d62d4a9651a93915d5fe94c6510b79756ba26 Author: aewing Date: Thu Jan 1 16:44:11 2026 +0000 Initial production setup with dual-token auth diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5690d66 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# 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/.gitignore b/.gitignore new file mode 100644 index 0000000..8d7020b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +*.log +logs/*.log +.DS_Store +bun.lockb +*.local +frontend/public/assets/output.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..4645a14 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# 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 new file mode 100644 index 0000000..4ca8909 --- /dev/null +++ b/backend/src/config/constants.ts @@ -0,0 +1,40 @@ +/** + * Application Constants + */ + +export const APP_NAME = 'Job Info'; +export const APP_VERSION = '2.0.0'; + +/** HTTP Status codes */ +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} as const; + +/** Error messages */ +export const ERROR_MESSAGES = { + UNAUTHORIZED: 'Authentication required', + TOKEN_EXPIRED: 'Token has expired', + TOKEN_INVALID: 'Invalid token', + TOKEN_REFRESH_FAILED: 'Failed to refresh token', + USER_NOT_FOUND: 'User not found', + JOB_NOT_FOUND: 'Job not found', + CACHE_ERROR: 'Cache operation failed', + POCKETBASE_ERROR: 'PocketBase operation failed', + GRAPH_API_ERROR: 'Microsoft Graph API error', + INTERNAL_ERROR: 'Internal server error', +} as const; + +/** Success messages */ +export const SUCCESS_MESSAGES = { + LOGIN_SUCCESS: 'Login successful', + LOGOUT_SUCCESS: 'Logout successful', + TOKEN_REFRESHED: 'Token refreshed successfully', +} as const; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..c4c956e --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,111 @@ +/** + * Environment Configuration + */ + +import { config } from 'dotenv'; + +// Load .env file +config(); + +interface EnvConfig { + PORT: number; + NODE_ENV: 'development' | 'production' | 'test'; + ALLOWED_ORIGINS: string[]; + + // Redis/Valkey + REDIS_HOST: string; + REDIS_PORT: number; + REDIS_PASSWORD: string; + + // PocketBase + POCKETBASE_URL: string; + PB_COLLECTION: string; + PB_AUTH_COLLECTION: string; + POCKETBASE_ADMIN_EMAIL: string; + POCKETBASE_ADMIN_PASSWORD: string; + + // Microsoft OAuth + MICROSOFT_CLIENT_ID: string; + MICROSOFT_CLIENT_SECRET: string; + MICROSOFT_TENANT: string; + MICROSOFT_REDIRECT_URI: string; + MS_GRAPH_TOKEN: string; + + // Microsoft Graph / SharePoint + DRIVE_ID: string; + ITEM_ID: string; + EXCEL_TABLE_NAME: string; + + // Logging + LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; + LOG_FILE: string; + ERROR_LOG_FILE: string; + + // Token settings + TOKEN_REFRESH_INTERVAL_MS: number; + TOKEN_EXPIRY_BUFFER_MS: number; +} + +function getEnv(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 new file mode 100644 index 0000000..f35d5b7 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,514 @@ +import { Hono } from 'hono'; +import { serveStatic } from 'hono/bun'; +import fs from 'fs'; +// @ts-ignore Bun project may not have dotenv types configured +import { config } from 'dotenv'; +import { getCache, setCache, isRedisConnected } from './redis-cache'; + +config(); + +const app = new Hono(); + +// Minimal log helpers +const logLine = (path: string, line: string) => { + try { + fs.appendFileSync(path, `${new Date().toISOString()} ${line}\n`); + } catch (err) { + console.error('Failed to write log:', err); + } +}; + +// Version endpoint sourced from package.json +app.get('/version', (c) => { + try { + const pkgRaw = fs.readFileSync('./package.json', 'utf-8'); + const pkg = JSON.parse(pkgRaw); + return c.json({ version: pkg.version || '0.0.0' }); + } catch (err) { + logLine('logs/error.log', `version endpoint error: ${(err as Error)?.message || String(err)}`); + return c.json({ version: '0.0.0' }, 200); + } +}); + +// Jobs endpoint with Redis caching +app.get('/api/jobs', async (c) => { + const page = parseInt(c.req.query('page') || '1'); + const perPage = parseInt(c.req.query('perPage') || '50'); + const sort = c.req.query('sort') || '-Job_Number'; + + const cacheKey = `jobs:page:${page}:perPage:${perPage}:sort:${sort}`; + const ttl = 300; // 5 minutes cache + + try { + // Try to get from cache first + if (isRedisConnected()) { + const cached = await getCache(cacheKey); + if (cached) { + logLine('logs/server.log', `Cache HIT for ${cacheKey}`); + return c.json(cached); + } + logLine('logs/server.log', `Cache MISS for ${cacheKey}`); + } + + // Fetch from PocketBase + const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=${sort}`; + const authHeader = c.req.header('Authorization') || ''; + + const response = await fetch(pbUrl, { + headers: authHeader ? { Authorization: authHeader } : {} + }); + + if (!response.ok) { + throw new Error(`PocketBase returned ${response.status}`); + } + + const data = await response.json(); + + // Cache the result + if (isRedisConnected()) { + await setCache(cacheKey, data, ttl); + logLine('logs/server.log', `Cached ${cacheKey} for ${ttl}s`); + } + + return c.json(data); + + } catch (err) { + logLine('logs/error.log', `jobs endpoint error: ${(err as Error)?.message || String(err)}`); + return c.json({ error: 'Failed to fetch jobs' }, 500); + } +}); + +// Fast endpoint: returns all jobs in a single cached request +app.get('/api/jobs-all', async (c) => { + const cacheKey = 'jobs:all'; + const ttl = 1800; // 30 minutes cache + try { + // Try cache first for instant response + if (isRedisConnected()) { + const cached = await getCache(cacheKey); + if (cached) { + logLine('logs/server.log', `Cache HIT for ${cacheKey} (${cached.items?.length || 0} jobs)`); + // Return cached data immediately + setImmediate(async () => { + try { + await refreshJobsCache(cacheKey, ttl, c.req.header('Authorization') || ''); + } catch (err) {} + }); + // For ultra-fast display, send only the first 40 jobs in the initial response + const fastBatch = Array.isArray(cached.items) ? cached.items.slice(0, 40) : []; + return c.json({ ...cached, items: fastBatch, partial: cached.items && cached.items.length > 40 }); + } else { + // No cache: return empty array instantly, trigger background fetch + setImmediate(async () => { + try { + const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || ''); + if (isRedisConnected()) { + await setCache(cacheKey, result, ttl); + logLine('logs/server.log', `Cached ${cacheKey} with ${result.items.length} jobs for ${ttl}s`); + } + } catch (err) { + logLine('logs/error.log', `jobs-all background fetch error: ${(err as Error)?.message || String(err)}`); + } + }); + return c.json({ items: [], partial: true }); + } + } + // Redis not connected: fallback to direct fetch (blocking) + const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || ''); + return c.json(result); + } catch (err) { + logLine('logs/error.log', `jobs-all endpoint error: ${(err as Error)?.message || String(err)}`); + return c.json({ error: 'Failed to fetch jobs' }, 500); + } +}); + +// Helper function to fetch all jobs from PocketBase +async function fetchAllJobsFromPocketBase(authHeader: string): Promise { + 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 token = getGraphToken(c); + if (!token) { + console.log('[job-files] No token found'); + return c.json({ error: 'GRAPH_TOKEN missing' }, 500); + } + console.log('[job-files] Token found:', token.substring(0, 20) + '...'); + const link = c.req.query('link'); + const q = c.req.query('q') || ''; + const category = c.req.query('category') || ''; + if (!link) return c.json({ error: 'link required' }, 400); + console.log('[job-files] Link:', link); + + const { driveId, itemId } = await resolveShareLink(link, token); + + let items: DriveItem[] = []; + if (q) { + items = await searchWithinFolder(driveId, itemId, q, token); + } else { + // Walk subfolders up to depth 5 + items = await listChildrenRecursive(driveId, itemId, 0, 5, token); + } + + // Filter to show only PDFs by default (can be disabled with pdfOnly=false query param) + const pdfOnly = c.req.query('pdfOnly') !== 'false'; + const filtered = filterByCategory(items, category, pdfOnly); + console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`); + + const mapped = filtered.map((i) => ({ + id: i.id, + name: i.name, + url: i.webUrl, + driveId: i.parentReference?.driveId || driveId, + size: i.size, + modified: i.lastModifiedDateTime, + contentType: i.file?.mimeType, + isFolder: Boolean(i.folder), + path: i.parentReference?.path || '', + })); + + return c.json({ items: mapped, total: mapped.length, source: q ? 'search' : 'walk', driveId }); + } catch (err) { + const message = (err as Error)?.message || String(err); + const status = (err as any)?.status || 500; + console.error('[job-files] ERROR:', message); + logLine('logs/error.log', `/api/job-files error: ${message}`); + return c.json({ error: message }, status); + } +}); + +// Stream file content via Graph to avoid CSP issues for inline preview +app.get('/api/job-file-content', async (c) => { + try { + const token = getGraphToken(c); + if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500); + const driveId = c.req.query('driveId'); + const itemId = c.req.query('itemId'); + if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400); + + const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const text = await res.text(); + logLine('logs/error.log', `/api/job-file-content failed: ${res.status} - ${text}`); + return c.json({ error: `Graph ${res.status}: ${text}` }, 502); + } + const headers: Record = {}; + const ct = res.headers.get('content-type'); + const cd = res.headers.get('content-disposition'); + if (ct) headers['Content-Type'] = ct; + if (cd) headers['Content-Disposition'] = cd; + return new Response(res.body, { status: 200, headers }); + } catch (err) { + const message = (err as Error)?.message || String(err); + const status = (err as any)?.status || 500; + logLine('logs/error.log', `/api/job-file-content error: ${message}`); + return c.json({ error: message }, status); + } +}); + +// Convert Word documents to PDF for display +app.get('/api/job-file-pdf', async (c) => { + try { + const token = getGraphToken(c); + if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500); + const driveId = c.req.query('driveId'); + const itemId = c.req.query('itemId'); + if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400); + + // Use Graph API to convert to PDF format + const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const text = await res.text(); + logLine('logs/error.log', `/api/job-file-pdf conversion failed: ${res.status} - ${text}`); + return c.json({ error: `PDF conversion failed: ${res.status}` }, 502); + } + const headers: Record = { + 'Content-Type': 'application/pdf', + }; + const cd = res.headers.get('content-disposition'); + if (cd) { + // Replace .docx/.doc extension with .pdf in filename + headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf'); + } + logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`); + return new Response(res.body, { status: 200, headers }); + } catch (err) { + const message = (err as Error)?.message || String(err); + const status = (err as any)?.status || 500; + logLine('logs/error.log', `/api/job-file-pdf error: ${message}`); + return c.json({ error: message }, status); + } +}); + +// Mutation logging endpoint +app.post('/log-change', async (c) => { + try { + const body = await c.req.json(); + const { action = 'unknown', user = 'unknown', target = '', detail = '' } = body || {}; + const payload = JSON.stringify({ action, user, target, detail }); + logLine('logs/changes.log', payload); + return c.json({ status: 'ok' }); + } catch (err) { + logLine('logs/error.log', `log-change error: ${(err as Error)?.message || String(err)}`); + return c.text('Bad Request', 400); + } +}); + +// Serve static files from the frontend directory (must come after API routes) +// Add cache headers for static assets +app.use('/assets/*', async (c, next) => { + await next(); + // Cache assets for 1 year + c.header('Cache-Control', 'public, max-age=31536000, immutable'); +}); +app.use('/*', serveStatic({ root: './frontend/public' })); + +// Error handler +app.onError((err, c) => { + logLine('logs/error.log', `Unhandled error: ${err?.message || err}`); + return c.text('Internal Server Error', 500); +}); + +// Default to 3005 for test instance; can be overridden by Environment=PORT +const PORT = Number(process.env.PORT || 3005); + +logLine('logs/server.log', `Starting frontend server (Redis version) on port ${PORT}`); + +// Warm up cache on startup +(async () => { + try { + if (isRedisConnected()) { + logLine('logs/server.log', 'Warming up jobs cache...'); + const result = await fetchAllJobsFromPocketBase(''); + await setCache('jobs:all', result, 1800); + logLine('logs/server.log', `✓ Cache warmed with ${result.items.length} jobs`); + } + } catch (err) { + logLine('logs/error.log', `Cache warmup failed: ${(err as Error)?.message || String(err)}`); + } +})(); + +// Background refresh every 5 minutes to keep cache hot +setInterval(async () => { + try { + if (isRedisConnected()) { + await refreshJobsCache('jobs:all', 1800, ''); + } + } catch (err) { + logLine('logs/error.log', `Periodic refresh error: ${(err as Error)?.message || String(err)}`); + } +}, 5 * 60 * 1000); // Every 5 minutes + +// Graceful shutdown logging +const shutdown = (signal: string) => { + logLine('logs/server.log', `Shutting down due to ${signal}`); + process.exit(0); +}; +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +export default { + port: PORT, + fetch: app.fetch, +}; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..f0d18f2 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,90 @@ +/** + * Auth Middleware - Token validation + */ + +import type { Context, Next } from 'hono'; +import { pocketbaseService } from '../services/pocketbaseService'; +import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants'; + +/** + * Validate auth token and attach user to context + */ +export async function authMiddleware(c: Context, next: Next): Promise { + 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 new file mode 100644 index 0000000..31869b5 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,84 @@ +/** + * Error Handler Middleware + */ + +import type { Context } from 'hono'; +import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants'; +import { env } from '../config/env'; + +/** Custom application error */ +export class AppError extends Error { + constructor( + public statusCode: number, + message: string, + public code?: string + ) { + super(message); + this.name = 'AppError'; + } +} + +/** Not found error */ +export class NotFoundError extends AppError { + constructor(resource: string = 'Resource') { + super(HTTP_STATUS.NOT_FOUND, `${resource} not found`, 'NOT_FOUND'); + } +} + +/** Unauthorized error */ +export class UnauthorizedError extends AppError { + constructor(message: string = ERROR_MESSAGES.UNAUTHORIZED) { + super(HTTP_STATUS.UNAUTHORIZED, message, 'UNAUTHORIZED'); + } +} + +/** Bad request error */ +export class BadRequestError extends AppError { + constructor(message: string) { + super(HTTP_STATUS.BAD_REQUEST, message, 'BAD_REQUEST'); + } +} + +/** + * Global error handler for Hono onError + */ +export function errorHandler(err: Error, c: Context): Response { + console.error('[ErrorHandler]', err); + + // Handle known app errors + if (err instanceof AppError) { + return c.json( + { + error: err.message, + code: err.code, + }, + err.statusCode as 400 | 401 | 403 | 404 | 500 + ); + } + + // Handle unknown errors + const message = env.NODE_ENV === 'production' + ? ERROR_MESSAGES.INTERNAL_ERROR + : err.message; + + return c.json( + { + error: message, + code: 'INTERNAL_ERROR', + }, + HTTP_STATUS.INTERNAL_SERVER_ERROR + ); +} + +/** + * Not found handler (for unmatched routes) + */ +export function notFoundHandler(c: Context): Response { + return c.json( + { + error: 'Endpoint not found', + code: 'NOT_FOUND', + }, + HTTP_STATUS.NOT_FOUND + ); +} diff --git a/backend/src/middleware/logger.ts b/backend/src/middleware/logger.ts new file mode 100644 index 0000000..c0618d0 --- /dev/null +++ b/backend/src/middleware/logger.ts @@ -0,0 +1,91 @@ +/** + * Logger Middleware - Request logging + */ + +import type { Context, Next } from 'hono'; +import { env } from '../config/env'; + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVELS: Record = { + 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 new file mode 100644 index 0000000..f6463a2 --- /dev/null +++ b/backend/src/redis-cache.ts @@ -0,0 +1,101 @@ +import Redis from 'ioredis'; + +// Create Redis client +const redis = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT || 6379), + password: process.env.REDIS_PASSWORD, + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + maxRetriesPerRequest: 3, + lazyConnect: true, +}); + +// Handle connection events +redis.on('connect', () => { + console.log('✓ Redis connected'); +}); + +redis.on('error', (err) => { + console.error('Redis error:', err.message); +}); + +redis.on('close', () => { + console.log('Redis connection closed'); +}); + +// Connect to Redis +(async () => { + try { + await redis.connect(); + } catch (err) { + console.error('Failed to connect to Redis:', err); + } +})(); + +/** + * Get a value from cache + */ +export async function getCache(key: string): Promise { + 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 new file mode 100644 index 0000000..b32120a --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,107 @@ +/** + * Auth Routes - Login, logout, token refresh + */ + +import { Hono } from 'hono'; +import { tokenService } from '../services/tokenService'; +import { authMiddleware, getUserId } from '../middleware/auth'; +import { BadRequestError } from '../middleware/errorHandler'; +import type { LoginCallbackRequest, TokenType } from '@shared/types/auth'; +import { HTTP_STATUS } from '../config/constants'; + +const auth = new Hono(); + +/** + * POST /api/auth/login-callback + * Called after OAuth flow to store tokens in cache + */ +auth.post('/login-callback', async (c) => { + const body = await c.req.json(); + + 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 new file mode 100644 index 0000000..cc77b07 --- /dev/null +++ b/backend/src/routes/files.ts @@ -0,0 +1,124 @@ +/** + * Files Routes - SharePoint file operations + * + * Note: Most file operations go through /api/jobs/:id/files + * This route provides additional file utilities + */ + +import { Hono } from 'hono'; +import { graphService } from '../services/graphService'; +import { tokenService } from '../services/tokenService'; +import { pocketbaseService } from '../services/pocketbaseService'; +import { authMiddleware, getUserId } from '../middleware/auth'; +import { BadRequestError, NotFoundError } from '../middleware/errorHandler'; + +const files = new Hono(); + +// All file routes require authentication +files.use('/*', authMiddleware); + +/** + * GET /api/files + * List files in a job's folder - requires jobId query param + */ +files.get('/', async (c) => { + const userId = getUserId(c); + const jobId = c.req.query('jobId'); + + if (!jobId) { + throw new BadRequestError('jobId query parameter required'); + } + + // Get job to find site/drive info + const job = await pocketbaseService.getJob(jobId); + if (!job) { + throw new NotFoundError('Job not found'); + } + + if (!job.site_id || !job.drive_id) { + return c.json({ files: [] }); + } + + const tokenResult = await tokenService.getToken(userId, 'graph'); + if (!tokenResult) { + throw new BadRequestError('Graph token not available'); + } + + const result = await graphService.getFiles( + tokenResult.token, + job.site_id, + job.drive_id, + job.folder_path + ); + + return c.json({ files: result.files }); +}); + +/** + * GET /api/files/download-url + * Get download URL for a file - requires jobId and fileId + */ +files.get('/download-url', async (c) => { + const userId = getUserId(c); + const jobId = c.req.query('jobId'); + const fileId = c.req.query('fileId'); + + if (!jobId || !fileId) { + throw new BadRequestError('jobId and fileId query parameters required'); + } + + // Get job to find site/drive info + const job = await pocketbaseService.getJob(jobId); + if (!job || !job.site_id || !job.drive_id) { + throw new NotFoundError('Job not found or not configured for files'); + } + + const tokenResult = await tokenService.getToken(userId, 'graph'); + if (!tokenResult) { + throw new BadRequestError('Graph token not available'); + } + + const url = await graphService.getFileDownloadUrl( + tokenResult.token, + job.site_id, + job.drive_id, + fileId + ); + + if (!url) { + throw new NotFoundError('File not found'); + } + + return c.json({ url }); +}); + +/** + * GET /api/files/search + * Search files in a job's SharePoint site + */ +files.get('/search', async (c) => { + const userId = getUserId(c); + const jobId = c.req.query('jobId'); + const query = c.req.query('q'); + + if (!jobId || !query) { + throw new BadRequestError('jobId and q query parameters required'); + } + + // Get job to find site info + const job = await pocketbaseService.getJob(jobId); + if (!job || !job.site_id) { + throw new NotFoundError('Job not found or not configured for files'); + } + + const tokenResult = await tokenService.getToken(userId, 'graph'); + if (!tokenResult) { + throw new BadRequestError('Graph token not available'); + } + + const results = await graphService.searchFiles(tokenResult.token, job.site_id, query); + + return c.json({ files: results }); +}); + +export default files; diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000..fbee8b9 --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,63 @@ +/** + * Health Routes - Server health check + */ + +import { Hono } from 'hono'; +import { cacheService } from '../services/cacheService'; + +const health = new Hono(); + +/** + * GET /api/health + * Basic health check + */ +health.get('/', (c) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }); +}); + +/** + * GET /api/health/ready + * Readiness check (includes cache connection) + */ +health.get('/ready', async (c) => { + const checks: Record = { + 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 new file mode 100644 index 0000000..34bb6a6 --- /dev/null +++ b/backend/src/routes/index.ts @@ -0,0 +1,20 @@ +/** + * Routes Index - Register all routes + */ + +import { Hono } from 'hono'; +import auth from './auth'; +import jobs from './jobs'; +import files from './files'; +import health from './health'; + +export function registerRoutes(app: Hono): void { + // Health routes (no /api prefix for k8s probes) + app.route('/health', health); + app.route('/api/health', health); + + // API routes + app.route('/api/auth', auth); + app.route('/api/jobs', jobs); + app.route('/api/files', files); +} diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts new file mode 100644 index 0000000..15d71f1 --- /dev/null +++ b/backend/src/routes/jobs.ts @@ -0,0 +1,122 @@ +/** + * Jobs Routes - Job listing and details + */ + +import { Hono } from 'hono'; +import { jobsService } from '../services/jobsService'; +import { authMiddleware, getUserId } from '../middleware/auth'; +import { BadRequestError, NotFoundError } from '../middleware/errorHandler'; + +const jobs = new Hono(); + +// All job routes require authentication +jobs.use('/*', authMiddleware); + +/** + * GET /api/jobs + * List all jobs for the user + */ +jobs.get('/', async (c) => { + const page = parseInt(c.req.query('page') || '1'); + const limit = parseInt(c.req.query('limit') || '50'); + + const result = await jobsService.getJobs({ page, perPage: limit }); + + return c.json(result); +}); + +/** + * GET /api/jobs/search + * Search jobs by query + */ +jobs.get('/search', async (c) => { + const query = c.req.query('q'); + + if (!query) { + throw new BadRequestError('Search query required'); + } + + const jobs = await jobsService.searchJobs(query); + + return c.json({ jobs, page: 1, perPage: jobs.length, totalItems: jobs.length, totalPages: 1 }); +}); + +/** + * GET /api/jobs/:id + * Get single job with all details + */ +jobs.get('/:id', async (c) => { + const userId = getUserId(c); + const jobId = c.req.param('id'); + + const result = await jobsService.getJobDetail(jobId, userId); + + if (!result) { + throw new NotFoundError('Job not found'); + } + + return c.json(result); +}); + +/** + * GET /api/jobs/:id/files + * Get files for a job from SharePoint + */ +jobs.get('/:id/files', async (c) => { + const userId = getUserId(c); + const jobId = c.req.param('id'); + + const result = await jobsService.getJobFiles(jobId, userId); + + return c.json({ files: result?.files ?? [] }); +}); + +/** + * POST /api/jobs/:id/notes + * Create a sticky note on a job + */ +jobs.post('/:id/notes', async (c) => { + const userId = getUserId(c); + const jobId = c.req.param('id'); + const body = await c.req.json<{ content: string; color?: string }>(); + + if (!body.content) { + throw new BadRequestError('Note content required'); + } + + const note = await jobsService.createNote( + jobId, + body.content, + userId, + { color: body.color as 'yellow' | 'blue' | 'green' | 'pink' | 'purple' | undefined } + ); + + return c.json({ note }); +}); + +/** + * PUT /api/jobs/:id/notes/:noteId + * Update a sticky note + */ +jobs.put('/:id/notes/:noteId', async (c) => { + const noteId = c.req.param('noteId'); + const body = await c.req.json<{ content?: string; color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple' }>(); + + const note = await jobsService.updateNote(noteId, body); + + return c.json({ note }); +}); + +/** + * DELETE /api/jobs/:id/notes/:noteId + * Delete a sticky note + */ +jobs.delete('/:id/notes/:noteId', async (c) => { + const noteId = c.req.param('noteId'); + + await jobsService.deleteNote(noteId); + + return c.json({ success: true }); +}); + +export default jobs; diff --git a/backend/src/services/cacheService.ts b/backend/src/services/cacheService.ts new file mode 100644 index 0000000..7817f6f --- /dev/null +++ b/backend/src/services/cacheService.ts @@ -0,0 +1,181 @@ +/** + * Cache Service - Valkey/Redis operations + */ + +import Redis from 'ioredis'; +import { env } from '../config/env'; + +class CacheService { + private client: Redis | null = null; + private isConnected = false; + + /** + * Initialize Redis connection + */ + async connect(): Promise { + 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 new file mode 100644 index 0000000..0d87004 --- /dev/null +++ b/backend/src/services/graphService.ts @@ -0,0 +1,226 @@ +/** + * Graph Service - Microsoft Graph API integration + */ + +import { env } from '../config/env'; +import type { JobFile } from '@shared/types/job'; + +interface GraphTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; +} + +interface GraphDriveItem { + id: string; + name: string; + size: number; + file?: { + mimeType: string; + }; + webUrl: string; + '@microsoft.graph.downloadUrl'?: string; + createdDateTime: string; + lastModifiedDateTime: string; + createdBy?: { + user: { + displayName: string; + email: string; + }; + }; + lastModifiedBy?: { + user: { + displayName: string; + email: string; + }; + }; +} + +interface GraphListResponse { + value: GraphDriveItem[]; + '@odata.nextLink'?: string; +} + +class GraphService { + private readonly tokenEndpoint: string; + private readonly graphBaseUrl = 'https://graph.microsoft.com/v1.0'; + + constructor() { + this.tokenEndpoint = `https://login.microsoftonline.com/${env.MICROSOFT_TENANT}/oauth2/v2.0/token`; + } + + /** + * Refresh access token using refresh token + */ + async refreshToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { + const params = new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + client_secret: env.MICROSOFT_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: 'refresh_token', + scope: 'offline_access Files.Read Sites.Read.All User.Read', + }); + + const response = await fetch(this.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + console.error('[Graph] Token refresh failed:', error); + throw new Error(`Token refresh failed: ${response.status}`); + } + + const data: GraphTokenResponse = await response.json(); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + }; + } + + /** + * Get files from a SharePoint drive folder + */ + async getFiles( + accessToken: string, + siteId: string, + driveId: string, + folderPath?: string + ): Promise<{ files: JobFile[]; nextLink?: string }> { + let endpoint: string; + + if (folderPath) { + endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root:/${folderPath}:/children`; + } else { + endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root/children`; + } + + const response = await fetch(endpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error = await response.text(); + console.error('[Graph] Get files failed:', error); + throw new Error(`Get files failed: ${response.status}`); + } + + const data: GraphListResponse = await response.json(); + + const files: JobFile[] = data.value + .filter(item => item.file) // Only files, not folders + .map(item => ({ + id: item.id, + name: item.name, + size: item.size, + mimeType: item.file?.mimeType ?? 'application/octet-stream', + webUrl: item.webUrl, + downloadUrl: item['@microsoft.graph.downloadUrl'], + createdDateTime: item.createdDateTime, + lastModifiedDateTime: item.lastModifiedDateTime, + createdBy: item.createdBy, + lastModifiedBy: item.lastModifiedBy, + })); + + return { + files, + nextLink: data['@odata.nextLink'], + }; + } + + /** + * Get file content/download URL + */ + async getFileDownloadUrl( + accessToken: string, + siteId: string, + driveId: string, + itemId: string + ): Promise { + 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 new file mode 100644 index 0000000..f906989 --- /dev/null +++ b/backend/src/services/jobsService.ts @@ -0,0 +1,165 @@ +/** + * Jobs Service - Business logic for jobs + */ + +import { pocketbaseService } from './pocketbaseService'; +import { graphService } from './graphService'; +import { cacheService } from './cacheService'; +import { tokenService } from './tokenService'; +import { CACHE_KEYS } from '@shared/constants'; +import type { Job, JobFile, StickyNote, JobListResponse, JobDetailResponse, JobFilesResponse } from '@shared/types/job'; + +/** Cache TTL for job files (5 minutes) */ +const FILES_CACHE_TTL = 300; + +class JobsService { + /** + * Get list of all jobs + */ + async getJobs(options?: { + page?: number; + perPage?: number; + filter?: string; + sort?: string; + }): Promise { + 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 new file mode 100644 index 0000000..9ee9e82 --- /dev/null +++ b/backend/src/services/pocketbaseService.ts @@ -0,0 +1,177 @@ +/** + * PocketBase Service - PocketBase integration + */ + +import PocketBase from 'pocketbase'; +import { env } from '../config/env'; +import type { PocketBaseUser } from '@shared/types/auth'; +import type { Job, StickyNote } from '@shared/types/job'; + +class PocketBaseService { + private client: PocketBase; + + constructor() { + this.client = new PocketBase(env.POCKETBASE_URL); + } + + /** + * Get PocketBase client instance + */ + get pb(): PocketBase { + return this.client; + } + + /** + * Authenticate with token + */ + async authenticateWithToken(token: string): Promise { + 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 new file mode 100644 index 0000000..7ca82f9 --- /dev/null +++ b/backend/src/services/tokenService.ts @@ -0,0 +1,271 @@ +/** + * Token Service - Token refresh and management + */ + +import { cacheService } from './cacheService'; +import { pocketbaseService } from './pocketbaseService'; +import { graphService } from './graphService'; +import { CACHE_KEYS, TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS, TOKEN_REFRESH_INTERVAL_MS } from '@shared/constants'; +import type { CachedToken, TokenType, TokenRefreshResponse } from '@shared/types/auth'; +import { env } from '../config/env'; + +class TokenService { + private refreshLoops = new Map(); + 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 new file mode 100644 index 0000000..7d09b06 --- /dev/null +++ b/bun.lock @@ -0,0 +1,70 @@ +{ + "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/public/assets/CCwhiteApp.png b/frontend/public/assets/CCwhiteApp.png new file mode 100644 index 0000000..e4ef4f1 Binary files /dev/null and b/frontend/public/assets/CCwhiteApp.png differ diff --git a/frontend/public/assets/DailyCheck.png b/frontend/public/assets/DailyCheck.png new file mode 100644 index 0000000..200eba4 Binary files /dev/null and b/frontend/public/assets/DailyCheck.png differ diff --git a/frontend/public/assets/PalmIsland.png b/frontend/public/assets/PalmIsland.png new file mode 100644 index 0000000..e31ab51 Binary files /dev/null and b/frontend/public/assets/PalmIsland.png differ diff --git a/frontend/public/assets/lightbulb.jpg b/frontend/public/assets/lightbulb.jpg new file mode 100644 index 0000000..f75425e Binary files /dev/null and b/frontend/public/assets/lightbulb.jpg differ diff --git a/frontend/public/auth.js b/frontend/public/auth.js new file mode 100644 index 0000000..7967cb8 --- /dev/null +++ b/frontend/public/auth.js @@ -0,0 +1,181 @@ +// 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 new file mode 100644 index 0000000..d71aa1d --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,2924 @@ + + + + + +Job Info — Vanilla JS + + + + + + + + + + + + +
+
+
+
+ CC +
+
+ +
+

Job Info

+ +
+
+
+ +
+ + +
+ + + + + +
+
+ +
+ + v1.0.0-beta2 +
+
+
+ + + + + + + + + + diff --git a/frontend/public/output.css b/frontend/public/output.css new file mode 100644 index 0000000..971f4ad --- /dev/null +++ b/frontend/public/output.css @@ -0,0 +1,5 @@ +/* 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 new file mode 100644 index 0000000..737ede9 --- /dev/null +++ b/frontend/public/signin.html @@ -0,0 +1,176 @@ + + + + + + Sign In - Job Info + + + + +
+

Job Info

+

Sign in with your Microsoft account

+ + + + + + +
+ + + + diff --git a/images/apple.svg b/images/apple.svg new file mode 100644 index 0000000..de4b0d1 --- /dev/null +++ b/images/apple.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/images/google.svg b/images/google.svg new file mode 100644 index 0000000..2eaf915 --- /dev/null +++ b/images/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa1a96a --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "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 new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/shared/constants/index.ts b/shared/constants/index.ts new file mode 100644 index 0000000..bf63984 --- /dev/null +++ b/shared/constants/index.ts @@ -0,0 +1,48 @@ +/** + * 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 new file mode 100644 index 0000000..926e324 --- /dev/null +++ b/shared/types/auth.ts @@ -0,0 +1,76 @@ +/** + * 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 new file mode 100644 index 0000000..cb87b4e --- /dev/null +++ b/shared/types/job.ts @@ -0,0 +1,90 @@ +/** + * 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 new file mode 100644 index 0000000..5d83d6e --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,30 @@ +/** @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 new file mode 100644 index 0000000..3cab6ee --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,39 @@ +{ + "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" + ] +}