Update Prism Notes branding and OneNote sync UI

This commit is contained in:
2026-03-24 17:15:19 +00:00
parent 04ae0c0c82
commit c76739d0d7
5 changed files with 747 additions and 3394 deletions
+16
View File
@@ -31,3 +31,19 @@ Open http://localhost:3030 and sign in with Microsoft.
- `getGraphToken()` in `server.ts` is ready for Graph API calls; extend with additional routes as needed. - `getGraphToken()` in `server.ts` is ready for Graph API calls; extend with additional routes as needed.
- `/api/submit` validates the provided `pbToken` then writes the payload to `PB_COLLECTION`; adjust fields to match your schema. - `/api/submit` validates the provided `pbToken` then writes the payload to `PB_COLLECTION`; adjust fields to match your schema.
- Static file serving uses `index.html` from the repo root. - Static file serving uses `index.html` from the repo root.
## OneNote sync (append MVP)
- Backend endpoint: `POST /api/onenote/append`
- Required headers:
- `Authorization: Bearer <delegated Microsoft access token>`
- `x-pb-token: <PocketBase user token>`
- Current hardcoded target in `server.ts`:
- Host: `czflex.sharepoint.com`
- Site path: `/sites/Team`
- Section ID: `ed504699-67be-47a3-838a-e01ec17198fb`
- Page ID: `addbbca7-18ce-4d8d-833e-96cc08d990bc`
- Frontend buttons:
- New note: `Submit + Sync OneNote`
- Note detail: `Sync to OneNote`
Important: sync requires a delegated Microsoft token with OneNote write permissions. The UI captures this from PocketBase Microsoft OAuth metadata when available.
+442 -3319
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@
"start": "bun run server.ts" "start": "bun run server.ts"
}, },
"dependencies": { "dependencies": {
"@azure/msal-browser": "^5.6.1",
"@azure/msal-node": "^2.6.7", "@azure/msal-node": "^2.6.7",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
+263 -58
View File
@@ -5,89 +5,289 @@ import { cors } from 'hono/cors';
import PocketBase from 'pocketbase'; import PocketBase from 'pocketbase';
import { ConfidentialClientApplication } from '@azure/msal-node'; import { ConfidentialClientApplication } from '@azure/msal-node';
// Load secrets from absolute path (not in project directory) // ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
config({ path: '/home/admin/secrets/.env' }); config({ path: '/home/admin/secrets/.env' });
// Known OneNote target
// Section/page IDs must come from the OneNote Graph API (sites/{siteId}/onenote/sections),
// NOT from SharePoint drive item listings (those are different GUIDs).
const ONENOTE_TARGET = {
siteHost: 'czflex.sharepoint.com',
sitePath: '/sites/Team',
// Placeholder IDs — will be verified/replaced via GET /api/onenote/site-sections
sectionId: '',
sectionName: 'Billing Notes',
pageId: '',
pageTitle: 'Billing Notes By Job',
notebookName: 'Billing Notes',
};
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
const app = new Hono(); const app = new Hono();
// Enable CORS app.use('/*', cors({ origin: '*', credentials: true }));
app.use('/*', cors({
origin: '*',
credentials: true,
}));
// PocketBase client // ---------------------------------------------------------------------------
// PocketBase
// ---------------------------------------------------------------------------
const pb = new PocketBase(process.env.PB_DB || 'http://127.0.0.1:8090'); const pb = new PocketBase(process.env.PB_DB || 'http://127.0.0.1:8090');
// Use user's PocketBase token function setPbToken(token: string) { pb.authStore.save(token, null); }
function setUserPocketBaseAuth(token: string) { function clearPbToken() { pb.authStore.clear(); }
pb.authStore.save(token, null);
async function validatePbToken(token: string): Promise<void> {
setPbToken(token);
await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authRefresh();
} }
// MSAL config for Graph token (client credentials) // ---------------------------------------------------------------------------
const msalConfig = { // Shared helpers
// ---------------------------------------------------------------------------
function getBearerToken(header: string | undefined | null): string | null {
const m = (header || '').match(/^Bearer\s+(.+)$/i);
return m?.[1] || null;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------------------
// MSAL app-only (Graph status check)
// ---------------------------------------------------------------------------
const cca = new ConfidentialClientApplication({
auth: { auth: {
clientId: process.env.CLIENT_ID || '', clientId: process.env.CLIENT_ID || '',
authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`, authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`,
clientSecret: process.env.CLIENT_SECRET || '', clientSecret: process.env.CLIENT_SECRET || '',
}, },
}; });
const cca = new ConfidentialClientApplication(msalConfig);
// Simple in-memory cache for Graph token
let graphTokenCache: { token: string; expiresOn: number } | null = null; let graphTokenCache: { token: string; expiresOn: number } | null = null;
async function getGraphToken(): Promise<{ token: string; expiresOn: number }> { async function getGraphToken() {
const now = Date.now(); const now = Date.now();
if (graphTokenCache && graphTokenCache.expiresOn - 60000 > now) { if (graphTokenCache && graphTokenCache.expiresOn - 60_000 > now) return graphTokenCache;
return graphTokenCache; const result = await cca.acquireTokenByClientCredential({ scopes: ['https://graph.microsoft.com/.default'] });
} if (!result?.accessToken) throw new Error('Failed to acquire Graph token');
const result = await cca.acquireTokenByClientCredential({ graphTokenCache = {
scopes: ['https://graph.microsoft.com/.default'], token: result.accessToken,
}); expiresOn: result.expiresOn ? new Date(result.expiresOn).getTime() : now + 55 * 60_000,
if (!result || !result.accessToken) { };
throw new Error('Failed to acquire Graph token');
}
const expiresOn = result.expiresOn ? new Date(result.expiresOn).getTime() : now + 55 * 60 * 1000;
graphTokenCache = { token: result.accessToken, expiresOn };
return graphTokenCache; return graphTokenCache;
} }
// Health check // Site ID cache — resolved once per server lifetime using a delegated token
app.get('/health', (c) => { let cachedSiteId: string | null = null;
return c.json({ ok: true, pbDB: process.env.PB_DB });
async function resolveSiteId(delegatedToken: string): Promise<string> {
if (cachedSiteId) return cachedSiteId;
const resp = await fetch(
`https://graph.microsoft.com/v1.0/sites/${ONENOTE_TARGET.siteHost}:${ONENOTE_TARGET.sitePath}`,
{ headers: { Authorization: `Bearer ${delegatedToken}` } },
);
if (!resp.ok) {
const err = await resp.text();
throw new Error(`Site ID resolution failed (${resp.status}): ${err}`);
}
const data = await resp.json();
if (!data?.id) throw new Error('Graph did not return a site ID');
cachedSiteId = data.id as string;
console.log('site_id_resolved', { siteId: cachedSiteId });
return cachedSiteId;
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
app.get('/health', (c) => c.json({ ok: true, pbDB: process.env.PB_DB }));
app.get('/api/auth/msal-config', (c) => {
const clientId = process.env.CLIENT_ID || '';
const tenantId = process.env.TENANT_ID || '';
const redirectUri = process.env.MSAL_REDIRECT_URI || '';
if (!clientId || !tenantId) {
return c.json({ success: false, message: 'Missing CLIENT_ID or TENANT_ID' }, 500);
}
return c.json({
success: true,
clientId,
tenantId,
authority: `https://login.microsoftonline.com/${tenantId}`,
redirectUri,
});
}); });
// Graph token status (acquires if missing/expired)
app.get('/api/graph/status', async (c) => { app.get('/api/graph/status', async (c) => {
try { try {
const tokenInfo = await getGraphToken(); const t = await getGraphToken();
return c.json({ active: true, expiresOnISO: new Date(tokenInfo.expiresOn).toISOString() }); return c.json({ active: true, expiresOnISO: new Date(t.expiresOn).toISOString() });
} catch (e) { } catch (e) {
return c.json({ active: false, error: (e as Error)?.message || 'Failed to acquire token' }, 500); return c.json({ active: false, error: (e as Error)?.message }, 500);
} }
}); });
// Submit endpoint (example) // GET /api/onenote/site-sections
// Requires: Authorization: Bearer <delegated token>
// Resolves site ID then lists OneNote sections within that site only (no tenant-wide scan).
app.get('/api/onenote/site-sections', async (c) => {
try {
const delegatedToken = getBearerToken(c.req.header('authorization'));
if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated token' }, 401);
const siteId = await resolveSiteId(delegatedToken);
const resp = await fetch(
`https://graph.microsoft.com/v1.0/sites/${siteId}/onenote/sections?$top=100&$select=id,displayName`,
{ headers: { Authorization: `Bearer ${delegatedToken}` } },
);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) return c.json({ success: false, siteId, error: data }, resp.status as any);
return c.json({ success: true, siteId, sections: data?.value || [] });
} catch (e) {
return c.json({ success: false, message: (e as Error)?.message }, 500);
}
});
// ---------------------------------------------------------------------------
// POST /api/onenote/append
// Requires: x-pb-token (PocketBase session) + Authorization: Bearer <delegated Graph token>
// Body: { noteId, title, bodyPlain, noteType?, userEmail?, jobNumber?, sectionId? }
//
// Strategy:
// 1. Resolve site ID via GET /sites/{host}:{path}
// 2. Try PATCH sites/{siteId}/onenote/pages/{pageId}/content → append to known page
// 3. If that fails, POST sites/{siteId}/onenote/sections/{sectionId}/pages → create new page
// ---------------------------------------------------------------------------
app.post('/api/onenote/append', async (c) => {
try {
/* ---- auth ---- */
const pbToken = c.req.header('x-pb-token');
if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401);
try {
await validatePbToken(pbToken);
} catch (e) {
return c.json({ success: false, message: 'Invalid PocketBase token' }, 401);
}
const delegatedToken = getBearerToken(c.req.header('authorization'));
if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated Graph bearer token' }, 401);
/* ---- payload ---- */
const body = await c.req.json();
const noteId = String(body?.noteId || '').trim();
const title = String(body?.title || 'Untitled').trim();
const bodyPlain = String(body?.bodyPlain || '').trim();
const noteType = String(body?.noteType || 'personal').trim();
const userEmail = String(body?.userEmail || 'unknown').trim();
const jobNumber = String(body?.jobNumber || '').trim();
if (!noteId) return c.json({ success: false, message: 'Missing noteId' }, 400);
if (!bodyPlain) return c.json({ success: false, message: 'Missing bodyPlain' }, 400);
const ts = new Date().toISOString();
const safeBody = escapeHtml(bodyPlain).replace(/\r\n|\r|\n/g, '<br/>');
const headerLine = [
`<strong>${escapeHtml(title)}</strong>`,
`type: ${escapeHtml(noteType)}`,
`user: ${escapeHtml(userEmail)}`,
jobNumber ? `job: ${escapeHtml(jobNumber)}` : '',
`note_id: ${escapeHtml(noteId)}`,
].filter(Boolean).join(' | ');
const appendHtml = `<div data-prism-notes-note-id="${escapeHtml(noteId)}" data-prism-notes-synced-at="${ts}"><p>${headerLine}</p><p>${safeBody}</p><p>---</p></div>`;
const pageHtml = [
'<!DOCTYPE html>',
'<html>',
`<head><title>${escapeHtml(title)}</title></head>`,
`<body>${appendHtml}</body>`,
'</html>',
].join('');
const sectionId = String(body?.sectionId || ONENOTE_TARGET.sectionId || '').trim();
const pageId = String(body?.pageId || ONENOTE_TARGET.pageId || '').trim();
if (!sectionId) return c.json({ success: false, message: 'No sectionId — run GET /api/onenote/site-sections first to retrieve valid IDs' }, 400);
const authHeader = { Authorization: `Bearer ${delegatedToken}` };
/* ---- resolve site ID ---- */
const siteId = await resolveSiteId(delegatedToken);
const base = `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote`;
/* ---- 1: try append to known page (if we have a valid pageId) ---- */
if (pageId) {
const patchResp = await fetch(
`${base}/pages/${pageId}/content`,
{
method: 'PATCH',
headers: { ...authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify([{ target: 'body', action: 'append', content: appendHtml }]),
},
);
if (patchResp.ok) {
console.log('onenote_append_success', { mode: 'patch', noteId, siteId });
return c.json({ success: true, mode: 'appended', syncedAt: ts });
}
const patchErr = await patchResp.text();
console.warn('onenote_patch_failed', { status: patchResp.status, error: patchErr });
}
/* ---- 2: create new page in known section ---- */
const postResp = await fetch(
`${base}/sections/${sectionId}/pages`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'text/html' },
body: pageHtml,
},
);
if (postResp.ok) {
const created = await postResp.json().catch(() => ({}));
console.log('onenote_create_success', { mode: 'create', noteId, pageId: created?.id, siteId });
return c.json({ success: true, mode: 'created', pageId: created?.id || null, syncedAt: ts });
}
const postErr = await postResp.text();
console.error('onenote_create_failed', { status: postResp.status, postErr, siteId, sectionId });
return c.json(
{ success: false, message: `OneNote write failed (create ${postResp.status})`, details: { create: postErr, siteId, sectionId } },
502,
);
} catch (e) {
console.error('onenote_append_error', { message: (e as Error)?.message });
return c.json({ success: false, message: (e as Error)?.message || 'OneNote append failed' }, 500);
} finally {
clearPbToken();
}
});
// ---------------------------------------------------------------------------
// POST /api/submit — save a record to PocketBase
// ---------------------------------------------------------------------------
app.post('/api/submit', async (c) => { app.post('/api/submit', async (c) => {
try { try {
const body = await c.req.json(); const body = await c.req.json();
const pbToken = body.pbToken; if (!body?.pbToken) return c.json({ success: false, message: 'Missing pbToken' }, 401);
if (!pbToken) {
return c.json({ success: false, message: 'Missing pbToken' }, 401);
}
// Validate user token
setUserPocketBaseAuth(pbToken);
try { try {
await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authRefresh(); await validatePbToken(body.pbToken);
} catch (e) { } catch (e) {
console.error('auth_validation_failed', { message: (e as Error)?.message });
return c.json({ success: false, message: 'Invalid token' }, 401); return c.json({ success: false, message: 'Invalid token' }, 401);
} }
// Store in PocketBase collection (adjust fields as needed)
const record = await pb const record = await pb
.collection(process.env.PB_COLLECTION || 'Job_Info_TestEnv') .collection(process.env.PB_COLLECTION || 'Job_Info_TestEnv')
.create({ .create({
@@ -97,31 +297,36 @@ app.post('/api/submit', async (c) => {
}); });
console.log('submission_success', { recordId: record.id }); console.log('submission_success', { recordId: record.id });
return c.json({ success: true, message: 'Feedback submitted successfully', recordId: record.id }); return c.json({ success: true, recordId: record.id });
} catch (err) { } catch (err) {
console.error('submit_error', { message: (err as Error)?.message }); console.error('submit_error', { message: (err as Error)?.message });
return c.json({ success: false, message: 'Internal error' }, 500); return c.json({ success: false, message: 'Internal error' }, 500);
} finally {
clearPbToken();
} }
}); });
// Serve static files (images, css, tools, etc) // ---------------------------------------------------------------------------
// Static assets
// ---------------------------------------------------------------------------
app.use('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.use('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname }));
app.use('/tools/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.use('/tools/*', serveStatic({ root: new URL('./', import.meta.url).pathname }));
// Serve static index.html app.get('/vendor/msal-browser.js', async (c) => {
const file = Bun.file(new URL('./node_modules/@azure/msal-browser/lib/msal-browser.min.js', import.meta.url));
if (!(await file.exists())) return c.text('MSAL browser bundle not found', 404);
c.header('Content-Type', 'application/javascript; charset=utf-8');
return c.body(await file.text());
});
app.get('/', async (c) => { app.get('/', async (c) => {
const html = await Bun.file(new URL('./index.html', import.meta.url)).text(); const html = await Bun.file(new URL('./index.html', import.meta.url)).text();
return c.html(html); return c.html(html);
}); });
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
const PORT = Number(process.env.PORT || 3030); const PORT = Number(process.env.PORT || 3030);
Bun.serve({ port: PORT, fetch: app.fetch });
// Start Bun server manually to avoid Bun's auto startup banner console.log(`Prism Notes running at http://localhost:${PORT}`);
const server = Bun.serve({
port: PORT,
fetch: app.fetch,
});
// Custom startup notes
console.log(`Prism Notes server running at http://localhost:${PORT}`);
console.log('Prism Notes API ready (health: /health)');
+25 -17
View File
@@ -17,6 +17,25 @@
// Cache for email lookups to minimize queries // Cache for email lookups to minimize queries
const emailLookupCache = {}; const emailLookupCache = {};
let associationsCollectionAvailable = true;
let associationsWarningShown = false;
function getAssociationEmail(record) {
return record?.emailtext || record?.email || 'Email not found';
}
function handleAssociationsError(error) {
const statusCode = Number(error?.status || error?.response?.status || 0);
if (statusCode === 404) {
associationsCollectionAvailable = false;
if (!associationsWarningShown) {
associationsWarningShown = true;
console.warn("'Associations' collection not found. Email hover lookup disabled.");
}
return;
}
console.error('Associations lookup failed:', error);
}
/** /**
* Fetch email from Associations collection by first name * Fetch email from Associations collection by first name
@@ -31,6 +50,7 @@ const emailLookupCache = {};
*/ */
async function getEmailByFirstName(pb, firstName) { async function getEmailByFirstName(pb, firstName) {
if (!firstName) return 'Email not found'; if (!firstName) return 'Email not found';
if (!associationsCollectionAvailable) return 'Email lookup unavailable';
// Check cache first // Check cache first
if (emailLookupCache[firstName]) { if (emailLookupCache[firstName]) {
@@ -38,36 +58,22 @@ async function getEmailByFirstName(pb, firstName) {
} }
try { try {
// First, get ALL records to inspect structure
const allRecords = await pb.collection('Associations').getList(1, 50);
console.log(`[Email Lookup] All Associations records:`, allRecords.items);
if (allRecords.items.length > 0) {
console.log(`[Email Lookup] Sample record fields:`, Object.keys(allRecords.items[0]));
console.log(`[Email Lookup] Sample record:`, allRecords.items[0]);
}
// Now try the filtered query
const records = await pb.collection('Associations').getList(1, 50, { const records = await pb.collection('Associations').getList(1, 50, {
filter: `first_name = "${firstName}"`, filter: `first_name = "${firstName}"`,
}); });
console.log(`[Email Lookup] Query: first_name="${firstName}" | Filtered results:`, records.items);
if (records.items.length === 0) { if (records.items.length === 0) {
console.log(`[Email Lookup] No records found for: ${firstName}`);
emailLookupCache[firstName] = 'Email not found'; emailLookupCache[firstName] = 'Email not found';
return 'Email not found'; return 'Email not found';
} }
// Use first match's emailtext field // Use first match's emailtext field
const firstRecord = records.items[0]; const firstRecord = records.items[0];
console.log(`[Email Lookup] First record:`, firstRecord); const email = getAssociationEmail(firstRecord);
const email = firstRecord.emailtext || 'Email not found';
emailLookupCache[firstName] = email; emailLookupCache[firstName] = email;
return email; return email;
} catch (error) { } catch (error) {
console.error(`Failed to lookup email for "${firstName}":`, error); handleAssociationsError(error);
console.error(`Error response:`, error.response?.data);
return 'Error loading email'; return 'Error loading email';
} }
} }
@@ -161,6 +167,8 @@ function attachEmailLookupTooltip(pb, capsuleEl) {
*/ */
window.clearEmailLookupCache = function() { window.clearEmailLookupCache = function() {
Object.keys(emailLookupCache).forEach(key => delete emailLookupCache[key]); Object.keys(emailLookupCache).forEach(key => delete emailLookupCache[key]);
associationsCollectionAvailable = true;
associationsWarningShown = false;
console.log('✓ Email lookup cache cleared'); console.log('✓ Email lookup cache cleared');
}; };