Files
Job-Info/instructions/OPUS_PROMPT_PHASE1.md
T
2026-01-09 04:08:06 +00:00

671 lines
20 KiB
Markdown

# Opus 4.5 Prompt: Job-Info-Prod Initial Architecture & Scaffolding
## Project Context & Mission
**Project:** Job Info - Production (Job-Info-Prod)
**Purpose:** Professional field management application for construction/project teams
**Critical Requirement:** Workers in field cannot afford frequent re-authentication
**Environment:** Field-deployed, potentially offline, mission-critical
---
## Core Architecture Requirements
### 1. Dual-Token Authentication System
The app uses **PocketBase OAuth through Microsoft** to acquire TWO tokens:
1. **PocketBase Token** (User session with PocketBase)
- For all app operations (jobs, files, notes)
- Managed by PocketBase authentication
- Expires: 3600 seconds (1 hour)
2. **Microsoft Graph Token** (User's Microsoft account access)
- For SharePoint file access & Office 365 integration
- Obtained during PocketBase OAuth flow
- Expires: 3600 seconds (1 hour)
### 2. Token Lifecycle (CRITICAL)
**Goal:** Users authenticate ONCE at startup, tokens stay fresh for entire session (hours/days)
```
Login Flow:
├── User clicks "Sign In"
├── Redirected to PocketBase OAuth (which includes Microsoft)
├── User grants permissions to app
├── App receives:
│ ├── PocketBase token → localStorage
│ ├── Graph token → localStorage
│ └── Refresh tokens → Valkey cache (backend)
├── App initializes background token refresh loop
└── User sees app, never needs to re-login
Token Refresh Loop (Backend):
├── Every 30 minutes (running in background)
├── Check token expiry times
├── Refresh if < 15 minutes to expiry
├── Update both Valkey cache AND localStorage
├── Silently continue (no UI interruption)
└── Never ask user to re-authenticate
```
### 3. Token Storage Strategy
**Frontend (localStorage):**
- `pocketbase_auth` - PocketBase session (includes token)
- `graph_token` - Microsoft Graph token
- `token_expires_pb` - Expiry timestamp
- `token_expires_graph` - Expiry timestamp
**Backend (Valkey/Redis Cache):**
- `tokens:${userId}:pb` - Fresh PocketBase token + refresh token (TTL: 1 hour)
- `tokens:${userId}:graph` - Fresh Graph token + refresh token (TTL: 1 hour)
- `tokens:${userId}:refresh_pb` - PocketBase refresh token (TTL: 30 days)
- `tokens:${userId}:refresh_graph` - Graph refresh token (TTL: 30 days)
**Why dual storage:**
- Frontend has tokens for immediate use
- Backend cache keeps tokens warm & refreshed
- If frontend token expires, backend can refresh silently
- If Redis goes down, frontend token still works
- If frontend loses token, can get from Redis
### 4. Single Token Check Logic
Before ANY API call:
```javascript
// Frontend check
async function ensureToken(type) {
// type: 'pb' or 'graph'
// 1. Get from localStorage
const token = localStorage.getItem(`${type}_token`);
const expiry = localStorage.getItem(`token_expires_${type}`);
// 2. Check if fresh (> 15 min remaining)
if (token && expiry > Date.now() + 15*60*1000) {
return token; // Use immediately
}
// 3. If not fresh, try backend refresh
const refreshed = await fetch('/api/refresh-token', {
method: 'POST',
body: JSON.stringify({ type })
});
if (refreshed.ok) {
const { token: newToken, expiresAt } = await refreshed.json();
localStorage.setItem(`${type}_token`, newToken);
localStorage.setItem(`token_expires_${type}`, expiresAt);
return newToken;
}
// 4. If backend refresh failed, redirect to login
if (!token) {
redirectToLogin();
throw new Error('Authentication required');
}
// 5. Use stale token as fallback (better than nothing in field)
console.warn('Using stale token - backend unreachable');
return token;
}
```
### 5. Background Token Refresh (CRITICAL)
Backend runs continuous refresh loop:
```typescript
// Backend service
class TokenRefreshService {
async refreshTokensInBackground() {
// Every 30 minutes for each logged-in user
for (const userId of getActiveUsers()) {
// Check PocketBase token
const pbToken = await cache.get(`tokens:${userId}:pb`);
if (isExpiring(pbToken)) {
const refreshed = await refreshPocketBaseToken(pbToken.refresh_token);
await cache.set(`tokens:${userId}:pb`, refreshed, ttl: 3600);
}
// Check Graph token
const graphToken = await cache.get(`tokens:${userId}:graph`);
if (isExpiring(graphToken)) {
const refreshed = await refreshMicrosoftGraphToken(graphToken.refresh_token);
await cache.set(`tokens:${userId}:graph`, refreshed, ttl: 3600);
}
}
}
}
```
### 6. Offline-First Capability
**When backend is down:**
- Frontend still works with cached tokens
- File list cached in memory (Map)
- File content cached (if accessed before)
- UI shows "Offline" indicator but remains functional
- All operations queue locally
- Sync when backend returns
**When network is intermittent:**
- Requests timeout after 30 seconds
- Automatic retry with exponential backoff
- Cache provides fallback data
- User sees "Trying to reconnect..." briefly
- App continues working
### 7. Error Handling & Graceful Degradation
```
Token Missing
├── During Init → Redirect to login
├── During API call → Prompt to login (non-blocking overlay)
└── In background → Log, don't interrupt user
Token Expired
├── < 15 min to expiry → Silently refresh from backend
├── Expired & refresh fails → Use stale token if available
└── Completely unavailable → Read from cache, queue for sync
Backend Unreachable
├── Token refresh → Skip, use current token
├── API call → Use cache, retry when back
└── File fetch → Use cached file list
```
---
## Project Structure
```
Job-Info-Prod/
├── auth/ # Auth rules from Job-Info-Test
│ ├── RULES.md, FLOWMAP.md, VIEWS.md, etc.
│ └── auth-universal.js # Base auth module (to be enhanced)
├── backend/
│ ├── src/
│ │ ├── index.ts # Main server
│ │ ├── config/
│ │ │ ├── env.ts # Environment config
│ │ │ └── constants.ts # App constants
│ │ ├── middleware/
│ │ │ ├── auth.ts # Token validation
│ │ │ ├── errorHandler.ts # Error handling
│ │ │ └── logger.ts # Request logging
│ │ ├── routes/
│ │ │ ├── auth.ts # /api/auth endpoints (login, refresh, logout)
│ │ │ ├── jobs.ts # /api/jobs endpoints
│ │ │ ├── files.ts # /api/job-files endpoints
│ │ │ ├── health.ts # /health endpoint
│ │ │ └── index.ts # Route registration
│ │ ├── services/
│ │ │ ├── tokenService.ts # Token refresh & management
│ │ │ ├── cacheService.ts # Redis operations
│ │ │ ├── pocketbaseService.ts # PocketBase integration
│ │ │ ├── graphService.ts # Microsoft Graph integration
│ │ │ └── jobsService.ts # Business logic
│ │ ├── types/
│ │ │ ├── auth.ts # Auth interfaces
│ │ │ ├── job.ts # Job interfaces
│ │ │ └── common.ts # Common types
│ │ └── utils/
│ │ ├── logger.ts # Logging utility
│ │ └── validators.ts # Input validation
│ ├── tests/
│ │ ├── auth.test.ts
│ │ ├── token-refresh.test.ts
│ │ └── jobs.test.ts
│ └── README.md
├── frontend/
│ ├── public/
│ │ ├── index.html # Main app
│ │ ├── signin.html # OAuth redirect page
│ │ ├── offline.html # Offline fallback
│ │ └── assets/
│ ├── src/
│ │ ├── main.ts # Entry point
│ │ ├── auth/
│ │ │ ├── client.ts # Token management
│ │ │ ├── pocketbase.ts # PocketBase instance
│ │ │ └── refresh-loop.ts # Background refresh
│ │ ├── views/
│ │ │ ├── auth-view.ts # Sign in page
│ │ │ ├── landing-view.ts # Jobs list
│ │ │ ├── job-detail-view.ts # Job detail
│ │ │ └── notes-view.ts # Expanded notes
│ │ ├── services/
│ │ │ ├── api-client.ts # API requests
│ │ │ ├── cache.ts # Local file cache
│ │ │ ├── state.ts # Global state
│ │ │ └── offline.ts # Offline handling
│ │ ├── components/
│ │ │ ├── job-card.ts
│ │ │ ├── file-list.ts
│ │ │ └── sticky-notes.ts
│ │ ├── types/
│ │ │ ├── auth.ts
│ │ │ └── job.ts
│ │ └── styles/
│ │ ├── main.css
│ │ └── tailwind.css
│ ├── tests/
│ └── README.md
├── shared/
│ ├── types/
│ │ ├── auth.ts # Auth interfaces
│ │ └── job.ts # Job interfaces
│ └── constants/
│ └── index.ts
├── docs/
│ ├── ARCHITECTURE.md # System design
│ ├── API.md # API reference
│ ├── TOKEN_LIFECYCLE.md # Token management detail
│ ├── OFFLINE_STRATEGY.md # Offline-first approach
│ └── DEPLOYMENT.md # Production deployment
├── scripts/
│ ├── setup.sh
│ └── deploy.sh
├── .env.example
├── .gitignore
├── package.json
├── tsconfig.json
├── tailwind.config.js
├── postcss.config.js
├── bun.lockb
├── README.md
└── CHANGELOG.md
```
---
## Key Implementation Details
### Authentication Flow
1. **User Visits App**
```
App Load
├── Check localStorage for auth
├── If found & fresh → Load app
├── If not found → Redirect to signin.html
└── If expired → Prompt to re-login
```
2. **Sign In Page (signin.html)**
```
signin.html (Hosted by backend)
├── Redirects to PocketBase OAuth
│ └── PocketBase includes Microsoft scopes
│ ├── offline_access (for refresh tokens)
│ ├── Files.Read (SharePoint)
│ ├── Sites.Read.All (Sites)
│ └── User.Read (Profile)
├── PocketBase handles OAuth flow
├── Returns both tokens
└── Redirects back to app with tokens
```
3. **Initial Token Setup**
```typescript
// Backend endpoint: POST /api/auth/login-callback
async function handleLoginCallback(req) {
const { pbToken, graphToken } = req.body;
// 1. Validate tokens
const pbUser = await validatePocketBaseToken(pbToken);
const graphUser = await validateGraphToken(graphToken);
// 2. Store in Valkey cache
const userId = pbUser.id;
await cache.set(`tokens:${userId}:pb`, {
token: pbToken,
refresh_token: pbToken.refresh,
expires_at: pbUser.expires
}, ttl: 3600);
await cache.set(`tokens:${userId}:graph`, {
token: graphToken,
refresh_token: graphToken.refresh,
expires_at: graphUser.expires
}, ttl: 3600);
// 3. Start background refresh loop for this user
tokenRefreshService.addUser(userId);
// 4. Return tokens to frontend
return {
pbToken,
graphToken,
expiresAt_pb: pbUser.expires,
expiresAt_graph: graphUser.expires
};
}
```
### Token Refresh Endpoints
**POST /api/refresh-token**
```typescript
async function refreshToken(c: Context) {
const userId = c.get('userId'); // From auth middleware
const type = c.req.query('type'); // 'pb' or 'graph'
// 1. Try to get fresh token from cache
const cached = await cache.get(`tokens:${userId}:${type}`);
if (cached && !isExpiring(cached)) {
return c.json({
token: cached.token,
expiresAt: cached.expires_at,
source: 'cache'
});
}
// 2. If expiring soon, refresh using refresh token
const refreshToken = cached?.refresh_token;
if (refreshToken) {
const refreshed = type === 'pb'
? await refreshPocketBaseToken(refreshToken)
: await refreshMicrosoftGraphToken(refreshToken);
await cache.set(`tokens:${userId}:${type}`, refreshed, ttl: 3600);
return c.json({
token: refreshed.token,
expiresAt: refreshed.expires_at,
source: 'refreshed'
});
}
// 3. If no refresh token, authentication required
return c.json({ error: 'Authentication required' }, 401);
}
```
### Background Refresh Service
```typescript
class TokenRefreshService {
private refreshLoops = new Map<string, NodeJS.Timer>();
addUser(userId: string) {
if (this.refreshLoops.has(userId)) return; // Already running
// Start 30-minute refresh loop for this user
const loop = setInterval(() => {
this.refreshUserTokens(userId).catch(err => {
console.error(`Token refresh failed for ${userId}:`, err);
});
}, 30 * 60 * 1000); // 30 minutes
this.refreshLoops.set(userId, loop);
console.log(`Token refresh loop started for user ${userId}`);
}
removeUser(userId: string) {
const loop = this.refreshLoops.get(userId);
if (loop) {
clearInterval(loop);
this.refreshLoops.delete(userId);
console.log(`Token refresh loop stopped for user ${userId}`);
}
}
private async refreshUserTokens(userId: string) {
// PocketBase
const pbToken = await cache.get(`tokens:${userId}:pb`);
if (pbToken && isExpiring(pbToken)) {
const refreshed = await refreshPocketBaseToken(pbToken.refresh_token);
await cache.set(`tokens:${userId}:pb`, refreshed, ttl: 3600);
console.log(`[Refresh] PocketBase token refreshed for ${userId}`);
}
// Graph
const graphToken = await cache.get(`tokens:${userId}:graph`);
if (graphToken && isExpiring(graphToken)) {
const refreshed = await refreshMicrosoftGraphToken(graphToken.refresh_token);
await cache.set(`tokens:${userId}:graph`, refreshed, ttl: 3600);
console.log(`[Refresh] Graph token refreshed for ${userId}`);
}
}
}
```
### Frontend Token Management
```typescript
// frontend/src/auth/client.ts
class TokenClient {
private pbToken: string | null = null;
private graphToken: string | null = null;
private pbExpiry: number = 0;
private graphExpiry: number = 0;
// Load from localStorage on app start
loadTokens() {
this.pbToken = localStorage.getItem('pocketbase_auth');
this.graphToken = localStorage.getItem('graph_token');
this.pbExpiry = parseInt(localStorage.getItem('token_expires_pb') || '0');
this.graphExpiry = parseInt(localStorage.getItem('token_expires_graph') || '0');
return this.pbToken && this.graphToken;
}
// Get token, refresh if needed
async ensureToken(type: 'pb' | 'graph'): Promise<string> {
const token = type === 'pb' ? this.pbToken : this.graphToken;
const expiry = type === 'pb' ? this.pbExpiry : this.graphExpiry;
// If fresh (> 15 min remaining), return immediately
if (token && expiry > Date.now() + 15*60*1000) {
return token;
}
// Try to refresh from backend
try {
const response = await fetch(`/api/refresh-token?type=${type}`);
if (response.ok) {
const { token: newToken, expiresAt } = await response.json();
this.storeToken(type, newToken, expiresAt);
return newToken;
}
} catch (err) {
console.warn(`Failed to refresh ${type} token:`, err);
}
// Fallback to stale token if available
if (token) {
console.warn(`Using stale ${type} token (backend unreachable)`);
return token;
}
// No token available - must re-authenticate
throw new Error('Authentication required');
}
private storeToken(type: 'pb' | 'graph', token: string, expiresAt: number) {
if (type === 'pb') {
this.pbToken = token;
this.pbExpiry = expiresAt;
localStorage.setItem('pocketbase_auth', token);
localStorage.setItem('token_expires_pb', expiresAt.toString());
} else {
this.graphToken = token;
this.graphExpiry = expiresAt;
localStorage.setItem('graph_token', token);
localStorage.setItem('token_expires_graph', expiresAt.toString());
}
}
}
```
### Logout
```typescript
// POST /api/auth/logout
async function logout(c: Context) {
const userId = c.get('userId');
// 1. Stop background refresh
tokenRefreshService.removeUser(userId);
// 2. Clear backend cache
await cache.del(`tokens:${userId}:pb`);
await cache.del(`tokens:${userId}:graph`);
await cache.del(`tokens:${userId}:refresh_pb`);
await cache.del(`tokens:${userId}:refresh_graph`);
// 3. Revoke tokens with PocketBase & Microsoft
// (if APIs support it)
return c.json({ success: true });
}
// Frontend
function handleLogout() {
// 1. Clear localStorage
localStorage.removeItem('pocketbase_auth');
localStorage.removeItem('graph_token');
localStorage.removeItem('token_expires_pb');
localStorage.removeItem('token_expires_graph');
// 2. Call backend logout
fetch('/api/auth/logout', { method: 'POST' });
// 3. Redirect to signin
window.location.href = 'signin.html';
}
```
---
## Environment Variables
```
# .env.example
# Server
PORT=3005
NODE_ENV=production
# Redis/Valkey
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# PocketBase
POCKETBASE_URL=https://pocketbase.ccllc.pro
POCKETBASE_ADMIN_EMAIL=admin@example.com
POCKETBASE_ADMIN_PASSWORD=
# Microsoft OAuth
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
MICROSOFT_REDIRECT_URI=http://localhost:3005/api/auth/callback
# Logging
LOG_LEVEL=info
LOG_FILE=logs/app.log
ERROR_LOG_FILE=logs/error.log
```
---
## Tech Stack (ENFORCED)
- **Runtime:** Bun (latest)
- **Framework:** Hono ^4.10.8
- **Auth:** PocketBase ^0.26.5 + Microsoft OAuth
- **Cache:** ioredis ^5.x (Valkey compatible)
- **CSS:** Tailwind CSS ^4.1.18
- **Language:** TypeScript ^5 (strict mode)
- **Testing:** Bun test + Vitest
---
## Deliverables for Phase 1
1. ✅ Complete project scaffolding
2. ✅ Backend structure (services, routes, types)
3. ✅ Frontend structure (views, services, auth)
4. ✅ Token lifecycle implementation
5. ✅ Background refresh service
6. ✅ Error handling & offline support
7. ✅ Configuration & environment
8. ✅ Comprehensive documentation
9. ✅ Example tests
**No implementations needed yet** - just professional structure & architecture.
---
## Important Notes
### Why Dual Tokens?
- PocketBase manages user session & app data
- Microsoft Graph needed for future Office 365 integration
- Both need to stay fresh independently
- One failing doesn't stop the app
### Why Background Refresh?
- Workers in field can't keep re-authenticating
- Tokens are automatically kept fresh
- Even if initial login was 8 hours ago, tokens stay valid
- Only system restart resets auth state
### Why Offline-First?
- Field workers lose connectivity
- Cached data keeps app functional
- When connectivity returns, data syncs
- Users never feel stuck
### Why This Matters
- **Field workers = mission critical**
- **Every minute they're not working = lost productivity**
- **Network is unreliable in field**
- **App must be bulletproof**
---
## Key Success Criteria
✅ Users authenticate once, tokens stay fresh indefinitely
✅ No user-facing re-authentication (except system restart)
✅ Graceful degradation when backend is down
✅ Offline functionality for cached data
✅ Both tokens always warm in cache
✅ Automatic background refresh (invisible to user)
✅ Fallback to stale tokens if refresh fails
✅ Enterprise-grade error handling
✅ Comprehensive logging for debugging
✅ Production-ready from day one
---
## Ready for Implementation
This prompt provides:
- ✅ Complete architecture understanding
- ✅ Dual-token lifecycle strategy
- ✅ Field-worker requirements (no re-auth)
- ✅ Offline-first approach
- ✅ Detailed implementation patterns
- ✅ Project structure
- ✅ Environment setup
- ✅ Tech stack enforcement
**Proceed with scaffolding Job-Info-Prod following this architecture.**