Clean up: keep only Mode6Test and ModeSwitch folders
@@ -1,28 +0,0 @@
|
|||||||
# Server
|
|
||||||
PORT=3005
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Redis/Valkey
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=
|
|
||||||
|
|
||||||
# PocketBase
|
|
||||||
POCKETBASE_URL=https://pocketbase.ccllc.pro
|
|
||||||
POCKETBASE_ADMIN_EMAIL=
|
|
||||||
POCKETBASE_ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# Microsoft OAuth
|
|
||||||
MICROSOFT_CLIENT_ID=
|
|
||||||
MICROSOFT_CLIENT_SECRET=
|
|
||||||
MICROSOFT_TENANT=common
|
|
||||||
MICROSOFT_REDIRECT_URI=http://localhost:3005/api/auth/callback
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=info
|
|
||||||
LOG_FILE=logs/app.log
|
|
||||||
ERROR_LOG_FILE=logs/error.log
|
|
||||||
|
|
||||||
# Token Settings
|
|
||||||
TOKEN_REFRESH_INTERVAL_MS=1800000
|
|
||||||
TOKEN_EXPIRY_BUFFER_MS=900000
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Session Changelog - January 1, 2026
|
|
||||||
|
|
||||||
## Production Setup Session
|
|
||||||
|
|
||||||
### What Was Done
|
|
||||||
|
|
||||||
1. **Backend Setup**
|
|
||||||
- Copied working `server.ts` from Job-Info-Test
|
|
||||||
- Added `redis-cache.ts` for Valkey/Redis caching
|
|
||||||
- Fixed static file path to serve from `frontend/public`
|
|
||||||
- Fast job loading: returns first 40 jobs instantly, then full list on poll
|
|
||||||
|
|
||||||
2. **Frontend Setup**
|
|
||||||
- Copied working frontend from Job-Info-Test to `frontend/public/`
|
|
||||||
- Simplified token validation at startup:
|
|
||||||
- Check PocketBase session valid
|
|
||||||
- Check Graph token in localStorage
|
|
||||||
- If either missing → redirect to signin
|
|
||||||
- Removed redundant token refresh loops
|
|
||||||
- Added localStorage storage for Graph token in signin.html
|
|
||||||
|
|
||||||
3. **Authentication Flow**
|
|
||||||
- PocketBase OAuth with Microsoft provider
|
|
||||||
- Graph token stored in localStorage after signin
|
|
||||||
- Both tokens checked once at page load
|
|
||||||
- No repeated token validation during app usage
|
|
||||||
|
|
||||||
4. **Systemd Service**
|
|
||||||
- Created `/etc/systemd/system/job-info-prod.service`
|
|
||||||
- Runs on port 3005
|
|
||||||
- Auto-restarts on failure
|
|
||||||
|
|
||||||
5. **Git Repository**
|
|
||||||
- Initialized and pushed to: https://gitea.ccllc.pro/Cardoza_Construction/Job-Info-Prod.git
|
|
||||||
- Branch: main
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `backend/src/index.ts` | Main Hono server with all endpoints |
|
|
||||||
| `backend/src/redis-cache.ts` | Redis/Valkey cache helpers |
|
|
||||||
| `frontend/public/index.html` | Main app UI |
|
|
||||||
| `frontend/public/signin.html` | OAuth signin page (purple styling) |
|
|
||||||
| `frontend/public/auth.js` | PocketBase auth bootstrap |
|
|
||||||
|
|
||||||
### Endpoints
|
|
||||||
|
|
||||||
- `GET /` - Serve frontend
|
|
||||||
- `GET /version` - App version
|
|
||||||
- `GET /api/health` - Health check
|
|
||||||
- `GET /api/jobs-all` - All jobs (first 40 fast, then full)
|
|
||||||
- `GET /api/jobs` - Paginated jobs
|
|
||||||
- `GET /api/job-files` - SharePoint files via Graph API
|
|
||||||
- `GET /api/job-file-content` - File content/download
|
|
||||||
- `GET /api/job-file-pdf` - PDF proxy
|
|
||||||
- `POST /log-change` - Log changes
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
- Port: 3005
|
|
||||||
- PocketBase: https://pocketbase.ccllc.pro
|
|
||||||
- Collection: pbc_1407612689
|
|
||||||
- Redis/Valkey: localhost:6379
|
|
||||||
|
|
||||||
### Known Limitations
|
|
||||||
|
|
||||||
- Graph tokens expire after ~60 minutes (Microsoft limitation)
|
|
||||||
- No automatic Graph token refresh (would require direct OAuth flow)
|
|
||||||
- User must re-signin when Graph token expires
|
|
||||||
|
|
||||||
### Next Steps
|
|
||||||
|
|
||||||
- Consider implementing direct Microsoft OAuth for refresh token support
|
|
||||||
- Add error boundaries for better UX on token expiry
|
|
||||||
- Performance monitoring
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# Job-Info-Prod
|
|
||||||
|
|
||||||
Production-ready job information management system for field workers.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Dual-Token Authentication**: PocketBase + Microsoft Graph tokens
|
|
||||||
- **Always-Warm Cache**: Background refresh loop keeps tokens fresh
|
|
||||||
- **Offline-First**: Works without internet using cached data
|
|
||||||
- **Zero Re-Auth**: Users never need to login again after initial auth
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Runtime**: Bun
|
|
||||||
- **Backend**: Hono
|
|
||||||
- **Auth**: PocketBase + Microsoft OAuth
|
|
||||||
- **Cache**: Redis/Valkey via ioredis
|
|
||||||
- **Frontend**: TypeScript + Tailwind CSS
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Copy environment file
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Edit .env with your configuration
|
|
||||||
nano .env
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
See `.env.example` for all required variables:
|
|
||||||
|
|
||||||
- `PORT` - Server port (default: 3000)
|
|
||||||
- `POCKETBASE_URL` - PocketBase instance URL
|
|
||||||
- `MS_CLIENT_ID` - Microsoft OAuth client ID
|
|
||||||
- `MS_CLIENT_SECRET` - Microsoft OAuth client secret
|
|
||||||
- `MS_TENANT_ID` - Microsoft tenant ID
|
|
||||||
- `REDIS_HOST` - Redis/Valkey host
|
|
||||||
- `REDIS_PORT` - Redis/Valkey port
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐
|
|
||||||
│ Frontend │────▶│ Hono Backend │
|
|
||||||
│ (TypeScript) │◀────│ │
|
|
||||||
└─────────────────┘ └────────┬─────────┘
|
|
||||||
│
|
|
||||||
┌────────────────────────┼────────────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
||||||
│ PocketBase │ │ Redis/Valkey │ │ Microsoft Graph │
|
|
||||||
│ (Auth+Data) │ │ (Token Cache) │ │ (SharePoint) │
|
|
||||||
└───────────────┘ └─────────────────┘ └─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Token Refresh Flow
|
|
||||||
|
|
||||||
1. User completes OAuth flow → tokens stored in localStorage + cache
|
|
||||||
2. Backend starts 30-min refresh loop per user
|
|
||||||
3. Frontend checks backend for fresh tokens every 25 min
|
|
||||||
4. If backend unreachable, frontend uses localStorage cache
|
|
||||||
5. Stale tokens returned as fallback (field workers can't wait)
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
- `bun run dev` - Start development server with watch
|
|
||||||
- `bun run start` - Start production server
|
|
||||||
- `bun run build:css` - Build Tailwind CSS
|
|
||||||
- `bun run typecheck` - Run TypeScript type checking
|
|
||||||
- `bun run test` - Run tests
|
|
||||||
|
|
||||||
## Folder Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Job-Info-Prod/
|
|
||||||
├── backend/
|
|
||||||
│ └── src/
|
|
||||||
│ ├── config/ # Environment and constants
|
|
||||||
│ ├── middleware/ # Auth, logging, error handling
|
|
||||||
│ ├── routes/ # API route handlers
|
|
||||||
│ ├── services/ # Business logic
|
|
||||||
│ └── index.ts # Server entry point
|
|
||||||
├── frontend/
|
|
||||||
│ ├── public/ # Static HTML files
|
|
||||||
│ └── src/
|
|
||||||
│ ├── auth/ # Auth client
|
|
||||||
│ ├── components/ # UI components
|
|
||||||
│ ├── services/ # API clients
|
|
||||||
│ ├── styles/ # Tailwind CSS
|
|
||||||
│ └── app.ts # Main app entry
|
|
||||||
└── shared/
|
|
||||||
├── constants/ # Shared constants
|
|
||||||
└── types/ # TypeScript types
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* Application Constants
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const APP_NAME = 'Job Info';
|
|
||||||
export const APP_VERSION = '2.0.0';
|
|
||||||
|
|
||||||
/** HTTP Status codes */
|
|
||||||
export const HTTP_STATUS = {
|
|
||||||
OK: 200,
|
|
||||||
CREATED: 201,
|
|
||||||
NO_CONTENT: 204,
|
|
||||||
BAD_REQUEST: 400,
|
|
||||||
UNAUTHORIZED: 401,
|
|
||||||
FORBIDDEN: 403,
|
|
||||||
NOT_FOUND: 404,
|
|
||||||
INTERNAL_SERVER_ERROR: 500,
|
|
||||||
SERVICE_UNAVAILABLE: 503,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Error messages */
|
|
||||||
export const ERROR_MESSAGES = {
|
|
||||||
UNAUTHORIZED: 'Authentication required',
|
|
||||||
TOKEN_EXPIRED: 'Token has expired',
|
|
||||||
TOKEN_INVALID: 'Invalid token',
|
|
||||||
TOKEN_REFRESH_FAILED: 'Failed to refresh token',
|
|
||||||
USER_NOT_FOUND: 'User not found',
|
|
||||||
JOB_NOT_FOUND: 'Job not found',
|
|
||||||
CACHE_ERROR: 'Cache operation failed',
|
|
||||||
POCKETBASE_ERROR: 'PocketBase operation failed',
|
|
||||||
GRAPH_API_ERROR: 'Microsoft Graph API error',
|
|
||||||
INTERNAL_ERROR: 'Internal server error',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Success messages */
|
|
||||||
export const SUCCESS_MESSAGES = {
|
|
||||||
LOGIN_SUCCESS: 'Login successful',
|
|
||||||
LOGOUT_SUCCESS: 'Logout successful',
|
|
||||||
TOKEN_REFRESHED: 'Token refreshed successfully',
|
|
||||||
} as const;
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Environment Configuration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { config } from 'dotenv';
|
|
||||||
|
|
||||||
// Load .env file
|
|
||||||
config();
|
|
||||||
|
|
||||||
interface EnvConfig {
|
|
||||||
PORT: number;
|
|
||||||
NODE_ENV: 'development' | 'production' | 'test';
|
|
||||||
ALLOWED_ORIGINS: string[];
|
|
||||||
|
|
||||||
// Redis/Valkey
|
|
||||||
REDIS_HOST: string;
|
|
||||||
REDIS_PORT: number;
|
|
||||||
REDIS_PASSWORD: string;
|
|
||||||
|
|
||||||
// PocketBase
|
|
||||||
POCKETBASE_URL: string;
|
|
||||||
PB_COLLECTION: string;
|
|
||||||
PB_AUTH_COLLECTION: string;
|
|
||||||
POCKETBASE_ADMIN_EMAIL: string;
|
|
||||||
POCKETBASE_ADMIN_PASSWORD: string;
|
|
||||||
|
|
||||||
// Microsoft OAuth
|
|
||||||
MICROSOFT_CLIENT_ID: string;
|
|
||||||
MICROSOFT_CLIENT_SECRET: string;
|
|
||||||
MICROSOFT_TENANT: string;
|
|
||||||
MICROSOFT_REDIRECT_URI: string;
|
|
||||||
MS_GRAPH_TOKEN: string;
|
|
||||||
|
|
||||||
// Microsoft Graph / SharePoint
|
|
||||||
DRIVE_ID: string;
|
|
||||||
ITEM_ID: string;
|
|
||||||
EXCEL_TABLE_NAME: string;
|
|
||||||
|
|
||||||
// Logging
|
|
||||||
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
LOG_FILE: string;
|
|
||||||
ERROR_LOG_FILE: string;
|
|
||||||
|
|
||||||
// Token settings
|
|
||||||
TOKEN_REFRESH_INTERVAL_MS: number;
|
|
||||||
TOKEN_EXPIRY_BUFFER_MS: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnv<T extends string | number>(key: string, defaultValue: T): T {
|
|
||||||
const value = process.env[key];
|
|
||||||
if (value === undefined) return defaultValue;
|
|
||||||
|
|
||||||
if (typeof defaultValue === 'number') {
|
|
||||||
return parseInt(value, 10) as T;
|
|
||||||
}
|
|
||||||
return value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnvArray(key: string, defaultValue: string[]): string[] {
|
|
||||||
const value = process.env[key];
|
|
||||||
if (!value) return defaultValue;
|
|
||||||
return value.split(',').map(s => s.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export const env: EnvConfig = {
|
|
||||||
PORT: getEnv('PORT', 3005),
|
|
||||||
NODE_ENV: getEnv('NODE_ENV', 'development') as EnvConfig['NODE_ENV'],
|
|
||||||
ALLOWED_ORIGINS: getEnvArray('ALLOWED_ORIGINS', ['http://localhost:3005', 'http://localhost:5173']),
|
|
||||||
|
|
||||||
REDIS_HOST: getEnv('REDIS_HOST', 'localhost'),
|
|
||||||
REDIS_PORT: getEnv('REDIS_PORT', 6379),
|
|
||||||
REDIS_PASSWORD: getEnv('REDIS_PASSWORD', ''),
|
|
||||||
|
|
||||||
POCKETBASE_URL: getEnv('POCKETBASE_URL', 'https://pocketbase.ccllc.pro'),
|
|
||||||
PB_COLLECTION: getEnv('PB_COLLECTION', 'Job_Info_Prod'),
|
|
||||||
PB_AUTH_COLLECTION: getEnv('PB_AUTH_COLLECTION', 'Users'),
|
|
||||||
POCKETBASE_ADMIN_EMAIL: getEnv('POCKETBASE_ADMIN_EMAIL', ''),
|
|
||||||
POCKETBASE_ADMIN_PASSWORD: getEnv('POCKETBASE_ADMIN_PASSWORD', ''),
|
|
||||||
|
|
||||||
MICROSOFT_CLIENT_ID: getEnv('MICROSOFT_CLIENT_ID', ''),
|
|
||||||
MICROSOFT_CLIENT_SECRET: getEnv('MICROSOFT_CLIENT_SECRET', ''),
|
|
||||||
MICROSOFT_TENANT: getEnv('MICROSOFT_TENANT', '3fd97ea7-b124-41f1-855f-52d8ac3b16c7'),
|
|
||||||
MICROSOFT_REDIRECT_URI: getEnv('MICROSOFT_REDIRECT_URI', 'https://pocketbase.ccllc.pro/api/oauth2-redirect'),
|
|
||||||
MS_GRAPH_TOKEN: getEnv('MS_GRAPH_TOKEN', ''),
|
|
||||||
|
|
||||||
DRIVE_ID: getEnv('DRIVE_ID', ''),
|
|
||||||
ITEM_ID: getEnv('ITEM_ID', ''),
|
|
||||||
EXCEL_TABLE_NAME: getEnv('EXCEL_TABLE_NAME', 'Job_List'),
|
|
||||||
|
|
||||||
LOG_LEVEL: getEnv('LOG_LEVEL', 'info') as EnvConfig['LOG_LEVEL'],
|
|
||||||
LOG_FILE: getEnv('LOG_FILE', 'logs/app.log'),
|
|
||||||
ERROR_LOG_FILE: getEnv('ERROR_LOG_FILE', 'logs/error.log'),
|
|
||||||
|
|
||||||
TOKEN_REFRESH_INTERVAL_MS: getEnv('TOKEN_REFRESH_INTERVAL_MS', 1800000),
|
|
||||||
TOKEN_EXPIRY_BUFFER_MS: getEnv('TOKEN_EXPIRY_BUFFER_MS', 900000),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateEnv(): void {
|
|
||||||
const required = [
|
|
||||||
'POCKETBASE_URL',
|
|
||||||
'MICROSOFT_CLIENT_ID',
|
|
||||||
'MICROSOFT_CLIENT_SECRET',
|
|
||||||
'MICROSOFT_TENANT',
|
|
||||||
];
|
|
||||||
|
|
||||||
const missing = required.filter(key => !process.env[key]);
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
console.warn(`Warning: Missing environment variables: ${missing.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,905 +0,0 @@
|
|||||||
import { Hono } from 'hono';
|
|
||||||
import { serveStatic } from 'hono/bun';
|
|
||||||
import fs from 'fs';
|
|
||||||
// @ts-ignore Bun project may not have dotenv types configured
|
|
||||||
import { config } from 'dotenv';
|
|
||||||
import { getCache, setCache, isRedisConnected } from './redis-cache';
|
|
||||||
|
|
||||||
config();
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
// Service Principal token cache
|
|
||||||
let appOnlyToken: string | null = null;
|
|
||||||
let appOnlyTokenExpiry: number = 0;
|
|
||||||
|
|
||||||
// Get app-only token using client credentials flow
|
|
||||||
async function getAppOnlyToken(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
// Check if we have a valid cached token
|
|
||||||
if (appOnlyToken && appOnlyTokenExpiry > Date.now()) {
|
|
||||||
return appOnlyToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientId = process.env.MICROSOFT_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
|
|
||||||
const tenant = process.env.MICROSOFT_TENANT || 'common';
|
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
|
||||||
return null; // Service principal not configured
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
scope: 'https://graph.microsoft.com/.default',
|
|
||||||
grant_type: 'client_credentials'
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(tokenUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: params.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
logLine('logs/error.log', `Failed to get app-only token: ${error}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as any;
|
|
||||||
appOnlyToken = data.access_token;
|
|
||||||
// Set expiry to 55 minutes (token is valid for 60 minutes)
|
|
||||||
appOnlyTokenExpiry = Date.now() + (55 * 60 * 1000);
|
|
||||||
|
|
||||||
logLine('logs/server.log', `✓ Got app-only token (expires in 55 minutes)`);
|
|
||||||
return appOnlyToken;
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `getAppOnlyToken error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal log helpers
|
|
||||||
const logLine = (path: string, line: string) => {
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(path, `${new Date().toISOString()} ${line}\n`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to write log:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Version endpoint sourced from package.json
|
|
||||||
app.get('/version', (c) => {
|
|
||||||
try {
|
|
||||||
const pkgRaw = fs.readFileSync('./package.json', 'utf-8');
|
|
||||||
const pkg = JSON.parse(pkgRaw);
|
|
||||||
return c.json({ version: pkg.version || '0.0.0' });
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `version endpoint error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
return c.json({ version: '0.0.0' }, 200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Jobs endpoint with Redis caching
|
|
||||||
app.get('/api/jobs', async (c) => {
|
|
||||||
const page = parseInt(c.req.query('page') || '1');
|
|
||||||
const perPage = parseInt(c.req.query('perPage') || '50');
|
|
||||||
const sort = c.req.query('sort') || '-Job_Number';
|
|
||||||
|
|
||||||
const cacheKey = `jobs:page:${page}:perPage:${perPage}:sort:${sort}`;
|
|
||||||
const ttl = 300; // 5 minutes cache
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to get from cache first
|
|
||||||
if (isRedisConnected()) {
|
|
||||||
const cached = await getCache(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
logLine('logs/server.log', `Cache HIT for ${cacheKey}`);
|
|
||||||
return c.json(cached);
|
|
||||||
}
|
|
||||||
logLine('logs/server.log', `Cache MISS for ${cacheKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch from PocketBase
|
|
||||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=${sort}`;
|
|
||||||
const authHeader = c.req.header('Authorization') || '';
|
|
||||||
|
|
||||||
const response = await fetch(pbUrl, {
|
|
||||||
headers: authHeader ? { Authorization: authHeader } : {}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`PocketBase returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
if (isRedisConnected()) {
|
|
||||||
await setCache(cacheKey, data, ttl);
|
|
||||||
logLine('logs/server.log', `Cached ${cacheKey} for ${ttl}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(data);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `jobs endpoint error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
return c.json({ error: 'Failed to fetch jobs' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fast endpoint: returns all jobs in a single cached request
|
|
||||||
app.get('/api/jobs-all', async (c) => {
|
|
||||||
const cacheKey = 'jobs:all';
|
|
||||||
const ttl = 1800; // 30 minutes cache
|
|
||||||
try {
|
|
||||||
// Try cache first for instant response
|
|
||||||
if (isRedisConnected()) {
|
|
||||||
const cached = await getCache(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
// Return first 100 jobs immediately for instant display, rest loads in background
|
|
||||||
const fastBatch = Array.isArray(cached.items) ? cached.items.slice(0, 100) : [];
|
|
||||||
const hasMore = cached.items && cached.items.length > 100;
|
|
||||||
|
|
||||||
logLine('logs/server.log', `Cache HIT for ${cacheKey} - sending ${fastBatch.length} jobs (${hasMore ? 'partial' : 'complete'})`);
|
|
||||||
|
|
||||||
// Refresh cache in background
|
|
||||||
setImmediate(async () => {
|
|
||||||
try {
|
|
||||||
await refreshJobsCache(cacheKey, ttl, c.req.header('Authorization') || '');
|
|
||||||
} catch (err) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
page: 1,
|
|
||||||
perPage: fastBatch.length,
|
|
||||||
totalItems: cached.totalItems || fastBatch.length,
|
|
||||||
totalPages: 1,
|
|
||||||
items: fastBatch,
|
|
||||||
partial: hasMore
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No cache: return empty array instantly, trigger background fetch
|
|
||||||
setImmediate(async () => {
|
|
||||||
try {
|
|
||||||
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
|
|
||||||
if (isRedisConnected()) {
|
|
||||||
await setCache(cacheKey, result, ttl);
|
|
||||||
logLine('logs/server.log', `Cached ${cacheKey} with ${result.items.length} jobs for ${ttl}s`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `jobs-all background fetch error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return c.json({ items: [], partial: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Redis not connected: fallback to direct fetch (blocking)
|
|
||||||
const result = await fetchAllJobsFromPocketBase(c.req.header('Authorization') || '');
|
|
||||||
return c.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `jobs-all endpoint error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
return c.json({ error: 'Failed to fetch jobs' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get remaining jobs after initial batch
|
|
||||||
app.get('/api/jobs-remaining', async (c) => {
|
|
||||||
const cacheKey = 'jobs:all';
|
|
||||||
try {
|
|
||||||
if (isRedisConnected()) {
|
|
||||||
const cached = await getCache(cacheKey);
|
|
||||||
if (cached && cached.items && cached.items.length > 100) {
|
|
||||||
const remaining = cached.items.slice(100);
|
|
||||||
return c.json({
|
|
||||||
items: remaining,
|
|
||||||
totalItems: cached.totalItems || cached.items.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c.json({ items: [] });
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `jobs-remaining error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
return c.json({ error: 'Failed to fetch remaining jobs' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper function to fetch all jobs from PocketBase
|
|
||||||
async function fetchAllJobsFromPocketBase(authHeader: string): Promise<any> {
|
|
||||||
const allItems: any[] = [];
|
|
||||||
let page = 1;
|
|
||||||
const perPage = 500;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
|
|
||||||
const response = await fetch(pbUrl, {
|
|
||||||
headers: authHeader ? { Authorization: authHeader } : {}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`PocketBase returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const items = data.items || [];
|
|
||||||
|
|
||||||
if (items.length === 0) break;
|
|
||||||
allItems.push(...items);
|
|
||||||
|
|
||||||
if (allItems.length >= (data.totalItems || 0)) break;
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
page: 1,
|
|
||||||
perPage: allItems.length,
|
|
||||||
totalItems: allItems.length,
|
|
||||||
totalPages: 1,
|
|
||||||
items: allItems
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to refresh jobs cache in background
|
|
||||||
async function refreshJobsCache(cacheKey: string, ttl: number, authHeader: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const result = await fetchAllJobsFromPocketBase(authHeader);
|
|
||||||
await setCache(cacheKey, result, ttl);
|
|
||||||
logLine('logs/server.log', `Background refresh: cached ${cacheKey} with ${result.items.length} jobs`);
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `Background refresh error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Microsoft Graph helpers ---
|
|
||||||
const getGraphToken = (c?: any) => {
|
|
||||||
const headerToken = c?.req?.header ? c.req.header('x-graph-token') : '';
|
|
||||||
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
|
||||||
const token = headerToken || envToken;
|
|
||||||
console.log('[getGraphToken] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE');
|
|
||||||
console.log('[getGraphToken] Env token:', envToken ? 'SET' : 'NOT SET');
|
|
||||||
console.log('[getGraphToken] Using:', token ? token.substring(0, 20) + '...' : 'NONE');
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
const b64Url = (input: string) =>
|
|
||||||
Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
||||||
|
|
||||||
type DriveItem = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
webUrl?: string;
|
|
||||||
size?: number;
|
|
||||||
lastModifiedDateTime?: string;
|
|
||||||
file?: { mimeType?: string };
|
|
||||||
folder?: { childCount?: number };
|
|
||||||
parentReference?: { path?: string; driveId?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJson = async (url: string, token: string) => {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
const err: any = new Error(`Graph ${res.status}: ${text}`);
|
|
||||||
err.status = res.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveShareLink = async (link: string, token: string) => {
|
|
||||||
// Decode the link in case it comes pre-encoded from the database
|
|
||||||
const decodedLink = decodeURIComponent(link);
|
|
||||||
console.log('[resolveShareLink] Decoded link:', decodedLink);
|
|
||||||
|
|
||||||
// Check if this is a short sharing link (/:f:/ or /:b:/) or a direct document library URL
|
|
||||||
if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) {
|
|
||||||
// Short sharing link - use shares API
|
|
||||||
const encoded = `u!${b64Url(decodedLink)}`;
|
|
||||||
console.log('[resolveShareLink] Using shares API, encoded:', encoded);
|
|
||||||
const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token);
|
|
||||||
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
|
|
||||||
} else {
|
|
||||||
// Direct document library URL - parse it and use drive API
|
|
||||||
// Example: https://czflex.sharepoint.com/sites/Team/Shared Documents/Forms/AllItems.aspx?RootFolder=/sites/Team/Shared Documents/General/...
|
|
||||||
const url = new URL(decodedLink);
|
|
||||||
const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/);
|
|
||||||
if (!pathMatch) throw new Error('Invalid SharePoint URL');
|
|
||||||
|
|
||||||
const hostname = pathMatch[1]; // e.g., 'czflex'
|
|
||||||
const sitePath = url.pathname.split('/Shared')[0]; // e.g., /sites/Team
|
|
||||||
const rootFolderParam = url.searchParams.get('RootFolder');
|
|
||||||
if (!rootFolderParam) throw new Error('RootFolder parameter missing');
|
|
||||||
|
|
||||||
// Extract the folder path relative to the document library
|
|
||||||
// RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/...
|
|
||||||
// We need: General/Operations [Server]/...
|
|
||||||
const folderPath = rootFolderParam.split('/Shared Documents/')[1];
|
|
||||||
if (!folderPath) throw new Error('Could not parse folder path');
|
|
||||||
|
|
||||||
console.log('[resolveShareLink] Hostname:', hostname);
|
|
||||||
console.log('[resolveShareLink] Site path:', sitePath);
|
|
||||||
console.log('[resolveShareLink] Folder path:', folderPath);
|
|
||||||
|
|
||||||
// Get the site ID first using hostname:sitePath format
|
|
||||||
const siteData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${hostname}.sharepoint.com:${sitePath}`, token);
|
|
||||||
const siteId = siteData.id;
|
|
||||||
|
|
||||||
// Get the default document library drive
|
|
||||||
const driveData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, token);
|
|
||||||
const driveId = driveData.id;
|
|
||||||
|
|
||||||
// Get the folder item by path
|
|
||||||
const itemData = await fetchJson(
|
|
||||||
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${folderPath}`,
|
|
||||||
token
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id);
|
|
||||||
return { driveId, itemId: itemData.id };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const listChildrenRecursive = async (
|
|
||||||
driveId: string,
|
|
||||||
itemId: string,
|
|
||||||
depth = 0,
|
|
||||||
maxDepth = 5,
|
|
||||||
token: string,
|
|
||||||
): Promise<DriveItem[]> => {
|
|
||||||
const out: DriveItem[] = [];
|
|
||||||
const data = await fetchJson(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/children`, token);
|
|
||||||
for (const child of data.value || []) {
|
|
||||||
out.push(child as DriveItem);
|
|
||||||
if (child.folder && depth < maxDepth) {
|
|
||||||
const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token);
|
|
||||||
out.push(...nested);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchWithinFolder = async (driveId: string, itemId: string, query: string, token: string): Promise<DriveItem[]> => {
|
|
||||||
const data = await fetchJson(
|
|
||||||
`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/search(q='${encodeURIComponent(query)}')`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
return (data.value || []) as DriveItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolean = true) => {
|
|
||||||
// First filter: show PDF files and Word documents (which will be converted to PDF)
|
|
||||||
let filtered = items;
|
|
||||||
if (pdfOnly) {
|
|
||||||
filtered = items.filter((i) => {
|
|
||||||
if (i.folder) return false;
|
|
||||||
const name = i.name.toLowerCase();
|
|
||||||
const mimeType = i.file?.mimeType?.toLowerCase() || '';
|
|
||||||
// Include PDFs, Word documents, and images
|
|
||||||
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
|
|
||||||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
|
|
||||||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
|
|
||||||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
|
|
||||||
name.endsWith('.webp') || mimeType.includes('image');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second filter: category filtering (if specified)
|
|
||||||
if (!category) return filtered;
|
|
||||||
const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w));
|
|
||||||
const lc = (s: string) => (s || '').toLowerCase();
|
|
||||||
const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'];
|
|
||||||
const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'];
|
|
||||||
return filtered.filter((i) => {
|
|
||||||
const n = lc(i.name);
|
|
||||||
if (category === 'contracts') return nameHas(n, contractWords);
|
|
||||||
if (category === 'plans') return nameHas(n, planWords);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// List/search job files via Graph using a shared folder link
|
|
||||||
app.get('/api/job-files', async (c) => {
|
|
||||||
try {
|
|
||||||
console.log('[job-files] Request received');
|
|
||||||
const link = c.req.query('link');
|
|
||||||
if (!link) return c.json({ error: 'link required' }, 400);
|
|
||||||
console.log('[job-files] Link:', link);
|
|
||||||
|
|
||||||
const q = c.req.query('q') || '';
|
|
||||||
const category = c.req.query('category') || '';
|
|
||||||
|
|
||||||
// Check cache first (only for non-search, non-category requests)
|
|
||||||
if (!q && !category && isRedisConnected()) {
|
|
||||||
const cacheKey = `job-files:${link}`;
|
|
||||||
const cached = await getCache(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
console.log('[job-files] Cache HIT for', link);
|
|
||||||
// Apply filtering if requested
|
|
||||||
const pdfOnly = c.req.query('pdfOnly') !== 'false';
|
|
||||||
const items = cached.items || [];
|
|
||||||
const filtered = pdfOnly
|
|
||||||
? items.filter((i: any) => {
|
|
||||||
if (i.isFolder) return false;
|
|
||||||
const name = (i.name || '').toLowerCase();
|
|
||||||
const mimeType = (i.mimeType || '').toLowerCase();
|
|
||||||
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
|
|
||||||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
|
|
||||||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
|
|
||||||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
|
|
||||||
name.endsWith('.webp') || mimeType.includes('image');
|
|
||||||
})
|
|
||||||
: items;
|
|
||||||
|
|
||||||
const mapped = filtered.map((i: any) => ({
|
|
||||||
id: i.id,
|
|
||||||
name: i.name,
|
|
||||||
url: i.webUrl,
|
|
||||||
driveId: i.driveId,
|
|
||||||
size: i.size,
|
|
||||||
modified: i.lastModifiedDateTime,
|
|
||||||
contentType: i.mimeType,
|
|
||||||
isFolder: i.isFolder,
|
|
||||||
path: i.parentPath || '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
return c.json({ items: mapped, total: mapped.length, source: 'cache', driveId: cached.driveId });
|
|
||||||
}
|
|
||||||
console.log('[job-files] Cache MISS for', link);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not in cache or search/category request - fetch from Graph API
|
|
||||||
const token = getGraphToken(c);
|
|
||||||
if (!token) {
|
|
||||||
console.log('[job-files] No token found');
|
|
||||||
return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
|
|
||||||
}
|
|
||||||
console.log('[job-files] Token found:', token.substring(0, 20) + '...');
|
|
||||||
|
|
||||||
const { driveId, itemId } = await resolveShareLink(link, token);
|
|
||||||
|
|
||||||
let items: DriveItem[] = [];
|
|
||||||
if (q) {
|
|
||||||
items = await searchWithinFolder(driveId, itemId, q, token);
|
|
||||||
} else {
|
|
||||||
// Walk subfolders up to depth 5
|
|
||||||
items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to show only PDFs by default (can be disabled with pdfOnly=false query param)
|
|
||||||
const pdfOnly = c.req.query('pdfOnly') !== 'false';
|
|
||||||
const filtered = filterByCategory(items, category, pdfOnly);
|
|
||||||
console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`);
|
|
||||||
|
|
||||||
const mapped = filtered.map((i) => ({
|
|
||||||
id: i.id,
|
|
||||||
name: i.name,
|
|
||||||
url: i.webUrl,
|
|
||||||
driveId: i.parentReference?.driveId || driveId,
|
|
||||||
size: i.size,
|
|
||||||
modified: i.lastModifiedDateTime,
|
|
||||||
contentType: i.file?.mimeType,
|
|
||||||
isFolder: Boolean(i.folder),
|
|
||||||
path: i.parentReference?.path || '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
return c.json({ items: mapped, total: mapped.length, source: q ? 'search' : 'walk', driveId });
|
|
||||||
} catch (err) {
|
|
||||||
const message = (err as Error)?.message || String(err);
|
|
||||||
const status = (err as any)?.status || 500;
|
|
||||||
console.error('[job-files] ERROR:', message);
|
|
||||||
logLine('logs/error.log', `/api/job-files error: ${message}`);
|
|
||||||
return c.json({ error: message }, status);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stream file content via Graph to avoid CSP issues for inline preview
|
|
||||||
app.get('/api/job-file-content', async (c) => {
|
|
||||||
try {
|
|
||||||
const token = getGraphToken(c);
|
|
||||||
if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
|
|
||||||
const driveId = c.req.query('driveId');
|
|
||||||
const itemId = c.req.query('itemId');
|
|
||||||
if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400);
|
|
||||||
|
|
||||||
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
logLine('logs/error.log', `/api/job-file-content failed: ${res.status} - ${text}`);
|
|
||||||
return c.json({ error: `Graph ${res.status}: ${text}` }, 502);
|
|
||||||
}
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const ct = res.headers.get('content-type');
|
|
||||||
const cd = res.headers.get('content-disposition');
|
|
||||||
if (ct) headers['Content-Type'] = ct;
|
|
||||||
if (cd) headers['Content-Disposition'] = cd;
|
|
||||||
return new Response(res.body, { status: 200, headers });
|
|
||||||
} catch (err) {
|
|
||||||
const message = (err as Error)?.message || String(err);
|
|
||||||
const status = (err as any)?.status || 500;
|
|
||||||
logLine('logs/error.log', `/api/job-file-content error: ${message}`);
|
|
||||||
return c.json({ error: message }, status);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert Word documents to PDF for display
|
|
||||||
app.get('/api/job-file-pdf', async (c) => {
|
|
||||||
try {
|
|
||||||
const token = getGraphToken(c);
|
|
||||||
if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
|
|
||||||
const driveId = c.req.query('driveId');
|
|
||||||
const itemId = c.req.query('itemId');
|
|
||||||
if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cacheKey = `job-file-pdf:${driveId}:${itemId}`;
|
|
||||||
if (isRedisConnected()) {
|
|
||||||
const cached = await getCache(cacheKey);
|
|
||||||
if (cached && cached.data) {
|
|
||||||
logLine('logs/server.log', `PDF cache HIT: ${driveId}:${itemId}`);
|
|
||||||
const buffer = Buffer.from(cached.data, 'base64');
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/pdf',
|
|
||||||
};
|
|
||||||
if (cached.filename) {
|
|
||||||
headers['Content-Disposition'] = `inline; filename="${cached.filename}"`;
|
|
||||||
}
|
|
||||||
return new Response(buffer, { status: 200, headers });
|
|
||||||
} else {
|
|
||||||
logLine('logs/server.log', `PDF cache MISS: ${driveId}:${itemId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Graph API to convert to PDF format
|
|
||||||
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
logLine('logs/error.log', `/api/job-file-pdf conversion failed: ${res.status} - ${text}`);
|
|
||||||
return c.json({ error: `PDF conversion failed: ${res.status}` }, 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfBuffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/pdf',
|
|
||||||
};
|
|
||||||
const cd = res.headers.get('content-disposition');
|
|
||||||
let filename = 'document.pdf';
|
|
||||||
if (cd) {
|
|
||||||
// Replace .docx/.doc extension with .pdf in filename
|
|
||||||
headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf');
|
|
||||||
const match = cd.match(/filename="?([^"]+)"?/);
|
|
||||||
if (match) filename = match[1].replace(/\.(docx?)/gi, '.pdf');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the converted PDF for 30 minutes
|
|
||||||
if (isRedisConnected()) {
|
|
||||||
await setCache(cacheKey, {
|
|
||||||
data: pdfBuffer.toString('base64'),
|
|
||||||
filename
|
|
||||||
}, 1800);
|
|
||||||
}
|
|
||||||
|
|
||||||
logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`);
|
|
||||||
return new Response(pdfBuffer, { status: 200, headers });
|
|
||||||
} catch (err) {
|
|
||||||
const message = (err as Error)?.message || String(err);
|
|
||||||
const status = (err as any)?.status || 500;
|
|
||||||
logLine('logs/error.log', `/api/job-file-pdf error: ${message}`);
|
|
||||||
return c.json({ error: message }, status);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mutation logging endpoint
|
|
||||||
app.post('/log-change', async (c) => {
|
|
||||||
try {
|
|
||||||
const body = await c.req.json();
|
|
||||||
const { action = 'unknown', user = 'unknown', target = '', detail = '' } = body || {};
|
|
||||||
const payload = JSON.stringify({ action, user, target, detail });
|
|
||||||
logLine('logs/changes.log', payload);
|
|
||||||
return c.json({ status: 'ok' });
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `log-change error: ${(err as Error)?.message || String(err)}`);
|
|
||||||
return c.text('Bad Request', 400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve static files from the frontend directory (must come after API routes)
|
|
||||||
// Add cache headers for static assets
|
|
||||||
app.use('/assets/*', async (c, next) => {
|
|
||||||
await next();
|
|
||||||
// Cache assets for 1 year
|
|
||||||
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
||||||
});
|
|
||||||
app.use('/*', serveStatic({ root: './frontend/public' }));
|
|
||||||
|
|
||||||
// Error handler
|
|
||||||
app.onError((err, c) => {
|
|
||||||
logLine('logs/error.log', `Unhandled error: ${err?.message || err}`);
|
|
||||||
return c.text('Internal Server Error', 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default to 3005 for test instance; can be overridden by Environment=PORT
|
|
||||||
const PORT = Number(process.env.PORT || 3005);
|
|
||||||
|
|
||||||
logLine('logs/server.log', `Starting frontend server (Redis version) on port ${PORT}`);
|
|
||||||
|
|
||||||
// Warm up cache on startup - wait for Redis to be ready
|
|
||||||
(async () => {
|
|
||||||
// Wait for Redis to be ready (max 10 seconds)
|
|
||||||
let retries = 0;
|
|
||||||
while (!isRedisConnected() && retries < 20) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
retries++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRedisConnected()) {
|
|
||||||
logLine('logs/error.log', 'Redis not connected after 10 seconds, skipping cache warmup');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logLine('logs/server.log', 'Warming up jobs cache...');
|
|
||||||
const result = await fetchAllJobsFromPocketBase('');
|
|
||||||
await setCache('jobs:all', result, 1800);
|
|
||||||
logLine('logs/server.log', `✓ Cache warmed with ${result.items.length} total jobs`);
|
|
||||||
|
|
||||||
// Try to start file caching if service principal is configured
|
|
||||||
const token = await getAppOnlyToken();
|
|
||||||
if (token) {
|
|
||||||
logLine('logs/server.log', 'Service principal authenticated, starting background file caching...');
|
|
||||||
// Cache files for ALL jobs with folder links
|
|
||||||
startFileCaching(result.items, token);
|
|
||||||
} else {
|
|
||||||
logLine('logs/server.log', 'Service principal not configured, file caching will occur on-demand');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logLine('logs/error.log', `Cache warmup failed: ${(err as Error)?.message || String(err)}`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Background file caching state
|
|
||||||
let fileCachingActive = false;
|
|
||||||
let fileCachingIndex = 0;
|
|
||||||
let fileCachingJobs: any[] = [];
|
|
||||||
let fileCachingToken: string = '';
|
|
||||||
let fileCachingTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
async function startFileCaching(jobs: any[], token: string) {
|
|
||||||
if (fileCachingActive) return;
|
|
||||||
|
|
||||||
fileCachingJobs = jobs;
|
|
||||||
if (fileCachingJobs.length === 0) {
|
|
||||||
logLine('logs/server.log', 'No jobs to cache');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileCachingActive = true;
|
|
||||||
fileCachingIndex = 0;
|
|
||||||
fileCachingToken = token;
|
|
||||||
|
|
||||||
logLine('logs/server.log', `Starting background file caching for ${fileCachingJobs.length} jobs...`);
|
|
||||||
cacheNextFileBatch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cacheNextFileBatch() {
|
|
||||||
if (!fileCachingActive || !isRedisConnected()) return;
|
|
||||||
|
|
||||||
const batchSize = 10; // Cache 10 jobs at a time for faster initial coverage
|
|
||||||
const maxJobs = fileCachingJobs.length;
|
|
||||||
|
|
||||||
if (fileCachingIndex >= maxJobs) {
|
|
||||||
// Completed initial full cache of all jobs
|
|
||||||
console.log(`✓ Completed caching all ${maxJobs} jobs. Scheduling refresh in 10 minutes...`);
|
|
||||||
logLine('logs/server.log', `✓ Completed caching all ${maxJobs} jobs. Scheduling refresh in 10 minutes...`);
|
|
||||||
fileCachingIndex = 0;
|
|
||||||
|
|
||||||
// After completing all jobs, wait 10 minutes before refreshing
|
|
||||||
fileCachingTimer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
// Refresh the jobs cache first
|
|
||||||
await refreshJobsCache('jobs:all', 1800, '');
|
|
||||||
|
|
||||||
// Get updated job list from cache
|
|
||||||
const cached = await getCache('jobs:all');
|
|
||||||
if (cached && cached.items) {
|
|
||||||
fileCachingJobs = cached.items;
|
|
||||||
console.log(`Starting refresh cycle: ${fileCachingJobs.length} jobs to update`);
|
|
||||||
logLine('logs/server.log', `Starting refresh cycle: ${fileCachingJobs.length} jobs to update`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh token if needed
|
|
||||||
const newToken = await getAppOnlyToken();
|
|
||||||
if (newToken) {
|
|
||||||
fileCachingToken = newToken;
|
|
||||||
cacheNextFileBatch();
|
|
||||||
} else {
|
|
||||||
console.error('Failed to refresh app-only token, stopping file caching');
|
|
||||||
logLine('logs/error.log', 'Failed to refresh app-only token, stopping file caching');
|
|
||||||
fileCachingActive = false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`File cache cycle restart error: ${(err as Error)?.message}`);
|
|
||||||
logLine('logs/error.log', `File cache cycle restart error: ${(err as Error)?.message}`);
|
|
||||||
}
|
|
||||||
}, 10 * 60 * 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get next batch of jobs to cache
|
|
||||||
const batch = fileCachingJobs.slice(fileCachingIndex, fileCachingIndex + batchSize);
|
|
||||||
const jobNumbers = batch.map(j => j.Job_Number).join(', ');
|
|
||||||
const batchMsg = `[Cache Batch] Processing jobs: ${jobNumbers} (${fileCachingIndex}/${maxJobs})`;
|
|
||||||
console.log(batchMsg);
|
|
||||||
logLine('logs/server.log', batchMsg);
|
|
||||||
|
|
||||||
for (const job of batch) {
|
|
||||||
try {
|
|
||||||
await cacheJobFiles(job, fileCachingToken);
|
|
||||||
} catch (err) {
|
|
||||||
// Silently continue on errors
|
|
||||||
logLine('logs/error.log', `File cache error for job ${job.Job_Number}: ${(err as Error)?.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileCachingIndex += batchSize;
|
|
||||||
|
|
||||||
// Continue with next batch after a short delay (1 second between batches for faster throughput)
|
|
||||||
fileCachingTimer = setTimeout(() => cacheNextFileBatch(), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cacheJobFiles(job: any, token: string) {
|
|
||||||
const link = job.Job_Folder_Link;
|
|
||||||
if (!link) {
|
|
||||||
// Job has no folder link, just mark it as cached
|
|
||||||
const cacheKey = `job-files:${job.Job_Number}`;
|
|
||||||
await setCache(cacheKey, { items: [] }, 1800);
|
|
||||||
console.log(`✓ No folder for job ${job.Job_Number}`);
|
|
||||||
logLine('logs/server.log', `✓ No folder for job ${job.Job_Number}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = `job-files:${link}`;
|
|
||||||
const ttl = 1800; // 30 minutes
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { driveId, itemId } = await resolveShareLink(link, token);
|
|
||||||
const items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
driveId,
|
|
||||||
items: items.map(item => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
webUrl: item.webUrl,
|
|
||||||
size: item.size,
|
|
||||||
lastModifiedDateTime: item.lastModifiedDateTime,
|
|
||||||
mimeType: item.file?.mimeType,
|
|
||||||
isFolder: !!item.folder,
|
|
||||||
parentPath: item.parentReference?.path,
|
|
||||||
driveId: item.parentReference?.driveId || driveId
|
|
||||||
})),
|
|
||||||
cachedAt: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
await setCache(cacheKey, data, ttl);
|
|
||||||
|
|
||||||
// Pre-cache PDF conversions for Word/Excel documents AND native PDF files
|
|
||||||
const officeFiles = items.filter(item => {
|
|
||||||
const name = (item.name || '').toLowerCase();
|
|
||||||
return name.endsWith('.docx') || name.endsWith('.doc') || name.endsWith('.xlsx') || name.endsWith('.xls');
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdfFiles = items.filter(item => {
|
|
||||||
const name = (item.name || '').toLowerCase();
|
|
||||||
return name.endsWith('.pdf');
|
|
||||||
});
|
|
||||||
|
|
||||||
let pdfsCached = 0;
|
|
||||||
|
|
||||||
// Cache converted Office documents
|
|
||||||
for (const file of officeFiles) {
|
|
||||||
try {
|
|
||||||
const fileDriveId = file.parentReference?.driveId || driveId;
|
|
||||||
const pdfCacheKey = `job-file-pdf:${fileDriveId}:${file.id}`;
|
|
||||||
|
|
||||||
// Check if already cached
|
|
||||||
const existing = await getCache(pdfCacheKey);
|
|
||||||
if (existing) {
|
|
||||||
pdfsCached++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert and cache
|
|
||||||
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${fileDriveId}/items/${file.id}/content?format=pdf`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const pdfBuffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
const cd = res.headers.get('content-disposition');
|
|
||||||
let filename = file.name.replace(/\.(docx?|xlsx?)$/gi, '.pdf');
|
|
||||||
if (cd) {
|
|
||||||
const match = cd.match(/filename="?([^"]+)"?/);
|
|
||||||
if (match) filename = match[1].replace(/\.(docx?|xlsx?)$/gi, '.pdf');
|
|
||||||
}
|
|
||||||
|
|
||||||
await setCache(pdfCacheKey, {
|
|
||||||
data: pdfBuffer.toString('base64'),
|
|
||||||
filename
|
|
||||||
}, ttl);
|
|
||||||
pdfsCached++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Silently skip PDF caching errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache native PDF files
|
|
||||||
for (const file of pdfFiles) {
|
|
||||||
try {
|
|
||||||
const fileDriveId = file.parentReference?.driveId || driveId;
|
|
||||||
const pdfCacheKey = `job-file-pdf:${fileDriveId}:${file.id}`;
|
|
||||||
|
|
||||||
// Check if already cached
|
|
||||||
const existing = await getCache(pdfCacheKey);
|
|
||||||
if (existing) {
|
|
||||||
pdfsCached++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch and cache native PDF
|
|
||||||
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${fileDriveId}/items/${file.id}/content`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const pdfBuffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
await setCache(pdfCacheKey, {
|
|
||||||
data: pdfBuffer.toString('base64'),
|
|
||||||
filename: file.name
|
|
||||||
}, ttl);
|
|
||||||
pdfsCached++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Silently skip PDF caching errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPDFs = officeFiles.length + pdfFiles.length;
|
|
||||||
const msg = `✓ Cached ${items.length} files${totalPDFs > 0 ? ` + ${pdfsCached}/${totalPDFs} PDFs` : ''} for job ${job.Job_Number}`;
|
|
||||||
console.log(msg);
|
|
||||||
logLine('logs/server.log', msg);
|
|
||||||
} catch (err: any) {
|
|
||||||
// Only log if not a token expiration issue
|
|
||||||
if (!err.message?.includes('401') && !err.message?.includes('token')) {
|
|
||||||
logLine('logs/error.log', `File cache skip for job ${job.Job_Number}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown logging
|
|
||||||
const shutdown = (signal: string) => {
|
|
||||||
logLine('logs/server.log', `Shutting down due to ${signal}`);
|
|
||||||
if (fileCachingTimer) clearTimeout(fileCachingTimer);
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
||||||
|
|
||||||
export default {
|
|
||||||
port: PORT,
|
|
||||||
fetch: app.fetch,
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auth Middleware - Token validation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Context, Next } from 'hono';
|
|
||||||
import { pocketbaseService } from '../services/pocketbaseService';
|
|
||||||
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate auth token and attach user to context
|
|
||||||
*/
|
|
||||||
export async function authMiddleware(c: Context, next: Next): Promise<Response | void> {
|
|
||||||
const authHeader = c.req.header('Authorization');
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
return c.json(
|
|
||||||
{ error: ERROR_MESSAGES.UNAUTHORIZED },
|
|
||||||
HTTP_STATUS.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.slice(7); // Remove 'Bearer '
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate token with PocketBase
|
|
||||||
const user = await pocketbaseService.authenticateWithToken(token);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return c.json(
|
|
||||||
{ error: ERROR_MESSAGES.TOKEN_INVALID },
|
|
||||||
HTTP_STATUS.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach user info to context
|
|
||||||
c.set('userId', user.id);
|
|
||||||
c.set('user', user);
|
|
||||||
c.set('token', token);
|
|
||||||
|
|
||||||
await next();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[AuthMiddleware] Token validation failed:', err);
|
|
||||||
return c.json(
|
|
||||||
{ error: ERROR_MESSAGES.TOKEN_INVALID },
|
|
||||||
HTTP_STATUS.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional auth - doesn't fail if no token, but attaches user if present
|
|
||||||
*/
|
|
||||||
export async function optionalAuthMiddleware(c: Context, next: Next): Promise<Response | void> {
|
|
||||||
const authHeader = c.req.header('Authorization');
|
|
||||||
|
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
||||||
const token = authHeader.slice(7);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await pocketbaseService.authenticateWithToken(token);
|
|
||||||
if (user) {
|
|
||||||
c.set('userId', user.id);
|
|
||||||
c.set('user', user);
|
|
||||||
c.set('token', token);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently continue without auth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user ID from context (for use after auth middleware)
|
|
||||||
*/
|
|
||||||
export function getUserId(c: Context): string {
|
|
||||||
const userId = c.get('userId');
|
|
||||||
if (!userId) {
|
|
||||||
throw new Error('User ID not found in context - auth middleware not applied?');
|
|
||||||
}
|
|
||||||
return userId as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user from context
|
|
||||||
*/
|
|
||||||
export function getUser(c: Context): unknown {
|
|
||||||
return c.get('user');
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* Error Handler Middleware
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Context } from 'hono';
|
|
||||||
import { HTTP_STATUS, ERROR_MESSAGES } from '../config/constants';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
/** Custom application error */
|
|
||||||
export class AppError extends Error {
|
|
||||||
constructor(
|
|
||||||
public statusCode: number,
|
|
||||||
message: string,
|
|
||||||
public code?: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'AppError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Not found error */
|
|
||||||
export class NotFoundError extends AppError {
|
|
||||||
constructor(resource: string = 'Resource') {
|
|
||||||
super(HTTP_STATUS.NOT_FOUND, `${resource} not found`, 'NOT_FOUND');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unauthorized error */
|
|
||||||
export class UnauthorizedError extends AppError {
|
|
||||||
constructor(message: string = ERROR_MESSAGES.UNAUTHORIZED) {
|
|
||||||
super(HTTP_STATUS.UNAUTHORIZED, message, 'UNAUTHORIZED');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bad request error */
|
|
||||||
export class BadRequestError extends AppError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(HTTP_STATUS.BAD_REQUEST, message, 'BAD_REQUEST');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global error handler for Hono onError
|
|
||||||
*/
|
|
||||||
export function errorHandler(err: Error, c: Context): Response {
|
|
||||||
console.error('[ErrorHandler]', err);
|
|
||||||
|
|
||||||
// Handle known app errors
|
|
||||||
if (err instanceof AppError) {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: err.message,
|
|
||||||
code: err.code,
|
|
||||||
},
|
|
||||||
err.statusCode as 400 | 401 | 403 | 404 | 500
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle unknown errors
|
|
||||||
const message = env.NODE_ENV === 'production'
|
|
||||||
? ERROR_MESSAGES.INTERNAL_ERROR
|
|
||||||
: err.message;
|
|
||||||
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: message,
|
|
||||||
code: 'INTERNAL_ERROR',
|
|
||||||
},
|
|
||||||
HTTP_STATUS.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Not found handler (for unmatched routes)
|
|
||||||
*/
|
|
||||||
export function notFoundHandler(c: Context): Response {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: 'Endpoint not found',
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
},
|
|
||||||
HTTP_STATUS.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* Logger Middleware - Request logging
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Context, Next } from 'hono';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
|
|
||||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
||||||
debug: 0,
|
|
||||||
info: 1,
|
|
||||||
warn: 2,
|
|
||||||
error: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current log level from env
|
|
||||||
*/
|
|
||||||
function shouldLog(level: LogLevel): boolean {
|
|
||||||
return LOG_LEVELS[level] >= LOG_LEVELS[env.LOG_LEVEL];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format log message
|
|
||||||
*/
|
|
||||||
function formatLog(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
|
|
||||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logger utility
|
|
||||||
*/
|
|
||||||
export const logger = {
|
|
||||||
debug(message: string, meta?: Record<string, unknown>): void {
|
|
||||||
if (shouldLog('debug')) {
|
|
||||||
console.log(formatLog('debug', message, meta));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
info(message: string, meta?: Record<string, unknown>): void {
|
|
||||||
if (shouldLog('info')) {
|
|
||||||
console.log(formatLog('info', message, meta));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
warn(message: string, meta?: Record<string, unknown>): void {
|
|
||||||
if (shouldLog('warn')) {
|
|
||||||
console.warn(formatLog('warn', message, meta));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
error(message: string, meta?: Record<string, unknown>): void {
|
|
||||||
if (shouldLog('error')) {
|
|
||||||
console.error(formatLog('error', message, meta));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request logging middleware
|
|
||||||
*/
|
|
||||||
export async function loggerMiddleware(c: Context, next: Next): Promise<void> {
|
|
||||||
const method = c.req.method;
|
|
||||||
const path = c.req.path;
|
|
||||||
|
|
||||||
// Skip logging for health check endpoints (UptimeKuma polling)
|
|
||||||
const skipLogging = path === '/version' || path === '/api/health';
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
const requestId = crypto.randomUUID().slice(0, 8);
|
|
||||||
|
|
||||||
// Attach request ID to context
|
|
||||||
c.set('requestId', requestId);
|
|
||||||
|
|
||||||
if (!skipLogging) {
|
|
||||||
logger.info(`→ ${method} ${path}`, { requestId });
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
|
|
||||||
if (!skipLogging) {
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
const status = c.res.status;
|
|
||||||
|
|
||||||
const logFn = status >= 500 ? logger.error : status >= 400 ? logger.warn : logger.info;
|
|
||||||
logFn(`← ${method} ${path} ${status} ${duration}ms`, { requestId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import Redis from 'ioredis';
|
|
||||||
|
|
||||||
// Create Redis client
|
|
||||||
const redis = new Redis({
|
|
||||||
host: process.env.REDIS_HOST || 'localhost',
|
|
||||||
port: Number(process.env.REDIS_PORT || 6379),
|
|
||||||
password: process.env.REDIS_PASSWORD,
|
|
||||||
retryStrategy(times) {
|
|
||||||
const delay = Math.min(times * 50, 2000);
|
|
||||||
return delay;
|
|
||||||
},
|
|
||||||
maxRetriesPerRequest: 3,
|
|
||||||
lazyConnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle connection events
|
|
||||||
redis.on('connect', () => {
|
|
||||||
console.log('✓ Redis connected');
|
|
||||||
});
|
|
||||||
|
|
||||||
redis.on('error', (err) => {
|
|
||||||
console.error('Redis error:', err.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
redis.on('close', () => {
|
|
||||||
console.log('Redis connection closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect to Redis
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await redis.connect();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to connect to Redis:', err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a value from cache
|
|
||||||
*/
|
|
||||||
export async function getCache(key: string): Promise<any | null> {
|
|
||||||
try {
|
|
||||||
const value = await redis.get(key);
|
|
||||||
if (!value) return null;
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Cache get error for key "${key}":`, err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value in cache with TTL in seconds
|
|
||||||
*/
|
|
||||||
export async function setCache(key: string, value: any, ttl: number = 300): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(value);
|
|
||||||
await redis.setex(key, ttl, serialized);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Cache set error for key "${key}":`, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a key from cache
|
|
||||||
*/
|
|
||||||
export async function deleteCache(key: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await redis.del(key);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Cache delete error for key "${key}":`, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all cache keys matching a pattern
|
|
||||||
*/
|
|
||||||
export async function clearCachePattern(pattern: string): Promise<number> {
|
|
||||||
try {
|
|
||||||
const keys = await redis.keys(pattern);
|
|
||||||
if (keys.length === 0) return 0;
|
|
||||||
await redis.del(...keys);
|
|
||||||
return keys.length;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Cache clear error for pattern "${pattern}":`, err);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Redis is connected
|
|
||||||
*/
|
|
||||||
export function isRedisConnected(): boolean {
|
|
||||||
return redis.status === 'ready';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default redis;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auth Routes - Login, logout, token refresh
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { tokenService } from '../services/tokenService';
|
|
||||||
import { authMiddleware, getUserId } from '../middleware/auth';
|
|
||||||
import { BadRequestError } from '../middleware/errorHandler';
|
|
||||||
import type { LoginCallbackRequest, TokenType } from '@shared/types/auth';
|
|
||||||
import { HTTP_STATUS } from '../config/constants';
|
|
||||||
|
|
||||||
const auth = new Hono();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/auth/login-callback
|
|
||||||
* Called after OAuth flow to store tokens in cache
|
|
||||||
*/
|
|
||||||
auth.post('/login-callback', async (c) => {
|
|
||||||
const body = await c.req.json<LoginCallbackRequest>();
|
|
||||||
|
|
||||||
if (!body.pbToken || !body.graphToken || !body.user) {
|
|
||||||
throw new BadRequestError('Missing required token data');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store tokens in cache
|
|
||||||
await tokenService.storeTokens(
|
|
||||||
body.user.id,
|
|
||||||
body.pbToken,
|
|
||||||
body.graphToken,
|
|
||||||
body.refreshTokenPb ?? '',
|
|
||||||
body.refreshTokenGraph ?? ''
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start background refresh loop
|
|
||||||
tokenService.startRefreshLoop(body.user.id);
|
|
||||||
|
|
||||||
const expiresAt = Date.now() + 3600 * 1000; // 1 hour
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
success: true,
|
|
||||||
user: body.user,
|
|
||||||
expiresAt_pb: expiresAt,
|
|
||||||
expiresAt_graph: expiresAt,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/auth/logout
|
|
||||||
* Clear tokens and stop refresh loop
|
|
||||||
*/
|
|
||||||
auth.post('/logout', authMiddleware, async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
|
|
||||||
await tokenService.clearUserTokens(userId);
|
|
||||||
|
|
||||||
return c.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/refresh-token
|
|
||||||
* Get fresh token from cache or refresh
|
|
||||||
*/
|
|
||||||
auth.get('/refresh-token', authMiddleware, async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const type = c.req.query('type') as TokenType | undefined;
|
|
||||||
|
|
||||||
if (!type || !['pb', 'graph'].includes(type)) {
|
|
||||||
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await tokenService.getToken(userId, type);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return c.json(
|
|
||||||
{ error: 'Token not available' },
|
|
||||||
HTTP_STATUS.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/refresh-token
|
|
||||||
* Alternative POST endpoint for token refresh
|
|
||||||
*/
|
|
||||||
auth.post('/refresh-token', authMiddleware, async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const body = await c.req.json<{ type: TokenType }>();
|
|
||||||
|
|
||||||
if (!body.type || !['pb', 'graph'].includes(body.type)) {
|
|
||||||
throw new BadRequestError('Invalid token type. Must be "pb" or "graph"');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await tokenService.getToken(userId, body.type);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return c.json(
|
|
||||||
{ error: 'Token not available' },
|
|
||||||
HTTP_STATUS.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default auth;
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
/**
|
|
||||||
* Files Routes - SharePoint file operations
|
|
||||||
*
|
|
||||||
* Note: Most file operations go through /api/jobs/:id/files
|
|
||||||
* This route provides additional file utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { graphService } from '../services/graphService';
|
|
||||||
import { tokenService } from '../services/tokenService';
|
|
||||||
import { pocketbaseService } from '../services/pocketbaseService';
|
|
||||||
import { authMiddleware, getUserId } from '../middleware/auth';
|
|
||||||
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
|
|
||||||
|
|
||||||
const files = new Hono();
|
|
||||||
|
|
||||||
// All file routes require authentication
|
|
||||||
files.use('/*', authMiddleware);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files
|
|
||||||
* List files in a job's folder - requires jobId query param
|
|
||||||
*/
|
|
||||||
files.get('/', async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const jobId = c.req.query('jobId');
|
|
||||||
|
|
||||||
if (!jobId) {
|
|
||||||
throw new BadRequestError('jobId query parameter required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get job to find site/drive info
|
|
||||||
const job = await pocketbaseService.getJob(jobId);
|
|
||||||
if (!job) {
|
|
||||||
throw new NotFoundError('Job not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!job.site_id || !job.drive_id) {
|
|
||||||
return c.json({ files: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
|
||||||
if (!tokenResult) {
|
|
||||||
throw new BadRequestError('Graph token not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await graphService.getFiles(
|
|
||||||
tokenResult.token,
|
|
||||||
job.site_id,
|
|
||||||
job.drive_id,
|
|
||||||
job.folder_path
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ files: result.files });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files/download-url
|
|
||||||
* Get download URL for a file - requires jobId and fileId
|
|
||||||
*/
|
|
||||||
files.get('/download-url', async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const jobId = c.req.query('jobId');
|
|
||||||
const fileId = c.req.query('fileId');
|
|
||||||
|
|
||||||
if (!jobId || !fileId) {
|
|
||||||
throw new BadRequestError('jobId and fileId query parameters required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get job to find site/drive info
|
|
||||||
const job = await pocketbaseService.getJob(jobId);
|
|
||||||
if (!job || !job.site_id || !job.drive_id) {
|
|
||||||
throw new NotFoundError('Job not found or not configured for files');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
|
||||||
if (!tokenResult) {
|
|
||||||
throw new BadRequestError('Graph token not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await graphService.getFileDownloadUrl(
|
|
||||||
tokenResult.token,
|
|
||||||
job.site_id,
|
|
||||||
job.drive_id,
|
|
||||||
fileId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
throw new NotFoundError('File not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ url });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files/search
|
|
||||||
* Search files in a job's SharePoint site
|
|
||||||
*/
|
|
||||||
files.get('/search', async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const jobId = c.req.query('jobId');
|
|
||||||
const query = c.req.query('q');
|
|
||||||
|
|
||||||
if (!jobId || !query) {
|
|
||||||
throw new BadRequestError('jobId and q query parameters required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get job to find site info
|
|
||||||
const job = await pocketbaseService.getJob(jobId);
|
|
||||||
if (!job || !job.site_id) {
|
|
||||||
throw new NotFoundError('Job not found or not configured for files');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
|
||||||
if (!tokenResult) {
|
|
||||||
throw new BadRequestError('Graph token not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await graphService.searchFiles(tokenResult.token, job.site_id, query);
|
|
||||||
|
|
||||||
return c.json({ files: results });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default files;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* Health Routes - Server health check
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { cacheService } from '../services/cacheService';
|
|
||||||
|
|
||||||
const health = new Hono();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/health
|
|
||||||
* Basic health check
|
|
||||||
*/
|
|
||||||
health.get('/', (c) => {
|
|
||||||
return c.json({
|
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/health/ready
|
|
||||||
* Readiness check (includes cache connection)
|
|
||||||
*/
|
|
||||||
health.get('/ready', async (c) => {
|
|
||||||
const checks: Record<string, boolean> = {
|
|
||||||
server: true,
|
|
||||||
cache: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check cache connection
|
|
||||||
try {
|
|
||||||
await cacheService.get('health-check');
|
|
||||||
checks.cache = true;
|
|
||||||
} catch {
|
|
||||||
checks.cache = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allHealthy = Object.values(checks).every(Boolean);
|
|
||||||
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
status: allHealthy ? 'ready' : 'degraded',
|
|
||||||
checks,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
allHealthy ? 200 : 503
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/health/live
|
|
||||||
* Liveness check (just confirms server is running)
|
|
||||||
*/
|
|
||||||
health.get('/live', (c) => {
|
|
||||||
return c.json({
|
|
||||||
status: 'alive',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export default health;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Routes Index - Register all routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import auth from './auth';
|
|
||||||
import jobs from './jobs';
|
|
||||||
import files from './files';
|
|
||||||
import health from './health';
|
|
||||||
|
|
||||||
export function registerRoutes(app: Hono): void {
|
|
||||||
// Health routes (no /api prefix for k8s probes)
|
|
||||||
app.route('/health', health);
|
|
||||||
app.route('/api/health', health);
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
app.route('/api/auth', auth);
|
|
||||||
app.route('/api/jobs', jobs);
|
|
||||||
app.route('/api/files', files);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Jobs Routes - Job listing and details
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { jobsService } from '../services/jobsService';
|
|
||||||
import { authMiddleware, getUserId } from '../middleware/auth';
|
|
||||||
import { BadRequestError, NotFoundError } from '../middleware/errorHandler';
|
|
||||||
|
|
||||||
const jobs = new Hono();
|
|
||||||
|
|
||||||
// All job routes require authentication
|
|
||||||
jobs.use('/*', authMiddleware);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jobs
|
|
||||||
* List all jobs for the user
|
|
||||||
*/
|
|
||||||
jobs.get('/', async (c) => {
|
|
||||||
const page = parseInt(c.req.query('page') || '1');
|
|
||||||
const limit = parseInt(c.req.query('limit') || '50');
|
|
||||||
|
|
||||||
const result = await jobsService.getJobs({ page, perPage: limit });
|
|
||||||
|
|
||||||
return c.json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jobs/search
|
|
||||||
* Search jobs by query
|
|
||||||
*/
|
|
||||||
jobs.get('/search', async (c) => {
|
|
||||||
const query = c.req.query('q');
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
throw new BadRequestError('Search query required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobs = await jobsService.searchJobs(query);
|
|
||||||
|
|
||||||
return c.json({ jobs, page: 1, perPage: jobs.length, totalItems: jobs.length, totalPages: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jobs/:id
|
|
||||||
* Get single job with all details
|
|
||||||
*/
|
|
||||||
jobs.get('/:id', async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const jobId = c.req.param('id');
|
|
||||||
|
|
||||||
const result = await jobsService.getJobDetail(jobId, userId);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new NotFoundError('Job not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/jobs/:id/files
|
|
||||||
* Get files for a job from SharePoint
|
|
||||||
*/
|
|
||||||
jobs.get('/:id/files', async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const jobId = c.req.param('id');
|
|
||||||
|
|
||||||
const result = await jobsService.getJobFiles(jobId, userId);
|
|
||||||
|
|
||||||
return c.json({ files: result?.files ?? [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/jobs/:id/notes
|
|
||||||
* Create a sticky note on a job
|
|
||||||
*/
|
|
||||||
jobs.post('/:id/notes', async (c) => {
|
|
||||||
const userId = getUserId(c);
|
|
||||||
const jobId = c.req.param('id');
|
|
||||||
const body = await c.req.json<{ content: string; color?: string }>();
|
|
||||||
|
|
||||||
if (!body.content) {
|
|
||||||
throw new BadRequestError('Note content required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const note = await jobsService.createNote(
|
|
||||||
jobId,
|
|
||||||
body.content,
|
|
||||||
userId,
|
|
||||||
{ color: body.color as 'yellow' | 'blue' | 'green' | 'pink' | 'purple' | undefined }
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ note });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/jobs/:id/notes/:noteId
|
|
||||||
* Update a sticky note
|
|
||||||
*/
|
|
||||||
jobs.put('/:id/notes/:noteId', async (c) => {
|
|
||||||
const noteId = c.req.param('noteId');
|
|
||||||
const body = await c.req.json<{ content?: string; color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple' }>();
|
|
||||||
|
|
||||||
const note = await jobsService.updateNote(noteId, body);
|
|
||||||
|
|
||||||
return c.json({ note });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/jobs/:id/notes/:noteId
|
|
||||||
* Delete a sticky note
|
|
||||||
*/
|
|
||||||
jobs.delete('/:id/notes/:noteId', async (c) => {
|
|
||||||
const noteId = c.req.param('noteId');
|
|
||||||
|
|
||||||
await jobsService.deleteNote(noteId);
|
|
||||||
|
|
||||||
return c.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default jobs;
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cache Service - Valkey/Redis operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Redis from 'ioredis';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
class CacheService {
|
|
||||||
private client: Redis | null = null;
|
|
||||||
private isConnected = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize Redis connection
|
|
||||||
*/
|
|
||||||
async connect(): Promise<void> {
|
|
||||||
if (this.client && this.isConnected) return;
|
|
||||||
|
|
||||||
this.client = new Redis({
|
|
||||||
host: env.REDIS_HOST,
|
|
||||||
port: env.REDIS_PORT,
|
|
||||||
password: env.REDIS_PASSWORD || undefined,
|
|
||||||
retryStrategy: (times) => {
|
|
||||||
if (times > 3) {
|
|
||||||
console.error('[Cache] Max retry attempts reached');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.min(times * 200, 2000);
|
|
||||||
},
|
|
||||||
maxRetriesPerRequest: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.on('connect', () => {
|
|
||||||
this.isConnected = true;
|
|
||||||
console.log('[Cache] Connected to Redis/Valkey');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.on('error', (err) => {
|
|
||||||
console.error('[Cache] Redis error:', err.message);
|
|
||||||
this.isConnected = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.on('close', () => {
|
|
||||||
this.isConnected = false;
|
|
||||||
console.log('[Cache] Redis connection closed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get value from cache
|
|
||||||
*/
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
|
||||||
if (!this.client) {
|
|
||||||
console.warn('[Cache] Not connected, returning null');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = await this.client.get(key);
|
|
||||||
if (!value) return null;
|
|
||||||
return JSON.parse(value) as T;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Cache] Get error for key ${key}:`, err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set value in cache with optional TTL
|
|
||||||
*/
|
|
||||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<boolean> {
|
|
||||||
if (!this.client) {
|
|
||||||
console.warn('[Cache] Not connected, skipping set');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(value);
|
|
||||||
if (ttlSeconds) {
|
|
||||||
await this.client.setex(key, ttlSeconds, serialized);
|
|
||||||
} else {
|
|
||||||
await this.client.set(key, serialized);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Cache] Set error for key ${key}:`, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete key from cache
|
|
||||||
*/
|
|
||||||
async del(key: string): Promise<boolean> {
|
|
||||||
if (!this.client) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.client.del(key);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Cache] Delete error for key ${key}:`, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete keys matching pattern
|
|
||||||
*/
|
|
||||||
async delPattern(pattern: string): Promise<number> {
|
|
||||||
if (!this.client) return 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const keys = await this.client.keys(pattern);
|
|
||||||
if (keys.length === 0) return 0;
|
|
||||||
return await this.client.del(...keys);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Cache] Delete pattern error for ${pattern}:`, err);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if connected
|
|
||||||
*/
|
|
||||||
get connected(): boolean {
|
|
||||||
return this.isConnected;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add to set
|
|
||||||
*/
|
|
||||||
async sadd(key: string, member: string): Promise<boolean> {
|
|
||||||
if (!this.client) return false;
|
|
||||||
try {
|
|
||||||
await this.client.sadd(key, member);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Cache] SADD error for key ${key}:`, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove from set
|
|
||||||
*/
|
|
||||||
async srem(key: string, member: string): Promise<boolean> {
|
|
||||||
if (!this.client) return false;
|
|
||||||
try {
|
|
||||||
await this.client.srem(key, member);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Cache] SREM error for key ${key}:`, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all members of set
|
|
||||||
*/
|
|
||||||
async smembers(key: string): Promise<string[]> {
|
|
||||||
if (!this.client) return [];
|
|
||||||
try {
|
|
||||||
return await this.client.smembers(key);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Cache] SMEMBERS error for key ${key}:`, err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from Redis
|
|
||||||
*/
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
if (this.client) {
|
|
||||||
await this.client.quit();
|
|
||||||
this.client = null;
|
|
||||||
this.isConnected = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cacheService = new CacheService();
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* Graph Service - Microsoft Graph API integration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { env } from '../config/env';
|
|
||||||
import type { JobFile } from '@shared/types/job';
|
|
||||||
|
|
||||||
interface GraphTokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
token_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphDriveItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
file?: {
|
|
||||||
mimeType: string;
|
|
||||||
};
|
|
||||||
webUrl: string;
|
|
||||||
'@microsoft.graph.downloadUrl'?: string;
|
|
||||||
createdDateTime: string;
|
|
||||||
lastModifiedDateTime: string;
|
|
||||||
createdBy?: {
|
|
||||||
user: {
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
lastModifiedBy?: {
|
|
||||||
user: {
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphListResponse {
|
|
||||||
value: GraphDriveItem[];
|
|
||||||
'@odata.nextLink'?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class GraphService {
|
|
||||||
private readonly tokenEndpoint: string;
|
|
||||||
private readonly graphBaseUrl = 'https://graph.microsoft.com/v1.0';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.tokenEndpoint = `https://login.microsoftonline.com/${env.MICROSOFT_TENANT}/oauth2/v2.0/token`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh access token using refresh token
|
|
||||||
*/
|
|
||||||
async refreshToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: env.MICROSOFT_CLIENT_ID,
|
|
||||||
client_secret: env.MICROSOFT_CLIENT_SECRET,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
scope: 'offline_access Files.Read Sites.Read.All User.Read',
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(this.tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
console.error('[Graph] Token refresh failed:', error);
|
|
||||||
throw new Error(`Token refresh failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: GraphTokenResponse = await response.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: data.access_token,
|
|
||||||
refreshToken: data.refresh_token,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get files from a SharePoint drive folder
|
|
||||||
*/
|
|
||||||
async getFiles(
|
|
||||||
accessToken: string,
|
|
||||||
siteId: string,
|
|
||||||
driveId: string,
|
|
||||||
folderPath?: string
|
|
||||||
): Promise<{ files: JobFile[]; nextLink?: string }> {
|
|
||||||
let endpoint: string;
|
|
||||||
|
|
||||||
if (folderPath) {
|
|
||||||
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root:/${folderPath}:/children`;
|
|
||||||
} else {
|
|
||||||
endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/root/children`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
console.error('[Graph] Get files failed:', error);
|
|
||||||
throw new Error(`Get files failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: GraphListResponse = await response.json();
|
|
||||||
|
|
||||||
const files: JobFile[] = data.value
|
|
||||||
.filter(item => item.file) // Only files, not folders
|
|
||||||
.map(item => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
size: item.size,
|
|
||||||
mimeType: item.file?.mimeType ?? 'application/octet-stream',
|
|
||||||
webUrl: item.webUrl,
|
|
||||||
downloadUrl: item['@microsoft.graph.downloadUrl'],
|
|
||||||
createdDateTime: item.createdDateTime,
|
|
||||||
lastModifiedDateTime: item.lastModifiedDateTime,
|
|
||||||
createdBy: item.createdBy,
|
|
||||||
lastModifiedBy: item.lastModifiedBy,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
files,
|
|
||||||
nextLink: data['@odata.nextLink'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file content/download URL
|
|
||||||
*/
|
|
||||||
async getFileDownloadUrl(
|
|
||||||
accessToken: string,
|
|
||||||
siteId: string,
|
|
||||||
driveId: string,
|
|
||||||
itemId: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drives/${driveId}/items/${itemId}`;
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('[Graph] Get download URL failed:', response.status);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: GraphDriveItem = await response.json();
|
|
||||||
return data['@microsoft.graph.downloadUrl'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user profile
|
|
||||||
*/
|
|
||||||
async getUserProfile(accessToken: string): Promise<{ displayName: string; mail: string } | null> {
|
|
||||||
const endpoint = `${this.graphBaseUrl}/me`;
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('[Graph] Get user profile failed:', response.status);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for files in SharePoint
|
|
||||||
*/
|
|
||||||
async searchFiles(
|
|
||||||
accessToken: string,
|
|
||||||
siteId: string,
|
|
||||||
query: string
|
|
||||||
): Promise<JobFile[]> {
|
|
||||||
const endpoint = `${this.graphBaseUrl}/sites/${siteId}/drive/root/search(q='${encodeURIComponent(query)}')`;
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('[Graph] Search files failed:', response.status);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: GraphListResponse = await response.json();
|
|
||||||
|
|
||||||
return data.value
|
|
||||||
.filter(item => item.file)
|
|
||||||
.map(item => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
size: item.size,
|
|
||||||
mimeType: item.file?.mimeType ?? 'application/octet-stream',
|
|
||||||
webUrl: item.webUrl,
|
|
||||||
downloadUrl: item['@microsoft.graph.downloadUrl'],
|
|
||||||
createdDateTime: item.createdDateTime,
|
|
||||||
lastModifiedDateTime: item.lastModifiedDateTime,
|
|
||||||
createdBy: item.createdBy,
|
|
||||||
lastModifiedBy: item.lastModifiedBy,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const graphService = new GraphService();
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Jobs Service - Business logic for jobs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { pocketbaseService } from './pocketbaseService';
|
|
||||||
import { graphService } from './graphService';
|
|
||||||
import { cacheService } from './cacheService';
|
|
||||||
import { tokenService } from './tokenService';
|
|
||||||
import { CACHE_KEYS } from '@shared/constants';
|
|
||||||
import type { Job, JobFile, StickyNote, JobListResponse, JobDetailResponse, JobFilesResponse } from '@shared/types/job';
|
|
||||||
|
|
||||||
/** Cache TTL for job files (5 minutes) */
|
|
||||||
const FILES_CACHE_TTL = 300;
|
|
||||||
|
|
||||||
class JobsService {
|
|
||||||
/**
|
|
||||||
* Get list of all jobs
|
|
||||||
*/
|
|
||||||
async getJobs(options?: {
|
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
filter?: string;
|
|
||||||
sort?: string;
|
|
||||||
}): Promise<JobListResponse> {
|
|
||||||
const result = await pocketbaseService.getJobs(options);
|
|
||||||
|
|
||||||
return {
|
|
||||||
jobs: result.items,
|
|
||||||
page: options?.page ?? 1,
|
|
||||||
perPage: options?.perPage ?? 50,
|
|
||||||
totalItems: result.totalItems,
|
|
||||||
totalPages: result.totalPages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get job detail with files and notes
|
|
||||||
*/
|
|
||||||
async getJobDetail(jobId: string, userId: string): Promise<JobDetailResponse | null> {
|
|
||||||
// Get job from PocketBase
|
|
||||||
const job = await pocketbaseService.getJob(jobId);
|
|
||||||
if (!job) return null;
|
|
||||||
|
|
||||||
// Get notes from PocketBase
|
|
||||||
const notes = await pocketbaseService.getJobNotes(jobId);
|
|
||||||
|
|
||||||
// Get files from SharePoint (if job has site/drive configured)
|
|
||||||
let files: JobFile[] = [];
|
|
||||||
if (job.site_id && job.drive_id) {
|
|
||||||
const filesResult = await this.getJobFiles(jobId, userId);
|
|
||||||
files = filesResult?.files ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { job, files, notes };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get files for a job from SharePoint
|
|
||||||
*/
|
|
||||||
async getJobFiles(jobId: string, userId: string): Promise<JobFilesResponse | null> {
|
|
||||||
// Try cache first
|
|
||||||
const cached = await cacheService.get<JobFilesResponse>(CACHE_KEYS.JOB_FILES(jobId));
|
|
||||||
if (cached) {
|
|
||||||
return { ...cached, cached: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get job to find site/drive info
|
|
||||||
const job = await pocketbaseService.getJob(jobId);
|
|
||||||
if (!job || !job.site_id || !job.drive_id) {
|
|
||||||
return { jobId, files: [], cached: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Graph token for user
|
|
||||||
const tokenResult = await tokenService.getToken(userId, 'graph');
|
|
||||||
if (!tokenResult) {
|
|
||||||
console.error('[JobsService] No Graph token available for user', userId);
|
|
||||||
return { jobId, files: [], cached: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch files from SharePoint
|
|
||||||
const result = await graphService.getFiles(
|
|
||||||
tokenResult.token,
|
|
||||||
job.site_id,
|
|
||||||
job.drive_id,
|
|
||||||
job.folder_path
|
|
||||||
);
|
|
||||||
|
|
||||||
const response: JobFilesResponse = {
|
|
||||||
jobId,
|
|
||||||
files: result.files,
|
|
||||||
nextLink: result.nextLink,
|
|
||||||
cached: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
await cacheService.set(CACHE_KEYS.JOB_FILES(jobId), response, FILES_CACHE_TTL);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[JobsService] Failed to get files for job', jobId, err);
|
|
||||||
return { jobId, files: [], cached: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search jobs by name or number
|
|
||||||
*/
|
|
||||||
async searchJobs(query: string): Promise<Job[]> {
|
|
||||||
const result = await pocketbaseService.getJobs({
|
|
||||||
filter: `job_name ~ "${query}" || job_number ~ "${query}"`,
|
|
||||||
perPage: 20,
|
|
||||||
});
|
|
||||||
return result.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get job notes
|
|
||||||
*/
|
|
||||||
async getJobNotes(jobId: string): Promise<StickyNote[]> {
|
|
||||||
return pocketbaseService.getJobNotes(jobId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a note for a job
|
|
||||||
*/
|
|
||||||
async createNote(
|
|
||||||
jobId: string,
|
|
||||||
content: string,
|
|
||||||
userId: string,
|
|
||||||
options?: { color?: StickyNote['color']; position_x?: number; position_y?: number }
|
|
||||||
): Promise<StickyNote | null> {
|
|
||||||
return pocketbaseService.createNote({
|
|
||||||
job_id: jobId,
|
|
||||||
content,
|
|
||||||
color: options?.color ?? 'yellow',
|
|
||||||
position_x: options?.position_x ?? 0,
|
|
||||||
position_y: options?.position_y ?? 0,
|
|
||||||
created_by: userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a note
|
|
||||||
*/
|
|
||||||
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
|
|
||||||
return pocketbaseService.updateNote(noteId, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a note
|
|
||||||
*/
|
|
||||||
async deleteNote(noteId: string): Promise<boolean> {
|
|
||||||
return pocketbaseService.deleteNote(noteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate file cache for a job
|
|
||||||
*/
|
|
||||||
async invalidateFileCache(jobId: string): Promise<void> {
|
|
||||||
await cacheService.del(CACHE_KEYS.JOB_FILES(jobId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const jobsService = new JobsService();
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
/**
|
|
||||||
* PocketBase Service - PocketBase integration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import PocketBase from 'pocketbase';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
import type { PocketBaseUser } from '@shared/types/auth';
|
|
||||||
import type { Job, StickyNote } from '@shared/types/job';
|
|
||||||
|
|
||||||
class PocketBaseService {
|
|
||||||
private client: PocketBase;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.client = new PocketBase(env.POCKETBASE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get PocketBase client instance
|
|
||||||
*/
|
|
||||||
get pb(): PocketBase {
|
|
||||||
return this.client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate with token
|
|
||||||
*/
|
|
||||||
async authenticateWithToken(token: string): Promise<PocketBaseUser | null> {
|
|
||||||
try {
|
|
||||||
this.client.authStore.save(token, null);
|
|
||||||
const authData = await this.client.collection('users').authRefresh();
|
|
||||||
return authData.record as unknown as PocketBaseUser;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Auth with token failed:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh token using refresh token
|
|
||||||
*/
|
|
||||||
async refreshToken(refreshToken: string): Promise<{ token: string; refreshToken: string }> {
|
|
||||||
try {
|
|
||||||
// PocketBase SDK handles refresh internally
|
|
||||||
// We need to set the token first and then refresh
|
|
||||||
this.client.authStore.save(refreshToken, null);
|
|
||||||
const authData = await this.client.collection('users').authRefresh();
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: authData.token,
|
|
||||||
refreshToken: authData.token, // PocketBase uses same token for refresh
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Token refresh failed:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user by ID
|
|
||||||
*/
|
|
||||||
async getUser(userId: string): Promise<PocketBaseUser | null> {
|
|
||||||
try {
|
|
||||||
const record = await this.client.collection('users').getOne(userId);
|
|
||||||
return record as unknown as PocketBaseUser;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Get user failed:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all jobs (with optional filters)
|
|
||||||
*/
|
|
||||||
async getJobs(options?: {
|
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
filter?: string;
|
|
||||||
sort?: string;
|
|
||||||
}): Promise<{ items: Job[]; totalItems: number; totalPages: number }> {
|
|
||||||
try {
|
|
||||||
const result = await this.client.collection('jobs').getList(
|
|
||||||
options?.page ?? 1,
|
|
||||||
options?.perPage ?? 50,
|
|
||||||
{
|
|
||||||
filter: options?.filter,
|
|
||||||
sort: options?.sort ?? '-created',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: result.items as unknown as Job[],
|
|
||||||
totalItems: result.totalItems,
|
|
||||||
totalPages: result.totalPages,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Get jobs failed:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single job by ID
|
|
||||||
*/
|
|
||||||
async getJob(jobId: string): Promise<Job | null> {
|
|
||||||
try {
|
|
||||||
const record = await this.client.collection('jobs').getOne(jobId);
|
|
||||||
return record as unknown as Job;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Get job failed:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get notes for a job
|
|
||||||
*/
|
|
||||||
async getJobNotes(jobId: string): Promise<StickyNote[]> {
|
|
||||||
try {
|
|
||||||
const result = await this.client.collection('sticky_notes').getFullList({
|
|
||||||
filter: `job_id = "${jobId}"`,
|
|
||||||
sort: '-created',
|
|
||||||
});
|
|
||||||
return result as unknown as StickyNote[];
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Get job notes failed:', err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a sticky note
|
|
||||||
*/
|
|
||||||
async createNote(note: Omit<StickyNote, 'id' | 'created' | 'updated'>): Promise<StickyNote | null> {
|
|
||||||
try {
|
|
||||||
const record = await this.client.collection('sticky_notes').create(note);
|
|
||||||
return record as unknown as StickyNote;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Create note failed:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a sticky note
|
|
||||||
*/
|
|
||||||
async updateNote(noteId: string, data: Partial<StickyNote>): Promise<StickyNote | null> {
|
|
||||||
try {
|
|
||||||
const record = await this.client.collection('sticky_notes').update(noteId, data);
|
|
||||||
return record as unknown as StickyNote;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Update note failed:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a sticky note
|
|
||||||
*/
|
|
||||||
async deleteNote(noteId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await this.client.collection('sticky_notes').delete(noteId);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PocketBase] Delete note failed:', err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear auth state
|
|
||||||
*/
|
|
||||||
clearAuth(): void {
|
|
||||||
this.client.authStore.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pocketbaseService = new PocketBaseService();
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
/**
|
|
||||||
* Token Service - Token refresh and management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cacheService } from './cacheService';
|
|
||||||
import { pocketbaseService } from './pocketbaseService';
|
|
||||||
import { graphService } from './graphService';
|
|
||||||
import { CACHE_KEYS, TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS, TOKEN_REFRESH_INTERVAL_MS } from '@shared/constants';
|
|
||||||
import type { CachedToken, TokenType, TokenRefreshResponse } from '@shared/types/auth';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
class TokenService {
|
|
||||||
private refreshLoops = new Map<string, Timer>();
|
|
||||||
private activeUsers = new Set<string>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if token is expiring soon (within buffer)
|
|
||||||
*/
|
|
||||||
isExpiring(expiresAt: number): boolean {
|
|
||||||
return expiresAt - Date.now() < env.TOKEN_EXPIRY_BUFFER_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store tokens in cache after login
|
|
||||||
*/
|
|
||||||
async storeTokens(
|
|
||||||
userId: string,
|
|
||||||
pbToken: string,
|
|
||||||
graphToken: string,
|
|
||||||
refreshPb: string,
|
|
||||||
refreshGraph: string
|
|
||||||
): Promise<void> {
|
|
||||||
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
|
|
||||||
|
|
||||||
// Store PocketBase token
|
|
||||||
await cacheService.set<CachedToken>(
|
|
||||||
CACHE_KEYS.TOKEN_PB(userId),
|
|
||||||
{
|
|
||||||
token: pbToken,
|
|
||||||
refresh_token: refreshPb,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
userId: userId,
|
|
||||||
},
|
|
||||||
TOKEN_TTL_SECONDS
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store Graph token
|
|
||||||
await cacheService.set<CachedToken>(
|
|
||||||
CACHE_KEYS.TOKEN_GRAPH(userId),
|
|
||||||
{
|
|
||||||
token: graphToken,
|
|
||||||
refresh_token: refreshGraph,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
userId: userId,
|
|
||||||
},
|
|
||||||
TOKEN_TTL_SECONDS
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store refresh tokens with longer TTL
|
|
||||||
await cacheService.set(
|
|
||||||
CACHE_KEYS.REFRESH_PB(userId),
|
|
||||||
refreshPb,
|
|
||||||
REFRESH_TOKEN_TTL_SECONDS
|
|
||||||
);
|
|
||||||
|
|
||||||
await cacheService.set(
|
|
||||||
CACHE_KEYS.REFRESH_GRAPH(userId),
|
|
||||||
refreshGraph,
|
|
||||||
REFRESH_TOKEN_TTL_SECONDS
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track active user
|
|
||||||
this.activeUsers.add(userId);
|
|
||||||
await cacheService.sadd(CACHE_KEYS.ACTIVE_USERS, userId);
|
|
||||||
|
|
||||||
console.log(`[TokenService] Tokens stored for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get fresh token for user (from cache or refresh)
|
|
||||||
*/
|
|
||||||
async getToken(userId: string, type: TokenType): Promise<TokenRefreshResponse | null> {
|
|
||||||
const cacheKey = type === 'pb'
|
|
||||||
? CACHE_KEYS.TOKEN_PB(userId)
|
|
||||||
: CACHE_KEYS.TOKEN_GRAPH(userId);
|
|
||||||
|
|
||||||
// Try to get from cache
|
|
||||||
const cached = await cacheService.get<CachedToken>(cacheKey);
|
|
||||||
|
|
||||||
if (cached && !this.isExpiring(cached.expiresAt)) {
|
|
||||||
return {
|
|
||||||
token: cached.token,
|
|
||||||
expiresAt: cached.expiresAt,
|
|
||||||
source: 'cache',
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to refresh
|
|
||||||
const refreshed = await this.refreshToken(userId, type);
|
|
||||||
if (refreshed) {
|
|
||||||
return {
|
|
||||||
token: refreshed.token,
|
|
||||||
expiresAt: refreshed.expiresAt,
|
|
||||||
source: 'refreshed',
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return stale token if available (better than nothing for field workers)
|
|
||||||
if (cached) {
|
|
||||||
console.warn(`[TokenService] Returning stale ${type} token for user ${userId}`);
|
|
||||||
return {
|
|
||||||
token: cached.token,
|
|
||||||
expiresAt: cached.expiresAt,
|
|
||||||
source: 'cache',
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh a specific token type
|
|
||||||
*/
|
|
||||||
async refreshToken(userId: string, type: TokenType): Promise<CachedToken | null> {
|
|
||||||
const refreshKey = type === 'pb'
|
|
||||||
? CACHE_KEYS.REFRESH_PB(userId)
|
|
||||||
: CACHE_KEYS.REFRESH_GRAPH(userId);
|
|
||||||
|
|
||||||
const refreshToken = await cacheService.get<string>(refreshKey);
|
|
||||||
if (!refreshToken) {
|
|
||||||
console.error(`[TokenService] No refresh token for ${type} user ${userId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let newToken: string;
|
|
||||||
let newRefreshToken: string;
|
|
||||||
|
|
||||||
if (type === 'pb') {
|
|
||||||
const result = await pocketbaseService.refreshToken(refreshToken);
|
|
||||||
newToken = result.token;
|
|
||||||
newRefreshToken = result.refreshToken;
|
|
||||||
} else {
|
|
||||||
const result = await graphService.refreshToken(refreshToken);
|
|
||||||
newToken = result.accessToken;
|
|
||||||
newRefreshToken = result.refreshToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt = Date.now() + TOKEN_TTL_SECONDS * 1000;
|
|
||||||
const cachedToken: CachedToken = {
|
|
||||||
token: newToken,
|
|
||||||
refresh_token: newRefreshToken,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
userId: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update cache
|
|
||||||
const cacheKey = type === 'pb'
|
|
||||||
? CACHE_KEYS.TOKEN_PB(userId)
|
|
||||||
: CACHE_KEYS.TOKEN_GRAPH(userId);
|
|
||||||
|
|
||||||
await cacheService.set(cacheKey, cachedToken, TOKEN_TTL_SECONDS);
|
|
||||||
await cacheService.set(refreshKey, newRefreshToken, REFRESH_TOKEN_TTL_SECONDS);
|
|
||||||
|
|
||||||
console.log(`[TokenService] Refreshed ${type} token for user ${userId}`);
|
|
||||||
return cachedToken;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[TokenService] Failed to refresh ${type} token for user ${userId}:`, err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start background refresh loop for a user
|
|
||||||
*/
|
|
||||||
startRefreshLoop(userId: string): void {
|
|
||||||
if (this.refreshLoops.has(userId)) {
|
|
||||||
console.log(`[TokenService] Refresh loop already running for ${userId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loop = setInterval(async () => {
|
|
||||||
await this.refreshUserTokens(userId);
|
|
||||||
}, TOKEN_REFRESH_INTERVAL_MS);
|
|
||||||
|
|
||||||
this.refreshLoops.set(userId, loop);
|
|
||||||
this.activeUsers.add(userId);
|
|
||||||
console.log(`[TokenService] Started refresh loop for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop background refresh loop for a user
|
|
||||||
*/
|
|
||||||
stopRefreshLoop(userId: string): void {
|
|
||||||
const loop = this.refreshLoops.get(userId);
|
|
||||||
if (loop) {
|
|
||||||
clearInterval(loop);
|
|
||||||
this.refreshLoops.delete(userId);
|
|
||||||
this.activeUsers.delete(userId);
|
|
||||||
console.log(`[TokenService] Stopped refresh loop for user ${userId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh all tokens for a user (called by background loop)
|
|
||||||
*/
|
|
||||||
private async refreshUserTokens(userId: string): Promise<void> {
|
|
||||||
console.log(`[TokenService] Background refresh for user ${userId}`);
|
|
||||||
|
|
||||||
// Check PocketBase token
|
|
||||||
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
|
|
||||||
if (pbToken && this.isExpiring(pbToken.expiresAt)) {
|
|
||||||
await this.refreshToken(userId, 'pb');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Graph token
|
|
||||||
const graphToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_GRAPH(userId));
|
|
||||||
if (graphToken && this.isExpiring(graphToken.expiresAt)) {
|
|
||||||
await this.refreshToken(userId, 'graph');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all tokens for a user (logout)
|
|
||||||
*/
|
|
||||||
async clearUserTokens(userId: string): Promise<void> {
|
|
||||||
this.stopRefreshLoop(userId);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
cacheService.del(CACHE_KEYS.TOKEN_PB(userId)),
|
|
||||||
cacheService.del(CACHE_KEYS.TOKEN_GRAPH(userId)),
|
|
||||||
cacheService.del(CACHE_KEYS.REFRESH_PB(userId)),
|
|
||||||
cacheService.del(CACHE_KEYS.REFRESH_GRAPH(userId)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
|
|
||||||
console.log(`[TokenService] Cleared all tokens for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore refresh loops for active users (on server restart)
|
|
||||||
*/
|
|
||||||
async restoreRefreshLoops(): Promise<void> {
|
|
||||||
const activeUsers = await cacheService.smembers(CACHE_KEYS.ACTIVE_USERS);
|
|
||||||
|
|
||||||
for (const userId of activeUsers) {
|
|
||||||
// Check if user still has valid tokens
|
|
||||||
const pbToken = await cacheService.get<CachedToken>(CACHE_KEYS.TOKEN_PB(userId));
|
|
||||||
if (pbToken) {
|
|
||||||
this.startRefreshLoop(userId);
|
|
||||||
} else {
|
|
||||||
// Remove stale user
|
|
||||||
await cacheService.srem(CACHE_KEYS.ACTIVE_USERS, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[TokenService] Restored ${this.refreshLoops.size} refresh loops`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active user count
|
|
||||||
*/
|
|
||||||
get activeUserCount(): number {
|
|
||||||
return this.activeUsers.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tokenService = new TokenService();
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "job-info-prod",
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"hono": "^4.10.8",
|
|
||||||
"ioredis": "^5.3.2",
|
|
||||||
"pocketbase": "^0.26.5",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest",
|
|
||||||
"postcss": "^8.4.32",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
|
||||||
|
|
||||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
||||||
|
|
||||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
|
||||||
|
|
||||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
|
||||||
|
|
||||||
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
|
|
||||||
|
|
||||||
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
|
|
||||||
|
|
||||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
|
||||||
|
|
||||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
|
||||||
|
|
||||||
"pocketbase": ["pocketbase@0.26.5", "", {}, "sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ=="],
|
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
|
||||||
|
|
||||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
|
||||||
|
|
||||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
|
||||||
|
|
||||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -1,39 +0,0 @@
|
|||||||
// PocketBase auth bootstrap shared by pages
|
|
||||||
(function (global) {
|
|
||||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
|
||||||
|
|
||||||
function authHeaders() {
|
|
||||||
if (pb.authStore.isValid && pb.authStore.token) {
|
|
||||||
return { Authorization: `Bearer ${pb.authStore.token}` };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayName() {
|
|
||||||
const model = pb.authStore.model || {};
|
|
||||||
return (
|
|
||||||
model.display_name ||
|
|
||||||
model.name ||
|
|
||||||
model.email ||
|
|
||||||
model.username ||
|
|
||||||
'Unknown'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEmail() {
|
|
||||||
const model = pb.authStore.model || {};
|
|
||||||
// Prefer email, but fall back to username if email missing
|
|
||||||
return model.email || model.username || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureAuth() {
|
|
||||||
if (pb.authStore.isValid) return true;
|
|
||||||
const path = new URL(window.location.href).pathname;
|
|
||||||
if (!path.endsWith('signin.html')) {
|
|
||||||
window.location.href = 'signin.html';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Auth = { pb, authHeaders, ensureAuth, getDisplayName, getEmail };
|
|
||||||
})(window);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/* Generated Tailwind CSS - Basic version */
|
|
||||||
/* This will be updated by tailwind CLI on dev */
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
Before Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -1,181 +0,0 @@
|
|||||||
// PocketBase auth bootstrap shared by pages
|
|
||||||
(function (global) {
|
|
||||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
|
||||||
const GRAPH_TOKEN_KEY = 'graphAccessToken';
|
|
||||||
const USER_ID_KEY = 'userId';
|
|
||||||
|
|
||||||
function authHeaders() {
|
|
||||||
if (pb.authStore.isValid && pb.authStore.token) {
|
|
||||||
return { Authorization: `Bearer ${pb.authStore.token}` };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayName() {
|
|
||||||
const model = pb.authStore.model || {};
|
|
||||||
return (
|
|
||||||
model.display_name ||
|
|
||||||
model.name ||
|
|
||||||
model.email ||
|
|
||||||
model.username ||
|
|
||||||
'Unknown'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEmail() {
|
|
||||||
const model = pb.authStore.model || {};
|
|
||||||
return model.email || model.username || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserId() {
|
|
||||||
return pb.authStore.model?.id || localStorage.getItem(USER_ID_KEY) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Graph token - first from localStorage, then try to refresh from backend
|
|
||||||
async function getGraphToken() {
|
|
||||||
// First check localStorage
|
|
||||||
let token = localStorage.getItem(GRAPH_TOKEN_KEY);
|
|
||||||
|
|
||||||
// If we have a valid PB session but no graph token, try to get from backend
|
|
||||||
if (!token && pb.authStore.isValid) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/refresh-token?type=graph', {
|
|
||||||
headers: authHeaders()
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.token) {
|
|
||||||
token = data.token;
|
|
||||||
localStorage.setItem(GRAPH_TOKEN_KEY, token);
|
|
||||||
console.log('[Auth] Got Graph token from backend cache');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Could not get Graph token from backend:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronous version for backwards compatibility
|
|
||||||
function getGraphTokenSync() {
|
|
||||||
return localStorage.getItem(GRAPH_TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureAuth() {
|
|
||||||
if (pb.authStore.isValid) {
|
|
||||||
// Try to refresh tokens from backend on page load
|
|
||||||
try {
|
|
||||||
await refreshTokensFromBackend();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Token refresh failed:', err);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const path = new URL(window.location.href).pathname;
|
|
||||||
if (!path.endsWith('signin.html')) {
|
|
||||||
window.location.href = 'signin.html';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh tokens from backend cache
|
|
||||||
async function refreshTokensFromBackend() {
|
|
||||||
if (!pb.authStore.isValid) return;
|
|
||||||
|
|
||||||
const userId = getUserId();
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get fresh Graph token from backend
|
|
||||||
const response = await fetch('/api/auth/refresh-token?type=graph', {
|
|
||||||
headers: authHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.token) {
|
|
||||||
localStorage.setItem(GRAPH_TOKEN_KEY, data.token);
|
|
||||||
console.log('[Auth] Refreshed Graph token from backend, source:', data.source);
|
|
||||||
}
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
// Token expired or invalid in backend, but we might still have a valid PB session
|
|
||||||
// Try to re-store our current tokens
|
|
||||||
console.log('[Auth] Backend token expired, attempting to re-store');
|
|
||||||
await restoreTokensToBackend();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Token refresh error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-store tokens to backend (in case backend cache was cleared)
|
|
||||||
async function restoreTokensToBackend() {
|
|
||||||
if (!pb.authStore.isValid) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Refresh PocketBase auth to potentially get a new Graph token
|
|
||||||
const authData = await pb.collection('users').authRefresh();
|
|
||||||
|
|
||||||
const graphToken = extractGraphToken(authData) || localStorage.getItem(GRAPH_TOKEN_KEY);
|
|
||||||
|
|
||||||
if (graphToken) {
|
|
||||||
const response = await fetch('/api/auth/login-callback', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...authHeaders()
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
pbToken: pb.authStore.token,
|
|
||||||
graphToken: graphToken,
|
|
||||||
refreshTokenPb: '',
|
|
||||||
refreshTokenGraph: authData?.meta?.refreshToken || '',
|
|
||||||
user: {
|
|
||||||
id: pb.authStore.model?.id,
|
|
||||||
email: pb.authStore.model?.email,
|
|
||||||
name: pb.authStore.model?.name || pb.authStore.model?.display_name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('[Auth] Re-stored tokens in backend');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Failed to re-store tokens:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractGraphToken(authData) {
|
|
||||||
return (
|
|
||||||
authData?.meta?.graphAccessToken ||
|
|
||||||
authData?.meta?.graph_token ||
|
|
||||||
authData?.meta?.graphToken ||
|
|
||||||
authData?.meta?.accessToken ||
|
|
||||||
authData?.meta?.access_token ||
|
|
||||||
authData?.meta?.token ||
|
|
||||||
authData?.meta?.rawToken ||
|
|
||||||
authData?.meta?.authData?.access_token ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Auth = {
|
|
||||||
pb,
|
|
||||||
authHeaders,
|
|
||||||
ensureAuth,
|
|
||||||
getDisplayName,
|
|
||||||
getEmail,
|
|
||||||
getUserId,
|
|
||||||
getGraphToken,
|
|
||||||
getGraphTokenSync,
|
|
||||||
refreshTokensFromBackend,
|
|
||||||
restoreTokensToBackend,
|
|
||||||
extractGraphToken,
|
|
||||||
GRAPH_TOKEN_KEY,
|
|
||||||
USER_ID_KEY
|
|
||||||
};
|
|
||||||
})(window);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/* Generated Tailwind CSS - Basic version */
|
|
||||||
/* This will be updated by tailwind CLI on dev */
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Sign In - Job Info</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
|
|
||||||
</head>
|
|
||||||
<body class="font-sans bg-gradient-to-br from-indigo-500 to-purple-600 min-h-screen flex items-center justify-center">
|
|
||||||
<div class="bg-white p-12 rounded-2xl shadow-2xl text-center max-w-md w-[90%]">
|
|
||||||
<h1 class="text-gray-800 mb-2 text-3xl font-bold">Job Info</h1>
|
|
||||||
<p class="text-gray-600 mb-8">Sign in with your Microsoft account</p>
|
|
||||||
|
|
||||||
<button id="loginBtn" onclick="login()"
|
|
||||||
class="bg-purple-600 text-white border-none py-3.5 px-8 text-base rounded-lg cursor-pointer transition-all duration-200 font-medium hover:bg-purple-700 hover:shadow-lg disabled:bg-gray-400 disabled:cursor-not-allowed w-full">
|
|
||||||
Sign in with Microsoft
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="text-red-600 mt-4 hidden text-sm" id="error"></div>
|
|
||||||
|
|
||||||
<div class="hidden mt-8 text-left bg-gray-50 p-6 rounded-lg" id="userInfo">
|
|
||||||
<h2 class="text-lg mb-4 text-gray-800 font-semibold">✓ Signed In</h2>
|
|
||||||
<p class="mb-3 text-gray-600"><strong class="text-gray-800">Name:</strong> <span id="displayName"></span></p>
|
|
||||||
<p class="mb-3 text-gray-600"><strong class="text-gray-800">Email:</strong> <span id="email"></span></p>
|
|
||||||
|
|
||||||
<button onclick="goToApp()"
|
|
||||||
class="mt-4 bg-purple-600 text-white border-none py-3 px-6 text-base rounded-lg cursor-pointer transition-all duration-200 font-medium hover:bg-purple-700 hover:shadow-lg w-full">
|
|
||||||
Continue to App →
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button onclick="logout()"
|
|
||||||
class="mt-3 bg-gray-200 text-gray-700 border-none py-2.5 px-6 text-sm rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-300 w-full">
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
|
||||||
|
|
||||||
const loginBtn = document.getElementById('loginBtn');
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
const userInfo = document.getElementById('userInfo');
|
|
||||||
|
|
||||||
function goToApp() {
|
|
||||||
window.location.href = 'index.html';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(msg) {
|
|
||||||
errorEl.textContent = msg;
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideError() {
|
|
||||||
errorEl.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract Graph token from OAuth response
|
|
||||||
function extractGraphToken(authData) {
|
|
||||||
return authData?.meta?.accessToken ||
|
|
||||||
authData?.meta?.access_token ||
|
|
||||||
authData?.meta?.graphAccessToken ||
|
|
||||||
authData?.meta?.token ||
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store tokens in backend Redis cache
|
|
||||||
async function storeTokensInBackend(authData, graphToken) {
|
|
||||||
const userId = pb.authStore.model?.id;
|
|
||||||
if (!userId) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/login-callback', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${pb.authStore.token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
pbToken: pb.authStore.token,
|
|
||||||
graphToken: graphToken,
|
|
||||||
refreshTokenPb: '',
|
|
||||||
refreshTokenGraph: authData?.meta?.refreshToken || '',
|
|
||||||
user: {
|
|
||||||
id: userId,
|
|
||||||
email: pb.authStore.model?.email || '',
|
|
||||||
name: pb.authStore.model?.name || pb.authStore.model?.display_name || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('[Auth] Tokens stored in backend');
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
console.warn('[Auth] Backend storage failed:', response.status);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Backend error:', err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAuthedUI() {
|
|
||||||
const model = pb.authStore.model;
|
|
||||||
loginBtn.classList.add('hidden');
|
|
||||||
userInfo.classList.remove('hidden');
|
|
||||||
document.getElementById('displayName').textContent = model?.name || model?.display_name || 'User';
|
|
||||||
document.getElementById('email').textContent = model?.email || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
loginBtn.disabled = true;
|
|
||||||
loginBtn.textContent = 'Signing in...';
|
|
||||||
hideError();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
|
|
||||||
const graphToken = extractGraphToken(authData);
|
|
||||||
|
|
||||||
// Store in backend cache for persistence
|
|
||||||
// Store in localStorage for index.html to find
|
|
||||||
if (graphToken) localStorage.setItem('graphAccessToken', graphToken);
|
|
||||||
await storeTokensInBackend(authData, graphToken);
|
|
||||||
|
|
||||||
showAuthedUI();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error);
|
|
||||||
showError(error.message || 'Authentication failed');
|
|
||||||
loginBtn.disabled = false;
|
|
||||||
loginBtn.textContent = 'Sign in with Microsoft';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/auth/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${pb.authStore.token}` }
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Logout error:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
pb.authStore.clear();
|
|
||||||
loginBtn.classList.remove('hidden');
|
|
||||||
loginBtn.disabled = false;
|
|
||||||
loginBtn.textContent = 'Sign in with Microsoft';
|
|
||||||
userInfo.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check existing session on load
|
|
||||||
async function checkSession() {
|
|
||||||
if (!pb.authStore.isValid) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pb.collection('users').authRefresh();
|
|
||||||
showAuthedUI();
|
|
||||||
|
|
||||||
// Check if coming from reauth flow
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (params.get('reauth') === '1') {
|
|
||||||
goToApp();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Auth] Session expired');
|
|
||||||
pb.authStore.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkSession();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Sign In</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
|
|
||||||
<script src="auth.js"></script>
|
|
||||||
</head>
|
|
||||||
<body class="font-sans bg-gradient-to-br from-indigo-500 to-purple-600 min-h-screen flex items-center justify-center">
|
|
||||||
<div class="bg-white p-12 rounded-2xl shadow-2xl text-center max-w-md w-[90%]">
|
|
||||||
<h1 class="text-gray-800 mb-2 text-3xl">Welcome</h1>
|
|
||||||
<p class="text-gray-600 mb-8">Sign in with your Microsoft account</p>
|
|
||||||
|
|
||||||
<button id="loginBtn" onclick="login()" class="bg-blue-600 text-white border-none py-3.5 px-8 text-base rounded-md cursor-pointer transition-colors duration-200 font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed">Sign in with Microsoft</button>
|
|
||||||
|
|
||||||
<div class="text-red-600 mt-4 hidden" id="error"></div>
|
|
||||||
|
|
||||||
<div class="hidden mt-8 text-left bg-gray-50 p-6 rounded-lg" id="userInfo">
|
|
||||||
<h2 class="text-lg mb-4 text-gray-800">Signed in</h2>
|
|
||||||
<p class="mb-3 text-gray-600 break-all"><strong class="text-gray-800">Name:</strong> <span id="displayName"></span></p>
|
|
||||||
<p class="mb-3 text-gray-600 break-all"><strong class="text-gray-800">Email:</strong> <span id="email"></span></p>
|
|
||||||
|
|
||||||
<button id="continueBtn" onclick="goToIndex()" class="hidden mt-4 bg-blue-600 text-white border-none py-3.5 px-8 text-base rounded-md cursor-pointer transition-colors duration-200 font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed w-full">Continue to Job Info</button>
|
|
||||||
<button onclick="logout()" class="bg-gray-600 text-white border-none py-3.5 px-8 text-base rounded-md cursor-pointer transition-colors duration-200 font-medium hover:bg-gray-700 mt-4">Sign out</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const { pb, getDisplayName } = window.Auth;
|
|
||||||
|
|
||||||
const GRAPH_TOKEN_KEY = 'graphAccessToken';
|
|
||||||
const extractGraphToken = (authData) => {
|
|
||||||
return (
|
|
||||||
authData?.meta?.graphAccessToken ||
|
|
||||||
authData?.meta?.graph_token ||
|
|
||||||
authData?.meta?.graphToken ||
|
|
||||||
authData?.meta?.accessToken ||
|
|
||||||
authData?.meta?.access_token ||
|
|
||||||
authData?.meta?.token ||
|
|
||||||
authData?.meta?.rawToken ||
|
|
||||||
authData?.meta?.authData?.access_token ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginBtn = document.getElementById('loginBtn');
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
const userInfo = document.getElementById('userInfo');
|
|
||||||
const continueBtn = document.getElementById('continueBtn');
|
|
||||||
const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
|
|
||||||
|
|
||||||
function goToIndex() {
|
|
||||||
window.location.href = 'index.html';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAuthedUI(displayName, email) {
|
|
||||||
loginBtn.classList.add('hidden');
|
|
||||||
loginBtn.disabled = false;
|
|
||||||
loginBtn.textContent = 'Sign in with Microsoft';
|
|
||||||
userInfo.classList.remove('hidden');
|
|
||||||
continueBtn.classList.remove('hidden');
|
|
||||||
document.getElementById('displayName').textContent = displayName || 'Unknown';
|
|
||||||
document.getElementById('email').textContent = email || 'Not available';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
loginBtn.disabled = true;
|
|
||||||
loginBtn.textContent = 'Signing in...';
|
|
||||||
errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authData = await pb.collection('users').authWithOAuth2({
|
|
||||||
provider: 'microsoft',
|
|
||||||
});
|
|
||||||
displayUserInfo(authData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error);
|
|
||||||
errorEl.textContent = error.message || 'Authentication failed. Please try again.';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
loginBtn.disabled = false;
|
|
||||||
loginBtn.textContent = 'Sign in with Microsoft';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
pb.authStore.clear();
|
|
||||||
localStorage.removeItem(GRAPH_TOKEN_KEY);
|
|
||||||
loginBtn.classList.remove('hidden');
|
|
||||||
userInfo.classList.add('hidden');
|
|
||||||
continueBtn.classList.add('hidden');
|
|
||||||
loginBtn.disabled = false;
|
|
||||||
loginBtn.textContent = 'Sign in with Microsoft';
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayUserInfo(authData) {
|
|
||||||
console.log('Auth response:', authData);
|
|
||||||
console.log('Auth meta:', authData?.meta);
|
|
||||||
const displayName = getDisplayName();
|
|
||||||
const email = authData?.meta?.rawUser?.email || authData?.record?.email || pb.authStore.model?.email || 'Not available';
|
|
||||||
const graphToken = extractGraphToken(authData);
|
|
||||||
console.log('Extracted Graph token:', graphToken ? graphToken.substring(0, 30) + '...' : 'NOT FOUND');
|
|
||||||
if (graphToken) {
|
|
||||||
localStorage.setItem(GRAPH_TOKEN_KEY, graphToken);
|
|
||||||
localStorage.removeItem(GRAPH_REAUTH_FLAG);
|
|
||||||
}
|
|
||||||
showAuthedUI(displayName, email);
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const reauth = params.get('reauth') === '1';
|
|
||||||
if (pb.authStore.isValid) {
|
|
||||||
try {
|
|
||||||
const authData = await pb.collection('users').authRefresh();
|
|
||||||
displayUserInfo(authData);
|
|
||||||
// If we came from a reauth flow and now have a token, go back automatically
|
|
||||||
const hasToken = localStorage.getItem(GRAPH_TOKEN_KEY);
|
|
||||||
if (reauth && hasToken) {
|
|
||||||
window.location.href = 'index.html';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
pb.authStore.clear();
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apple</title><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 650 B |
@@ -1 +0,0 @@
|
|||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 457 B |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "job-info-prod",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"description": "Professional field management application with dual-token authentication",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "bun run --watch backend/src/index.ts",
|
|
||||||
"start": "bun run backend/src/index.ts",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"hono": "^4.10.8",
|
|
||||||
"ioredis": "^5.3.2",
|
|
||||||
"pocketbase": "^0.26.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest",
|
|
||||||
"typescript": "^5.3.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared Constants
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Token refresh interval in milliseconds (30 minutes) */
|
|
||||||
export const TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Buffer before token expiry to trigger refresh (15 minutes) */
|
|
||||||
export const TOKEN_EXPIRY_BUFFER_MS = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Token TTL in cache (1 hour) */
|
|
||||||
export const TOKEN_TTL_SECONDS = 3600;
|
|
||||||
|
|
||||||
/** Refresh token TTL in cache (30 days) */
|
|
||||||
export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
/** API request timeout in milliseconds */
|
|
||||||
export const API_TIMEOUT_MS = 30000;
|
|
||||||
|
|
||||||
/** Cache key prefixes */
|
|
||||||
export const CACHE_KEYS = {
|
|
||||||
TOKEN_PB: (userId: string) => `tokens:${userId}:pb`,
|
|
||||||
TOKEN_GRAPH: (userId: string) => `tokens:${userId}:graph`,
|
|
||||||
REFRESH_PB: (userId: string) => `tokens:${userId}:refresh_pb`,
|
|
||||||
REFRESH_GRAPH: (userId: string) => `tokens:${userId}:refresh_graph`,
|
|
||||||
JOB_FILES: (jobId: string) => `jobs:${jobId}:files`,
|
|
||||||
ACTIVE_USERS: 'active_users',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Local storage keys */
|
|
||||||
export const STORAGE_KEYS = {
|
|
||||||
PB_TOKEN: 'job-info:pb_token',
|
|
||||||
GRAPH_TOKEN: 'job-info:graph_token',
|
|
||||||
USER: 'job-info:user',
|
|
||||||
EXPIRES_AT: 'job-info:expires_at',
|
|
||||||
LAST_SYNC: 'job-info:last_sync',
|
|
||||||
JOBS_CACHE: 'job-info:jobs',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** API endpoints */
|
|
||||||
export const API_ENDPOINTS = {
|
|
||||||
LOGIN_CALLBACK: '/api/auth/login-callback',
|
|
||||||
LOGOUT: '/api/auth/logout',
|
|
||||||
REFRESH_TOKEN: '/api/auth/refresh-token',
|
|
||||||
JOBS: '/api/jobs',
|
|
||||||
FILES: '/api/files',
|
|
||||||
HEALTH: '/api/health',
|
|
||||||
} as const;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication Types - Shared between frontend and backend
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Token types supported by the system */
|
|
||||||
export type TokenType = 'pb' | 'graph';
|
|
||||||
|
|
||||||
/** Stored token data in cache */
|
|
||||||
export interface CachedToken {
|
|
||||||
token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
expiresAt: number;
|
|
||||||
userId: string;
|
|
||||||
source?: 'cache' | 'refreshed';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Token response from refresh endpoint */
|
|
||||||
export interface TokenRefreshResponse {
|
|
||||||
token: string;
|
|
||||||
expiresAt: number;
|
|
||||||
source: 'cache' | 'refreshed';
|
|
||||||
type: TokenType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** User session data from PocketBase */
|
|
||||||
export interface PocketBaseUser {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
verified: boolean;
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alias for PocketBaseUser */
|
|
||||||
export type User = PocketBaseUser;
|
|
||||||
|
|
||||||
/** Authentication state for frontend */
|
|
||||||
export interface AuthState {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
user?: PocketBaseUser | null;
|
|
||||||
pbToken?: string | null;
|
|
||||||
graphToken?: string | null;
|
|
||||||
expiresAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Login callback request body */
|
|
||||||
export interface LoginCallbackRequest {
|
|
||||||
pbToken: string;
|
|
||||||
graphToken: string;
|
|
||||||
refreshTokenPb?: string;
|
|
||||||
refreshTokenGraph?: string;
|
|
||||||
user: PocketBaseUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Login callback response */
|
|
||||||
export interface LoginCallbackResponse {
|
|
||||||
success: boolean;
|
|
||||||
user: PocketBaseUser;
|
|
||||||
expiresAt_pb: number;
|
|
||||||
expiresAt_graph: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Logout response */
|
|
||||||
export interface LogoutResponse {
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Token validation result */
|
|
||||||
export interface TokenValidation {
|
|
||||||
valid: boolean;
|
|
||||||
expired: boolean;
|
|
||||||
expiresIn: number;
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Job Types - Shared between frontend and backend
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Job status */
|
|
||||||
export type JobStatus = 'active' | 'completed' | 'pending' | 'cancelled' | 'Active' | 'Completed' | 'Pending' | 'On Hold';
|
|
||||||
|
|
||||||
/** Job record from PocketBase */
|
|
||||||
export interface Job {
|
|
||||||
id: string;
|
|
||||||
job_number: string;
|
|
||||||
job_name: string;
|
|
||||||
client_name: string;
|
|
||||||
status: JobStatus;
|
|
||||||
site_id?: string;
|
|
||||||
drive_id?: string;
|
|
||||||
folder_path?: string;
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
// Computed/alias properties for frontend convenience
|
|
||||||
jobNumber?: string;
|
|
||||||
customer?: string;
|
|
||||||
address?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
notes?: StickyNote[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Job file from SharePoint/Graph API */
|
|
||||||
export interface JobFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
mimeType: string;
|
|
||||||
webUrl: string;
|
|
||||||
downloadUrl?: string;
|
|
||||||
createdDateTime: string;
|
|
||||||
lastModifiedDateTime: string;
|
|
||||||
createdBy?: {
|
|
||||||
user: {
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
lastModifiedBy?: {
|
|
||||||
user: {
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Job files response */
|
|
||||||
export interface JobFilesResponse {
|
|
||||||
jobId: string;
|
|
||||||
files: JobFile[];
|
|
||||||
nextLink?: string;
|
|
||||||
cached: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sticky note for a job */
|
|
||||||
export interface StickyNote {
|
|
||||||
id: string;
|
|
||||||
job_id: string;
|
|
||||||
content: string;
|
|
||||||
color?: 'yellow' | 'blue' | 'green' | 'pink' | 'purple';
|
|
||||||
position_x?: number;
|
|
||||||
position_y?: number;
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
created_by: string;
|
|
||||||
// Alias for frontend
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Job list response */
|
|
||||||
export interface JobListResponse {
|
|
||||||
jobs: Job[];
|
|
||||||
page: number;
|
|
||||||
perPage: number;
|
|
||||||
totalItems: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Job detail response */
|
|
||||||
export interface JobDetailResponse {
|
|
||||||
job: Job;
|
|
||||||
files: JobFile[];
|
|
||||||
notes: StickyNote[];
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
'./frontend/public/**/*.html',
|
|
||||||
'./frontend/src/**/*.{js,ts}',
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
// Brand colors
|
|
||||||
primary: {
|
|
||||||
50: '#eff6ff',
|
|
||||||
100: '#dbeafe',
|
|
||||||
200: '#bfdbfe',
|
|
||||||
300: '#93c5fd',
|
|
||||||
400: '#60a5fa',
|
|
||||||
500: '#3b82f6',
|
|
||||||
600: '#2563eb',
|
|
||||||
700: '#1d4ed8',
|
|
||||||
800: '#1e40af',
|
|
||||||
900: '#1e3a8a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"strictNullChecks": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": ".",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@backend/*": ["./backend/src/*"],
|
|
||||||
"@frontend/*": ["./frontend/src/*"],
|
|
||||||
"@shared/*": ["./shared/*"]
|
|
||||||
},
|
|
||||||
"types": ["bun-types"]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"backend/src/**/*.ts",
|
|
||||||
"frontend/src/**/*.ts",
|
|
||||||
"shared/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||