13 KiB
Job Info Data Handling Rules & Patterns
Version: 1.0
Status: PRODUCTION
Last Updated: January 2026
Overview
This document enforces the data handling patterns, caching strategies, and view structure used in Job Info. All developers must follow these rules to maintain consistency, performance, and reliability.
1. Authentication & Token Rules (DUAL-TOKEN SYSTEM)
Dual Token Architecture
The app uses PocketBase OAuth through Microsoft to obtain TWO tokens:
-
PocketBase Token - For app operations (jobs, files, notes)
- Obtained via PocketBase OAuth
- Stored in
localStorageunderpocketbase_auth - Expires: 3600 seconds (1 hour)
-
Microsoft Graph Token - For SharePoint/Office 365 integration
- Obtained during PocketBase OAuth (Microsoft scopes)
- Stored in
localStorageundergraph_token - Expires: 3600 seconds (1 hour)
Critical Requirement: NO User Re-authentication After Login
RULE: Once user logs in, they should NEVER be asked to authenticate again (except system restart)
HOW:
- Both tokens obtained once during initial OAuth flow
- Tokens stored in
localStorage(frontend) and Valkey cache (backend) - Background refresh loop keeps tokens fresh (every 30 minutes)
- If frontend token expires, backend provides fresh one
- If both fail, use stale token as fallback
- Only redirect to login if completely unavailable (system restart)
Token Storage
Frontend (localStorage):
pocketbase_auth // PocketBase token + metadata
graph_token // Microsoft Graph token
token_expires_pb // PocketBase expiry timestamp
token_expires_graph // Graph expiry timestamp
Backend (Valkey/Redis):
tokens:${userId}:pb // Fresh PocketBase token (TTL: 1 hour)
tokens:${userId}:graph // Fresh Graph token (TTL: 1 hour)
tokens:${userId}:refresh_pb // PocketBase refresh token (TTL: 30 days)
tokens:${userId}:refresh_graph // Graph refresh token (TTL: 30 days)
Single Token Check Pattern
Before ANY API call:
// ✅ CORRECT: Check & refresh if needed
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. If fresh (> 15 min remaining), use immediately
if (token && expiry > Date.now() + 15*60*1000) {
return token;
}
// 3. If expiring soon, refresh from backend
const response = await fetch(`/api/refresh-token?type=${type}`);
if (response.ok) {
const { token: newToken, expiresAt } = await response.json();
localStorage.setItem(`${type}_token`, newToken);
localStorage.setItem(`token_expires_${type}`, expiresAt);
return newToken;
}
// 4. Fallback: Use stale token if available (field workers!)
if (token) {
console.warn(`Using stale ${type} token - backend unreachable`);
return token;
}
// 5. Only last resort: Redirect to login
redirectToLogin();
throw new Error('Authentication required');
}
// ❌ WRONG: Refreshing on every request
const token = await pb.authRefresh(); // DON'T DO THIS
fetch('/api/endpoint', { headers: { 'Authorization': `Bearer ${token}` } });
Background Token Refresh (Backend)
// Runs every 30 minutes for each logged-in user
// Transparently refreshes tokens before they expire
// Users never see a re-auth prompt
async function refreshTokensInBackground(userId: string) {
// 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);
}
}
Sign Out
- Clear
localStorageitems:pocketbase_auth,graph_token, expiry times - Clear
sessionStorage - Call backend
/api/auth/logoutto stop refresh loops - Revoke tokens with PocketBase & Microsoft (if available)
- Redirect to signin page
2. Caching Strategy
Backend Cache (Redis/Valkey)
Rule: Cache GET requests with deterministic keys, NOT mutations.
Jobs List
// Cache key format: jobs:page:${page}:perPage:${perPage}:sort:${sort}
const cacheKey = `jobs:page:${page}:perPage:${perPage}:sort:${sort}`;
const ttl = 300; // 5 minutes
// Pattern:
const cached = await getCache(cacheKey);
if (cached) return c.json(cached);
const data = await fetchFromPocketBase(...);
await setCache(cacheKey, data, ttl);
return c.json(data);
Cache TTL Guidelines
| Data Type | TTL | Reason |
|---|---|---|
| Jobs List | 300s (5m) | Frequently searched, moderate change rate |
| Job Details | 600s (10m) | Less frequently accessed |
| File Lists | 900s (15m) | Rarely changes, expensive to fetch |
| Metadata | 1800s (30m) | Static reference data |
Cache Invalidation
- Manual:
clearCachePattern('jobs:*')after mutations - Automatic: TTL expiration
- Never cache: POST, PUT, DELETE requests
Frontend Cache (In-Memory)
File Cache Pattern (Current Implementation)
const fileCache = new Map();
// Check before fetching
if (fileCache.has(folderLink)) {
const cached = fileCache.get(folderLink);
fileListState.items = cached.items;
renderFileGroups(folderLink);
return;
}
// Fetch and cache
const data = await fetch(`/api/job-files?link=${link}`);
fileCache.set(folderLink, { items: data.items, driveId: data.driveId });
renderFileGroups(folderLink);
// Clear on sign out
fileCache.clear();
3. Data Filtering & Transformation
File Filtering Rules
Supported File Types:
- PDFs (
.pdf) - Word Documents (
.doc,.docx) - Images (
.jpg,.jpeg,.png,.gif,.bmp,.tiff,.webp)
Folders: Excluded (never shown)
Implementation:
function renderFileGroups(folderLink) {
const term = (fileListState.filter || '').toLowerCase().trim();
const filtered = fileListState.items.filter((f) => {
// Exclude folders
if (f.isFolder) return false;
// Check file type
const name = f.name.toLowerCase();
const contentType = (f.contentType || '').toLowerCase();
const isPdfOrWord = name.endsWith('.pdf') || contentType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') ||
contentType.includes('word');
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']
.some(ext => name.endsWith(`.${ext}`)) || contentType.includes('image');
if (!isPdfOrWord && !isImage) return false;
// Apply search term filter
return name.includes(term);
});
}
File Categorization
Categories: Manager Info → Contracts → Submittals → Plans → Other
Rules:
function categorize(filename) {
const name = filename.toLowerCase();
if (name.includes('manager')) return 'managerInfo';
if (name.includes('contract') || name.includes('estimate')) return 'contracts';
if (name.includes('submittal')) return 'submittals';
if (name.includes('plan') || name.includes('blueprint')) return 'plans';
return 'other';
}
Sorting Rules
Default Sort: -Job_Number (descending)
Available Sorts:
Job_Number, -Job_Number (job #)
Job_Full_Name, -Job_Full_Name (alphabetical)
Job_Start_Date, -Job_Start_Date (chronological)
Backend Implementation:
const sort = c.req.query('sort') || '-Job_Number';
const pbUrl = `...?sort=${encodeURIComponent(sort)}`;
4. State Management
Frontend State Object
// fileListState: Tracks current file view
const fileListState = {
items: [], // Array of file objects
filter: '', // Current search term
jobNumber: '', // Current job number
jobName: '', // Current job name
};
// Do NOT create other state stores
// All state flows through this single object
Rules
- Single source of truth per view
- State modified only by explicit functions
- No hidden/global state
- Clear state on logout
5. API Response Format
Success Response
{
"items": [...], // Array of records
"total": 50, // Total record count
"page": 1,
"perPage": 10,
"source": "cache|fetch" // For debugging
}
Error Response
{
"error": "User-friendly error message",
"status": 400
}
File List Response
{
"items": [
{
"id": "...",
"name": "document.pdf",
"url": "https://...",
"driveId": "...",
"size": 1024,
"modified": "2026-01-01T00:00:00Z",
"contentType": "application/pdf",
"isFolder": false,
"path": "/folder/path"
}
],
"total": 5,
"driveId": "...",
"source": "walk|search"
}
6. View Architecture (Industry Standard)
View Structure
AuthView
└── Login Form
└── Redirect to LandingView on success
LandingView (Home)
├── Header (User info, Sign Out)
├── JobCardView
│ ├── Search Input
│ ├── Filters (Status, Date Range)
│ └── Job Cards (Grid)
│ └── Click → ManagerInfoView
└── Loading/Error States
ManagerInfoView (Job Detail)
├── Job Header (Job #, Name, Status)
├── Job Info Panel (dates, manager, contacts)
├── File List Section
│ ├── File Search
│ ├── File Groups (categorized)
│ └── Preview/Download Buttons
└── Notes Section
└── Click → NotesView
NotesView (Expanded Notes)
├── Sticky Notes Panel (read-only for now)
├── Note Preview
└── Back to ManagerInfoView
7. Naming Conventions
File/Folder Naming
auth/- Authentication modulebackend/- Server codefrontend/- Client codedocs/- Documentationlogs/- Runtime logs (gitignored)tests/- Test files
Variable Naming
pb- PocketBase instanceauthHeaders- Auth token from PocketBasefileCache- Frontend file cache (Map)fileListState- Current file view state objectcacheKey- Redis key (format:noun:filter:value)
Class/Type Naming
JobCard- Individual job card componentFileGroup- Categorized file groupFileItem- Single file objectAuthSession- User session
8. Testing & Validation Rules
What to Test
- Cache hit/miss behavior
- File filtering (correct types shown)
- Search functionality (partial matches work)
- Categorization (files in correct group)
- Sorting (correct order)
- Auth session (token validity)
What NOT to Test
- External API responses (mock them)
- Redis connection (too fragile)
- Third-party library behavior
9. Performance Rules
Frontend
- Lazy load job files (don't fetch all at once)
- Use file cache to avoid redundant fetches
- Debounce search input (300ms)
- Virtualize long lists (50+ items)
Backend
- Cache GET requests always
- Limit page size to 50 records max
- Use pagination (default: 10 per page)
- Timeout file searches after 30 seconds
10. Error Handling
Frontend
try {
const resp = await fetch('/api/endpoint');
if (!resp.ok) throw new Error(`Server error: ${resp.status}`);
const data = await resp.json();
// Process data
} catch (err) {
console.error('Operation failed:', err);
showErrorUI(err.message);
}
Backend
try {
// Operation
} catch (err) {
const message = (err as Error)?.message || String(err);
console.error('[endpoint] ERROR:', message);
logLine('logs/error.log', `error message`);
return c.json({ error: message }, 500);
}
11. Logging Rules
What to Log
- Cache HIT/MISS (for debugging)
- API request start/end
- Errors with full stack trace
- Authentication events (login, logout)
What NOT to Log
- Tokens or secrets
- User passwords
- Personal data (except user ID)
- Verbose debug output in production
Log Format
[endpoint] message
[cache] Cache HIT for key
[auth] User logged in: user123
12. Version Control Rules
Commits
- Small, atomic commits
- Clear commit messages:
feat: add cachingorfix: file filter bug - One feature per commit
Branches
main- Production readydevelop- Integration branchfeature/*- New featuresfix/*- Bug fixes
.gitignore
node_modules/
.env
.env.local
logs/
*.log
dist/
.bun/
.DS_Store
Summary
| Topic | Rule |
|---|---|
| Authentication | Single PocketBase token, no refresh loops |
| Caching | Redis for GET, TTL-based, cache key format: noun:filters |
| Files | Only PDF, Word, Image types; categorize by filename |
| State | Single state object per view |
| Views | Auth → Landing → Detail → Notes |
| Performance | Lazy load, paginate, debounce |
| Errors | Catch, log, return JSON response |
| Testing | Mock external APIs, test core logic |
All new code must follow these rules. Violations will be caught in code review.