Initial production setup with dual-token auth
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Server
|
||||
PORT=3005
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis/Valkey
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# PocketBase
|
||||
POCKETBASE_URL=https://pocketbase.ccllc.pro
|
||||
POCKETBASE_ADMIN_EMAIL=
|
||||
POCKETBASE_ADMIN_PASSWORD=
|
||||
|
||||
# Microsoft OAuth
|
||||
MICROSOFT_CLIENT_ID=
|
||||
MICROSOFT_CLIENT_SECRET=
|
||||
MICROSOFT_TENANT=common
|
||||
MICROSOFT_REDIRECT_URI=http://localhost:3005/api/auth/callback
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
LOG_FILE=logs/app.log
|
||||
ERROR_LOG_FILE=logs/error.log
|
||||
|
||||
# Token Settings
|
||||
TOKEN_REFRESH_INTERVAL_MS=1800000
|
||||
TOKEN_EXPIRY_BUFFER_MS=900000
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
logs/*.log
|
||||
.DS_Store
|
||||
bun.lockb
|
||||
*.local
|
||||
frontend/public/assets/output.css
|
||||
@@ -0,0 +1,106 @@
|
||||
# Job-Info-Prod
|
||||
|
||||
Production-ready job information management system for field workers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual-Token Authentication**: PocketBase + Microsoft Graph tokens
|
||||
- **Always-Warm Cache**: Background refresh loop keeps tokens fresh
|
||||
- **Offline-First**: Works without internet using cached data
|
||||
- **Zero Re-Auth**: Users never need to login again after initial auth
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Bun
|
||||
- **Backend**: Hono
|
||||
- **Auth**: PocketBase + Microsoft OAuth
|
||||
- **Cache**: Redis/Valkey via ioredis
|
||||
- **Frontend**: TypeScript + Tailwind CSS
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your configuration
|
||||
nano .env
|
||||
|
||||
# Start development server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all required variables:
|
||||
|
||||
- `PORT` - Server port (default: 3000)
|
||||
- `POCKETBASE_URL` - PocketBase instance URL
|
||||
- `MS_CLIENT_ID` - Microsoft OAuth client ID
|
||||
- `MS_CLIENT_SECRET` - Microsoft OAuth client secret
|
||||
- `MS_TENANT_ID` - Microsoft tenant ID
|
||||
- `REDIS_HOST` - Redis/Valkey host
|
||||
- `REDIS_PORT` - Redis/Valkey port
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ Frontend │────▶│ Hono Backend │
|
||||
│ (TypeScript) │◀────│ │
|
||||
└─────────────────┘ └────────┬─────────┘
|
||||
│
|
||||
┌────────────────────────┼────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PocketBase │ │ Redis/Valkey │ │ Microsoft Graph │
|
||||
│ (Auth+Data) │ │ (Token Cache) │ │ (SharePoint) │
|
||||
└───────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Token Refresh Flow
|
||||
|
||||
1. User completes OAuth flow → tokens stored in localStorage + cache
|
||||
2. Backend starts 30-min refresh loop per user
|
||||
3. Frontend checks backend for fresh tokens every 25 min
|
||||
4. If backend unreachable, frontend uses localStorage cache
|
||||
5. Stale tokens returned as fallback (field workers can't wait)
|
||||
|
||||
## Scripts
|
||||
|
||||
- `bun run dev` - Start development server with watch
|
||||
- `bun run start` - Start production server
|
||||
- `bun run build:css` - Build Tailwind CSS
|
||||
- `bun run typecheck` - Run TypeScript type checking
|
||||
- `bun run test` - Run tests
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
Job-Info-Prod/
|
||||
├── backend/
|
||||
│ └── src/
|
||||
│ ├── config/ # Environment and constants
|
||||
│ ├── middleware/ # Auth, logging, error handling
|
||||
│ ├── routes/ # API route handlers
|
||||
│ ├── services/ # Business logic
|
||||
│ └── index.ts # Server entry point
|
||||
├── frontend/
|
||||
│ ├── public/ # Static HTML files
|
||||
│ └── src/
|
||||
│ ├── auth/ # Auth client
|
||||
│ ├── components/ # UI components
|
||||
│ ├── services/ # API clients
|
||||
│ ├── styles/ # Tailwind CSS
|
||||
│ └── app.ts # Main app entry
|
||||
└── shared/
|
||||
├── constants/ # Shared constants
|
||||
└── types/ # TypeScript types
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Application Constants
|
||||
*/
|
||||
|
||||
export const APP_NAME = 'Job Info';
|
||||
export const APP_VERSION = '2.0.0';
|
||||
|
||||
/** HTTP Status codes */
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
} as const;
|
||||
|
||||
/** Error messages */
|
||||
export const ERROR_MESSAGES = {
|
||||
UNAUTHORIZED: 'Authentication required',
|
||||
TOKEN_EXPIRED: 'Token has expired',
|
||||
TOKEN_INVALID: 'Invalid token',
|
||||
TOKEN_REFRESH_FAILED: 'Failed to refresh token',
|
||||
USER_NOT_FOUND: 'User not found',
|
||||
JOB_NOT_FOUND: 'Job not found',
|
||||
CACHE_ERROR: 'Cache operation failed',
|
||||
POCKETBASE_ERROR: 'PocketBase operation failed',
|
||||
GRAPH_API_ERROR: 'Microsoft Graph API error',
|
||||
INTERNAL_ERROR: 'Internal server error',
|
||||
} as const;
|
||||
|
||||
/** Success messages */
|
||||
export const SUCCESS_MESSAGES = {
|
||||
LOGIN_SUCCESS: 'Login successful',
|
||||
LOGOUT_SUCCESS: 'Logout successful',
|
||||
TOKEN_REFRESHED: 'Token refreshed successfully',
|
||||
} as const;
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Environment Configuration
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
|
||||
// Load .env file
|
||||
config();
|
||||
|
||||
interface EnvConfig {
|
||||
PORT: number;
|
||||
NODE_ENV: 'development' | 'production' | 'test';
|
||||
ALLOWED_ORIGINS: string[];
|
||||
|
||||
// Redis/Valkey
|
||||
REDIS_HOST: string;
|
||||
REDIS_PORT: number;
|
||||
REDIS_PASSWORD: string;
|
||||
|
||||
// PocketBase
|
||||
POCKETBASE_URL: string;
|
||||
PB_COLLECTION: string;
|
||||
PB_AUTH_COLLECTION: string;
|
||||
POCKETBASE_ADMIN_EMAIL: string;
|
||||
POCKETBASE_ADMIN_PASSWORD: string;
|
||||
|
||||
// Microsoft OAuth
|
||||
MICROSOFT_CLIENT_ID: string;
|
||||
MICROSOFT_CLIENT_SECRET: string;
|
||||
MICROSOFT_TENANT: string;
|
||||
MICROSOFT_REDIRECT_URI: string;
|
||||
MS_GRAPH_TOKEN: string;
|
||||
|
||||
// Microsoft Graph / SharePoint
|
||||
DRIVE_ID: string;
|
||||
ITEM_ID: string;
|
||||
EXCEL_TABLE_NAME: string;
|
||||
|
||||
// Logging
|
||||
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
|
||||
LOG_FILE: string;
|
||||
ERROR_LOG_FILE: string;
|
||||
|
||||
// Token settings
|
||||
TOKEN_REFRESH_INTERVAL_MS: number;
|
||||
TOKEN_EXPIRY_BUFFER_MS: number;
|
||||
}
|
||||
|
||||
function getEnv<T extends string | number>(key: string, defaultValue: T): T {
|
||||
const value = process.env[key];
|
||||
if (value === undefined) return defaultValue;
|
||||
|
||||
if (typeof defaultValue === 'number') {
|
||||
return parseInt(value, 10) as T;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function getEnvArray(key: string, defaultValue: string[]): string[] {
|
||||
const value = process.env[key];
|
||||
if (!value) return defaultValue;
|
||||
return value.split(',').map(s => s.trim());
|
||||
}
|
||||
|
||||
export const env: EnvConfig = {
|
||||
PORT: getEnv('PORT', 3005),
|
||||
NODE_ENV: getEnv('NODE_ENV', 'development') as EnvConfig['NODE_ENV'],
|
||||
ALLOWED_ORIGINS: getEnvArray('ALLOWED_ORIGINS', ['http://localhost:3005', 'http://localhost:5173']),
|
||||
|
||||
REDIS_HOST: getEnv('REDIS_HOST', 'localhost'),
|
||||
REDIS_PORT: getEnv('REDIS_PORT', 6379),
|
||||
REDIS_PASSWORD: getEnv('REDIS_PASSWORD', ''),
|
||||
|
||||
POCKETBASE_URL: getEnv('POCKETBASE_URL', 'https://pocketbase.ccllc.pro'),
|
||||
PB_COLLECTION: getEnv('PB_COLLECTION', 'Job_Info_Prod'),
|
||||
PB_AUTH_COLLECTION: getEnv('PB_AUTH_COLLECTION', 'Users'),
|
||||
POCKETBASE_ADMIN_EMAIL: getEnv('POCKETBASE_ADMIN_EMAIL', ''),
|
||||
POCKETBASE_ADMIN_PASSWORD: getEnv('POCKETBASE_ADMIN_PASSWORD', ''),
|
||||
|
||||
MICROSOFT_CLIENT_ID: getEnv('MICROSOFT_CLIENT_ID', ''),
|
||||
MICROSOFT_CLIENT_SECRET: getEnv('MICROSOFT_CLIENT_SECRET', ''),
|
||||
MICROSOFT_TENANT: getEnv('MICROSOFT_TENANT', '3fd97ea7-b124-41f1-855f-52d8ac3b16c7'),
|
||||
MICROSOFT_REDIRECT_URI: getEnv('MICROSOFT_REDIRECT_URI', 'https://pocketbase.ccllc.pro/api/oauth2-redirect'),
|
||||
MS_GRAPH_TOKEN: getEnv('MS_GRAPH_TOKEN', ''),
|
||||
|
||||
DRIVE_ID: getEnv('DRIVE_ID', ''),
|
||||
ITEM_ID: getEnv('ITEM_ID', ''),
|
||||
EXCEL_TABLE_NAME: getEnv('EXCEL_TABLE_NAME', 'Job_List'),
|
||||
|
||||
LOG_LEVEL: getEnv('LOG_LEVEL', 'info') as EnvConfig['LOG_LEVEL'],
|
||||
LOG_FILE: getEnv('LOG_FILE', 'logs/app.log'),
|
||||
ERROR_LOG_FILE: getEnv('ERROR_LOG_FILE', 'logs/error.log'),
|
||||
|
||||
TOKEN_REFRESH_INTERVAL_MS: getEnv('TOKEN_REFRESH_INTERVAL_MS', 1800000),
|
||||
TOKEN_EXPIRY_BUFFER_MS: getEnv('TOKEN_EXPIRY_BUFFER_MS', 900000),
|
||||
};
|
||||
|
||||
export function validateEnv(): void {
|
||||
const required = [
|
||||
'POCKETBASE_URL',
|
||||
'MICROSOFT_CLIENT_ID',
|
||||
'MICROSOFT_CLIENT_SECRET',
|
||||
'MICROSOFT_TENANT',
|
||||
];
|
||||
|
||||
const missing = required.filter(key => !process.env[key]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.warn(`Warning: Missing environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import fs from 'fs';
|
||||
// @ts-ignore Bun project may not have dotenv types configured
|
||||
import { config } from 'dotenv';
|
||||
import { getCache, setCache, isRedisConnected } from './redis-cache';
|
||||
|
||||
config();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Minimal log helpers
|
||||
const logLine = (path: string, line: string) => {
|
||||
try {
|
||||
fs.appendFileSync(path, `${new Date().toISOString()} ${line}\n`);
|
||||
} catch (err) {
|
||||
console.error('Failed to write log:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Version endpoint sourced from package.json
|
||||
app.get('/version', (c) => {
|
||||
try {
|
||||
const pkgRaw = fs.readFileSync('./package.json', 'utf-8');
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
return c.json({ version: pkg.version || '0.0.0' });
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `version endpoint error: ${(err as Error)?.message || String(err)}`);
|
||||
return c.json({ version: '0.0.0' }, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Jobs endpoint with Redis caching
|
||||
app.get('/api/jobs', async (c) => {
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const perPage = parseInt(c.req.query('perPage') || '50');
|
||||
const sort = c.req.query('sort') || '-Job_Number';
|
||||
|
||||
const cacheKey = `jobs:page:${page}:perPage:${perPage}:sort:${sort}`;
|
||||
const ttl = 300; // 5 minutes cache
|
||||
|
||||
try {
|
||||
// Try to get from cache first
|
||||
if (isRedisConnected()) {
|
||||
const cached = await getCache(cacheKey);
|
||||
if (cached) {
|
||||
logLine('logs/server.log', `Cache HIT for ${cacheKey}`);
|
||||
return c.json(cached);
|
||||
}
|
||||
logLine('logs/server.log', `Cache MISS for ${cacheKey}`);
|
||||
}
|
||||
|
||||
// Fetch from PocketBase
|
||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=${sort}`;
|
||||
const authHeader = c.req.header('Authorization') || '';
|
||||
|
||||
const response = await fetch(pbUrl, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PocketBase returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Cache the result
|
||||
if (isRedisConnected()) {
|
||||
await setCache(cacheKey, data, ttl);
|
||||
logLine('logs/server.log', `Cached ${cacheKey} for ${ttl}s`);
|
||||
}
|
||||
|
||||
return c.json(data);
|
||||
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `jobs endpoint error: ${(err as Error)?.message || String(err)}`);
|
||||
return c.json({ error: 'Failed to fetch jobs' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Fast endpoint: returns all jobs in a single cached request
|
||||
app.get('/api/jobs-all', async (c) => {
|
||||
const cacheKey = 'jobs:all';
|
||||
const ttl = 1800; // 30 minutes cache
|
||||
try {
|
||||
// Try cache first for instant response
|
||||
if (isRedisConnected()) {
|
||||
const cached = await getCache(cacheKey);
|
||||
if (cached) {
|
||||
logLine('logs/server.log', `Cache HIT for ${cacheKey} (${cached.items?.length || 0} jobs)`);
|
||||
// Return cached data immediately
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await refreshJobsCache(cacheKey, ttl, c.req.header('Authorization') || '');
|
||||
} catch (err) {}
|
||||
});
|
||||
// For ultra-fast display, send only the first 40 jobs in the initial response
|
||||
const fastBatch = Array.isArray(cached.items) ? cached.items.slice(0, 40) : [];
|
||||
return c.json({ ...cached, items: fastBatch, partial: cached.items && cached.items.length > 40 });
|
||||
} else {
|
||||
// No cache: return empty array instantly, trigger background fetch
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
|
||||
if (isRedisConnected()) {
|
||||
await setCache(cacheKey, result, ttl);
|
||||
logLine('logs/server.log', `Cached ${cacheKey} with ${result.items.length} jobs for ${ttl}s`);
|
||||
}
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `jobs-all background fetch error: ${(err as Error)?.message || String(err)}`);
|
||||
}
|
||||
});
|
||||
return c.json({ items: [], partial: true });
|
||||
}
|
||||
}
|
||||
// Redis not connected: fallback to direct fetch (blocking)
|
||||
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `jobs-all endpoint error: ${(err as Error)?.message || String(err)}`);
|
||||
return c.json({ error: 'Failed to fetch jobs' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to fetch all jobs from PocketBase
|
||||
async function fetchAllJobsFromPocketBase(authHeader: string): Promise<any> {
|
||||
const allItems: any[] = [];
|
||||
let page = 1;
|
||||
const perPage = 500;
|
||||
|
||||
while (true) {
|
||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
|
||||
const response = await fetch(pbUrl, {
|
||||
headers: authHeader ? { Authorization: authHeader } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PocketBase returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
|
||||
if (items.length === 0) break;
|
||||
allItems.push(...items);
|
||||
|
||||
if (allItems.length >= (data.totalItems || 0)) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return {
|
||||
page: 1,
|
||||
perPage: allItems.length,
|
||||
totalItems: allItems.length,
|
||||
totalPages: 1,
|
||||
items: allItems
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to refresh jobs cache in background
|
||||
async function refreshJobsCache(cacheKey: string, ttl: number, authHeader: string): Promise<void> {
|
||||
try {
|
||||
const result = await fetchAllJobsFromPocketBase(authHeader);
|
||||
await setCache(cacheKey, result, ttl);
|
||||
logLine('logs/server.log', `Background refresh: cached ${cacheKey} with ${result.items.length} jobs`);
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Background refresh error: ${(err as Error)?.message || String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Microsoft Graph helpers ---
|
||||
const getGraphToken = (c?: any) => {
|
||||
const headerToken = c?.req?.header ? c.req.header('x-graph-token') : '';
|
||||
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
||||
const token = headerToken || envToken;
|
||||
console.log('[getGraphToken] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE');
|
||||
console.log('[getGraphToken] Env token:', envToken ? 'SET' : 'NOT SET');
|
||||
console.log('[getGraphToken] Using:', token ? token.substring(0, 20) + '...' : 'NONE');
|
||||
return token;
|
||||
};
|
||||
|
||||
const b64Url = (input: string) =>
|
||||
Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
|
||||
type DriveItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
webUrl?: string;
|
||||
size?: number;
|
||||
lastModifiedDateTime?: string;
|
||||
file?: { mimeType?: string };
|
||||
folder?: { childCount?: number };
|
||||
parentReference?: { path?: string; driveId?: string };
|
||||
};
|
||||
|
||||
const fetchJson = async (url: string, token: string) => {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err: any = new Error(`Graph ${res.status}: ${text}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const resolveShareLink = async (link: string, token: string) => {
|
||||
// Decode the link in case it comes pre-encoded from the database
|
||||
const decodedLink = decodeURIComponent(link);
|
||||
console.log('[resolveShareLink] Decoded link:', decodedLink);
|
||||
|
||||
// Check if this is a short sharing link (/:f:/ or /:b:/) or a direct document library URL
|
||||
if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) {
|
||||
// Short sharing link - use shares API
|
||||
const encoded = `u!${b64Url(decodedLink)}`;
|
||||
console.log('[resolveShareLink] Using shares API, encoded:', encoded);
|
||||
const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token);
|
||||
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
|
||||
} else {
|
||||
// Direct document library URL - parse it and use drive API
|
||||
// Example: https://czflex.sharepoint.com/sites/Team/Shared Documents/Forms/AllItems.aspx?RootFolder=/sites/Team/Shared Documents/General/...
|
||||
const url = new URL(decodedLink);
|
||||
const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/);
|
||||
if (!pathMatch) throw new Error('Invalid SharePoint URL');
|
||||
|
||||
const hostname = pathMatch[1]; // e.g., 'czflex'
|
||||
const sitePath = url.pathname.split('/Shared')[0]; // e.g., /sites/Team
|
||||
const rootFolderParam = url.searchParams.get('RootFolder');
|
||||
if (!rootFolderParam) throw new Error('RootFolder parameter missing');
|
||||
|
||||
// Extract the folder path relative to the document library
|
||||
// RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/...
|
||||
// We need: General/Operations [Server]/...
|
||||
const folderPath = rootFolderParam.split('/Shared Documents/')[1];
|
||||
if (!folderPath) throw new Error('Could not parse folder path');
|
||||
|
||||
console.log('[resolveShareLink] Hostname:', hostname);
|
||||
console.log('[resolveShareLink] Site path:', sitePath);
|
||||
console.log('[resolveShareLink] Folder path:', folderPath);
|
||||
|
||||
// Get the site ID first using hostname:sitePath format
|
||||
const siteData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${hostname}.sharepoint.com:${sitePath}`, token);
|
||||
const siteId = siteData.id;
|
||||
|
||||
// Get the default document library drive
|
||||
const driveData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, token);
|
||||
const driveId = driveData.id;
|
||||
|
||||
// Get the folder item by path
|
||||
const itemData = await fetchJson(
|
||||
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${folderPath}`,
|
||||
token
|
||||
);
|
||||
|
||||
console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id);
|
||||
return { driveId, itemId: itemData.id };
|
||||
}
|
||||
};
|
||||
|
||||
const listChildrenRecursive = async (
|
||||
driveId: string,
|
||||
itemId: string,
|
||||
depth = 0,
|
||||
maxDepth = 5,
|
||||
token: string,
|
||||
): Promise<DriveItem[]> => {
|
||||
const out: DriveItem[] = [];
|
||||
const data = await fetchJson(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/children`, token);
|
||||
for (const child of data.value || []) {
|
||||
out.push(child as DriveItem);
|
||||
if (child.folder && depth < maxDepth) {
|
||||
const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token);
|
||||
out.push(...nested);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const searchWithinFolder = async (driveId: string, itemId: string, query: string, token: string): Promise<DriveItem[]> => {
|
||||
const data = await fetchJson(
|
||||
`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/search(q='${encodeURIComponent(query)}')`,
|
||||
token,
|
||||
);
|
||||
return (data.value || []) as DriveItem[];
|
||||
};
|
||||
|
||||
const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolean = true) => {
|
||||
// First filter: show PDF files and Word documents (which will be converted to PDF)
|
||||
let filtered = items;
|
||||
if (pdfOnly) {
|
||||
filtered = items.filter((i) => {
|
||||
if (i.folder) return false;
|
||||
const name = i.name.toLowerCase();
|
||||
const mimeType = i.file?.mimeType?.toLowerCase() || '';
|
||||
// Include PDFs, Word documents, and images
|
||||
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
|
||||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
|
||||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
|
||||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
|
||||
name.endsWith('.webp') || mimeType.includes('image');
|
||||
});
|
||||
}
|
||||
|
||||
// Second filter: category filtering (if specified)
|
||||
if (!category) return filtered;
|
||||
const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w));
|
||||
const lc = (s: string) => (s || '').toLowerCase();
|
||||
const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'];
|
||||
const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'];
|
||||
return filtered.filter((i) => {
|
||||
const n = lc(i.name);
|
||||
if (category === 'contracts') return nameHas(n, contractWords);
|
||||
if (category === 'plans') return nameHas(n, planWords);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// List/search job files via Graph using a shared folder link
|
||||
app.get('/api/job-files', async (c) => {
|
||||
try {
|
||||
console.log('[job-files] Request received');
|
||||
const token = getGraphToken(c);
|
||||
if (!token) {
|
||||
console.log('[job-files] No token found');
|
||||
return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
|
||||
}
|
||||
console.log('[job-files] Token found:', token.substring(0, 20) + '...');
|
||||
const link = c.req.query('link');
|
||||
const q = c.req.query('q') || '';
|
||||
const category = c.req.query('category') || '';
|
||||
if (!link) return c.json({ error: 'link required' }, 400);
|
||||
console.log('[job-files] Link:', link);
|
||||
|
||||
const { driveId, itemId } = await resolveShareLink(link, token);
|
||||
|
||||
let items: DriveItem[] = [];
|
||||
if (q) {
|
||||
items = await searchWithinFolder(driveId, itemId, q, token);
|
||||
} else {
|
||||
// Walk subfolders up to depth 5
|
||||
items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
|
||||
}
|
||||
|
||||
// Filter to show only PDFs by default (can be disabled with pdfOnly=false query param)
|
||||
const pdfOnly = c.req.query('pdfOnly') !== 'false';
|
||||
const filtered = filterByCategory(items, category, pdfOnly);
|
||||
console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`);
|
||||
|
||||
const mapped = filtered.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
url: i.webUrl,
|
||||
driveId: i.parentReference?.driveId || driveId,
|
||||
size: i.size,
|
||||
modified: i.lastModifiedDateTime,
|
||||
contentType: i.file?.mimeType,
|
||||
isFolder: Boolean(i.folder),
|
||||
path: i.parentReference?.path || '',
|
||||
}));
|
||||
|
||||
return c.json({ items: mapped, total: mapped.length, source: q ? 'search' : 'walk', driveId });
|
||||
} catch (err) {
|
||||
const message = (err as Error)?.message || String(err);
|
||||
const status = (err as any)?.status || 500;
|
||||
console.error('[job-files] ERROR:', message);
|
||||
logLine('logs/error.log', `/api/job-files error: ${message}`);
|
||||
return c.json({ error: message }, status);
|
||||
}
|
||||
});
|
||||
|
||||
// Stream file content via Graph to avoid CSP issues for inline preview
|
||||
app.get('/api/job-file-content', async (c) => {
|
||||
try {
|
||||
const token = getGraphToken(c);
|
||||
if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
|
||||
const driveId = c.req.query('driveId');
|
||||
const itemId = c.req.query('itemId');
|
||||
if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400);
|
||||
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
logLine('logs/error.log', `/api/job-file-content failed: ${res.status} - ${text}`);
|
||||
return c.json({ error: `Graph ${res.status}: ${text}` }, 502);
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
const ct = res.headers.get('content-type');
|
||||
const cd = res.headers.get('content-disposition');
|
||||
if (ct) headers['Content-Type'] = ct;
|
||||
if (cd) headers['Content-Disposition'] = cd;
|
||||
return new Response(res.body, { status: 200, headers });
|
||||
} catch (err) {
|
||||
const message = (err as Error)?.message || String(err);
|
||||
const status = (err as any)?.status || 500;
|
||||
logLine('logs/error.log', `/api/job-file-content error: ${message}`);
|
||||
return c.json({ error: message }, status);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert Word documents to PDF for display
|
||||
app.get('/api/job-file-pdf', async (c) => {
|
||||
try {
|
||||
const token = getGraphToken(c);
|
||||
if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
|
||||
const driveId = c.req.query('driveId');
|
||||
const itemId = c.req.query('itemId');
|
||||
if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400);
|
||||
|
||||
// Use Graph API to convert to PDF format
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
logLine('logs/error.log', `/api/job-file-pdf conversion failed: ${res.status} - ${text}`);
|
||||
return c.json({ error: `PDF conversion failed: ${res.status}` }, 502);
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/pdf',
|
||||
};
|
||||
const cd = res.headers.get('content-disposition');
|
||||
if (cd) {
|
||||
// Replace .docx/.doc extension with .pdf in filename
|
||||
headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf');
|
||||
}
|
||||
logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`);
|
||||
return new Response(res.body, { status: 200, headers });
|
||||
} catch (err) {
|
||||
const message = (err as Error)?.message || String(err);
|
||||
const status = (err as any)?.status || 500;
|
||||
logLine('logs/error.log', `/api/job-file-pdf error: ${message}`);
|
||||
return c.json({ error: message }, status);
|
||||
}
|
||||
});
|
||||
|
||||
// Mutation logging endpoint
|
||||
app.post('/log-change', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { action = 'unknown', user = 'unknown', target = '', detail = '' } = body || {};
|
||||
const payload = JSON.stringify({ action, user, target, detail });
|
||||
logLine('logs/changes.log', payload);
|
||||
return c.json({ status: 'ok' });
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `log-change error: ${(err as Error)?.message || String(err)}`);
|
||||
return c.text('Bad Request', 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve static files from the frontend directory (must come after API routes)
|
||||
// Add cache headers for static assets
|
||||
app.use('/assets/*', async (c, next) => {
|
||||
await next();
|
||||
// Cache assets for 1 year
|
||||
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
});
|
||||
app.use('/*', serveStatic({ root: './frontend/public' }));
|
||||
|
||||
// Error handler
|
||||
app.onError((err, c) => {
|
||||
logLine('logs/error.log', `Unhandled error: ${err?.message || err}`);
|
||||
return c.text('Internal Server Error', 500);
|
||||
});
|
||||
|
||||
// Default to 3005 for test instance; can be overridden by Environment=PORT
|
||||
const PORT = Number(process.env.PORT || 3005);
|
||||
|
||||
logLine('logs/server.log', `Starting frontend server (Redis version) on port ${PORT}`);
|
||||
|
||||
// Warm up cache on startup
|
||||
(async () => {
|
||||
try {
|
||||
if (isRedisConnected()) {
|
||||
logLine('logs/server.log', 'Warming up jobs cache...');
|
||||
const result = await fetchAllJobsFromPocketBase('');
|
||||
await setCache('jobs:all', result, 1800);
|
||||
logLine('logs/server.log', `✓ Cache warmed with ${result.items.length} jobs`);
|
||||
}
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Cache warmup failed: ${(err as Error)?.message || String(err)}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// Background refresh every 5 minutes to keep cache hot
|
||||
setInterval(async () => {
|
||||
try {
|
||||
if (isRedisConnected()) {
|
||||
await refreshJobsCache('jobs:all', 1800, '');
|
||||
}
|
||||
} catch (err) {
|
||||
logLine('logs/error.log', `Periodic refresh error: ${(err as Error)?.message || String(err)}`);
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
|
||||
// Graceful shutdown logging
|
||||
const shutdown = (signal: string) => {
|
||||
logLine('logs/server.log', `Shutting down due to ${signal}`);
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
export default {
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Auth Middleware - Token validation
|
||||
*/
|
||||
|
||||
import type { Context, Next } from 'hono';
|
||||
import { pocketbaseService } from '../services/pocketbaseService';
|
||||
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
|
||||
|
||||
/**
|
||||
* Validate auth token and attach user to context
|
||||
*/
|
||||
export async function authMiddleware(c: Context, next: Next): Promise<Response | void> {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json(
|
||||
{ error: ERROR_MESSAGES.UNAUTHORIZED },
|
||||
HTTP_STATUS.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7); // Remove 'Bearer '
|
||||
|
||||
try {
|
||||
// Validate token with PocketBase
|
||||
const user = await pocketbaseService.authenticateWithToken(token);
|
||||
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{ error: ERROR_MESSAGES.TOKEN_INVALID },
|
||||
HTTP_STATUS.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
// Attach user info to context
|
||||
c.set('userId', user.id);
|
||||
c.set('user', user);
|
||||
c.set('token', token);
|
||||
|
||||
await next();
|
||||
} catch (err) {
|
||||
console.error('[AuthMiddleware] Token validation failed:', err);
|
||||
return c.json(
|
||||
{ error: ERROR_MESSAGES.TOKEN_INVALID },
|
||||
HTTP_STATUS.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional auth - doesn't fail if no token, but attaches user if present
|
||||
*/
|
||||
export async function optionalAuthMiddleware(c: Context, next: Next): Promise<Response | void> {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
try {
|
||||
const user = await pocketbaseService.authenticateWithToken(token);
|
||||
if (user) {
|
||||
c.set('userId', user.id);
|
||||
c.set('user', user);
|
||||
c.set('token', token);
|
||||
}
|
||||
} catch {
|
||||
// Silently continue without auth
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user ID from context (for use after auth middleware)
|
||||
*/
|
||||
export function getUserId(c: Context): string {
|
||||
const userId = c.get('userId');
|
||||
if (!userId) {
|
||||
throw new Error('User ID not found in context - auth middleware not applied?');
|
||||
}
|
||||
return userId as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user from context
|
||||
*/
|
||||
export function getUser(c: Context): unknown {
|
||||
return c.get('user');
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Error Handler Middleware
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
|
||||
import { env } from '../config/env';
|
||||
|
||||
/** Custom application error */
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Not found error */
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string = 'Resource') {
|
||||
super(HTTP_STATUS.NOT_FOUND, `${resource} not found`, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
/** Unauthorized error */
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message: string = ERROR_MESSAGES.UNAUTHORIZED) {
|
||||
super(HTTP_STATUS.UNAUTHORIZED, message, 'UNAUTHORIZED');
|
||||
}
|
||||
}
|
||||
|
||||
/** Bad request error */
|
||||
export class BadRequestError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(HTTP_STATUS.BAD_REQUEST, message, 'BAD_REQUEST');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global error handler for Hono onError
|
||||
*/
|
||||
export function errorHandler(err: Error, c: Context): Response {
|
||||
console.error('[ErrorHandler]', err);
|
||||
|
||||
// Handle known app errors
|
||||
if (err instanceof AppError) {
|
||||
return c.json(
|
||||
{
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
},
|
||||
err.statusCode as 400 | 401 | 403 | 404 | 500
|
||||
);
|
||||
}
|
||||
|
||||
// Handle unknown errors
|
||||
const message = env.NODE_ENV === 'production'
|
||||
? ERROR_MESSAGES.INTERNAL_ERROR
|
||||
: err.message;
|
||||
|
||||
return c.json(
|
||||
{
|
||||
error: message,
|
||||
code: 'INTERNAL_ERROR',
|
||||
},
|
||||
HTTP_STATUS.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not found handler (for unmatched routes)
|
||||
*/
|
||||
export function notFoundHandler(c: Context): Response {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Endpoint not found',
|
||||
code: 'NOT_FOUND',
|
||||
},
|
||||
HTTP_STATUS.NOT_FOUND
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Logger Middleware - Request logging
|
||||
*/
|
||||
|
||||
import type { Context, Next } from 'hono';
|
||||
import { env } from '../config/env';
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current log level from env
|
||||
*/
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[env.LOG_LEVEL];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log message
|
||||
*/
|
||||
function formatLog(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger utility
|
||||
*/
|
||||
export const logger = {
|
||||
debug(message: string, meta?: Record<string, unknown>): void {
|
||||
if (shouldLog('debug')) {
|
||||
console.log(formatLog('debug', message, meta));
|
||||
}
|
||||
},
|
||||
|
||||
info(message: string, meta?: Record<string, unknown>): void {
|
||||
if (shouldLog('info')) {
|
||||
console.log(formatLog('info', message, meta));
|
||||
}
|
||||
},
|
||||
|
||||
warn(message: string, meta?: Record<string, unknown>): void {
|
||||
if (shouldLog('warn')) {
|
||||
console.warn(formatLog('warn', message, meta));
|
||||
}
|
||||
},
|
||||
|
||||
error(message: string, meta?: Record<string, unknown>): void {
|
||||
if (shouldLog('error')) {
|
||||
console.error(formatLog('error', message, meta));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Request logging middleware
|
||||
*/
|
||||
export async function loggerMiddleware(c: Context, next: Next): Promise<void> {
|
||||
const method = c.req.method;
|
||||
const path = c.req.path;
|
||||
|
||||
// Skip logging for health check endpoints (UptimeKuma polling)
|
||||
const skipLogging = path === '/version' || path === '/api/health';
|
||||
|
||||
const start = Date.now();
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
// Attach request ID to context
|
||||
c.set('requestId', requestId);
|
||||
|
||||
if (!skipLogging) {
|
||||
logger.info(`→ ${method} ${path}`, { requestId });
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
if (!skipLogging) {
|
||||
const duration = Date.now() - start;
|
||||
const status = c.res.status;
|
||||
|
||||
const logFn = status >= 500 ? logger.error : status >= 400 ? logger.warn : logger.info;
|
||||
logFn(`← ${method} ${path} ${status} ${duration}ms`, { requestId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
// Create Redis client
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: Number(process.env.REDIS_PORT || 6379),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
// Handle connection events
|
||||
redis.on('connect', () => {
|
||||
console.log('✓ Redis connected');
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('Redis error:', err.message);
|
||||
});
|
||||
|
||||
redis.on('close', () => {
|
||||
console.log('Redis connection closed');
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
(async () => {
|
||||
try {
|
||||
await redis.connect();
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to Redis:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Get a value from cache
|
||||
*/
|
||||
export async function getCache(key: string): Promise<any | null> {
|
||||
try {
|
||||
const value = await redis.get(key);
|
||||
if (!value) return null;
|
||||
return JSON.parse(value);
|
||||
} catch (err) {
|
||||
console.error(`Cache get error for key "${key}":`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in cache with TTL in seconds
|
||||
*/
|
||||
export async function setCache(key: string, value: any, ttl: number = 300): Promise<boolean> {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
await redis.setex(key, ttl, serialized);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Cache set error for key "${key}":`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key from cache
|
||||
*/
|
||||
export async function deleteCache(key: string): Promise<boolean> {
|
||||
try {
|
||||
await redis.del(key);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Cache delete error for key "${key}":`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache keys matching a pattern
|
||||
*/
|
||||
export async function clearCachePattern(pattern: string): Promise<number> {
|
||||
try {
|
||||
const keys = await redis.keys(pattern);
|
||||
if (keys.length === 0) return 0;
|
||||
await redis.del(...keys);
|
||||
return keys.length;
|
||||
} catch (err) {
|
||||
console.error(`Cache clear error for pattern "${pattern}":`, err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Redis is connected
|
||||
*/
|
||||
export function isRedisConnected(): boolean {
|
||||
return redis.status === 'ready';
|
||||
}
|
||||
|
||||
export default redis;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Auth Routes - Login, logout, token refresh
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { tokenService } from '../services/tokenService';
|
||||
import { authMiddleware, getUserId } from '../middleware/auth';
|
||||
import { BadRequestError } from '../middleware/errorHandler';
|
||||
import type { LoginCallbackRequest, TokenType } from '@shared/types/auth';
|
||||
import { HTTP_STATUS } from '../config/constants';
|
||||
|
||||
const auth = new Hono();
|
||||
|
||||
/**
|
||||
* POST /api/auth/login-callback
|
||||
* Called after OAuth flow to store tokens in cache
|
||||
*/
|
||||
auth.post('/login-callback', async (c) => {
|
||||
const body = await c.req.json<LoginCallbackRequest>();
|
||||
|
||||
if (!body.pbToken || !body.graphToken || !body.user) {
|
||||
throw new BadRequestError('Missing required token data');
|
||||
}
|
||||
|
||||
// Store tokens in cache
|
||||
await tokenService.storeTokens(
|
||||
body.user.id,
|
||||
body.pbToken,
|
||||
body.graphToken,
|
||||
body.refreshTokenPb ?? '',
|
||||
body.refreshTokenGraph ?? ''
|
||||
);
|
||||
|
||||
// Start background refresh loop
|
||||
tokenService.startRefreshLoop(body.user.id);
|
||||
|
||||
const expiresAt = Date.now() + 3600 * 1000; // 1 hour
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
user: body.user,
|
||||
expiresAt_pb: expiresAt,
|
||||
expiresAt_graph: expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Clear tokens and stop refresh loop
|
||||
*/
|
||||
auth.post('/logout', authMiddleware, async (c) => {
|
||||
const userId = getUserId(c);
|
||||
|
||||
await tokenService.clearUserTokens(userId);
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/refresh-token
|
||||
* Get fresh token from cache or refresh
|
||||
*/
|
||||
auth.get('/refresh-token', authMiddleware, async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const type = c.req.query('type') as TokenType | undefined;
|
||||
|
||||
if (!type || !['pb', 'graph'].includes(type)) {
|
||||
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
|
||||
}
|
||||
|
||||
const result = await tokenService.getToken(userId, type);
|
||||
|
||||
if (!result) {
|
||||
return c.json(
|
||||
{ error: 'Token not available' },
|
||||
HTTP_STATUS.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/refresh-token
|
||||
* Alternative POST endpoint for token refresh
|
||||
*/
|
||||
auth.post('/refresh-token', authMiddleware, async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const body = await c.req.json<{ type: TokenType }>();
|
||||
|
||||
if (!body.type || !['pb', 'graph'].includes(body.type)) {
|
||||
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
|
||||
}
|
||||
|
||||
const result = await tokenService.getToken(userId, body.type);
|
||||
|
||||
if (!result) {
|
||||
return c.json(
|
||||
{ error: 'Token not available' },
|
||||
HTTP_STATUS.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
export default auth;
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Files Routes - SharePoint file operations
|
||||
*
|
||||
* Note: Most file operations go through /api/jobs/:id/files
|
||||
* This route provides additional file utilities
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { graphService } from '../services/graphService';
|
||||
import { tokenService } from '../services/tokenService';
|
||||
import { pocketbaseService } from '../services/pocketbaseService';
|
||||
import { authMiddleware, getUserId } from '../middleware/auth';
|
||||
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
|
||||
|
||||
const files = new Hono();
|
||||
|
||||
// All file routes require authentication
|
||||
files.use('/*', authMiddleware);
|
||||
|
||||
/**
|
||||
* GET /api/files
|
||||
* List files in a job's folder - requires jobId query param
|
||||
*/
|
||||
files.get('/', async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const jobId = c.req.query('jobId');
|
||||
|
||||
if (!jobId) {
|
||||
throw new BadRequestError('jobId query parameter required');
|
||||
}
|
||||
|
||||
// Get job to find site/drive info
|
||||
const job = await pocketbaseService.getJob(jobId);
|
||||
if (!job) {
|
||||
throw new NotFoundError('Job not found');
|
||||
}
|
||||
|
||||
if (!job.site_id || !job.drive_id) {
|
||||
return c.json({ files: [] });
|
||||
}
|
||||
|
||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
||||
if (!tokenResult) {
|
||||
throw new BadRequestError('Graph token not available');
|
||||
}
|
||||
|
||||
const result = await graphService.getFiles(
|
||||
tokenResult.token,
|
||||
job.site_id,
|
||||
job.drive_id,
|
||||
job.folder_path
|
||||
);
|
||||
|
||||
return c.json({ files: result.files });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files/download-url
|
||||
* Get download URL for a file - requires jobId and fileId
|
||||
*/
|
||||
files.get('/download-url', async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const jobId = c.req.query('jobId');
|
||||
const fileId = c.req.query('fileId');
|
||||
|
||||
if (!jobId || !fileId) {
|
||||
throw new BadRequestError('jobId and fileId query parameters required');
|
||||
}
|
||||
|
||||
// Get job to find site/drive info
|
||||
const job = await pocketbaseService.getJob(jobId);
|
||||
if (!job || !job.site_id || !job.drive_id) {
|
||||
throw new NotFoundError('Job not found or not configured for files');
|
||||
}
|
||||
|
||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
||||
if (!tokenResult) {
|
||||
throw new BadRequestError('Graph token not available');
|
||||
}
|
||||
|
||||
const url = await graphService.getFileDownloadUrl(
|
||||
tokenResult.token,
|
||||
job.site_id,
|
||||
job.drive_id,
|
||||
fileId
|
||||
);
|
||||
|
||||
if (!url) {
|
||||
throw new NotFoundError('File not found');
|
||||
}
|
||||
|
||||
return c.json({ url });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files/search
|
||||
* Search files in a job's SharePoint site
|
||||
*/
|
||||
files.get('/search', async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const jobId = c.req.query('jobId');
|
||||
const query = c.req.query('q');
|
||||
|
||||
if (!jobId || !query) {
|
||||
throw new BadRequestError('jobId and q query parameters required');
|
||||
}
|
||||
|
||||
// Get job to find site info
|
||||
const job = await pocketbaseService.getJob(jobId);
|
||||
if (!job || !job.site_id) {
|
||||
throw new NotFoundError('Job not found or not configured for files');
|
||||
}
|
||||
|
||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
||||
if (!tokenResult) {
|
||||
throw new BadRequestError('Graph token not available');
|
||||
}
|
||||
|
||||
const results = await graphService.searchFiles(tokenResult.token, job.site_id, query);
|
||||
|
||||
return c.json({ files: results });
|
||||
});
|
||||
|
||||
export default files;
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Health Routes - Server health check
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cacheService } from '../services/cacheService';
|
||||
|
||||
const health = new Hono();
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Basic health check
|
||||
*/
|
||||
health.get('/', (c) => {
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/ready
|
||||
* Readiness check (includes cache connection)
|
||||
*/
|
||||
health.get('/ready', async (c) => {
|
||||
const checks: Record<string, boolean> = {
|
||||
server: true,
|
||||
cache: false,
|
||||
};
|
||||
|
||||
// Check cache connection
|
||||
try {
|
||||
await cacheService.get('health-check');
|
||||
checks.cache = true;
|
||||
} catch {
|
||||
checks.cache = false;
|
||||
}
|
||||
|
||||
const allHealthy = Object.values(checks).every(Boolean);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: allHealthy ? 'ready' : 'degraded',
|
||||
checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
allHealthy ? 200 : 503
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/live
|
||||
* Liveness check (just confirms server is running)
|
||||
*/
|
||||
health.get('/live', (c) => {
|
||||
return c.json({
|
||||
status: 'alive',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
export default health;
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Routes Index - Register all routes
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import auth from './auth';
|
||||
import jobs from './jobs';
|
||||
import files from './files';
|
||||
import health from './health';
|
||||
|
||||
export function registerRoutes(app: Hono): void {
|
||||
// Health routes (no /api prefix for k8s probes)
|
||||
app.route('/health', health);
|
||||
app.route('/api/health', health);
|
||||
|
||||
// API routes
|
||||
app.route('/api/auth', auth);
|
||||
app.route('/api/jobs', jobs);
|
||||
app.route('/api/files', files);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Jobs Routes - Job listing and details
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { jobsService } from '../services/jobsService';
|
||||
import { authMiddleware, getUserId } from '../middleware/auth';
|
||||
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
|
||||
|
||||
const jobs = new Hono();
|
||||
|
||||
// All job routes require authentication
|
||||
jobs.use('/*', authMiddleware);
|
||||
|
||||
/**
|
||||
* GET /api/jobs
|
||||
* List all jobs for the user
|
||||
*/
|
||||
jobs.get('/', async (c) => {
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const limit = parseInt(c.req.query('limit') || '50');
|
||||
|
||||
const result = await jobsService.getJobs({ page, perPage: limit });
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jobs/search
|
||||
* Search jobs by query
|
||||
*/
|
||||
jobs.get('/search', async (c) => {
|
||||
const query = c.req.query('q');
|
||||
|
||||
if (!query) {
|
||||
throw new BadRequestError('Search query required');
|
||||
}
|
||||
|
||||
const jobs = await jobsService.searchJobs(query);
|
||||
|
||||
return c.json({ jobs, page: 1, perPage: jobs.length, totalItems: jobs.length, totalPages: 1 });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jobs/:id
|
||||
* Get single job with all details
|
||||
*/
|
||||
jobs.get('/:id', async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const jobId = c.req.param('id');
|
||||
|
||||
const result = await jobsService.getJobDetail(jobId, userId);
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundError('Job not found');
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jobs/:id/files
|
||||
* Get files for a job from SharePoint
|
||||
*/
|
||||
jobs.get('/:id/files', async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const jobId = c.req.param('id');
|
||||
|
||||
const result = await jobsService.getJobFiles(jobId, userId);
|
||||
|
||||
return c.json({ files: result?.files ?? [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/notes
|
||||
* Create a sticky note on a job
|
||||
*/
|
||||
jobs.post('/:id/notes', async (c) => {
|
||||
const userId = getUserId(c);
|
||||
const jobId = c.req.param('id');
|
||||
const body = await c.req.json<{ content: string; color?: string }>();
|
||||
|
||||
if (!body.content) {
|
||||
throw new BadRequestError('Note content required');
|
||||
}
|
||||
|
||||
const note = await jobsService.createNote(
|
||||
jobId,
|
||||
body.content,
|
||||
userId,
|
||||
{ color: body.color as 'yellow' | 'blue' | 'green' | 'pink' | 'purple' | undefined }
|
||||
);
|
||||
|
||||
return c.json({ note });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/jobs/:id/notes/:noteId
|
||||
* Update a sticky note
|
||||
*/
|
||||
jobs.put('/:id/notes/:noteId', async (c) => {
|
||||
const noteId = c.req.param('noteId');
|
||||
const body = await c.req.json<{ content?: string; color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple' }>();
|
||||
|
||||
const note = await jobsService.updateNote(noteId, body);
|
||||
|
||||
return c.json({ note });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jobs/:id/notes/:noteId
|
||||
* Delete a sticky note
|
||||
*/
|
||||
jobs.delete('/:id/notes/:noteId', async (c) => {
|
||||
const noteId = c.req.param('noteId');
|
||||
|
||||
await jobsService.deleteNote(noteId);
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export default jobs;
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Cache Service - Valkey/Redis operations
|
||||
*/
|
||||
|
||||
import Redis from 'ioredis';
|
||||
import { env } from '../config/env';
|
||||
|
||||
class CacheService {
|
||||
private client: Redis | null = null;
|
||||
private isConnected = false;
|
||||
|
||||
/**
|
||||
* Initialize Redis connection
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.client && this.isConnected) return;
|
||||
|
||||
this.client = new Redis({
|
||||
host: env.REDIS_HOST,
|
||||
port: env.REDIS_PORT,
|
||||
password: env.REDIS_PASSWORD || undefined,
|
||||
retryStrategy: (times) => {
|
||||
if (times > 3) {
|
||||
console.error('[Cache] Max retry attempts reached');
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.isConnected = true;
|
||||
console.log('[Cache] Connected to Redis/Valkey');
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
console.error('[Cache] Redis error:', err.message);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
this.isConnected = false;
|
||||
console.log('[Cache] Redis connection closed');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from cache
|
||||
*/
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
if (!this.client) {
|
||||
console.warn('[Cache] Not connected, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await this.client.get(key);
|
||||
if (!value) return null;
|
||||
return JSON.parse(value) as T;
|
||||
} catch (err) {
|
||||
console.error(`[Cache] Get error for key ${key}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value in cache with optional TTL
|
||||
*/
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
console.warn('[Cache] Not connected, skipping set');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.client.setex(key, ttlSeconds, serialized);
|
||||
} else {
|
||||
await this.client.set(key, serialized);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Cache] Set error for key ${key}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete key from cache
|
||||
*/
|
||||
async del(key: string): Promise<boolean> {
|
||||
if (!this.client) return false;
|
||||
|
||||
try {
|
||||
await this.client.del(key);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Cache] Delete error for key ${key}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete keys matching pattern
|
||||
*/
|
||||
async delPattern(pattern: string): Promise<number> {
|
||||
if (!this.client) return 0;
|
||||
|
||||
try {
|
||||
const keys = await this.client.keys(pattern);
|
||||
if (keys.length === 0) return 0;
|
||||
return await this.client.del(...keys);
|
||||
} catch (err) {
|
||||
console.error(`[Cache] Delete pattern error for ${pattern}:`, err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
get connected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to set
|
||||
*/
|
||||
async sadd(key: string, member: string): Promise<boolean> {
|
||||
if (!this.client) return false;
|
||||
try {
|
||||
await this.client.sadd(key, member);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Cache] SADD error for key ${key}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove from set
|
||||
*/
|
||||
async srem(key: string, member: string): Promise<boolean> {
|
||||
if (!this.client) return false;
|
||||
try {
|
||||
await this.client.srem(key, member);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Cache] SREM error for key ${key}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of set
|
||||
*/
|
||||
async smembers(key: string): Promise<string[]> {
|
||||
if (!this.client) return [];
|
||||
try {
|
||||
return await this.client.smembers(key);
|
||||
} catch (err) {
|
||||
console.error(`[Cache] SMEMBERS error for key ${key}:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Redis
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
this.client = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheService = new CacheService();
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Graph Service - Microsoft Graph API integration
|
||||
*/
|
||||
|
||||
import { env } from '../config/env';
|
||||
import type { JobFile } from '@shared/types/job';
|
||||
|
||||
interface GraphTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
interface GraphDriveItem {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
file?: {
|
||||
mimeType: string;
|
||||
};
|
||||
webUrl: string;
|
||||
'@microsoft.graph.downloadUrl'?: string;
|
||||
createdDateTime: string;
|
||||
lastModifiedDateTime: string;
|
||||
createdBy?: {
|
||||
user: {
|
||||
displayName: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
lastModifiedBy?: {
|
||||
user: {
|
||||
displayName: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface GraphListResponse {
|
||||
value: GraphDriveItem[];
|
||||
'@odata.nextLink'?: string;
|
||||
}
|
||||
|
||||
class GraphService {
|
||||
private readonly tokenEndpoint: string;
|
||||
private readonly graphBaseUrl = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
constructor() {
|
||||
this.tokenEndpoint = `https://login.microsoftonline.com/${env.MICROSOFT_TENANT}/oauth2/v2.0/token`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.MICROSOFT_CLIENT_ID,
|
||||
client_secret: env.MICROSOFT_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
scope: 'offline_access Files.Read Sites.Read.All User.Read',
|
||||
});
|
||||
|
||||
const response = await fetch(this.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('[Graph] Token refresh failed:', error);
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: GraphTokenResponse = await response.json();
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files from a SharePoint drive folder
|
||||
*/
|
||||
async getFiles(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
driveId: string,
|
||||
folderPath?: string
|
||||
): Promise<{ files: JobFile[]; nextLink?: string }> {
|
||||
let endpoint: string;
|
||||
|
||||
if (folderPath) {
|
||||
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root:/${folderPath}:/children`;
|
||||
} else {
|
||||
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root/children`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('[Graph] Get files failed:', error);
|
||||
throw new Error(`Get files failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: GraphListResponse = await response.json();
|
||||
|
||||
const files: JobFile[] = data.value
|
||||
.filter(item => item.file) // Only files, not folders
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
mimeType: item.file?.mimeType ?? 'application/octet-stream',
|
||||
webUrl: item.webUrl,
|
||||
downloadUrl: item['@microsoft.graph.downloadUrl'],
|
||||
createdDateTime: item.createdDateTime,
|
||||
lastModifiedDateTime: item.lastModifiedDateTime,
|
||||
createdBy: item.createdBy,
|
||||
lastModifiedBy: item.lastModifiedBy,
|
||||
}));
|
||||
|
||||
return {
|
||||
files,
|
||||
nextLink: data['@odata.nextLink'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file content/download URL
|
||||
*/
|
||||
async getFileDownloadUrl(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
driveId: string,
|
||||
itemId: string
|
||||
): Promise<string | null> {
|
||||
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/items/${itemId}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Graph] Get download URL failed:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: GraphDriveItem = await response.json();
|
||||
return data['@microsoft.graph.downloadUrl'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile
|
||||
*/
|
||||
async getUserProfile(accessToken: string): Promise<{ displayName: string; mail: string } | null> {
|
||||
const endpoint = `${this.graphBaseUrl}/me`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Graph] Get user profile failed:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for files in SharePoint
|
||||
*/
|
||||
async searchFiles(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
query: string
|
||||
): Promise<JobFile[]> {
|
||||
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drive/root/search(q='${encodeURIComponent(query)}')`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Graph] Search files failed:', response.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: GraphListResponse = await response.json();
|
||||
|
||||
return data.value
|
||||
.filter(item => item.file)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
mimeType: item.file?.mimeType ?? 'application/octet-stream',
|
||||
webUrl: item.webUrl,
|
||||
downloadUrl: item['@microsoft.graph.downloadUrl'],
|
||||
createdDateTime: item.createdDateTime,
|
||||
lastModifiedDateTime: item.lastModifiedDateTime,
|
||||
createdBy: item.createdBy,
|
||||
lastModifiedBy: item.lastModifiedBy,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const graphService = new GraphService();
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Jobs Service - Business logic for jobs
|
||||
*/
|
||||
|
||||
import { pocketbaseService } from './pocketbaseService';
|
||||
import { graphService } from './graphService';
|
||||
import { cacheService } from './cacheService';
|
||||
import { tokenService } from './tokenService';
|
||||
import { CACHE_KEYS } from '@shared/constants';
|
||||
import type { Job, JobFile, StickyNote, JobListResponse, JobDetailResponse, JobFilesResponse } from '@shared/types/job';
|
||||
|
||||
/** Cache TTL for job files (5 minutes) */
|
||||
const FILES_CACHE_TTL = 300;
|
||||
|
||||
class JobsService {
|
||||
/**
|
||||
* Get list of all jobs
|
||||
*/
|
||||
async getJobs(options?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
filter?: string;
|
||||
sort?: string;
|
||||
}): Promise<JobListResponse> {
|
||||
const result = await pocketbaseService.getJobs(options);
|
||||
|
||||
return {
|
||||
jobs: result.items,
|
||||
page: options?.page ?? 1,
|
||||
perPage: options?.perPage ?? 50,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job detail with files and notes
|
||||
*/
|
||||
async getJobDetail(jobId: string, userId: string): Promise<JobDetailResponse | null> {
|
||||
// Get job from PocketBase
|
||||
const job = await pocketbaseService.getJob(jobId);
|
||||
if (!job) return null;
|
||||
|
||||
// Get notes from PocketBase
|
||||
const notes = await pocketbaseService.getJobNotes(jobId);
|
||||
|
||||
// Get files from SharePoint (if job has site/drive configured)
|
||||
let files: JobFile[] = [];
|
||||
if (job.site_id && job.drive_id) {
|
||||
const filesResult = await this.getJobFiles(jobId, userId);
|
||||
files = filesResult?.files ?? [];
|
||||
}
|
||||
|
||||
return { job, files, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files for a job from SharePoint
|
||||
*/
|
||||
async getJobFiles(jobId: string, userId: string): Promise<JobFilesResponse | null> {
|
||||
// Try cache first
|
||||
const cached = await cacheService.get<JobFilesResponse>(CACHE_KEYS.JOB_FILES(jobId));
|
||||
if (cached) {
|
||||
return { ...cached, cached: true };
|
||||
}
|
||||
|
||||
// Get job to find site/drive info
|
||||
const job = await pocketbaseService.getJob(jobId);
|
||||
if (!job || !job.site_id || !job.drive_id) {
|
||||
return { jobId, files: [], cached: false };
|
||||
}
|
||||
|
||||
// Get Graph token for user
|
||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
||||
if (!tokenResult) {
|
||||
console.error('[JobsService] No Graph token available for user', userId);
|
||||
return { jobId, files: [], cached: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch files from SharePoint
|
||||
const result = await graphService.getFiles(
|
||||
tokenResult.token,
|
||||
job.site_id,
|
||||
job.drive_id,
|
||||
job.folder_path
|
||||
);
|
||||
|
||||
const response: JobFilesResponse = {
|
||||
jobId,
|
||||
files: result.files,
|
||||
nextLink: result.nextLink,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cacheService.set(CACHE_KEYS.JOB_FILES(jobId), response, FILES_CACHE_TTL);
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error('[JobsService] Failed to get files for job', jobId, err);
|
||||
return { jobId, files: [], cached: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search jobs by name or number
|
||||
*/
|
||||
async searchJobs(query: string): Promise<Job[]> {
|
||||
const result = await pocketbaseService.getJobs({
|
||||
filter: `job_name ~ "${query}" || job_number ~ "${query}"`,
|
||||
perPage: 20,
|
||||
});
|
||||
return result.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job notes
|
||||
*/
|
||||
async getJobNotes(jobId: string): Promise<StickyNote[]> {
|
||||
return pocketbaseService.getJobNotes(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a note for a job
|
||||
*/
|
||||
async createNote(
|
||||
jobId: string,
|
||||
content: string,
|
||||
userId: string,
|
||||
options?: { color?: StickyNote['color']; position_x?: number; position_y?: number }
|
||||
): Promise<StickyNote | null> {
|
||||
return pocketbaseService.createNote({
|
||||
job_id: jobId,
|
||||
content,
|
||||
color: options?.color ?? 'yellow',
|
||||
position_x: options?.position_x ?? 0,
|
||||
position_y: options?.position_y ?? 0,
|
||||
created_by: userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a note
|
||||
*/
|
||||
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
|
||||
return pocketbaseService.updateNote(noteId, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a note
|
||||
*/
|
||||
async deleteNote(noteId: string): Promise<boolean> {
|
||||
return pocketbaseService.deleteNote(noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate file cache for a job
|
||||
*/
|
||||
async invalidateFileCache(jobId: string): Promise<void> {
|
||||
await cacheService.del(CACHE_KEYS.JOB_FILES(jobId));
|
||||
}
|
||||
}
|
||||
|
||||
export const jobsService = new JobsService();
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* PocketBase Service - PocketBase integration
|
||||
*/
|
||||
|
||||
import PocketBase from 'pocketbase';
|
||||
import { env } from '../config/env';
|
||||
import type { PocketBaseUser } from '@shared/types/auth';
|
||||
import type { Job, StickyNote } from '@shared/types/job';
|
||||
|
||||
class PocketBaseService {
|
||||
private client: PocketBase;
|
||||
|
||||
constructor() {
|
||||
this.client = new PocketBase(env.POCKETBASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PocketBase client instance
|
||||
*/
|
||||
get pb(): PocketBase {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with token
|
||||
*/
|
||||
async authenticateWithToken(token: string): Promise<PocketBaseUser | null> {
|
||||
try {
|
||||
this.client.authStore.save(token, null);
|
||||
const authData = await this.client.collection('users').authRefresh();
|
||||
return authData.record as unknown as PocketBaseUser;
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Auth with token failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token using refresh token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<{ token: string; refreshToken: string }> {
|
||||
try {
|
||||
// PocketBase SDK handles refresh internally
|
||||
// We need to set the token first and then refresh
|
||||
this.client.authStore.save(refreshToken, null);
|
||||
const authData = await this.client.collection('users').authRefresh();
|
||||
|
||||
return {
|
||||
token: authData.token,
|
||||
refreshToken: authData.token, // PocketBase uses same token for refresh
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Token refresh failed:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
async getUser(userId: string): Promise<PocketBaseUser | null> {
|
||||
try {
|
||||
const record = await this.client.collection('users').getOne(userId);
|
||||
return record as unknown as PocketBaseUser;
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Get user failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs (with optional filters)
|
||||
*/
|
||||
async getJobs(options?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
filter?: string;
|
||||
sort?: string;
|
||||
}): Promise<{ items: Job[]; totalItems: number; totalPages: number }> {
|
||||
try {
|
||||
const result = await this.client.collection('jobs').getList(
|
||||
options?.page ?? 1,
|
||||
options?.perPage ?? 50,
|
||||
{
|
||||
filter: options?.filter,
|
||||
sort: options?.sort ?? '-created',
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
items: result.items as unknown as Job[],
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Get jobs failed:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single job by ID
|
||||
*/
|
||||
async getJob(jobId: string): Promise<Job | null> {
|
||||
try {
|
||||
const record = await this.client.collection('jobs').getOne(jobId);
|
||||
return record as unknown as Job;
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Get job failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notes for a job
|
||||
*/
|
||||
async getJobNotes(jobId: string): Promise<StickyNote[]> {
|
||||
try {
|
||||
const result = await this.client.collection('sticky_notes').getFullList({
|
||||
filter: `job_id = "${jobId}"`,
|
||||
sort: '-created',
|
||||
});
|
||||
return result as unknown as StickyNote[];
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Get job notes failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sticky note
|
||||
*/
|
||||
async createNote(note: Omit<StickyNote, 'id' | 'created' | 'updated'>): Promise<StickyNote | null> {
|
||||
try {
|
||||
const record = await this.client.collection('sticky_notes').create(note);
|
||||
return record as unknown as StickyNote;
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Create note failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a sticky note
|
||||
*/
|
||||
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
|
||||
try {
|
||||
const record = await this.client.collection('sticky_notes').update(noteId, data);
|
||||
return record as unknown as StickyNote;
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Update note failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a sticky note
|
||||
*/
|
||||
async deleteNote(noteId: string): Promise<boolean> {
|
||||
try {
|
||||
await this.client.collection('sticky_notes').delete(noteId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[PocketBase] Delete note failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear auth state
|
||||
*/
|
||||
clearAuth(): void {
|
||||
this.client.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const pocketbaseService = new PocketBaseService();
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Token Service - Token refresh and management
|
||||
*/
|
||||
|
||||
import { cacheService } from './cacheService';
|
||||
import { pocketbaseService } from './pocketbaseService';
|
||||
import { graphService } from './graphService';
|
||||
import { CACHE_KEYS, TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS, TOKEN_REFRESH_INTERVAL_MS } from '@shared/constants';
|
||||
import type { CachedToken, TokenType, TokenRefreshResponse } from '@shared/types/auth';
|
||||
import { env } from '../config/env';
|
||||
|
||||
class TokenService {
|
||||
private refreshLoops = new Map<string, Timer>();
|
||||
private activeUsers = new Set<string>();
|
||||
|
||||
/**
|
||||
* Check if token is expiring soon (within buffer)
|
||||
*/
|
||||
isExpiring(expiresAt: number): boolean {
|
||||
return expiresAt - Date.now() < env.TOKEN_EXPIRY_BUFFER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store tokens in cache after login
|
||||
*/
|
||||
async storeTokens(
|
||||
userId: string,
|
||||
pbToken: string,
|
||||
graphToken: string,
|
||||
refreshPb: string,
|
||||
refreshGraph: string
|
||||
): Promise<void> {
|
||||
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
|
||||
|
||||
// Store PocketBase token
|
||||
await cacheService.set<CachedToken>(
|
||||
CACHE_KEYS.TOKEN_PB(userId),
|
||||
{
|
||||
token: pbToken,
|
||||
refresh_token: refreshPb,
|
||||
expiresAt: expiresAt,
|
||||
userId: userId,
|
||||
},
|
||||
TOKEN_TTL_SECONDS
|
||||
);
|
||||
|
||||
// Store Graph token
|
||||
await cacheService.set<CachedToken>(
|
||||
CACHE_KEYS.TOKEN_GRAPH(userId),
|
||||
{
|
||||
token: graphToken,
|
||||
refresh_token: refreshGraph,
|
||||
expiresAt: expiresAt,
|
||||
userId: userId,
|
||||
},
|
||||
TOKEN_TTL_SECONDS
|
||||
);
|
||||
|
||||
// Store refresh tokens with longer TTL
|
||||
await cacheService.set(
|
||||
CACHE_KEYS.REFRESH_PB(userId),
|
||||
refreshPb,
|
||||
REFRESH_TOKEN_TTL_SECONDS
|
||||
);
|
||||
|
||||
await cacheService.set(
|
||||
CACHE_KEYS.REFRESH_GRAPH(userId),
|
||||
refreshGraph,
|
||||
REFRESH_TOKEN_TTL_SECONDS
|
||||
);
|
||||
|
||||
// Track active user
|
||||
this.activeUsers.add(userId);
|
||||
await cacheService.sadd(CACHE_KEYS.ACTIVE_USERS, userId);
|
||||
|
||||
console.log(`[TokenService] Tokens stored for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fresh token for user (from cache or refresh)
|
||||
*/
|
||||
async getToken(userId: string, type: TokenType): Promise<TokenRefreshResponse | null> {
|
||||
const cacheKey = type === 'pb'
|
||||
? CACHE_KEYS.TOKEN_PB(userId)
|
||||
: CACHE_KEYS.TOKEN_GRAPH(userId);
|
||||
|
||||
// Try to get from cache
|
||||
const cached = await cacheService.get<CachedToken>(cacheKey);
|
||||
|
||||
if (cached && !this.isExpiring(cached.expiresAt)) {
|
||||
return {
|
||||
token: cached.token,
|
||||
expiresAt: cached.expiresAt,
|
||||
source: 'cache',
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
// Need to refresh
|
||||
const refreshed = await this.refreshToken(userId, type);
|
||||
if (refreshed) {
|
||||
return {
|
||||
token: refreshed.token,
|
||||
expiresAt: refreshed.expiresAt,
|
||||
source: 'refreshed',
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
// Return stale token if available (better than nothing for field workers)
|
||||
if (cached) {
|
||||
console.warn(`[TokenService] Returning stale ${type} token for user ${userId}`);
|
||||
return {
|
||||
token: cached.token,
|
||||
expiresAt: cached.expiresAt,
|
||||
source: 'cache',
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a specific token type
|
||||
*/
|
||||
async refreshToken(userId: string, type: TokenType): Promise<CachedToken | null> {
|
||||
const refreshKey = type === 'pb'
|
||||
? CACHE_KEYS.REFRESH_PB(userId)
|
||||
: CACHE_KEYS.REFRESH_GRAPH(userId);
|
||||
|
||||
const refreshToken = await cacheService.get<string>(refreshKey);
|
||||
if (!refreshToken) {
|
||||
console.error(`[TokenService] No refresh token for ${type} user ${userId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let newToken: string;
|
||||
let newRefreshToken: string;
|
||||
|
||||
if (type === 'pb') {
|
||||
const result = await pocketbaseService.refreshToken(refreshToken);
|
||||
newToken = result.token;
|
||||
newRefreshToken = result.refreshToken;
|
||||
} else {
|
||||
const result = await graphService.refreshToken(refreshToken);
|
||||
newToken = result.accessToken;
|
||||
newRefreshToken = result.refreshToken;
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
|
||||
const cachedToken: CachedToken = {
|
||||
token: newToken,
|
||||
refresh_token: newRefreshToken,
|
||||
expiresAt: expiresAt,
|
||||
userId: userId,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
const cacheKey = type === 'pb'
|
||||
? CACHE_KEYS.TOKEN_PB(userId)
|
||||
: CACHE_KEYS.TOKEN_GRAPH(userId);
|
||||
|
||||
await cacheService.set(cacheKey, cachedToken, TOKEN_TTL_SECONDS);
|
||||
await cacheService.set(refreshKey, newRefreshToken, REFRESH_TOKEN_TTL_SECONDS);
|
||||
|
||||
console.log(`[TokenService] Refreshed ${type} token for user ${userId}`);
|
||||
return cachedToken;
|
||||
} catch (err) {
|
||||
console.error(`[TokenService] Failed to refresh ${type} token for user ${userId}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background refresh loop for a user
|
||||
*/
|
||||
startRefreshLoop(userId: string): void {
|
||||
if (this.refreshLoops.has(userId)) {
|
||||
console.log(`[TokenService] Refresh loop already running for ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const loop = setInterval(async () => {
|
||||
await this.refreshUserTokens(userId);
|
||||
}, TOKEN_REFRESH_INTERVAL_MS);
|
||||
|
||||
this.refreshLoops.set(userId, loop);
|
||||
this.activeUsers.add(userId);
|
||||
console.log(`[TokenService] Started refresh loop for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop background refresh loop for a user
|
||||
*/
|
||||
stopRefreshLoop(userId: string): void {
|
||||
const loop = this.refreshLoops.get(userId);
|
||||
if (loop) {
|
||||
clearInterval(loop);
|
||||
this.refreshLoops.delete(userId);
|
||||
this.activeUsers.delete(userId);
|
||||
console.log(`[TokenService] Stopped refresh loop for user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all tokens for a user (called by background loop)
|
||||
*/
|
||||
private async refreshUserTokens(userId: string): Promise<void> {
|
||||
console.log(`[TokenService] Background refresh for user ${userId}`);
|
||||
|
||||
// Check PocketBase token
|
||||
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
|
||||
if (pbToken && this.isExpiring(pbToken.expiresAt)) {
|
||||
await this.refreshToken(userId, 'pb');
|
||||
}
|
||||
|
||||
// Check Graph token
|
||||
const graphToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_GRAPH(userId));
|
||||
if (graphToken && this.isExpiring(graphToken.expiresAt)) {
|
||||
await this.refreshToken(userId, 'graph');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tokens for a user (logout)
|
||||
*/
|
||||
async clearUserTokens(userId: string): Promise<void> {
|
||||
this.stopRefreshLoop(userId);
|
||||
|
||||
await Promise.all([
|
||||
cacheService.del(CACHE_KEYS.TOKEN_PB(userId)),
|
||||
cacheService.del(CACHE_KEYS.TOKEN_GRAPH(userId)),
|
||||
cacheService.del(CACHE_KEYS.REFRESH_PB(userId)),
|
||||
cacheService.del(CACHE_KEYS.REFRESH_GRAPH(userId)),
|
||||
]);
|
||||
|
||||
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
|
||||
console.log(`[TokenService] Cleared all tokens for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore refresh loops for active users (on server restart)
|
||||
*/
|
||||
async restoreRefreshLoops(): Promise<void> {
|
||||
const activeUsers = await cacheService.smembers(CACHE_KEYS.ACTIVE_USERS);
|
||||
|
||||
for (const userId of activeUsers) {
|
||||
// Check if user still has valid tokens
|
||||
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
|
||||
if (pbToken) {
|
||||
this.startRefreshLoop(userId);
|
||||
} else {
|
||||
// Remove stale user
|
||||
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[TokenService] Restored ${this.refreshLoops.size} refresh loops`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active user count
|
||||
*/
|
||||
get activeUserCount(): number {
|
||||
return this.activeUsers.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenService = new TokenService();
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "job-info-prod",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"ioredis": "^5.3.2",
|
||||
"pocketbase": "^0.26.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.3.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
|
||||
|
||||
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pocketbase": ["pocketbase@0.26.5", "", {}, "sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,181 @@
|
||||
// PocketBase auth bootstrap shared by pages
|
||||
(function (global) {
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
const GRAPH_TOKEN_KEY = 'graphAccessToken';
|
||||
const USER_ID_KEY = 'userId';
|
||||
|
||||
function authHeaders() {
|
||||
if (pb.authStore.isValid && pb.authStore.token) {
|
||||
return { Authorization: `Bearer ${pb.authStore.token}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getDisplayName() {
|
||||
const model = pb.authStore.model || {};
|
||||
return (
|
||||
model.display_name ||
|
||||
model.name ||
|
||||
model.email ||
|
||||
model.username ||
|
||||
'Unknown'
|
||||
);
|
||||
}
|
||||
|
||||
function getEmail() {
|
||||
const model = pb.authStore.model || {};
|
||||
return model.email || model.username || null;
|
||||
}
|
||||
|
||||
function getUserId() {
|
||||
return pb.authStore.model?.id || localStorage.getItem(USER_ID_KEY) || null;
|
||||
}
|
||||
|
||||
// Get Graph token - first from localStorage, then try to refresh from backend
|
||||
async function getGraphToken() {
|
||||
// First check localStorage
|
||||
let token = localStorage.getItem(GRAPH_TOKEN_KEY);
|
||||
|
||||
// If we have a valid PB session but no graph token, try to get from backend
|
||||
if (!token && pb.authStore.isValid) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh-token?type=graph', {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
token = data.token;
|
||||
localStorage.setItem(GRAPH_TOKEN_KEY, token);
|
||||
console.log('[Auth] Got Graph token from backend cache');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Auth] Could not get Graph token from backend:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Synchronous version for backwards compatibility
|
||||
function getGraphTokenSync() {
|
||||
return localStorage.getItem(GRAPH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function ensureAuth() {
|
||||
if (pb.authStore.isValid) {
|
||||
// Try to refresh tokens from backend on page load
|
||||
try {
|
||||
await refreshTokensFromBackend();
|
||||
} catch (err) {
|
||||
console.warn('[Auth] Token refresh failed:', err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const path = new URL(window.location.href).pathname;
|
||||
if (!path.endsWith('signin.html')) {
|
||||
window.location.href = 'signin.html';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Refresh tokens from backend cache
|
||||
async function refreshTokensFromBackend() {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const userId = getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
// Get fresh Graph token from backend
|
||||
const response = await fetch('/api/auth/refresh-token?type=graph', {
|
||||
headers: authHeaders()
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
localStorage.setItem(GRAPH_TOKEN_KEY, data.token);
|
||||
console.log('[Auth] Refreshed Graph token from backend, source:', data.source);
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
// Token expired or invalid in backend, but we might still have a valid PB session
|
||||
// Try to re-store our current tokens
|
||||
console.log('[Auth] Backend token expired, attempting to re-store');
|
||||
await restoreTokensToBackend();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Auth] Token refresh error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-store tokens to backend (in case backend cache was cleared)
|
||||
async function restoreTokensToBackend() {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
// Refresh PocketBase auth to potentially get a new Graph token
|
||||
const authData = await pb.collection('users').authRefresh();
|
||||
|
||||
const graphToken = extractGraphToken(authData) || localStorage.getItem(GRAPH_TOKEN_KEY);
|
||||
|
||||
if (graphToken) {
|
||||
const response = await fetch('/api/auth/login-callback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pbToken: pb.authStore.token,
|
||||
graphToken: graphToken,
|
||||
refreshTokenPb: '',
|
||||
refreshTokenGraph: authData?.meta?.refreshToken || '',
|
||||
user: {
|
||||
id: pb.authStore.model?.id,
|
||||
email: pb.authStore.model?.email,
|
||||
name: pb.authStore.model?.name || pb.authStore.model?.display_name
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Auth] Re-stored tokens in backend');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Auth] Failed to re-store tokens:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function extractGraphToken(authData) {
|
||||
return (
|
||||
authData?.meta?.graphAccessToken ||
|
||||
authData?.meta?.graph_token ||
|
||||
authData?.meta?.graphToken ||
|
||||
authData?.meta?.accessToken ||
|
||||
authData?.meta?.access_token ||
|
||||
authData?.meta?.token ||
|
||||
authData?.meta?.rawToken ||
|
||||
authData?.meta?.authData?.access_token ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
global.Auth = {
|
||||
pb,
|
||||
authHeaders,
|
||||
ensureAuth,
|
||||
getDisplayName,
|
||||
getEmail,
|
||||
getUserId,
|
||||
getGraphToken,
|
||||
getGraphTokenSync,
|
||||
refreshTokensFromBackend,
|
||||
restoreTokensToBackend,
|
||||
extractGraphToken,
|
||||
GRAPH_TOKEN_KEY,
|
||||
USER_ID_KEY
|
||||
};
|
||||
})(window);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
/* Generated Tailwind CSS - Basic version */
|
||||
/* This will be updated by tailwind CLI on dev */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,176 @@
|
||||
<!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>
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 650 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 457 B |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "job-info-prod",
|
||||
"version": "2.0.0",
|
||||
"description": "Professional field management application with dual-token authentication",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch backend/src/index.ts",
|
||||
"start": "bun run backend/src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.10.8",
|
||||
"ioredis": "^5.3.2",
|
||||
"pocketbase": "^0.26.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared Constants
|
||||
*/
|
||||
|
||||
/** Token refresh interval in milliseconds (30 minutes) */
|
||||
export const TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Buffer before token expiry to trigger refresh (15 minutes) */
|
||||
export const TOKEN_EXPIRY_BUFFER_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Token TTL in cache (1 hour) */
|
||||
export const TOKEN_TTL_SECONDS = 3600;
|
||||
|
||||
/** Refresh token TTL in cache (30 days) */
|
||||
export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
/** API request timeout in milliseconds */
|
||||
export const API_TIMEOUT_MS = 30000;
|
||||
|
||||
/** Cache key prefixes */
|
||||
export const CACHE_KEYS = {
|
||||
TOKEN_PB: (userId: string) => `tokens:${userId}:pb`,
|
||||
TOKEN_GRAPH: (userId: string) => `tokens:${userId}:graph`,
|
||||
REFRESH_PB: (userId: string) => `tokens:${userId}:refresh_pb`,
|
||||
REFRESH_GRAPH: (userId: string) => `tokens:${userId}:refresh_graph`,
|
||||
JOB_FILES: (jobId: string) => `jobs:${jobId}:files`,
|
||||
ACTIVE_USERS: 'active_users',
|
||||
} as const;
|
||||
|
||||
/** Local storage keys */
|
||||
export const STORAGE_KEYS = {
|
||||
PB_TOKEN: 'job-info:pb_token',
|
||||
GRAPH_TOKEN: 'job-info:graph_token',
|
||||
USER: 'job-info:user',
|
||||
EXPIRES_AT: 'job-info:expires_at',
|
||||
LAST_SYNC: 'job-info:last_sync',
|
||||
JOBS_CACHE: 'job-info:jobs',
|
||||
} as const;
|
||||
|
||||
/** API endpoints */
|
||||
export const API_ENDPOINTS = {
|
||||
LOGIN_CALLBACK: '/api/auth/login-callback',
|
||||
LOGOUT: '/api/auth/logout',
|
||||
REFRESH_TOKEN: '/api/auth/refresh-token',
|
||||
JOBS: '/api/jobs',
|
||||
FILES: '/api/files',
|
||||
HEALTH: '/api/health',
|
||||
} as const;
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Authentication Types - Shared between frontend and backend
|
||||
*/
|
||||
|
||||
/** Token types supported by the system */
|
||||
export type TokenType = 'pb' | 'graph';
|
||||
|
||||
/** Stored token data in cache */
|
||||
export interface CachedToken {
|
||||
token: string;
|
||||
refresh_token?: string;
|
||||
expiresAt: number;
|
||||
userId: string;
|
||||
source?: 'cache' | 'refreshed';
|
||||
}
|
||||
|
||||
/** Token response from refresh endpoint */
|
||||
export interface TokenRefreshResponse {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
source: 'cache' | 'refreshed';
|
||||
type: TokenType;
|
||||
}
|
||||
|
||||
/** User session data from PocketBase */
|
||||
export interface PocketBaseUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
verified: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
/** Alias for PocketBaseUser */
|
||||
export type User = PocketBaseUser;
|
||||
|
||||
/** Authentication state for frontend */
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user?: PocketBaseUser | null;
|
||||
pbToken?: string | null;
|
||||
graphToken?: string | null;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/** Login callback request body */
|
||||
export interface LoginCallbackRequest {
|
||||
pbToken: string;
|
||||
graphToken: string;
|
||||
refreshTokenPb?: string;
|
||||
refreshTokenGraph?: string;
|
||||
user: PocketBaseUser;
|
||||
}
|
||||
|
||||
/** Login callback response */
|
||||
export interface LoginCallbackResponse {
|
||||
success: boolean;
|
||||
user: PocketBaseUser;
|
||||
expiresAt_pb: number;
|
||||
expiresAt_graph: number;
|
||||
}
|
||||
|
||||
/** Logout response */
|
||||
export interface LogoutResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/** Token validation result */
|
||||
export interface TokenValidation {
|
||||
valid: boolean;
|
||||
expired: boolean;
|
||||
expiresIn: number;
|
||||
userId?: string;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Job Types - Shared between frontend and backend
|
||||
*/
|
||||
|
||||
/** Job status */
|
||||
export type JobStatus = 'active' | 'completed' | 'pending' | 'cancelled' | 'Active' | 'Completed' | 'Pending' | 'On Hold';
|
||||
|
||||
/** Job record from PocketBase */
|
||||
export interface Job {
|
||||
id: string;
|
||||
job_number: string;
|
||||
job_name: string;
|
||||
client_name: string;
|
||||
status: JobStatus;
|
||||
site_id?: string;
|
||||
drive_id?: string;
|
||||
folder_path?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
// Computed/alias properties for frontend convenience
|
||||
jobNumber?: string;
|
||||
customer?: string;
|
||||
address?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
notes?: StickyNote[];
|
||||
}
|
||||
|
||||
/** Job file from SharePoint/Graph API */
|
||||
export interface JobFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
webUrl: string;
|
||||
downloadUrl?: string;
|
||||
createdDateTime: string;
|
||||
lastModifiedDateTime: string;
|
||||
createdBy?: {
|
||||
user: {
|
||||
displayName: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
lastModifiedBy?: {
|
||||
user: {
|
||||
displayName: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Job files response */
|
||||
export interface JobFilesResponse {
|
||||
jobId: string;
|
||||
files: JobFile[];
|
||||
nextLink?: string;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
/** Sticky note for a job */
|
||||
export interface StickyNote {
|
||||
id: string;
|
||||
job_id: string;
|
||||
content: string;
|
||||
color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple';
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
created_by: string;
|
||||
// Alias for frontend
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/** Job list response */
|
||||
export interface JobListResponse {
|
||||
jobs: Job[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/** Job detail response */
|
||||
export interface JobDetailResponse {
|
||||
job: Job;
|
||||
files: JobFile[];
|
||||
notes: StickyNote[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./frontend/public/**/*.html',
|
||||
'./frontend/src/**/*.{js,ts}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand colors
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@backend/*": ["./backend/src/*"],
|
||||
"@frontend/*": ["./frontend/src/*"],
|
||||
"@shared/*": ["./shared/*"]
|
||||
},
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": [
|
||||
"backend/src/**/*.ts",
|
||||
"frontend/src/**/*.ts",
|
||||
"shared/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user