Clean up: keep only Mode6Test and ModeSwitch folders

This commit is contained in:
2026-01-20 19:38:19 +00:00
parent 7de75b0596
commit a3576bce4b
46 changed files with 0 additions and 9942 deletions
-28
View File
@@ -1,28 +0,0 @@
# Server
PORT=3005
NODE_ENV=development
# Redis/Valkey
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# PocketBase
POCKETBASE_URL=https://pocketbase.ccllc.pro
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
# Microsoft OAuth
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
MICROSOFT_TENANT=common
MICROSOFT_REDIRECT_URI=http://localhost:3005/api/auth/callback
# Logging
LOG_LEVEL=info
LOG_FILE=logs/app.log
ERROR_LOG_FILE=logs/error.log
# Token Settings
TOKEN_REFRESH_INTERVAL_MS=1800000
TOKEN_EXPIRY_BUFFER_MS=900000
-76
View File
@@ -1,76 +0,0 @@
# Session Changelog - January 1, 2026
## Production Setup Session
### What Was Done
1. **Backend Setup**
- Copied working `server.ts` from Job-Info-Test
- Added `redis-cache.ts` for Valkey/Redis caching
- Fixed static file path to serve from `frontend/public`
- Fast job loading: returns first 40 jobs instantly, then full list on poll
2. **Frontend Setup**
- Copied working frontend from Job-Info-Test to `frontend/public/`
- Simplified token validation at startup:
- Check PocketBase session valid
- Check Graph token in localStorage
- If either missing → redirect to signin
- Removed redundant token refresh loops
- Added localStorage storage for Graph token in signin.html
3. **Authentication Flow**
- PocketBase OAuth with Microsoft provider
- Graph token stored in localStorage after signin
- Both tokens checked once at page load
- No repeated token validation during app usage
4. **Systemd Service**
- Created `/etc/systemd/system/job-info-prod.service`
- Runs on port 3005
- Auto-restarts on failure
5. **Git Repository**
- Initialized and pushed to: https://gitea.ccllc.pro/Cardoza_Construction/Job-Info-Prod.git
- Branch: main
### Key Files
| File | Purpose |
|------|---------|
| `backend/src/index.ts` | Main Hono server with all endpoints |
| `backend/src/redis-cache.ts` | Redis/Valkey cache helpers |
| `frontend/public/index.html` | Main app UI |
| `frontend/public/signin.html` | OAuth signin page (purple styling) |
| `frontend/public/auth.js` | PocketBase auth bootstrap |
### Endpoints
- `GET /` - Serve frontend
- `GET /version` - App version
- `GET /api/health` - Health check
- `GET /api/jobs-all` - All jobs (first 40 fast, then full)
- `GET /api/jobs` - Paginated jobs
- `GET /api/job-files` - SharePoint files via Graph API
- `GET /api/job-file-content` - File content/download
- `GET /api/job-file-pdf` - PDF proxy
- `POST /log-change` - Log changes
### Configuration
- Port: 3005
- PocketBase: https://pocketbase.ccllc.pro
- Collection: pbc_1407612689
- Redis/Valkey: localhost:6379
### Known Limitations
- Graph tokens expire after ~60 minutes (Microsoft limitation)
- No automatic Graph token refresh (would require direct OAuth flow)
- User must re-signin when Graph token expires
### Next Steps
- Consider implementing direct Microsoft OAuth for refresh token support
- Add error boundaries for better UX on token expiry
- Performance monitoring
-106
View File
@@ -1,106 +0,0 @@
# Job-Info-Prod
Production-ready job information management system for field workers.
## Features
- **Dual-Token Authentication**: PocketBase + Microsoft Graph tokens
- **Always-Warm Cache**: Background refresh loop keeps tokens fresh
- **Offline-First**: Works without internet using cached data
- **Zero Re-Auth**: Users never need to login again after initial auth
## Tech Stack
- **Runtime**: Bun
- **Backend**: Hono
- **Auth**: PocketBase + Microsoft OAuth
- **Cache**: Redis/Valkey via ioredis
- **Frontend**: TypeScript + Tailwind CSS
## Quick Start
```bash
# Install dependencies
bun install
# Copy environment file
cp .env.example .env
# Edit .env with your configuration
nano .env
# Start development server
bun run dev
```
## Environment Variables
See `.env.example` for all required variables:
- `PORT` - Server port (default: 3000)
- `POCKETBASE_URL` - PocketBase instance URL
- `MS_CLIENT_ID` - Microsoft OAuth client ID
- `MS_CLIENT_SECRET` - Microsoft OAuth client secret
- `MS_TENANT_ID` - Microsoft tenant ID
- `REDIS_HOST` - Redis/Valkey host
- `REDIS_PORT` - Redis/Valkey port
## Architecture
```
┌─────────────────┐ ┌──────────────────┐
│ Frontend │────▶│ Hono Backend │
│ (TypeScript) │◀────│ │
└─────────────────┘ └────────┬─────────┘
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PocketBase │ │ Redis/Valkey │ │ Microsoft Graph │
│ (Auth+Data) │ │ (Token Cache) │ │ (SharePoint) │
└───────────────┘ └─────────────────┘ └─────────────────┘
```
## Token Refresh Flow
1. User completes OAuth flow → tokens stored in localStorage + cache
2. Backend starts 30-min refresh loop per user
3. Frontend checks backend for fresh tokens every 25 min
4. If backend unreachable, frontend uses localStorage cache
5. Stale tokens returned as fallback (field workers can't wait)
## Scripts
- `bun run dev` - Start development server with watch
- `bun run start` - Start production server
- `bun run build:css` - Build Tailwind CSS
- `bun run typecheck` - Run TypeScript type checking
- `bun run test` - Run tests
## Folder Structure
```
Job-Info-Prod/
├── backend/
│ └── src/
│ ├── config/ # Environment and constants
│ ├── middleware/ # Auth, logging, error handling
│ ├── routes/ # API route handlers
│ ├── services/ # Business logic
│ └── index.ts # Server entry point
├── frontend/
│ ├── public/ # Static HTML files
│ └── src/
│ ├── auth/ # Auth client
│ ├── components/ # UI components
│ ├── services/ # API clients
│ ├── styles/ # Tailwind CSS
│ └── app.ts # Main app entry
└── shared/
├── constants/ # Shared constants
└── types/ # TypeScript types
```
## License
MIT
-40
View File
@@ -1,40 +0,0 @@
/**
* Application Constants
*/
export const APP_NAME = 'Job Info';
export const APP_VERSION = '2.0.0';
/** HTTP Status codes */
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
} as const;
/** Error messages */
export const ERROR_MESSAGES = {
UNAUTHORIZED: 'Authentication required',
TOKEN_EXPIRED: 'Token has expired',
TOKEN_INVALID: 'Invalid token',
TOKEN_REFRESH_FAILED: 'Failed to refresh token',
USER_NOT_FOUND: 'User not found',
JOB_NOT_FOUND: 'Job not found',
CACHE_ERROR: 'Cache operation failed',
POCKETBASE_ERROR: 'PocketBase operation failed',
GRAPH_API_ERROR: 'Microsoft Graph API error',
INTERNAL_ERROR: 'Internal server error',
} as const;
/** Success messages */
export const SUCCESS_MESSAGES = {
LOGIN_SUCCESS: 'Login successful',
LOGOUT_SUCCESS: 'Logout successful',
TOKEN_REFRESHED: 'Token refreshed successfully',
} as const;
-111
View File
@@ -1,111 +0,0 @@
/**
* Environment Configuration
*/
import { config } from 'dotenv';
// Load .env file
config();
interface EnvConfig {
PORT: number;
NODE_ENV: 'development' | 'production' | 'test';
ALLOWED_ORIGINS: string[];
// Redis/Valkey
REDIS_HOST: string;
REDIS_PORT: number;
REDIS_PASSWORD: string;
// PocketBase
POCKETBASE_URL: string;
PB_COLLECTION: string;
PB_AUTH_COLLECTION: string;
POCKETBASE_ADMIN_EMAIL: string;
POCKETBASE_ADMIN_PASSWORD: string;
// Microsoft OAuth
MICROSOFT_CLIENT_ID: string;
MICROSOFT_CLIENT_SECRET: string;
MICROSOFT_TENANT: string;
MICROSOFT_REDIRECT_URI: string;
MS_GRAPH_TOKEN: string;
// Microsoft Graph / SharePoint
DRIVE_ID: string;
ITEM_ID: string;
EXCEL_TABLE_NAME: string;
// Logging
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
LOG_FILE: string;
ERROR_LOG_FILE: string;
// Token settings
TOKEN_REFRESH_INTERVAL_MS: number;
TOKEN_EXPIRY_BUFFER_MS: number;
}
function getEnv<T extends string | number>(key: string, defaultValue: T): T {
const value = process.env[key];
if (value === undefined) return defaultValue;
if (typeof defaultValue === 'number') {
return parseInt(value, 10) as T;
}
return value as T;
}
function getEnvArray(key: string, defaultValue: string[]): string[] {
const value = process.env[key];
if (!value) return defaultValue;
return value.split(',').map(s => s.trim());
}
export const env: EnvConfig = {
PORT: getEnv('PORT', 3005),
NODE_ENV: getEnv('NODE_ENV', 'development') as EnvConfig['NODE_ENV'],
ALLOWED_ORIGINS: getEnvArray('ALLOWED_ORIGINS', ['http://localhost:3005', 'http://localhost:5173']),
REDIS_HOST: getEnv('REDIS_HOST', 'localhost'),
REDIS_PORT: getEnv('REDIS_PORT', 6379),
REDIS_PASSWORD: getEnv('REDIS_PASSWORD', ''),
POCKETBASE_URL: getEnv('POCKETBASE_URL', 'https://pocketbase.ccllc.pro'),
PB_COLLECTION: getEnv('PB_COLLECTION', 'Job_Info_Prod'),
PB_AUTH_COLLECTION: getEnv('PB_AUTH_COLLECTION', 'Users'),
POCKETBASE_ADMIN_EMAIL: getEnv('POCKETBASE_ADMIN_EMAIL', ''),
POCKETBASE_ADMIN_PASSWORD: getEnv('POCKETBASE_ADMIN_PASSWORD', ''),
MICROSOFT_CLIENT_ID: getEnv('MICROSOFT_CLIENT_ID', ''),
MICROSOFT_CLIENT_SECRET: getEnv('MICROSOFT_CLIENT_SECRET', ''),
MICROSOFT_TENANT: getEnv('MICROSOFT_TENANT', '3fd97ea7-b124-41f1-855f-52d8ac3b16c7'),
MICROSOFT_REDIRECT_URI: getEnv('MICROSOFT_REDIRECT_URI', 'https://pocketbase.ccllc.pro/api/oauth2-redirect'),
MS_GRAPH_TOKEN: getEnv('MS_GRAPH_TOKEN', ''),
DRIVE_ID: getEnv('DRIVE_ID', ''),
ITEM_ID: getEnv('ITEM_ID', ''),
EXCEL_TABLE_NAME: getEnv('EXCEL_TABLE_NAME', 'Job_List'),
LOG_LEVEL: getEnv('LOG_LEVEL', 'info') as EnvConfig['LOG_LEVEL'],
LOG_FILE: getEnv('LOG_FILE', 'logs/app.log'),
ERROR_LOG_FILE: getEnv('ERROR_LOG_FILE', 'logs/error.log'),
TOKEN_REFRESH_INTERVAL_MS: getEnv('TOKEN_REFRESH_INTERVAL_MS', 1800000),
TOKEN_EXPIRY_BUFFER_MS: getEnv('TOKEN_EXPIRY_BUFFER_MS', 900000),
};
export function validateEnv(): void {
const required = [
'POCKETBASE_URL',
'MICROSOFT_CLIENT_ID',
'MICROSOFT_CLIENT_SECRET',
'MICROSOFT_TENANT',
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.warn(`Warning: Missing environment variables: ${missing.join(', ')}`);
}
}
-905
View File
@@ -1,905 +0,0 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import fs from 'fs';
// @ts-ignore Bun project may not have dotenv types configured
import { config } from 'dotenv';
import { getCache, setCache, isRedisConnected } from './redis-cache';
config();
const app = new Hono();
// Service Principal token cache
let appOnlyToken: string | null = null;
let appOnlyTokenExpiry: number = 0;
// Get app-only token using client credentials flow
async function getAppOnlyToken(): Promise<string | null> {
try {
// Check if we have a valid cached token
if (appOnlyToken && appOnlyTokenExpiry > Date.now()) {
return appOnlyToken;
}
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
const tenant = process.env.MICROSOFT_TENANT || 'common';
if (!clientId || !clientSecret) {
return null; // Service principal not configured
}
const tokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`;
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials'
});
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
logLine('logs/error.log', `Failed to get app-only token: ${error}`);
return null;
}
const data = await response.json() as any;
appOnlyToken = data.access_token;
// Set expiry to 55 minutes (token is valid for 60 minutes)
appOnlyTokenExpiry = Date.now() + (55 * 60 * 1000);
logLine('logs/server.log', `✓ Got app-only token (expires in 55 minutes)`);
return appOnlyToken;
} catch (err) {
logLine('logs/error.log', `getAppOnlyToken error: ${(err as Error)?.message || String(err)}`);
return null;
}
}
// Minimal log helpers
const logLine = (path: string, line: string) => {
try {
fs.appendFileSync(path, `${new Date().toISOString()} ${line}\n`);
} catch (err) {
console.error('Failed to write log:', err);
}
};
// Version endpoint sourced from package.json
app.get('/version', (c) => {
try {
const pkgRaw = fs.readFileSync('./package.json', 'utf-8');
const pkg = JSON.parse(pkgRaw);
return c.json({ version: pkg.version || '0.0.0' });
} catch (err) {
logLine('logs/error.log', `version endpoint error: ${(err as Error)?.message || String(err)}`);
return c.json({ version: '0.0.0' }, 200);
}
});
// Jobs endpoint with Redis caching
app.get('/api/jobs', async (c) => {
const page = parseInt(c.req.query('page') || '1');
const perPage = parseInt(c.req.query('perPage') || '50');
const sort = c.req.query('sort') || '-Job_Number';
const cacheKey = `jobs:page:${page}:perPage:${perPage}:sort:${sort}`;
const ttl = 300; // 5 minutes cache
try {
// Try to get from cache first
if (isRedisConnected()) {
const cached = await getCache(cacheKey);
if (cached) {
logLine('logs/server.log', `Cache HIT for ${cacheKey}`);
return c.json(cached);
}
logLine('logs/server.log', `Cache MISS for ${cacheKey}`);
}
// Fetch from PocketBase
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=${sort}`;
const authHeader = c.req.header('Authorization') || '';
const response = await fetch(pbUrl, {
headers: authHeader ? { Authorization: authHeader } : {}
});
if (!response.ok) {
throw new Error(`PocketBase returned ${response.status}`);
}
const data = await response.json();
// Cache the result
if (isRedisConnected()) {
await setCache(cacheKey, data, ttl);
logLine('logs/server.log', `Cached ${cacheKey} for ${ttl}s`);
}
return c.json(data);
} catch (err) {
logLine('logs/error.log', `jobs endpoint error: ${(err as Error)?.message || String(err)}`);
return c.json({ error: 'Failed to fetch jobs' }, 500);
}
});
// Fast endpoint: returns all jobs in a single cached request
app.get('/api/jobs-all', async (c) => {
const cacheKey = 'jobs:all';
const ttl = 1800; // 30 minutes cache
try {
// Try cache first for instant response
if (isRedisConnected()) {
const cached = await getCache(cacheKey);
if (cached) {
// Return first 100 jobs immediately for instant display, rest loads in background
const fastBatch = Array.isArray(cached.items) ? cached.items.slice(0, 100) : [];
const hasMore = cached.items && cached.items.length > 100;
logLine('logs/server.log', `Cache HIT for ${cacheKey} - sending ${fastBatch.length} jobs (${hasMore ? 'partial' : 'complete'})`);
// Refresh cache in background
setImmediate(async () => {
try {
await refreshJobsCache(cacheKey, ttl, c.req.header('Authorization') || '');
} catch (err) {}
});
return c.json({
page: 1,
perPage: fastBatch.length,
totalItems: cached.totalItems || fastBatch.length,
totalPages: 1,
items: fastBatch,
partial: hasMore
});
} else {
// No cache: return empty array instantly, trigger background fetch
setImmediate(async () => {
try {
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
if (isRedisConnected()) {
await setCache(cacheKey, result, ttl);
logLine('logs/server.log', `Cached ${cacheKey} with ${result.items.length} jobs for ${ttl}s`);
}
} catch (err) {
logLine('logs/error.log', `jobs-all background fetch error: ${(err as Error)?.message || String(err)}`);
}
});
return c.json({ items: [], partial: true });
}
}
// Redis not connected: fallback to direct fetch (blocking)
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
return c.json(result);
} catch (err) {
logLine('logs/error.log', `jobs-all endpoint error: ${(err as Error)?.message || String(err)}`);
return c.json({ error: 'Failed to fetch jobs' }, 500);
}
});
// Get remaining jobs after initial batch
app.get('/api/jobs-remaining', async (c) => {
const cacheKey = 'jobs:all';
try {
if (isRedisConnected()) {
const cached = await getCache(cacheKey);
if (cached && cached.items && cached.items.length > 100) {
const remaining = cached.items.slice(100);
return c.json({
items: remaining,
totalItems: cached.totalItems || cached.items.length
});
}
}
return c.json({ items: [] });
} catch (err) {
logLine('logs/error.log', `jobs-remaining error: ${(err as Error)?.message || String(err)}`);
return c.json({ error: 'Failed to fetch remaining jobs' }, 500);
}
});
// Helper function to fetch all jobs from PocketBase
async function fetchAllJobsFromPocketBase(authHeader: string): Promise<any> {
const allItems: any[] = [];
let page = 1;
const perPage = 500;
while (true) {
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
const response = await fetch(pbUrl, {
headers: authHeader ? { Authorization: authHeader } : {}
});
if (!response.ok) {
throw new Error(`PocketBase returned ${response.status}`);
}
const data = await response.json();
const items = data.items || [];
if (items.length === 0) break;
allItems.push(...items);
if (allItems.length >= (data.totalItems || 0)) break;
page++;
}
return {
page: 1,
perPage: allItems.length,
totalItems: allItems.length,
totalPages: 1,
items: allItems
};
}
// Helper function to refresh jobs cache in background
async function refreshJobsCache(cacheKey: string, ttl: number, authHeader: string): Promise<void> {
try {
const result = await fetchAllJobsFromPocketBase(authHeader);
await setCache(cacheKey, result, ttl);
logLine('logs/server.log', `Background refresh: cached ${cacheKey} with ${result.items.length} jobs`);
} catch (err) {
logLine('logs/error.log', `Background refresh error: ${(err as Error)?.message || String(err)}`);
}
}
// --- Microsoft Graph helpers ---
const getGraphToken = (c?: any) => {
const headerToken = c?.req?.header ? c.req.header('x-graph-token') : '';
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
const token = headerToken || envToken;
console.log('[getGraphToken] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE');
console.log('[getGraphToken] Env token:', envToken ? 'SET' : 'NOT SET');
console.log('[getGraphToken] Using:', token ? token.substring(0, 20) + '...' : 'NONE');
return token;
};
const b64Url = (input: string) =>
Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
type DriveItem = {
id: string;
name: string;
webUrl?: string;
size?: number;
lastModifiedDateTime?: string;
file?: { mimeType?: string };
folder?: { childCount?: number };
parentReference?: { path?: string; driveId?: string };
};
const fetchJson = async (url: string, token: string) => {
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
});
if (!res.ok) {
const text = await res.text();
const err: any = new Error(`Graph ${res.status}: ${text}`);
err.status = res.status;
throw err;
}
return res.json();
};
const resolveShareLink = async (link: string, token: string) => {
// Decode the link in case it comes pre-encoded from the database
const decodedLink = decodeURIComponent(link);
console.log('[resolveShareLink] Decoded link:', decodedLink);
// Check if this is a short sharing link (/:f:/ or /:b:/) or a direct document library URL
if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) {
// Short sharing link - use shares API
const encoded = `u!${b64Url(decodedLink)}`;
console.log('[resolveShareLink] Using shares API, encoded:', encoded);
const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token);
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
} else {
// Direct document library URL - parse it and use drive API
// Example: https://czflex.sharepoint.com/sites/Team/Shared Documents/Forms/AllItems.aspx?RootFolder=/sites/Team/Shared Documents/General/...
const url = new URL(decodedLink);
const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/);
if (!pathMatch) throw new Error('Invalid SharePoint URL');
const hostname = pathMatch[1]; // e.g., 'czflex'
const sitePath = url.pathname.split('/Shared')[0]; // e.g., /sites/Team
const rootFolderParam = url.searchParams.get('RootFolder');
if (!rootFolderParam) throw new Error('RootFolder parameter missing');
// Extract the folder path relative to the document library
// RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/...
// We need: General/Operations [Server]/...
const folderPath = rootFolderParam.split('/Shared Documents/')[1];
if (!folderPath) throw new Error('Could not parse folder path');
console.log('[resolveShareLink] Hostname:', hostname);
console.log('[resolveShareLink] Site path:', sitePath);
console.log('[resolveShareLink] Folder path:', folderPath);
// Get the site ID first using hostname:sitePath format
const siteData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${hostname}.sharepoint.com:${sitePath}`, token);
const siteId = siteData.id;
// Get the default document library drive
const driveData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, token);
const driveId = driveData.id;
// Get the folder item by path
const itemData = await fetchJson(
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${folderPath}`,
token
);
console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id);
return { driveId, itemId: itemData.id };
}
};
const listChildrenRecursive = async (
driveId: string,
itemId: string,
depth = 0,
maxDepth = 5,
token: string,
): Promise<DriveItem[]> => {
const out: DriveItem[] = [];
const data = await fetchJson(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/children`, token);
for (const child of data.value || []) {
out.push(child as DriveItem);
if (child.folder && depth < maxDepth) {
const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token);
out.push(...nested);
}
}
return out;
};
const searchWithinFolder = async (driveId: string, itemId: string, query: string, token: string): Promise<DriveItem[]> => {
const data = await fetchJson(
`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/search(q='${encodeURIComponent(query)}')`,
token,
);
return (data.value || []) as DriveItem[];
};
const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolean = true) => {
// First filter: show PDF files and Word documents (which will be converted to PDF)
let filtered = items;
if (pdfOnly) {
filtered = items.filter((i) => {
if (i.folder) return false;
const name = i.name.toLowerCase();
const mimeType = i.file?.mimeType?.toLowerCase() || '';
// Include PDFs, Word documents, and images
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
name.endsWith('.webp') || mimeType.includes('image');
});
}
// Second filter: category filtering (if specified)
if (!category) return filtered;
const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w));
const lc = (s: string) => (s || '').toLowerCase();
const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'];
const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'];
return filtered.filter((i) => {
const n = lc(i.name);
if (category === 'contracts') return nameHas(n, contractWords);
if (category === 'plans') return nameHas(n, planWords);
return true;
});
};
// List/search job files via Graph using a shared folder link
app.get('/api/job-files', async (c) => {
try {
console.log('[job-files] Request received');
const link = c.req.query('link');
if (!link) return c.json({ error: 'link required' }, 400);
console.log('[job-files] Link:', link);
const q = c.req.query('q') || '';
const category = c.req.query('category') || '';
// Check cache first (only for non-search, non-category requests)
if (!q && !category && isRedisConnected()) {
const cacheKey = `job-files:${link}`;
const cached = await getCache(cacheKey);
if (cached) {
console.log('[job-files] Cache HIT for', link);
// Apply filtering if requested
const pdfOnly = c.req.query('pdfOnly') !== 'false';
const items = cached.items || [];
const filtered = pdfOnly
? items.filter((i: any) => {
if (i.isFolder) return false;
const name = (i.name || '').toLowerCase();
const mimeType = (i.mimeType || '').toLowerCase();
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
name.endsWith('.webp') || mimeType.includes('image');
})
: items;
const mapped = filtered.map((i: any) => ({
id: i.id,
name: i.name,
url: i.webUrl,
driveId: i.driveId,
size: i.size,
modified: i.lastModifiedDateTime,
contentType: i.mimeType,
isFolder: i.isFolder,
path: i.parentPath || '',
}));
return c.json({ items: mapped, total: mapped.length, source: 'cache', driveId: cached.driveId });
}
console.log('[job-files] Cache MISS for', link);
}
// Not in cache or search/category request - fetch from Graph API
const token = getGraphToken(c);
if (!token) {
console.log('[job-files] No token found');
return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
}
console.log('[job-files] Token found:', token.substring(0, 20) + '...');
const { driveId, itemId } = await resolveShareLink(link, token);
let items: DriveItem[] = [];
if (q) {
items = await searchWithinFolder(driveId, itemId, q, token);
} else {
// Walk subfolders up to depth 5
items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
}
// Filter to show only PDFs by default (can be disabled with pdfOnly=false query param)
const pdfOnly = c.req.query('pdfOnly') !== 'false';
const filtered = filterByCategory(items, category, pdfOnly);
console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`);
const mapped = filtered.map((i) => ({
id: i.id,
name: i.name,
url: i.webUrl,
driveId: i.parentReference?.driveId || driveId,
size: i.size,
modified: i.lastModifiedDateTime,
contentType: i.file?.mimeType,
isFolder: Boolean(i.folder),
path: i.parentReference?.path || '',
}));
return c.json({ items: mapped, total: mapped.length, source: q ? 'search' : 'walk', driveId });
} catch (err) {
const message = (err as Error)?.message || String(err);
const status = (err as any)?.status || 500;
console.error('[job-files] ERROR:', message);
logLine('logs/error.log', `/api/job-files error: ${message}`);
return c.json({ error: message }, status);
}
});
// Stream file content via Graph to avoid CSP issues for inline preview
app.get('/api/job-file-content', async (c) => {
try {
const token = getGraphToken(c);
if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
const driveId = c.req.query('driveId');
const itemId = c.req.query('itemId');
if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400);
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text();
logLine('logs/error.log', `/api/job-file-content failed: ${res.status} - ${text}`);
return c.json({ error: `Graph ${res.status}: ${text}` }, 502);
}
const headers: Record<string, string> = {};
const ct = res.headers.get('content-type');
const cd = res.headers.get('content-disposition');
if (ct) headers['Content-Type'] = ct;
if (cd) headers['Content-Disposition'] = cd;
return new Response(res.body, { status: 200, headers });
} catch (err) {
const message = (err as Error)?.message || String(err);
const status = (err as any)?.status || 500;
logLine('logs/error.log', `/api/job-file-content error: ${message}`);
return c.json({ error: message }, status);
}
});
// Convert Word documents to PDF for display
app.get('/api/job-file-pdf', async (c) => {
try {
const token = getGraphToken(c);
if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
const driveId = c.req.query('driveId');
const itemId = c.req.query('itemId');
if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400);
// Check cache first
const cacheKey = `job-file-pdf:${driveId}:${itemId}`;
if (isRedisConnected()) {
const cached = await getCache(cacheKey);
if (cached && cached.data) {
logLine('logs/server.log', `PDF cache HIT: ${driveId}:${itemId}`);
const buffer = Buffer.from(cached.data, 'base64');
const headers: Record<string, string> = {
'Content-Type': 'application/pdf',
};
if (cached.filename) {
headers['Content-Disposition'] = `inline; filename="${cached.filename}"`;
}
return new Response(buffer, { status: 200, headers });
} else {
logLine('logs/server.log', `PDF cache MISS: ${driveId}:${itemId}`);
}
}
// Use Graph API to convert to PDF format
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text();
logLine('logs/error.log', `/api/job-file-pdf conversion failed: ${res.status} - ${text}`);
return c.json({ error: `PDF conversion failed: ${res.status}` }, 502);
}
const pdfBuffer = Buffer.from(await res.arrayBuffer());
const headers: Record<string, string> = {
'Content-Type': 'application/pdf',
};
const cd = res.headers.get('content-disposition');
let filename = 'document.pdf';
if (cd) {
// Replace .docx/.doc extension with .pdf in filename
headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf');
const match = cd.match(/filename="?([^"]+)"?/);
if (match) filename = match[1].replace(/\.(docx?)/gi, '.pdf');
}
// Cache the converted PDF for 30 minutes
if (isRedisConnected()) {
await setCache(cacheKey, {
data: pdfBuffer.toString('base64'),
filename
}, 1800);
}
logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`);
return new Response(pdfBuffer, { status: 200, headers });
} catch (err) {
const message = (err as Error)?.message || String(err);
const status = (err as any)?.status || 500;
logLine('logs/error.log', `/api/job-file-pdf error: ${message}`);
return c.json({ error: message }, status);
}
});
// Mutation logging endpoint
app.post('/log-change', async (c) => {
try {
const body = await c.req.json();
const { action = 'unknown', user = 'unknown', target = '', detail = '' } = body || {};
const payload = JSON.stringify({ action, user, target, detail });
logLine('logs/changes.log', payload);
return c.json({ status: 'ok' });
} catch (err) {
logLine('logs/error.log', `log-change error: ${(err as Error)?.message || String(err)}`);
return c.text('Bad Request', 400);
}
});
// Serve static files from the frontend directory (must come after API routes)
// Add cache headers for static assets
app.use('/assets/*', async (c, next) => {
await next();
// Cache assets for 1 year
c.header('Cache-Control', 'public, max-age=31536000, immutable');
});
app.use('/*', serveStatic({ root: './frontend/public' }));
// Error handler
app.onError((err, c) => {
logLine('logs/error.log', `Unhandled error: ${err?.message || err}`);
return c.text('Internal Server Error', 500);
});
// Default to 3005 for test instance; can be overridden by Environment=PORT
const PORT = Number(process.env.PORT || 3005);
logLine('logs/server.log', `Starting frontend server (Redis version) on port ${PORT}`);
// Warm up cache on startup - wait for Redis to be ready
(async () => {
// Wait for Redis to be ready (max 10 seconds)
let retries = 0;
while (!isRedisConnected() && retries < 20) {
await new Promise(resolve => setTimeout(resolve, 500));
retries++;
}
if (!isRedisConnected()) {
logLine('logs/error.log', 'Redis not connected after 10 seconds, skipping cache warmup');
return;
}
try {
logLine('logs/server.log', 'Warming up jobs cache...');
const result = await fetchAllJobsFromPocketBase('');
await setCache('jobs:all', result, 1800);
logLine('logs/server.log', `✓ Cache warmed with ${result.items.length} total jobs`);
// Try to start file caching if service principal is configured
const token = await getAppOnlyToken();
if (token) {
logLine('logs/server.log', 'Service principal authenticated, starting background file caching...');
// Cache files for ALL jobs with folder links
startFileCaching(result.items, token);
} else {
logLine('logs/server.log', 'Service principal not configured, file caching will occur on-demand');
}
} catch (err) {
logLine('logs/error.log', `Cache warmup failed: ${(err as Error)?.message || String(err)}`);
}
})();
// Background file caching state
let fileCachingActive = false;
let fileCachingIndex = 0;
let fileCachingJobs: any[] = [];
let fileCachingToken: string = '';
let fileCachingTimer: NodeJS.Timeout | null = null;
async function startFileCaching(jobs: any[], token: string) {
if (fileCachingActive) return;
fileCachingJobs = jobs;
if (fileCachingJobs.length === 0) {
logLine('logs/server.log', 'No jobs to cache');
return;
}
fileCachingActive = true;
fileCachingIndex = 0;
fileCachingToken = token;
logLine('logs/server.log', `Starting background file caching for ${fileCachingJobs.length} jobs...`);
cacheNextFileBatch();
}
async function cacheNextFileBatch() {
if (!fileCachingActive || !isRedisConnected()) return;
const batchSize = 10; // Cache 10 jobs at a time for faster initial coverage
const maxJobs = fileCachingJobs.length;
if (fileCachingIndex >= maxJobs) {
// Completed initial full cache of all jobs
console.log(`✓ Completed caching all ${maxJobs} jobs. Scheduling refresh in 10 minutes...`);
logLine('logs/server.log', `✓ Completed caching all ${maxJobs} jobs. Scheduling refresh in 10 minutes...`);
fileCachingIndex = 0;
// After completing all jobs, wait 10 minutes before refreshing
fileCachingTimer = setTimeout(async () => {
try {
// Refresh the jobs cache first
await refreshJobsCache('jobs:all', 1800, '');
// Get updated job list from cache
const cached = await getCache('jobs:all');
if (cached && cached.items) {
fileCachingJobs = cached.items;
console.log(`Starting refresh cycle: ${fileCachingJobs.length} jobs to update`);
logLine('logs/server.log', `Starting refresh cycle: ${fileCachingJobs.length} jobs to update`);
}
// Refresh token if needed
const newToken = await getAppOnlyToken();
if (newToken) {
fileCachingToken = newToken;
cacheNextFileBatch();
} else {
console.error('Failed to refresh app-only token, stopping file caching');
logLine('logs/error.log', 'Failed to refresh app-only token, stopping file caching');
fileCachingActive = false;
}
} catch (err) {
console.error(`File cache cycle restart error: ${(err as Error)?.message}`);
logLine('logs/error.log', `File cache cycle restart error: ${(err as Error)?.message}`);
}
}, 10 * 60 * 1000);
return;
}
// Get next batch of jobs to cache
const batch = fileCachingJobs.slice(fileCachingIndex, fileCachingIndex + batchSize);
const jobNumbers = batch.map(j => j.Job_Number).join(', ');
const batchMsg = `[Cache Batch] Processing jobs: ${jobNumbers} (${fileCachingIndex}/${maxJobs})`;
console.log(batchMsg);
logLine('logs/server.log', batchMsg);
for (const job of batch) {
try {
await cacheJobFiles(job, fileCachingToken);
} catch (err) {
// Silently continue on errors
logLine('logs/error.log', `File cache error for job ${job.Job_Number}: ${(err as Error)?.message}`);
}
}
fileCachingIndex += batchSize;
// Continue with next batch after a short delay (1 second between batches for faster throughput)
fileCachingTimer = setTimeout(() => cacheNextFileBatch(), 1000);
}
async function cacheJobFiles(job: any, token: string) {
const link = job.Job_Folder_Link;
if (!link) {
// Job has no folder link, just mark it as cached
const cacheKey = `job-files:${job.Job_Number}`;
await setCache(cacheKey, { items: [] }, 1800);
console.log(`✓ No folder for job ${job.Job_Number}`);
logLine('logs/server.log', `✓ No folder for job ${job.Job_Number}`);
return;
}
const cacheKey = `job-files:${link}`;
const ttl = 1800; // 30 minutes
try {
const { driveId, itemId } = await resolveShareLink(link, token);
const items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
const data = {
driveId,
items: items.map(item => ({
id: item.id,
name: item.name,
webUrl: item.webUrl,
size: item.size,
lastModifiedDateTime: item.lastModifiedDateTime,
mimeType: item.file?.mimeType,
isFolder: !!item.folder,
parentPath: item.parentReference?.path,
driveId: item.parentReference?.driveId || driveId
})),
cachedAt: Date.now()
};
await setCache(cacheKey, data, ttl);
// Pre-cache PDF conversions for Word/Excel documents AND native PDF files
const officeFiles = items.filter(item => {
const name = (item.name || '').toLowerCase();
return name.endsWith('.docx') || name.endsWith('.doc') || name.endsWith('.xlsx') || name.endsWith('.xls');
});
const pdfFiles = items.filter(item => {
const name = (item.name || '').toLowerCase();
return name.endsWith('.pdf');
});
let pdfsCached = 0;
// Cache converted Office documents
for (const file of officeFiles) {
try {
const fileDriveId = file.parentReference?.driveId || driveId;
const pdfCacheKey = `job-file-pdf:${fileDriveId}:${file.id}`;
// Check if already cached
const existing = await getCache(pdfCacheKey);
if (existing) {
pdfsCached++;
continue;
}
// Convert and cache
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${fileDriveId}/items/${file.id}/content?format=pdf`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const pdfBuffer = Buffer.from(await res.arrayBuffer());
const cd = res.headers.get('content-disposition');
let filename = file.name.replace(/\.(docx?|xlsx?)$/gi, '.pdf');
if (cd) {
const match = cd.match(/filename="?([^"]+)"?/);
if (match) filename = match[1].replace(/\.(docx?|xlsx?)$/gi, '.pdf');
}
await setCache(pdfCacheKey, {
data: pdfBuffer.toString('base64'),
filename
}, ttl);
pdfsCached++;
}
} catch (err) {
// Silently skip PDF caching errors
}
}
// Cache native PDF files
for (const file of pdfFiles) {
try {
const fileDriveId = file.parentReference?.driveId || driveId;
const pdfCacheKey = `job-file-pdf:${fileDriveId}:${file.id}`;
// Check if already cached
const existing = await getCache(pdfCacheKey);
if (existing) {
pdfsCached++;
continue;
}
// Fetch and cache native PDF
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${fileDriveId}/items/${file.id}/content`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const pdfBuffer = Buffer.from(await res.arrayBuffer());
await setCache(pdfCacheKey, {
data: pdfBuffer.toString('base64'),
filename: file.name
}, ttl);
pdfsCached++;
}
} catch (err) {
// Silently skip PDF caching errors
}
}
const totalPDFs = officeFiles.length + pdfFiles.length;
const msg = `✓ Cached ${items.length} files${totalPDFs > 0 ? ` + ${pdfsCached}/${totalPDFs} PDFs` : ''} for job ${job.Job_Number}`;
console.log(msg);
logLine('logs/server.log', msg);
} catch (err: any) {
// Only log if not a token expiration issue
if (!err.message?.includes('401') && !err.message?.includes('token')) {
logLine('logs/error.log', `File cache skip for job ${job.Job_Number}: ${err.message}`);
}
}
}
// Graceful shutdown logging
const shutdown = (signal: string) => {
logLine('logs/server.log', `Shutting down due to ${signal}`);
if (fileCachingTimer) clearTimeout(fileCachingTimer);
process.exit(0);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
export default {
port: PORT,
fetch: app.fetch,
};
-90
View File
@@ -1,90 +0,0 @@
/**
* Auth Middleware - Token validation
*/
import type { Context, Next } from 'hono';
import { pocketbaseService } from '../services/pocketbaseService';
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
/**
* Validate auth token and attach user to context
*/
export async function authMiddleware(c: Context, next: Next): Promise<Response | void> {
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json(
{ error: ERROR_MESSAGES.UNAUTHORIZED },
HTTP_STATUS.UNAUTHORIZED
);
}
const token = authHeader.slice(7); // Remove 'Bearer '
try {
// Validate token with PocketBase
const user = await pocketbaseService.authenticateWithToken(token);
if (!user) {
return c.json(
{ error: ERROR_MESSAGES.TOKEN_INVALID },
HTTP_STATUS.UNAUTHORIZED
);
}
// Attach user info to context
c.set('userId', user.id);
c.set('user', user);
c.set('token', token);
await next();
} catch (err) {
console.error('[AuthMiddleware] Token validation failed:', err);
return c.json(
{ error: ERROR_MESSAGES.TOKEN_INVALID },
HTTP_STATUS.UNAUTHORIZED
);
}
}
/**
* Optional auth - doesn't fail if no token, but attaches user if present
*/
export async function optionalAuthMiddleware(c: Context, next: Next): Promise<Response | void> {
const authHeader = c.req.header('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
try {
const user = await pocketbaseService.authenticateWithToken(token);
if (user) {
c.set('userId', user.id);
c.set('user', user);
c.set('token', token);
}
} catch {
// Silently continue without auth
}
}
await next();
}
/**
* Get user ID from context (for use after auth middleware)
*/
export function getUserId(c: Context): string {
const userId = c.get('userId');
if (!userId) {
throw new Error('User ID not found in context - auth middleware not applied?');
}
return userId as string;
}
/**
* Get user from context
*/
export function getUser(c: Context): unknown {
return c.get('user');
}
-84
View File
@@ -1,84 +0,0 @@
/**
* Error Handler Middleware
*/
import type { Context } from 'hono';
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
import { env } from '../config/env';
/** Custom application error */
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
this.name = 'AppError';
}
}
/** Not found error */
export class NotFoundError extends AppError {
constructor(resource: string = 'Resource') {
super(HTTP_STATUS.NOT_FOUND, `${resource} not found`, 'NOT_FOUND');
}
}
/** Unauthorized error */
export class UnauthorizedError extends AppError {
constructor(message: string = ERROR_MESSAGES.UNAUTHORIZED) {
super(HTTP_STATUS.UNAUTHORIZED, message, 'UNAUTHORIZED');
}
}
/** Bad request error */
export class BadRequestError extends AppError {
constructor(message: string) {
super(HTTP_STATUS.BAD_REQUEST, message, 'BAD_REQUEST');
}
}
/**
* Global error handler for Hono onError
*/
export function errorHandler(err: Error, c: Context): Response {
console.error('[ErrorHandler]', err);
// Handle known app errors
if (err instanceof AppError) {
return c.json(
{
error: err.message,
code: err.code,
},
err.statusCode as 400 | 401 | 403 | 404 | 500
);
}
// Handle unknown errors
const message = env.NODE_ENV === 'production'
? ERROR_MESSAGES.INTERNAL_ERROR
: err.message;
return c.json(
{
error: message,
code: 'INTERNAL_ERROR',
},
HTTP_STATUS.INTERNAL_SERVER_ERROR
);
}
/**
* Not found handler (for unmatched routes)
*/
export function notFoundHandler(c: Context): Response {
return c.json(
{
error: 'Endpoint not found',
code: 'NOT_FOUND',
},
HTTP_STATUS.NOT_FOUND
);
}
-91
View File
@@ -1,91 +0,0 @@
/**
* Logger Middleware - Request logging
*/
import type { Context, Next } from 'hono';
import { env } from '../config/env';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/**
* Get current log level from env
*/
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[env.LOG_LEVEL];
}
/**
* Format log message
*/
function formatLog(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
const timestamp = new Date().toISOString();
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
}
/**
* Logger utility
*/
export const logger = {
debug(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('debug')) {
console.log(formatLog('debug', message, meta));
}
},
info(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('info')) {
console.log(formatLog('info', message, meta));
}
},
warn(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('warn')) {
console.warn(formatLog('warn', message, meta));
}
},
error(message: string, meta?: Record<string, unknown>): void {
if (shouldLog('error')) {
console.error(formatLog('error', message, meta));
}
},
};
/**
* Request logging middleware
*/
export async function loggerMiddleware(c: Context, next: Next): Promise<void> {
const method = c.req.method;
const path = c.req.path;
// Skip logging for health check endpoints (UptimeKuma polling)
const skipLogging = path === '/version' || path === '/api/health';
const start = Date.now();
const requestId = crypto.randomUUID().slice(0, 8);
// Attach request ID to context
c.set('requestId', requestId);
if (!skipLogging) {
logger.info(`${method} ${path}`, { requestId });
}
await next();
if (!skipLogging) {
const duration = Date.now() - start;
const status = c.res.status;
const logFn = status >= 500 ? logger.error : status >= 400 ? logger.warn : logger.info;
logFn(`${method} ${path} ${status} ${duration}ms`, { requestId });
}
}
-101
View File
@@ -1,101 +0,0 @@
import Redis from 'ioredis';
// Create Redis client
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
lazyConnect: true,
});
// Handle connection events
redis.on('connect', () => {
console.log('✓ Redis connected');
});
redis.on('error', (err) => {
console.error('Redis error:', err.message);
});
redis.on('close', () => {
console.log('Redis connection closed');
});
// Connect to Redis
(async () => {
try {
await redis.connect();
} catch (err) {
console.error('Failed to connect to Redis:', err);
}
})();
/**
* Get a value from cache
*/
export async function getCache(key: string): Promise<any | null> {
try {
const value = await redis.get(key);
if (!value) return null;
return JSON.parse(value);
} catch (err) {
console.error(`Cache get error for key "${key}":`, err);
return null;
}
}
/**
* Set a value in cache with TTL in seconds
*/
export async function setCache(key: string, value: any, ttl: number = 300): Promise<boolean> {
try {
const serialized = JSON.stringify(value);
await redis.setex(key, ttl, serialized);
return true;
} catch (err) {
console.error(`Cache set error for key "${key}":`, err);
return false;
}
}
/**
* Delete a key from cache
*/
export async function deleteCache(key: string): Promise<boolean> {
try {
await redis.del(key);
return true;
} catch (err) {
console.error(`Cache delete error for key "${key}":`, err);
return false;
}
}
/**
* Clear all cache keys matching a pattern
*/
export async function clearCachePattern(pattern: string): Promise<number> {
try {
const keys = await redis.keys(pattern);
if (keys.length === 0) return 0;
await redis.del(...keys);
return keys.length;
} catch (err) {
console.error(`Cache clear error for pattern "${pattern}":`, err);
return 0;
}
}
/**
* Check if Redis is connected
*/
export function isRedisConnected(): boolean {
return redis.status === 'ready';
}
export default redis;
-107
View File
@@ -1,107 +0,0 @@
/**
* Auth Routes - Login, logout, token refresh
*/
import { Hono } from 'hono';
import { tokenService } from '../services/tokenService';
import { authMiddleware, getUserId } from '../middleware/auth';
import { BadRequestError } from '../middleware/errorHandler';
import type { LoginCallbackRequest, TokenType } from '@shared/types/auth';
import { HTTP_STATUS } from '../config/constants';
const auth = new Hono();
/**
* POST /api/auth/login-callback
* Called after OAuth flow to store tokens in cache
*/
auth.post('/login-callback', async (c) => {
const body = await c.req.json<LoginCallbackRequest>();
if (!body.pbToken || !body.graphToken || !body.user) {
throw new BadRequestError('Missing required token data');
}
// Store tokens in cache
await tokenService.storeTokens(
body.user.id,
body.pbToken,
body.graphToken,
body.refreshTokenPb ?? '',
body.refreshTokenGraph ?? ''
);
// Start background refresh loop
tokenService.startRefreshLoop(body.user.id);
const expiresAt = Date.now() + 3600 * 1000; // 1 hour
return c.json({
success: true,
user: body.user,
expiresAt_pb: expiresAt,
expiresAt_graph: expiresAt,
});
});
/**
* POST /api/auth/logout
* Clear tokens and stop refresh loop
*/
auth.post('/logout', authMiddleware, async (c) => {
const userId = getUserId(c);
await tokenService.clearUserTokens(userId);
return c.json({ success: true });
});
/**
* GET /api/refresh-token
* Get fresh token from cache or refresh
*/
auth.get('/refresh-token', authMiddleware, async (c) => {
const userId = getUserId(c);
const type = c.req.query('type') as TokenType | undefined;
if (!type || !['pb', 'graph'].includes(type)) {
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
}
const result = await tokenService.getToken(userId, type);
if (!result) {
return c.json(
{ error: 'Token not available' },
HTTP_STATUS.UNAUTHORIZED
);
}
return c.json(result);
});
/**
* POST /api/refresh-token
* Alternative POST endpoint for token refresh
*/
auth.post('/refresh-token', authMiddleware, async (c) => {
const userId = getUserId(c);
const body = await c.req.json<{ type: TokenType }>();
if (!body.type || !['pb', 'graph'].includes(body.type)) {
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
}
const result = await tokenService.getToken(userId, body.type);
if (!result) {
return c.json(
{ error: 'Token not available' },
HTTP_STATUS.UNAUTHORIZED
);
}
return c.json(result);
});
export default auth;
-124
View File
@@ -1,124 +0,0 @@
/**
* Files Routes - SharePoint file operations
*
* Note: Most file operations go through /api/jobs/:id/files
* This route provides additional file utilities
*/
import { Hono } from 'hono';
import { graphService } from '../services/graphService';
import { tokenService } from '../services/tokenService';
import { pocketbaseService } from '../services/pocketbaseService';
import { authMiddleware, getUserId } from '../middleware/auth';
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
const files = new Hono();
// All file routes require authentication
files.use('/*', authMiddleware);
/**
* GET /api/files
* List files in a job's folder - requires jobId query param
*/
files.get('/', async (c) => {
const userId = getUserId(c);
const jobId = c.req.query('jobId');
if (!jobId) {
throw new BadRequestError('jobId query parameter required');
}
// Get job to find site/drive info
const job = await pocketbaseService.getJob(jobId);
if (!job) {
throw new NotFoundError('Job not found');
}
if (!job.site_id || !job.drive_id) {
return c.json({ files: [] });
}
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
throw new BadRequestError('Graph token not available');
}
const result = await graphService.getFiles(
tokenResult.token,
job.site_id,
job.drive_id,
job.folder_path
);
return c.json({ files: result.files });
});
/**
* GET /api/files/download-url
* Get download URL for a file - requires jobId and fileId
*/
files.get('/download-url', async (c) => {
const userId = getUserId(c);
const jobId = c.req.query('jobId');
const fileId = c.req.query('fileId');
if (!jobId || !fileId) {
throw new BadRequestError('jobId and fileId query parameters required');
}
// Get job to find site/drive info
const job = await pocketbaseService.getJob(jobId);
if (!job || !job.site_id || !job.drive_id) {
throw new NotFoundError('Job not found or not configured for files');
}
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
throw new BadRequestError('Graph token not available');
}
const url = await graphService.getFileDownloadUrl(
tokenResult.token,
job.site_id,
job.drive_id,
fileId
);
if (!url) {
throw new NotFoundError('File not found');
}
return c.json({ url });
});
/**
* GET /api/files/search
* Search files in a job's SharePoint site
*/
files.get('/search', async (c) => {
const userId = getUserId(c);
const jobId = c.req.query('jobId');
const query = c.req.query('q');
if (!jobId || !query) {
throw new BadRequestError('jobId and q query parameters required');
}
// Get job to find site info
const job = await pocketbaseService.getJob(jobId);
if (!job || !job.site_id) {
throw new NotFoundError('Job not found or not configured for files');
}
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
throw new BadRequestError('Graph token not available');
}
const results = await graphService.searchFiles(tokenResult.token, job.site_id, query);
return c.json({ files: results });
});
export default files;
-63
View File
@@ -1,63 +0,0 @@
/**
* Health Routes - Server health check
*/
import { Hono } from 'hono';
import { cacheService } from '../services/cacheService';
const health = new Hono();
/**
* GET /api/health
* Basic health check
*/
health.get('/', (c) => {
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});
/**
* GET /api/health/ready
* Readiness check (includes cache connection)
*/
health.get('/ready', async (c) => {
const checks: Record<string, boolean> = {
server: true,
cache: false,
};
// Check cache connection
try {
await cacheService.get('health-check');
checks.cache = true;
} catch {
checks.cache = false;
}
const allHealthy = Object.values(checks).every(Boolean);
return c.json(
{
status: allHealthy ? 'ready' : 'degraded',
checks,
timestamp: new Date().toISOString(),
},
allHealthy ? 200 : 503
);
});
/**
* GET /api/health/live
* Liveness check (just confirms server is running)
*/
health.get('/live', (c) => {
return c.json({
status: 'alive',
timestamp: new Date().toISOString(),
});
});
export default health;
-20
View File
@@ -1,20 +0,0 @@
/**
* Routes Index - Register all routes
*/
import { Hono } from 'hono';
import auth from './auth';
import jobs from './jobs';
import files from './files';
import health from './health';
export function registerRoutes(app: Hono): void {
// Health routes (no /api prefix for k8s probes)
app.route('/health', health);
app.route('/api/health', health);
// API routes
app.route('/api/auth', auth);
app.route('/api/jobs', jobs);
app.route('/api/files', files);
}
-122
View File
@@ -1,122 +0,0 @@
/**
* Jobs Routes - Job listing and details
*/
import { Hono } from 'hono';
import { jobsService } from '../services/jobsService';
import { authMiddleware, getUserId } from '../middleware/auth';
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
const jobs = new Hono();
// All job routes require authentication
jobs.use('/*', authMiddleware);
/**
* GET /api/jobs
* List all jobs for the user
*/
jobs.get('/', async (c) => {
const page = parseInt(c.req.query('page') || '1');
const limit = parseInt(c.req.query('limit') || '50');
const result = await jobsService.getJobs({ page, perPage: limit });
return c.json(result);
});
/**
* GET /api/jobs/search
* Search jobs by query
*/
jobs.get('/search', async (c) => {
const query = c.req.query('q');
if (!query) {
throw new BadRequestError('Search query required');
}
const jobs = await jobsService.searchJobs(query);
return c.json({ jobs, page: 1, perPage: jobs.length, totalItems: jobs.length, totalPages: 1 });
});
/**
* GET /api/jobs/:id
* Get single job with all details
*/
jobs.get('/:id', async (c) => {
const userId = getUserId(c);
const jobId = c.req.param('id');
const result = await jobsService.getJobDetail(jobId, userId);
if (!result) {
throw new NotFoundError('Job not found');
}
return c.json(result);
});
/**
* GET /api/jobs/:id/files
* Get files for a job from SharePoint
*/
jobs.get('/:id/files', async (c) => {
const userId = getUserId(c);
const jobId = c.req.param('id');
const result = await jobsService.getJobFiles(jobId, userId);
return c.json({ files: result?.files ?? [] });
});
/**
* POST /api/jobs/:id/notes
* Create a sticky note on a job
*/
jobs.post('/:id/notes', async (c) => {
const userId = getUserId(c);
const jobId = c.req.param('id');
const body = await c.req.json<{ content: string; color?: string }>();
if (!body.content) {
throw new BadRequestError('Note content required');
}
const note = await jobsService.createNote(
jobId,
body.content,
userId,
{ color: body.color as 'yellow' | 'blue' | 'green' | 'pink' | 'purple' | undefined }
);
return c.json({ note });
});
/**
* PUT /api/jobs/:id/notes/:noteId
* Update a sticky note
*/
jobs.put('/:id/notes/:noteId', async (c) => {
const noteId = c.req.param('noteId');
const body = await c.req.json<{ content?: string; color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple' }>();
const note = await jobsService.updateNote(noteId, body);
return c.json({ note });
});
/**
* DELETE /api/jobs/:id/notes/:noteId
* Delete a sticky note
*/
jobs.delete('/:id/notes/:noteId', async (c) => {
const noteId = c.req.param('noteId');
await jobsService.deleteNote(noteId);
return c.json({ success: true });
});
export default jobs;
-181
View File
@@ -1,181 +0,0 @@
/**
* Cache Service - Valkey/Redis operations
*/
import Redis from 'ioredis';
import { env } from '../config/env';
class CacheService {
private client: Redis | null = null;
private isConnected = false;
/**
* Initialize Redis connection
*/
async connect(): Promise<void> {
if (this.client && this.isConnected) return;
this.client = new Redis({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
password: env.REDIS_PASSWORD || undefined,
retryStrategy: (times) => {
if (times > 3) {
console.error('[Cache] Max retry attempts reached');
return null;
}
return Math.min(times * 200, 2000);
},
maxRetriesPerRequest: 3,
});
this.client.on('connect', () => {
this.isConnected = true;
console.log('[Cache] Connected to Redis/Valkey');
});
this.client.on('error', (err) => {
console.error('[Cache] Redis error:', err.message);
this.isConnected = false;
});
this.client.on('close', () => {
this.isConnected = false;
console.log('[Cache] Redis connection closed');
});
}
/**
* Get value from cache
*/
async get<T>(key: string): Promise<T | null> {
if (!this.client) {
console.warn('[Cache] Not connected, returning null');
return null;
}
try {
const value = await this.client.get(key);
if (!value) return null;
return JSON.parse(value) as T;
} catch (err) {
console.error(`[Cache] Get error for key ${key}:`, err);
return null;
}
}
/**
* Set value in cache with optional TTL
*/
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<boolean> {
if (!this.client) {
console.warn('[Cache] Not connected, skipping set');
return false;
}
try {
const serialized = JSON.stringify(value);
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, serialized);
} else {
await this.client.set(key, serialized);
}
return true;
} catch (err) {
console.error(`[Cache] Set error for key ${key}:`, err);
return false;
}
}
/**
* Delete key from cache
*/
async del(key: string): Promise<boolean> {
if (!this.client) return false;
try {
await this.client.del(key);
return true;
} catch (err) {
console.error(`[Cache] Delete error for key ${key}:`, err);
return false;
}
}
/**
* Delete keys matching pattern
*/
async delPattern(pattern: string): Promise<number> {
if (!this.client) return 0;
try {
const keys = await this.client.keys(pattern);
if (keys.length === 0) return 0;
return await this.client.del(...keys);
} catch (err) {
console.error(`[Cache] Delete pattern error for ${pattern}:`, err);
return 0;
}
}
/**
* Check if connected
*/
get connected(): boolean {
return this.isConnected;
}
/**
* Add to set
*/
async sadd(key: string, member: string): Promise<boolean> {
if (!this.client) return false;
try {
await this.client.sadd(key, member);
return true;
} catch (err) {
console.error(`[Cache] SADD error for key ${key}:`, err);
return false;
}
}
/**
* Remove from set
*/
async srem(key: string, member: string): Promise<boolean> {
if (!this.client) return false;
try {
await this.client.srem(key, member);
return true;
} catch (err) {
console.error(`[Cache] SREM error for key ${key}:`, err);
return false;
}
}
/**
* Get all members of set
*/
async smembers(key: string): Promise<string[]> {
if (!this.client) return [];
try {
return await this.client.smembers(key);
} catch (err) {
console.error(`[Cache] SMEMBERS error for key ${key}:`, err);
return [];
}
}
/**
* Disconnect from Redis
*/
async disconnect(): Promise<void> {
if (this.client) {
await this.client.quit();
this.client = null;
this.isConnected = false;
}
}
}
export const cacheService = new CacheService();
-226
View File
@@ -1,226 +0,0 @@
/**
* Graph Service - Microsoft Graph API integration
*/
import { env } from '../config/env';
import type { JobFile } from '@shared/types/job';
interface GraphTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}
interface GraphDriveItem {
id: string;
name: string;
size: number;
file?: {
mimeType: string;
};
webUrl: string;
'@microsoft.graph.downloadUrl'?: string;
createdDateTime: string;
lastModifiedDateTime: string;
createdBy?: {
user: {
displayName: string;
email: string;
};
};
lastModifiedBy?: {
user: {
displayName: string;
email: string;
};
};
}
interface GraphListResponse {
value: GraphDriveItem[];
'@odata.nextLink'?: string;
}
class GraphService {
private readonly tokenEndpoint: string;
private readonly graphBaseUrl = 'https://graph.microsoft.com/v1.0';
constructor() {
this.tokenEndpoint = `https://login.microsoftonline.com/${env.MICROSOFT_TENANT}/oauth2/v2.0/token`;
}
/**
* Refresh access token using refresh token
*/
async refreshToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
const params = new URLSearchParams({
client_id: env.MICROSOFT_CLIENT_ID,
client_secret: env.MICROSOFT_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: 'offline_access Files.Read Sites.Read.All User.Read',
});
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const error = await response.text();
console.error('[Graph] Token refresh failed:', error);
throw new Error(`Token refresh failed: ${response.status}`);
}
const data: GraphTokenResponse = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
}
/**
* Get files from a SharePoint drive folder
*/
async getFiles(
accessToken: string,
siteId: string,
driveId: string,
folderPath?: string
): Promise<{ files: JobFile[]; nextLink?: string }> {
let endpoint: string;
if (folderPath) {
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root:/${folderPath}:/children`;
} else {
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root/children`;
}
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.text();
console.error('[Graph] Get files failed:', error);
throw new Error(`Get files failed: ${response.status}`);
}
const data: GraphListResponse = await response.json();
const files: JobFile[] = data.value
.filter(item => item.file) // Only files, not folders
.map(item => ({
id: item.id,
name: item.name,
size: item.size,
mimeType: item.file?.mimeType ?? 'application/octet-stream',
webUrl: item.webUrl,
downloadUrl: item['@microsoft.graph.downloadUrl'],
createdDateTime: item.createdDateTime,
lastModifiedDateTime: item.lastModifiedDateTime,
createdBy: item.createdBy,
lastModifiedBy: item.lastModifiedBy,
}));
return {
files,
nextLink: data['@odata.nextLink'],
};
}
/**
* Get file content/download URL
*/
async getFileDownloadUrl(
accessToken: string,
siteId: string,
driveId: string,
itemId: string
): Promise<string | null> {
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/items/${itemId}`;
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error('[Graph] Get download URL failed:', response.status);
return null;
}
const data: GraphDriveItem = await response.json();
return data['@microsoft.graph.downloadUrl'] ?? null;
}
/**
* Get user profile
*/
async getUserProfile(accessToken: string): Promise<{ displayName: string; mail: string } | null> {
const endpoint = `${this.graphBaseUrl}/me`;
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error('[Graph] Get user profile failed:', response.status);
return null;
}
return response.json();
}
/**
* Search for files in SharePoint
*/
async searchFiles(
accessToken: string,
siteId: string,
query: string
): Promise<JobFile[]> {
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drive/root/search(q='${encodeURIComponent(query)}')`;
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error('[Graph] Search files failed:', response.status);
return [];
}
const data: GraphListResponse = await response.json();
return data.value
.filter(item => item.file)
.map(item => ({
id: item.id,
name: item.name,
size: item.size,
mimeType: item.file?.mimeType ?? 'application/octet-stream',
webUrl: item.webUrl,
downloadUrl: item['@microsoft.graph.downloadUrl'],
createdDateTime: item.createdDateTime,
lastModifiedDateTime: item.lastModifiedDateTime,
createdBy: item.createdBy,
lastModifiedBy: item.lastModifiedBy,
}));
}
}
export const graphService = new GraphService();
-165
View File
@@ -1,165 +0,0 @@
/**
* Jobs Service - Business logic for jobs
*/
import { pocketbaseService } from './pocketbaseService';
import { graphService } from './graphService';
import { cacheService } from './cacheService';
import { tokenService } from './tokenService';
import { CACHE_KEYS } from '@shared/constants';
import type { Job, JobFile, StickyNote, JobListResponse, JobDetailResponse, JobFilesResponse } from '@shared/types/job';
/** Cache TTL for job files (5 minutes) */
const FILES_CACHE_TTL = 300;
class JobsService {
/**
* Get list of all jobs
*/
async getJobs(options?: {
page?: number;
perPage?: number;
filter?: string;
sort?: string;
}): Promise<JobListResponse> {
const result = await pocketbaseService.getJobs(options);
return {
jobs: result.items,
page: options?.page ?? 1,
perPage: options?.perPage ?? 50,
totalItems: result.totalItems,
totalPages: result.totalPages,
};
}
/**
* Get job detail with files and notes
*/
async getJobDetail(jobId: string, userId: string): Promise<JobDetailResponse | null> {
// Get job from PocketBase
const job = await pocketbaseService.getJob(jobId);
if (!job) return null;
// Get notes from PocketBase
const notes = await pocketbaseService.getJobNotes(jobId);
// Get files from SharePoint (if job has site/drive configured)
let files: JobFile[] = [];
if (job.site_id && job.drive_id) {
const filesResult = await this.getJobFiles(jobId, userId);
files = filesResult?.files ?? [];
}
return { job, files, notes };
}
/**
* Get files for a job from SharePoint
*/
async getJobFiles(jobId: string, userId: string): Promise<JobFilesResponse | null> {
// Try cache first
const cached = await cacheService.get<JobFilesResponse>(CACHE_KEYS.JOB_FILES(jobId));
if (cached) {
return { ...cached, cached: true };
}
// Get job to find site/drive info
const job = await pocketbaseService.getJob(jobId);
if (!job || !job.site_id || !job.drive_id) {
return { jobId, files: [], cached: false };
}
// Get Graph token for user
const tokenResult = await tokenService.getToken(userId, 'graph');
if (!tokenResult) {
console.error('[JobsService] No Graph token available for user', userId);
return { jobId, files: [], cached: false };
}
try {
// Fetch files from SharePoint
const result = await graphService.getFiles(
tokenResult.token,
job.site_id,
job.drive_id,
job.folder_path
);
const response: JobFilesResponse = {
jobId,
files: result.files,
nextLink: result.nextLink,
cached: false,
};
// Cache the result
await cacheService.set(CACHE_KEYS.JOB_FILES(jobId), response, FILES_CACHE_TTL);
return response;
} catch (err) {
console.error('[JobsService] Failed to get files for job', jobId, err);
return { jobId, files: [], cached: false };
}
}
/**
* Search jobs by name or number
*/
async searchJobs(query: string): Promise<Job[]> {
const result = await pocketbaseService.getJobs({
filter: `job_name ~ "${query}" || job_number ~ "${query}"`,
perPage: 20,
});
return result.items;
}
/**
* Get job notes
*/
async getJobNotes(jobId: string): Promise<StickyNote[]> {
return pocketbaseService.getJobNotes(jobId);
}
/**
* Create a note for a job
*/
async createNote(
jobId: string,
content: string,
userId: string,
options?: { color?: StickyNote['color']; position_x?: number; position_y?: number }
): Promise<StickyNote | null> {
return pocketbaseService.createNote({
job_id: jobId,
content,
color: options?.color ?? 'yellow',
position_x: options?.position_x ?? 0,
position_y: options?.position_y ?? 0,
created_by: userId,
});
}
/**
* Update a note
*/
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
return pocketbaseService.updateNote(noteId, data);
}
/**
* Delete a note
*/
async deleteNote(noteId: string): Promise<boolean> {
return pocketbaseService.deleteNote(noteId);
}
/**
* Invalidate file cache for a job
*/
async invalidateFileCache(jobId: string): Promise<void> {
await cacheService.del(CACHE_KEYS.JOB_FILES(jobId));
}
}
export const jobsService = new JobsService();
-177
View File
@@ -1,177 +0,0 @@
/**
* PocketBase Service - PocketBase integration
*/
import PocketBase from 'pocketbase';
import { env } from '../config/env';
import type { PocketBaseUser } from '@shared/types/auth';
import type { Job, StickyNote } from '@shared/types/job';
class PocketBaseService {
private client: PocketBase;
constructor() {
this.client = new PocketBase(env.POCKETBASE_URL);
}
/**
* Get PocketBase client instance
*/
get pb(): PocketBase {
return this.client;
}
/**
* Authenticate with token
*/
async authenticateWithToken(token: string): Promise<PocketBaseUser | null> {
try {
this.client.authStore.save(token, null);
const authData = await this.client.collection('users').authRefresh();
return authData.record as unknown as PocketBaseUser;
} catch (err) {
console.error('[PocketBase] Auth with token failed:', err);
return null;
}
}
/**
* Refresh token using refresh token
*/
async refreshToken(refreshToken: string): Promise<{ token: string; refreshToken: string }> {
try {
// PocketBase SDK handles refresh internally
// We need to set the token first and then refresh
this.client.authStore.save(refreshToken, null);
const authData = await this.client.collection('users').authRefresh();
return {
token: authData.token,
refreshToken: authData.token, // PocketBase uses same token for refresh
};
} catch (err) {
console.error('[PocketBase] Token refresh failed:', err);
throw err;
}
}
/**
* Get user by ID
*/
async getUser(userId: string): Promise<PocketBaseUser | null> {
try {
const record = await this.client.collection('users').getOne(userId);
return record as unknown as PocketBaseUser;
} catch (err) {
console.error('[PocketBase] Get user failed:', err);
return null;
}
}
/**
* Get all jobs (with optional filters)
*/
async getJobs(options?: {
page?: number;
perPage?: number;
filter?: string;
sort?: string;
}): Promise<{ items: Job[]; totalItems: number; totalPages: number }> {
try {
const result = await this.client.collection('jobs').getList(
options?.page ?? 1,
options?.perPage ?? 50,
{
filter: options?.filter,
sort: options?.sort ?? '-created',
}
);
return {
items: result.items as unknown as Job[],
totalItems: result.totalItems,
totalPages: result.totalPages,
};
} catch (err) {
console.error('[PocketBase] Get jobs failed:', err);
throw err;
}
}
/**
* Get single job by ID
*/
async getJob(jobId: string): Promise<Job | null> {
try {
const record = await this.client.collection('jobs').getOne(jobId);
return record as unknown as Job;
} catch (err) {
console.error('[PocketBase] Get job failed:', err);
return null;
}
}
/**
* Get notes for a job
*/
async getJobNotes(jobId: string): Promise<StickyNote[]> {
try {
const result = await this.client.collection('sticky_notes').getFullList({
filter: `job_id = "${jobId}"`,
sort: '-created',
});
return result as unknown as StickyNote[];
} catch (err) {
console.error('[PocketBase] Get job notes failed:', err);
return [];
}
}
/**
* Create a sticky note
*/
async createNote(note: Omit<StickyNote, 'id' | 'created' | 'updated'>): Promise<StickyNote | null> {
try {
const record = await this.client.collection('sticky_notes').create(note);
return record as unknown as StickyNote;
} catch (err) {
console.error('[PocketBase] Create note failed:', err);
return null;
}
}
/**
* Update a sticky note
*/
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
try {
const record = await this.client.collection('sticky_notes').update(noteId, data);
return record as unknown as StickyNote;
} catch (err) {
console.error('[PocketBase] Update note failed:', err);
return null;
}
}
/**
* Delete a sticky note
*/
async deleteNote(noteId: string): Promise<boolean> {
try {
await this.client.collection('sticky_notes').delete(noteId);
return true;
} catch (err) {
console.error('[PocketBase] Delete note failed:', err);
return false;
}
}
/**
* Clear auth state
*/
clearAuth(): void {
this.client.authStore.clear();
}
}
export const pocketbaseService = new PocketBaseService();
-271
View File
@@ -1,271 +0,0 @@
/**
* Token Service - Token refresh and management
*/
import { cacheService } from './cacheService';
import { pocketbaseService } from './pocketbaseService';
import { graphService } from './graphService';
import { CACHE_KEYS, TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS, TOKEN_REFRESH_INTERVAL_MS } from '@shared/constants';
import type { CachedToken, TokenType, TokenRefreshResponse } from '@shared/types/auth';
import { env } from '../config/env';
class TokenService {
private refreshLoops = new Map<string, Timer>();
private activeUsers = new Set<string>();
/**
* Check if token is expiring soon (within buffer)
*/
isExpiring(expiresAt: number): boolean {
return expiresAt - Date.now() < env.TOKEN_EXPIRY_BUFFER_MS;
}
/**
* Store tokens in cache after login
*/
async storeTokens(
userId: string,
pbToken: string,
graphToken: string,
refreshPb: string,
refreshGraph: string
): Promise<void> {
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
// Store PocketBase token
await cacheService.set<CachedToken>(
CACHE_KEYS.TOKEN_PB(userId),
{
token: pbToken,
refresh_token: refreshPb,
expiresAt: expiresAt,
userId: userId,
},
TOKEN_TTL_SECONDS
);
// Store Graph token
await cacheService.set<CachedToken>(
CACHE_KEYS.TOKEN_GRAPH(userId),
{
token: graphToken,
refresh_token: refreshGraph,
expiresAt: expiresAt,
userId: userId,
},
TOKEN_TTL_SECONDS
);
// Store refresh tokens with longer TTL
await cacheService.set(
CACHE_KEYS.REFRESH_PB(userId),
refreshPb,
REFRESH_TOKEN_TTL_SECONDS
);
await cacheService.set(
CACHE_KEYS.REFRESH_GRAPH(userId),
refreshGraph,
REFRESH_TOKEN_TTL_SECONDS
);
// Track active user
this.activeUsers.add(userId);
await cacheService.sadd(CACHE_KEYS.ACTIVE_USERS, userId);
console.log(`[TokenService] Tokens stored for user ${userId}`);
}
/**
* Get fresh token for user (from cache or refresh)
*/
async getToken(userId: string, type: TokenType): Promise<TokenRefreshResponse | null> {
const cacheKey = type === 'pb'
? CACHE_KEYS.TOKEN_PB(userId)
: CACHE_KEYS.TOKEN_GRAPH(userId);
// Try to get from cache
const cached = await cacheService.get<CachedToken>(cacheKey);
if (cached && !this.isExpiring(cached.expiresAt)) {
return {
token: cached.token,
expiresAt: cached.expiresAt,
source: 'cache',
type,
};
}
// Need to refresh
const refreshed = await this.refreshToken(userId, type);
if (refreshed) {
return {
token: refreshed.token,
expiresAt: refreshed.expiresAt,
source: 'refreshed',
type,
};
}
// Return stale token if available (better than nothing for field workers)
if (cached) {
console.warn(`[TokenService] Returning stale ${type} token for user ${userId}`);
return {
token: cached.token,
expiresAt: cached.expiresAt,
source: 'cache',
type,
};
}
return null;
}
/**
* Refresh a specific token type
*/
async refreshToken(userId: string, type: TokenType): Promise<CachedToken | null> {
const refreshKey = type === 'pb'
? CACHE_KEYS.REFRESH_PB(userId)
: CACHE_KEYS.REFRESH_GRAPH(userId);
const refreshToken = await cacheService.get<string>(refreshKey);
if (!refreshToken) {
console.error(`[TokenService] No refresh token for ${type} user ${userId}`);
return null;
}
try {
let newToken: string;
let newRefreshToken: string;
if (type === 'pb') {
const result = await pocketbaseService.refreshToken(refreshToken);
newToken = result.token;
newRefreshToken = result.refreshToken;
} else {
const result = await graphService.refreshToken(refreshToken);
newToken = result.accessToken;
newRefreshToken = result.refreshToken;
}
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
const cachedToken: CachedToken = {
token: newToken,
refresh_token: newRefreshToken,
expiresAt: expiresAt,
userId: userId,
};
// Update cache
const cacheKey = type === 'pb'
? CACHE_KEYS.TOKEN_PB(userId)
: CACHE_KEYS.TOKEN_GRAPH(userId);
await cacheService.set(cacheKey, cachedToken, TOKEN_TTL_SECONDS);
await cacheService.set(refreshKey, newRefreshToken, REFRESH_TOKEN_TTL_SECONDS);
console.log(`[TokenService] Refreshed ${type} token for user ${userId}`);
return cachedToken;
} catch (err) {
console.error(`[TokenService] Failed to refresh ${type} token for user ${userId}:`, err);
return null;
}
}
/**
* Start background refresh loop for a user
*/
startRefreshLoop(userId: string): void {
if (this.refreshLoops.has(userId)) {
console.log(`[TokenService] Refresh loop already running for ${userId}`);
return;
}
const loop = setInterval(async () => {
await this.refreshUserTokens(userId);
}, TOKEN_REFRESH_INTERVAL_MS);
this.refreshLoops.set(userId, loop);
this.activeUsers.add(userId);
console.log(`[TokenService] Started refresh loop for user ${userId}`);
}
/**
* Stop background refresh loop for a user
*/
stopRefreshLoop(userId: string): void {
const loop = this.refreshLoops.get(userId);
if (loop) {
clearInterval(loop);
this.refreshLoops.delete(userId);
this.activeUsers.delete(userId);
console.log(`[TokenService] Stopped refresh loop for user ${userId}`);
}
}
/**
* Refresh all tokens for a user (called by background loop)
*/
private async refreshUserTokens(userId: string): Promise<void> {
console.log(`[TokenService] Background refresh for user ${userId}`);
// Check PocketBase token
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
if (pbToken && this.isExpiring(pbToken.expiresAt)) {
await this.refreshToken(userId, 'pb');
}
// Check Graph token
const graphToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_GRAPH(userId));
if (graphToken && this.isExpiring(graphToken.expiresAt)) {
await this.refreshToken(userId, 'graph');
}
}
/**
* Clear all tokens for a user (logout)
*/
async clearUserTokens(userId: string): Promise<void> {
this.stopRefreshLoop(userId);
await Promise.all([
cacheService.del(CACHE_KEYS.TOKEN_PB(userId)),
cacheService.del(CACHE_KEYS.TOKEN_GRAPH(userId)),
cacheService.del(CACHE_KEYS.REFRESH_PB(userId)),
cacheService.del(CACHE_KEYS.REFRESH_GRAPH(userId)),
]);
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
console.log(`[TokenService] Cleared all tokens for user ${userId}`);
}
/**
* Restore refresh loops for active users (on server restart)
*/
async restoreRefreshLoops(): Promise<void> {
const activeUsers = await cacheService.smembers(CACHE_KEYS.ACTIVE_USERS);
for (const userId of activeUsers) {
// Check if user still has valid tokens
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
if (pbToken) {
this.startRefreshLoop(userId);
} else {
// Remove stale user
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
}
}
console.log(`[TokenService] Restored ${this.refreshLoops.size} refresh loops`);
}
/**
* Get active user count
*/
get activeUserCount(): number {
return this.activeUsers.size;
}
}
export const tokenService = new TokenService();
-70
View File
@@ -1,70 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "job-info-prod",
"dependencies": {
"dotenv": "^17.2.3",
"hono": "^4.10.8",
"ioredis": "^5.3.2",
"pocketbase": "^0.26.5",
},
"devDependencies": {
"@types/bun": "latest",
"postcss": "^8.4.32",
"tailwindcss": "^4.1.18",
"typescript": "^5.3.3",
},
},
},
"packages": {
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pocketbase": ["pocketbase@0.26.5", "", {}, "sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

-39
View File
@@ -1,39 +0,0 @@
// PocketBase auth bootstrap shared by pages
(function (global) {
const pb = new PocketBase('https://pocketbase.ccllc.pro');
function authHeaders() {
if (pb.authStore.isValid && pb.authStore.token) {
return { Authorization: `Bearer ${pb.authStore.token}` };
}
return {};
}
function getDisplayName() {
const model = pb.authStore.model || {};
return (
model.display_name ||
model.name ||
model.email ||
model.username ||
'Unknown'
);
}
function getEmail() {
const model = pb.authStore.model || {};
// Prefer email, but fall back to username if email missing
return model.email || model.username || null;
}
async function ensureAuth() {
if (pb.authStore.isValid) return true;
const path = new URL(window.location.href).pathname;
if (!path.endsWith('signin.html')) {
window.location.href = 'signin.html';
}
return false;
}
global.Auth = { pb, authHeaders, ensureAuth, getDisplayName, getEmail };
})(window);
-2965
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
/* Generated Tailwind CSS - Basic version */
/* This will be updated by tailwind CLI on dev */
@tailwind base;
@tailwind components;
@tailwind utilities;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

-181
View File
@@ -1,181 +0,0 @@
// PocketBase auth bootstrap shared by pages
(function (global) {
const pb = new PocketBase('https://pocketbase.ccllc.pro');
const GRAPH_TOKEN_KEY = 'graphAccessToken';
const USER_ID_KEY = 'userId';
function authHeaders() {
if (pb.authStore.isValid && pb.authStore.token) {
return { Authorization: `Bearer ${pb.authStore.token}` };
}
return {};
}
function getDisplayName() {
const model = pb.authStore.model || {};
return (
model.display_name ||
model.name ||
model.email ||
model.username ||
'Unknown'
);
}
function getEmail() {
const model = pb.authStore.model || {};
return model.email || model.username || null;
}
function getUserId() {
return pb.authStore.model?.id || localStorage.getItem(USER_ID_KEY) || null;
}
// Get Graph token - first from localStorage, then try to refresh from backend
async function getGraphToken() {
// First check localStorage
let token = localStorage.getItem(GRAPH_TOKEN_KEY);
// If we have a valid PB session but no graph token, try to get from backend
if (!token && pb.authStore.isValid) {
try {
const response = await fetch('/api/auth/refresh-token?type=graph', {
headers: authHeaders()
});
if (response.ok) {
const data = await response.json();
if (data.token) {
token = data.token;
localStorage.setItem(GRAPH_TOKEN_KEY, token);
console.log('[Auth] Got Graph token from backend cache');
}
}
} catch (err) {
console.warn('[Auth] Could not get Graph token from backend:', err);
}
}
return token;
}
// Synchronous version for backwards compatibility
function getGraphTokenSync() {
return localStorage.getItem(GRAPH_TOKEN_KEY);
}
async function ensureAuth() {
if (pb.authStore.isValid) {
// Try to refresh tokens from backend on page load
try {
await refreshTokensFromBackend();
} catch (err) {
console.warn('[Auth] Token refresh failed:', err);
}
return true;
}
const path = new URL(window.location.href).pathname;
if (!path.endsWith('signin.html')) {
window.location.href = 'signin.html';
}
return false;
}
// Refresh tokens from backend cache
async function refreshTokensFromBackend() {
if (!pb.authStore.isValid) return;
const userId = getUserId();
if (!userId) return;
try {
// Get fresh Graph token from backend
const response = await fetch('/api/auth/refresh-token?type=graph', {
headers: authHeaders()
});
if (response.ok) {
const data = await response.json();
if (data.token) {
localStorage.setItem(GRAPH_TOKEN_KEY, data.token);
console.log('[Auth] Refreshed Graph token from backend, source:', data.source);
}
} else if (response.status === 401) {
// Token expired or invalid in backend, but we might still have a valid PB session
// Try to re-store our current tokens
console.log('[Auth] Backend token expired, attempting to re-store');
await restoreTokensToBackend();
}
} catch (err) {
console.warn('[Auth] Token refresh error:', err);
}
}
// Re-store tokens to backend (in case backend cache was cleared)
async function restoreTokensToBackend() {
if (!pb.authStore.isValid) return;
try {
// Refresh PocketBase auth to potentially get a new Graph token
const authData = await pb.collection('users').authRefresh();
const graphToken = extractGraphToken(authData) || localStorage.getItem(GRAPH_TOKEN_KEY);
if (graphToken) {
const response = await fetch('/api/auth/login-callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({
pbToken: pb.authStore.token,
graphToken: graphToken,
refreshTokenPb: '',
refreshTokenGraph: authData?.meta?.refreshToken || '',
user: {
id: pb.authStore.model?.id,
email: pb.authStore.model?.email,
name: pb.authStore.model?.name || pb.authStore.model?.display_name
}
})
});
if (response.ok) {
console.log('[Auth] Re-stored tokens in backend');
}
}
} catch (err) {
console.warn('[Auth] Failed to re-store tokens:', err);
}
}
function extractGraphToken(authData) {
return (
authData?.meta?.graphAccessToken ||
authData?.meta?.graph_token ||
authData?.meta?.graphToken ||
authData?.meta?.accessToken ||
authData?.meta?.access_token ||
authData?.meta?.token ||
authData?.meta?.rawToken ||
authData?.meta?.authData?.access_token ||
''
);
}
global.Auth = {
pb,
authHeaders,
ensureAuth,
getDisplayName,
getEmail,
getUserId,
getGraphToken,
getGraphTokenSync,
refreshTokensFromBackend,
restoreTokensToBackend,
extractGraphToken,
GRAPH_TOKEN_KEY,
USER_ID_KEY
};
})(window);
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
/* Generated Tailwind CSS - Basic version */
/* This will be updated by tailwind CLI on dev */
@tailwind base;
@tailwind components;
@tailwind utilities;
-176
View File
@@ -1,176 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In - Job Info</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
</head>
<body class="font-sans bg-gradient-to-br from-indigo-500 to-purple-600 min-h-screen flex items-center justify-center">
<div class="bg-white p-12 rounded-2xl shadow-2xl text-center max-w-md w-[90%]">
<h1 class="text-gray-800 mb-2 text-3xl font-bold">Job Info</h1>
<p class="text-gray-600 mb-8">Sign in with your Microsoft account</p>
<button id="loginBtn" onclick="login()"
class="bg-purple-600 text-white border-none py-3.5 px-8 text-base rounded-lg cursor-pointer transition-all duration-200 font-medium hover:bg-purple-700 hover:shadow-lg disabled:bg-gray-400 disabled:cursor-not-allowed w-full">
Sign in with Microsoft
</button>
<div class="text-red-600 mt-4 hidden text-sm" id="error"></div>
<div class="hidden mt-8 text-left bg-gray-50 p-6 rounded-lg" id="userInfo">
<h2 class="text-lg mb-4 text-gray-800 font-semibold">✓ Signed In</h2>
<p class="mb-3 text-gray-600"><strong class="text-gray-800">Name:</strong> <span id="displayName"></span></p>
<p class="mb-3 text-gray-600"><strong class="text-gray-800">Email:</strong> <span id="email"></span></p>
<button onclick="goToApp()"
class="mt-4 bg-purple-600 text-white border-none py-3 px-6 text-base rounded-lg cursor-pointer transition-all duration-200 font-medium hover:bg-purple-700 hover:shadow-lg w-full">
Continue to App →
</button>
<button onclick="logout()"
class="mt-3 bg-gray-200 text-gray-700 border-none py-2.5 px-6 text-sm rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-300 w-full">
Sign out
</button>
</div>
</div>
<script>
const pb = new PocketBase('https://pocketbase.ccllc.pro');
const loginBtn = document.getElementById('loginBtn');
const errorEl = document.getElementById('error');
const userInfo = document.getElementById('userInfo');
function goToApp() {
window.location.href = 'index.html';
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
}
function hideError() {
errorEl.classList.add('hidden');
}
// Extract Graph token from OAuth response
function extractGraphToken(authData) {
return authData?.meta?.accessToken ||
authData?.meta?.access_token ||
authData?.meta?.graphAccessToken ||
authData?.meta?.token ||
'';
}
// Store tokens in backend Redis cache
async function storeTokensInBackend(authData, graphToken) {
const userId = pb.authStore.model?.id;
if (!userId) return false;
try {
const response = await fetch('/api/auth/login-callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${pb.authStore.token}`
},
body: JSON.stringify({
pbToken: pb.authStore.token,
graphToken: graphToken,
refreshTokenPb: '',
refreshTokenGraph: authData?.meta?.refreshToken || '',
user: {
id: userId,
email: pb.authStore.model?.email || '',
name: pb.authStore.model?.name || pb.authStore.model?.display_name || ''
}
})
});
if (response.ok) {
console.log('[Auth] Tokens stored in backend');
return true;
} else {
console.warn('[Auth] Backend storage failed:', response.status);
return false;
}
} catch (err) {
console.warn('[Auth] Backend error:', err);
return false;
}
}
function showAuthedUI() {
const model = pb.authStore.model;
loginBtn.classList.add('hidden');
userInfo.classList.remove('hidden');
document.getElementById('displayName').textContent = model?.name || model?.display_name || 'User';
document.getElementById('email').textContent = model?.email || '';
}
async function login() {
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in...';
hideError();
try {
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
const graphToken = extractGraphToken(authData);
// Store in backend cache for persistence
// Store in localStorage for index.html to find
if (graphToken) localStorage.setItem('graphAccessToken', graphToken);
await storeTokensInBackend(authData, graphToken);
showAuthedUI();
} catch (error) {
console.error('Login failed:', error);
showError(error.message || 'Authentication failed');
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with Microsoft';
}
}
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${pb.authStore.token}` }
});
} catch (err) {
console.warn('[Auth] Logout error:', err);
}
pb.authStore.clear();
loginBtn.classList.remove('hidden');
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with Microsoft';
userInfo.classList.add('hidden');
}
// Check existing session on load
async function checkSession() {
if (!pb.authStore.isValid) return;
try {
await pb.collection('users').authRefresh();
showAuthedUI();
// Check if coming from reauth flow
const params = new URLSearchParams(window.location.search);
if (params.get('reauth') === '1') {
goToApp();
}
} catch (err) {
console.warn('[Auth] Session expired');
pb.authStore.clear();
}
}
checkSession();
</script>
</body>
</html>
-131
View File
@@ -1,131 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
<script src="auth.js"></script>
</head>
<body class="font-sans bg-gradient-to-br from-indigo-500 to-purple-600 min-h-screen flex items-center justify-center">
<div class="bg-white p-12 rounded-2xl shadow-2xl text-center max-w-md w-[90%]">
<h1 class="text-gray-800 mb-2 text-3xl">Welcome</h1>
<p class="text-gray-600 mb-8">Sign in with your Microsoft account</p>
<button id="loginBtn" onclick="login()" class="bg-blue-600 text-white border-none py-3.5 px-8 text-base rounded-md cursor-pointer transition-colors duration-200 font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed">Sign in with Microsoft</button>
<div class="text-red-600 mt-4 hidden" id="error"></div>
<div class="hidden mt-8 text-left bg-gray-50 p-6 rounded-lg" id="userInfo">
<h2 class="text-lg mb-4 text-gray-800">Signed in</h2>
<p class="mb-3 text-gray-600 break-all"><strong class="text-gray-800">Name:</strong> <span id="displayName"></span></p>
<p class="mb-3 text-gray-600 break-all"><strong class="text-gray-800">Email:</strong> <span id="email"></span></p>
<button id="continueBtn" onclick="goToIndex()" class="hidden mt-4 bg-blue-600 text-white border-none py-3.5 px-8 text-base rounded-md cursor-pointer transition-colors duration-200 font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed w-full">Continue to Job Info</button>
<button onclick="logout()" class="bg-gray-600 text-white border-none py-3.5 px-8 text-base rounded-md cursor-pointer transition-colors duration-200 font-medium hover:bg-gray-700 mt-4">Sign out</button>
</div>
</div>
<script>
const { pb, getDisplayName } = window.Auth;
const GRAPH_TOKEN_KEY = 'graphAccessToken';
const 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 ||
''
);
};
const loginBtn = document.getElementById('loginBtn');
const errorEl = document.getElementById('error');
const userInfo = document.getElementById('userInfo');
const continueBtn = document.getElementById('continueBtn');
const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
function goToIndex() {
window.location.href = 'index.html';
}
function showAuthedUI(displayName, email) {
loginBtn.classList.add('hidden');
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with Microsoft';
userInfo.classList.remove('hidden');
continueBtn.classList.remove('hidden');
document.getElementById('displayName').textContent = displayName || 'Unknown';
document.getElementById('email').textContent = email || 'Not available';
}
async function login() {
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in...';
errorEl.classList.add('hidden');
try {
const authData = await pb.collection('users').authWithOAuth2({
provider: 'microsoft',
});
displayUserInfo(authData);
} catch (error) {
console.error('Login failed:', error);
errorEl.textContent = error.message || 'Authentication failed. Please try again.';
errorEl.classList.remove('hidden');
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with Microsoft';
}
}
function logout() {
pb.authStore.clear();
localStorage.removeItem(GRAPH_TOKEN_KEY);
loginBtn.classList.remove('hidden');
userInfo.classList.add('hidden');
continueBtn.classList.add('hidden');
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with Microsoft';
}
function displayUserInfo(authData) {
console.log('Auth response:', authData);
console.log('Auth meta:', authData?.meta);
const displayName = getDisplayName();
const email = authData?.meta?.rawUser?.email || authData?.record?.email || pb.authStore.model?.email || 'Not available';
const graphToken = extractGraphToken(authData);
console.log('Extracted Graph token:', graphToken ? graphToken.substring(0, 30) + '...' : 'NOT FOUND');
if (graphToken) {
localStorage.setItem(GRAPH_TOKEN_KEY, graphToken);
localStorage.removeItem(GRAPH_REAUTH_FLAG);
}
showAuthedUI(displayName, email);
}
(async () => {
const params = new URLSearchParams(window.location.search);
const reauth = params.get('reauth') === '1';
if (pb.authStore.isValid) {
try {
const authData = await pb.collection('users').authRefresh();
displayUserInfo(authData);
// If we came from a reauth flow and now have a token, go back automatically
const hasToken = localStorage.getItem(GRAPH_TOKEN_KEY);
if (reauth && hasToken) {
window.location.href = 'index.html';
}
} catch (err) {
pb.authStore.clear();
logout();
}
}
})();
</script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apple</title><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg>

Before

Width:  |  Height:  |  Size: 650 B

-1
View File
@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>

Before

Width:  |  Height:  |  Size: 457 B

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