Update Prism Notes branding and OneNote sync UI
This commit is contained in:
@@ -5,89 +5,289 @@ import { cors } from 'hono/cors';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
|
||||
// Load secrets from absolute path (not in project directory)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
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();
|
||||
|
||||
// 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');
|
||||
|
||||
// Use user's PocketBase token
|
||||
function setUserPocketBaseAuth(token: string) {
|
||||
pb.authStore.save(token, null);
|
||||
function setPbToken(token: string) { pb.authStore.save(token, null); }
|
||||
function clearPbToken() { pb.authStore.clear(); }
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MSAL app-only (Graph status check)
|
||||
// ---------------------------------------------------------------------------
|
||||
const cca = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId: process.env.CLIENT_ID || '',
|
||||
authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`,
|
||||
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;
|
||||
|
||||
async function getGraphToken(): Promise<{ token: string; expiresOn: number }> {
|
||||
async function getGraphToken() {
|
||||
const now = Date.now();
|
||||
if (graphTokenCache && graphTokenCache.expiresOn - 60000 > now) {
|
||||
return graphTokenCache;
|
||||
}
|
||||
const result = await cca.acquireTokenByClientCredential({
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
});
|
||||
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 };
|
||||
if (graphTokenCache && graphTokenCache.expiresOn - 60_000 > now) return graphTokenCache;
|
||||
const result = await cca.acquireTokenByClientCredential({ scopes: ['https://graph.microsoft.com/.default'] });
|
||||
if (!result?.accessToken) throw new Error('Failed to acquire Graph token');
|
||||
graphTokenCache = {
|
||||
token: result.accessToken,
|
||||
expiresOn: result.expiresOn ? new Date(result.expiresOn).getTime() : now + 55 * 60_000,
|
||||
};
|
||||
return graphTokenCache;
|
||||
}
|
||||
|
||||
// Health check
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ ok: true, pbDB: process.env.PB_DB });
|
||||
// Site ID cache — resolved once per server lifetime using a delegated token
|
||||
let cachedSiteId: string | null = null;
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const tokenInfo = await getGraphToken();
|
||||
return c.json({ active: true, expiresOnISO: new Date(tokenInfo.expiresOn).toISOString() });
|
||||
const t = await getGraphToken();
|
||||
return c.json({ active: true, expiresOnISO: new Date(t.expiresOn).toISOString() });
|
||||
} 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) => {
|
||||
try {
|
||||
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 {
|
||||
await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authRefresh();
|
||||
await validatePbToken(body.pbToken);
|
||||
} catch (e) {
|
||||
console.error('auth_validation_failed', { message: (e as Error)?.message });
|
||||
return c.json({ success: false, message: 'Invalid token' }, 401);
|
||||
}
|
||||
|
||||
// Store in PocketBase collection (adjust fields as needed)
|
||||
const record = await pb
|
||||
.collection(process.env.PB_COLLECTION || 'Job_Info_TestEnv')
|
||||
.create({
|
||||
@@ -97,31 +297,36 @@ app.post('/api/submit', async (c) => {
|
||||
});
|
||||
|
||||
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) {
|
||||
console.error('submit_error', { message: (err as Error)?.message });
|
||||
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('/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) => {
|
||||
const html = await Bun.file(new URL('./index.html', import.meta.url)).text();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
const PORT = Number(process.env.PORT || 3030);
|
||||
|
||||
// Start Bun server manually to avoid Bun's auto startup banner
|
||||
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)');
|
||||
Bun.serve({ port: PORT, fetch: app.fetch });
|
||||
console.log(`Prism Notes running at http://localhost:${PORT}`);
|
||||
|
||||
Reference in New Issue
Block a user