import { config } from 'dotenv'; import { Hono } from 'hono'; import { serveStatic } from 'hono/bun'; import { cors } from 'hono/cors'; import PocketBase from 'pocketbase'; import { ConfidentialClientApplication } from '@azure/msal-node'; // --------------------------------------------------------------------------- // 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(); app.use('/*', cors({ origin: '*', credentials: true })); // --------------------------------------------------------------------------- // PocketBase // --------------------------------------------------------------------------- const PB_BASE_URL = process.env.PB_URL || process.env.PB_DB || ''; const PB_AUTH_COLLECTION = process.env.PB_AUTH_COLLECTION || 'Users'; const PB_OAUTH_PROVIDER = 'microsoft'; const PB_NOTES_COLLECTION = process.env.PB_NOTES_COLLECTION || 'Notes'; const PB_TASKS_COLLECTION = 'Tasgird'; const pb = new PocketBase(PB_BASE_URL); function setPbToken(token: string) { pb.authStore.save(token, null); } function clearPbToken() { pb.authStore.clear(); } async function validatePbToken(token: string): Promise { setPbToken(token); await pb.collection(PB_AUTH_COLLECTION).authRefresh(); } async function createValidatedPbClient(token: string): Promise { const client = new PocketBase(PB_BASE_URL); client.authStore.save(token, null); await client.collection(PB_AUTH_COLLECTION).authRefresh(); return client; } // --------------------------------------------------------------------------- // 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, '''); } function buildNotesPayload(input: { user: any; bodyPlain: string; title: string; noteType: string; jobNumber: string; userEmail?: string; noteId?: string; onenoteId?: string; }): Record { const bodyHtml = input.bodyPlain .split(/\r\n|\r|\n/) .map((line) => `

${line || ' '}

`) .join(''); const payload: Record = { body_plain: input.bodyPlain, body_html: bodyHtml, title: input.title, type: input.noteType, email: input.user?.email || input.userEmail || '', Username: input.user?.name || input.user?.username || input.user?.email || '', userId: input.user?.id || '', job_note: input.noteType === 'job' || !!input.jobNumber, shared: false, shared_with: [], }; if (input.jobNumber) { payload['Job_Number'] = input.jobNumber; } if (input.noteId) { payload['note_id'] = input.noteId; } if (input.onenoteId) { payload['onenote_id'] = input.onenoteId; } return payload; } function decodeHtmlEntities(value: string): string { return value .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); } function toPlainText(html: string): string { return decodeHtmlEntities(html.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim(); } function toStructuredPlainText(html: string): string { return decodeHtmlEntities( html .replace(/<\s*br\s*\/?>/gi, '\n') .replace(/<\/(p|div|pre|li|tr|table|section|article|h[1-6])>/gi, '\n') .replace(/<[^>]+>/g, ' '), ) .replace(/\r\n|\r/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]+/g, ' ') .trim(); } function findNearestGeneratedId(source: string, pivot: number): string { if (pivot < 0) return ''; const windowStart = Math.max(0, pivot - 4000); const slice = source.slice(windowStart, pivot); const idRegex = /id="([a-z]+:\{[^}]+\}\{\d+\})"/gi; let match: RegExpExecArray | null = null; let lastId = ''; while ((match = idRegex.exec(slice))) { lastId = decodeHtmlEntities(match[1] || '').trim(); } return lastId; } function parseObjectAnchor(raw: string): { guid: string; suffix?: string } | null { const value = String(raw || '').trim(); if (!value) return null; const linkMatch = value.match(/object-id=\{([0-9A-Fa-f-]{36})\}&([0-9A-Fa-f]+)/i); if (linkMatch) { return { guid: linkMatch[1], suffix: linkMatch[2] }; } const braceMatch = value.match(/\{([0-9A-Fa-f-]{36})\}(?:&([0-9A-Fa-f]+))?/); if (braceMatch) { return { guid: braceMatch[1], suffix: braceMatch[2] }; } return null; } function objectTargetCandidates(rawTarget: string): string[] { const raw = String(rawTarget || '').trim(); if (!raw) return []; if (/^[a-z]+:\{[0-9A-Fa-f-]{36}\}\{\d+\}$/i.test(raw)) { return [raw, `#${raw}`]; } const parsed = parseObjectAnchor(raw); if (!parsed) { return [raw, raw.startsWith('#') ? raw.slice(1) : `#${raw}`]; } const guid = parsed.guid; const candidates: string[] = []; // Data-id style candidates (must use # for data-id targets) candidates.push(`{${guid}}`); candidates.push(`#${`{${guid}}`}`); if (parsed.suffix) { const dataId = `{${guid}}&${parsed.suffix}`; candidates.push(dataId); candidates.push(`#${dataId}`); } if (parsed.suffix) { const asNum = Number.parseInt(parsed.suffix, 16); if (Number.isFinite(asNum)) { candidates.push(`div:{${guid}}{${asNum}}`); candidates.push(`#${`div:{${guid}}{${asNum}}`}`); candidates.push(`p:{${guid}}{${asNum}}`); candidates.push(`#${`p:{${guid}}{${asNum}}`}`); candidates.push(`table:{${guid}}{${asNum}}`); candidates.push(`#${`table:{${guid}}{${asNum}}`}`); candidates.push(`outline:{${guid}}{${asNum}}`); candidates.push(`#${`outline:{${guid}}{${asNum}}`}`); candidates.push(`object:{${guid}}{${asNum}}`); candidates.push(`#${`object:{${guid}}{${asNum}}`}`); } } candidates.push(`div:{${guid}}`); candidates.push(`#${`div:{${guid}}`}`); candidates.push(`p:{${guid}}`); candidates.push(`#${`p:{${guid}}`}`); candidates.push(`table:{${guid}}`); candidates.push(`#${`table:{${guid}}`}`); candidates.push(`object:{${guid}}`); candidates.push(`#${`object:{${guid}}`}`); return Array.from(new Set(candidates)); } function extractCodeBlocks(blockHtml: string): string[] { const codeBlocks: string[] = []; const preRegex = /]*>([\s\S]*?)<\/pre>/gi; let preMatch: RegExpExecArray | null = null; while ((preMatch = preRegex.exec(blockHtml))) { const code = toPlainText(preMatch[1] || '').trim(); if (code) codeBlocks.push(code.slice(0, 400)); } const plain = toPlainText(blockHtml); const fencedRegex = /```[\w-]*\n([\s\S]*?)```/g; let fencedMatch: RegExpExecArray | null = null; while ((fencedMatch = fencedRegex.exec(plain))) { const code = String(fencedMatch[1] || '').trim(); if (code) codeBlocks.push(code.slice(0, 400)); } return codeBlocks; } function extractPageNoteBlocks(pageHtml: string): Array<{ noteId: string; syncedAt: string; preview: string; codeBlocks: string[]; targetId: string }> { const noteBlocks: Array<{ noteId: string; syncedAt: string; preview: string; codeBlocks: string[]; targetId: string }> = []; const divRegex = /]*)\bdata-prism-notes-note-id\s*=\s*(["'])([^"']+)\2([^>]*)>([\s\S]*?)<\/div>/gi; let match: RegExpExecArray | null = null; while ((match = divRegex.exec(pageHtml))) { const attrs = `${match[1] || ''} ${match[4] || ''}`; const blockHtml = match[5] || ''; const generatedIdMatch = attrs.match(/\bid="([^"]+)"/i); const dataIdMatch = attrs.match(/\bdata-id="([^"]+)"/i); const syncedAtMatch = attrs.match(/data-prism-notes-synced-at="([^"]*)"/i); noteBlocks.push({ noteId: decodeHtmlEntities(match[3] || '').trim(), syncedAt: decodeHtmlEntities(syncedAtMatch?.[1] || '').trim(), preview: toPlainText(blockHtml).slice(0, 220), codeBlocks: extractCodeBlocks(blockHtml), targetId: decodeHtmlEntities(generatedIdMatch?.[1] || dataIdMatch?.[1] || '').trim(), }); } if (noteBlocks.length > 0) { return noteBlocks; } const structuredPlain = toStructuredPlainText(pageHtml); const noteIdRegex = /note_id\s*:\s*([a-zA-Z0-9._:-]+)/gi; const matches = Array.from(structuredPlain.matchAll(noteIdRegex)); if (matches.length > 0) { for (let i = 0; i < matches.length; i++) { const match = matches[i]; const noteId = decodeHtmlEntities(match[1] || '').trim(); if (!noteId) continue; const start = match.index ?? 0; const end = i + 1 < matches.length ? (matches[i + 1].index ?? structuredPlain.length) : structuredPlain.length; const segment = structuredPlain.slice(start, end).trim(); const codeBlocks: string[] = []; const fencedRegex = /```[\w-]*\n([\s\S]*?)```/g; let fencedMatch: RegExpExecArray | null = null; while ((fencedMatch = fencedRegex.exec(segment))) { const code = String(fencedMatch[1] || '').trim(); if (code) codeBlocks.push(code.slice(0, 400)); } noteBlocks.push({ noteId, syncedAt: '', preview: segment.slice(0, 220), codeBlocks, targetId: findNearestGeneratedId(pageHtml, pageHtml.search(new RegExp(`note_id\\s*:\\s*${noteId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'))), }); } return noteBlocks; } const chunks = pageHtml.split(/

\s*---\s*<\/p>/gi); for (const chunk of chunks) { const plain = toPlainText(chunk); const noteIdMatch = plain.match(/note_id\s*:\s*([a-zA-Z0-9._:-]+)/i); if (!noteIdMatch) continue; const noteId = decodeHtmlEntities(noteIdMatch[1] || '').trim(); if (!noteId) continue; const rawIndex = pageHtml.indexOf(chunk); noteBlocks.push({ noteId, syncedAt: '', preview: plain.slice(0, 220), codeBlocks: extractCodeBlocks(chunk), targetId: findNearestGeneratedId(pageHtml, rawIndex), }); } return noteBlocks; } function findRecordFieldName(record: Record, patterns: RegExp[]): string | null { const keys = Object.keys(record || {}); for (const pattern of patterns) { const match = keys.find((key) => pattern.test(key)); if (match) return match; } return null; } // --------------------------------------------------------------------------- // 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 || '', }, }); let graphTokenCache: { token: string; expiresOn: number } | null = null; async function getGraphToken() { const now = Date.now(); 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; } // Site ID cache — resolved once per server lifetime using a delegated token let cachedSiteId: string | null = null; async function resolveSiteId(delegatedToken: string): Promise { 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: PB_BASE_URL })); // --------------------------------------------------------------------------- // Intuit app setup URLs (for QuickBooks Time / Intuit app configuration) // --------------------------------------------------------------------------- app.get('/integrations/intuit/launch', async (c) => { const html = await Bun.file(new URL('./index.html', import.meta.url)).text(); return c.html(html); }); app.get('/integrations/intuit/connect', (c) => { return c.json({ success: true, message: 'Intuit connect endpoint is reachable. OAuth flow wiring can be added here.', }); }); app.get('/integrations/intuit/reconnect', (c) => { return c.redirect('/integrations/intuit/connect', 302); }); app.all('/integrations/intuit/disconnect', (c) => { return c.json({ success: true, message: 'Intuit disconnect endpoint is reachable. Token revocation/disconnect flow can be added here.', }); }); app.get('/api/auth/pocketbase-config', (c) => { if (!PB_BASE_URL) { return c.json({ success: false, message: 'Missing PB_URL or PB_DB' }, 500); } return c.json({ success: true, pbUrl: PB_BASE_URL, collection: PB_AUTH_COLLECTION, provider: PB_OAUTH_PROVIDER, }); }); 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, }); }); // GET /api/tasks/users // Requires: x-pb-token app.get('/api/tasks/users', async (c) => { const pbToken = c.req.header('x-pb-token'); if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401); let taskPb: PocketBase; try { taskPb = await createValidatedPbClient(pbToken); } catch { return c.json({ success: false, message: 'Invalid PocketBase token' }, 401); } try { const me = taskPb.authStore.model; let meRecord: any = me; let users: any[] = []; let warning = ''; if (me?.id) { try { meRecord = await taskPb.collection(PB_AUTH_COLLECTION).getOne(String(me.id), { fields: 'id,email,name,username', }); } catch { meRecord = me; } } try { users = await taskPb.collection(PB_AUTH_COLLECTION).getFullList({ sort: 'email', fields: 'id,email,name,username', }); } catch (listErr) { warning = 'Users list is restricted by PocketBase rules; showing current user only.'; users = meRecord ? [meRecord] : []; } return c.json({ success: true, me: { id: meRecord?.id || '', email: meRecord?.email || '', name: (meRecord?.name || meRecord?.username || meRecord?.email || 'Me') as string, lookupName: String(meRecord?.name || '').trim(), }, users: users.map((user: any) => ({ id: user.id, email: user.email || '', name: user.name || user.username || user.email || user.id, lookupName: String(user.name || '').trim(), })), warning: warning || undefined, }); } catch (e) { return c.json({ success: false, message: (e as Error)?.message || 'Failed to load users' }, 500); } }); // GET /api/tasks/list?userId= // Requires: x-pb-token app.get('/api/tasks/list', async (c) => { const pbToken = c.req.header('x-pb-token'); if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401); let taskPb: PocketBase; try { taskPb = await createValidatedPbClient(pbToken); } catch { return c.json({ success: false, message: 'Invalid PocketBase token' }, 401); } try { const me = taskPb.authStore.model; const meId = String(me?.id || '').trim(); let meRecord: any = me; if (meId) { try { meRecord = await taskPb.collection(PB_AUTH_COLLECTION).getOne(meId, { fields: 'id,email,name,username', }); } catch { meRecord = me; } } const meName = String(meRecord?.name || '').trim(); const requestedUserName = String(c.req.query('userName') || '').trim(); const requestedUserId = String(c.req.query('userId') || '').trim(); const targetUserName = requestedUserName || meName; if (!targetUserName && !requestedUserId && !meId) { return c.json({ success: false, message: 'No target user available' }, 400); } const baseFields = 'id,user,title,startDate,dueDate,priority,completed,content,size,status,owner,assigned_to,user_name,Username,expand.user.id,expand.user.email,expand.user.name,expand.user.username'; const fetchTasksByRelationIds = async (userIds: string[]) => { const ids = Array.from(new Set(userIds.map((id) => String(id || '').trim()).filter(Boolean))); if (!ids.length) return []; const relationFilter = ids.map((id) => `user = "${id.replace(/"/g, '\\"')}"`).join(' || '); return taskPb.collection(PB_TASKS_COLLECTION).getFullList({ filter: relationFilter, sort: '-startDate,-created', expand: 'user', fields: baseFields, }); }; const resolveUserIdsByName = async (name: string): Promise => { const target = String(name || '').trim(); if (!target) return []; const escaped = target.replace(/"/g, '\\"'); const matched = await taskPb.collection(PB_AUTH_COLLECTION).getFullList({ filter: `name = "${escaped}"`, fields: 'id,name,email,username', }); if (matched.length) { return matched.map((user: any) => String(user.id || '').trim()).filter(Boolean); } if (meId && target === meName) { return [meId]; } return []; }; let records: any[] = []; let warning = ''; let effectiveUserId = requestedUserId || meId; let effectiveUserName = targetUserName; try { if (targetUserName) { const targetUserIds = await resolveUserIdsByName(targetUserName); if (!targetUserIds.length) { warning = `No Users record matched name "${targetUserName}".`; records = []; } else { records = await fetchTasksByRelationIds(targetUserIds); if (!records.length) { warning = `No accessible Tasgird tasks matched Users.name "${targetUserName}".`; } effectiveUserId = targetUserIds[0] || effectiveUserId; } } else if (requestedUserId || meId) { const fallbackId = String(requestedUserId || meId).trim(); records = await fetchTasksByRelationIds([fallbackId]); if (!records.length) { warning = 'No accessible Tasgird tasks matched the current user relation.'; } } else { records = []; } } catch (listErr) { const message = (listErr as Error)?.message || String(listErr); return c.json({ success: false, message: 'Failed to load accessible tasks from PocketBase', details: message, userId: meId || requestedUserId, userName: targetUserName || meName, }, 500); } return c.json({ success: true, userId: effectiveUserId, userName: effectiveUserName, count: records.length, warning: warning || undefined, tasks: records.map((task: any) => ({ id: task.id, user: task.user || task.expand?.user?.id || null, userDisplay: task.expand?.user ? (task.expand.user.name || task.expand.user.username || task.expand.user.email || task.expand.user.id) : (task.user_name || task.Username || task.owner || task.assigned_to || task.user || null), title: task.title ?? '', startDate: task.startDate ?? null, dueDate: task.dueDate ?? null, priority: task.priority ?? null, completed: !!task.completed, content: task.content ?? null, size: task.size ?? null, status: task.status ?? null, })), }); } catch (e) { return c.json({ success: false, message: (e as Error)?.message || 'Failed to load tasks' }, 500); } }); app.get('/api/graph/status', async (c) => { try { 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 }, 500); } }); // GET /api/onenote/site-sections // Requires: Authorization: Bearer // 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); } }); // GET /api/onenote/section-pages?sectionId=... // Requires: Authorization: Bearer app.get('/api/onenote/section-pages', async (c) => { try { const delegatedToken = getBearerToken(c.req.header('authorization')); if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated token' }, 401); const sectionId = String(c.req.query('sectionId') || '').trim(); if (!sectionId) return c.json({ success: false, message: 'Missing sectionId' }, 400); const siteId = await resolveSiteId(delegatedToken); const resp = await fetch( `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote/sections/${encodeURIComponent(sectionId)}/pages?$top=100&$select=id,title,createdDateTime,lastModifiedDateTime`, { headers: { Authorization: `Bearer ${delegatedToken}` } }, ); const data = await resp.json().catch(() => ({})); if (!resp.ok) return c.json({ success: false, siteId, sectionId, error: data }, resp.status as any); return c.json({ success: true, siteId, sectionId, pages: data?.value || [] }); } catch (e) { return c.json({ success: false, message: (e as Error)?.message }, 500); } }); // GET /api/onenote/page-note-blocks?pageId=... // Requires: Authorization: Bearer app.get('/api/onenote/page-note-blocks', async (c) => { try { const delegatedToken = getBearerToken(c.req.header('authorization')); if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated token' }, 401); const pageId = String(c.req.query('pageId') || '').trim(); if (!pageId) return c.json({ success: false, message: 'Missing pageId' }, 400); const siteId = await resolveSiteId(delegatedToken); const resp = await fetch( `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`, { headers: { Authorization: `Bearer ${delegatedToken}`, Accept: 'text/html', }, }, ); const html = await resp.text(); if (!resp.ok) { return c.json({ success: false, siteId, pageId, error: html }, resp.status as any); } const noteBlocks = extractPageNoteBlocks(html); return c.json({ success: true, siteId, pageId, noteBlocks }); } 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 // Body: { // noteId, title, bodyPlain, noteType?, userEmail?, jobNumber?, // sectionId?, pageId?, pageTitle?, syncMode?: 'add_separate'|'add_to'|'edit', targetNoteId?, targetObjectId? // } // // Strategy: // 1. Resolve site ID via GET /sites/{host}:{path} // 2. If syncMode=edit: PATCH replace note block in known page by noteId // 3. If syncMode=add_to: PATCH append content to existing note block by noteId // 4. If syncMode=add_separate and pageId: PATCH append a new note block to page // 5. If syncMode=add_separate and no pageId: POST create new page in section // --------------------------------------------------------------------------- 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(); const pageTitle = String(body?.pageTitle || title || ONENOTE_TARGET.pageTitle || 'Prism Notes').trim(); const requestedMode = String(body?.syncMode || 'add_to').trim().toLowerCase(); const syncMode = requestedMode === 'add_to' ? 'add_to' : requestedMode === 'edit' ? 'edit' : 'add_separate'; const targetNoteId = String(body?.targetNoteId || noteId || '').trim(); const targetObjectId = String(body?.targetObjectId || '').trim(); const effectiveNoteId = syncMode === 'add_separate' ? noteId : targetNoteId; 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, '
'); const headerLine = [ `${escapeHtml(title)}`, `type: ${escapeHtml(noteType)}`, `user: ${escapeHtml(userEmail)}`, jobNumber ? `job: ${escapeHtml(jobNumber)}` : '', `note_id: ${escapeHtml(effectiveNoteId)}`, ].filter(Boolean).join(' | '); const noteDataId = `pn-${escapeHtml(effectiveNoteId)}`; const appendHtml = `

${headerLine}

${safeBody}

---

`; const separateNoteHtml = [ ``, '', '', '', '
', `
`, '

Prism Notes Entry

', `

${headerLine}

`, `

${safeBody}

`, '

---

', '
', '
', ].join(''); const appendToExistingNoteHtml = `

update_at: ${escapeHtml(ts)}

${safeBody}

---

`; const pageHtml = [ '', '', `${escapeHtml(pageTitle)}`, `${appendHtml}`, '', ].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); if ((syncMode === 'edit' || syncMode === 'add_to') && !pageId) { return c.json({ success: false, message: 'Add/Edit mode requires pageId' }, 400); } if ((syncMode === 'edit' || syncMode === 'add_to') && !targetNoteId) { return c.json({ success: false, message: 'Add/Edit mode requires targetNoteId or noteId' }, 400); } if (syncMode === 'add_separate' && pageId && !targetObjectId) { return c.json({ success: false, message: 'Separate mode requires a discovered OneNote target object on the selected page' }, 400); } const user = pb.authStore.model; const notesWarnings: string[] = []; const notesPayload = buildNotesPayload({ user, bodyPlain, title, noteType, jobNumber, userEmail, noteId: effectiveNoteId, }); let notesRecord: any; try { notesRecord = await pb.collection(PB_NOTES_COLLECTION).create(notesPayload); } catch (createErr) { const createMessage = (createErr as Error)?.message || String(createErr); notesWarnings.push(`Notes create retry without note_id: ${createMessage}`); const fallbackPayload: Record = { ...notesPayload }; delete fallbackPayload.note_id; delete fallbackPayload.onenote_id; try { notesRecord = await pb.collection(PB_NOTES_COLLECTION).create(fallbackPayload); } catch (fallbackErr) { const fallbackMessage = (fallbackErr as Error)?.message || String(fallbackErr); return c.json( { success: false, message: 'Failed to create Notes record before OneNote sync', details: { create: fallbackMessage } }, 500, ); } } const notesRecordKeys = Object.keys(notesRecord || {}); const noteIdField = findRecordFieldName(notesRecord as Record, [/^note_?id$/i, /^noteid$/i]); const oneNoteIdField = findRecordFieldName(notesRecord as Record, [/^onenote_?id$/i, /^one_?note_?id$/i, /^onenoteid$/i]); const upsertSyncFields = async (effectiveNoteId: string): Promise => { try { const updatePayload: Record = {}; if (noteIdField) updatePayload[noteIdField] = effectiveNoteId; if (oneNoteIdField) updatePayload[oneNoteIdField] = effectiveNoteId; if (!noteIdField && !oneNoteIdField) { notesWarnings.push(`No note_id/onenote_id fields found on Notes record. Fields: ${notesRecordKeys.join(', ')}`); return; } await pb.collection(PB_NOTES_COLLECTION).update(notesRecord.id, updatePayload); } catch (updateErr) { notesWarnings.push(`Notes sync field update failed: ${(updateErr as Error)?.message || String(updateErr)}`); } }; const authHeader = { Authorization: `Bearer ${delegatedToken}` }; /* ---- resolve site ID ---- */ const siteId = await resolveSiteId(delegatedToken); const base = `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote`; if (syncMode === 'edit') { const selectorNoteId = targetNoteId.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const replaceResp = await fetch( `${base}/pages/${pageId}/content`, { method: 'PATCH', headers: { ...authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify([ { target: `div[data-prism-notes-note-id=\"${selectorNoteId}\"]`, action: 'replace', content: appendHtml, }, ]), }, ); if (replaceResp.ok) { const onenoteId = targetNoteId; await upsertSyncFields(targetNoteId); console.log('onenote_edit_success', { mode: 'edit', noteId, targetNoteId, pageId, siteId }); return c.json({ success: true, mode: 'edited', pageId, noteId: targetNoteId, onenoteId, notesRecordId: notesRecord.id, notesWarnings, syncedAt: ts, }); } const replaceErr = await replaceResp.text(); console.warn('onenote_edit_failed', { status: replaceResp.status, error: replaceErr, pageId, targetNoteId }); return c.json( { success: false, message: `OneNote edit failed (${replaceResp.status})`, details: { edit: replaceErr, pageId, targetNoteId, siteId, notesRecordId: notesRecord.id, notesWarnings }, }, 502, ); } if (syncMode === 'add_to') { const selectorNoteId = targetNoteId.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const appendToNoteResp = await fetch( `${base}/pages/${pageId}/content`, { method: 'PATCH', headers: { ...authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify([ { target: `div[data-prism-notes-note-id=\"${selectorNoteId}\"]`, action: 'append', content: appendToExistingNoteHtml, }, ]), }, ); if (appendToNoteResp.ok) { const onenoteId = targetNoteId; await upsertSyncFields(targetNoteId); console.log('onenote_add_to_note_success', { mode: 'add_to', noteId, targetNoteId, pageId, siteId }); return c.json({ success: true, mode: 'added_to_note', pageId, noteId: targetNoteId, onenoteId, notesRecordId: notesRecord.id, notesWarnings, syncedAt: ts, }); } const appendToNoteErr = await appendToNoteResp.text(); console.warn('onenote_add_to_note_failed', { status: appendToNoteResp.status, error: appendToNoteErr, pageId, targetNoteId }); return c.json( { success: false, message: `OneNote add-to-note failed (${appendToNoteResp.status})`, details: { addToNote: appendToNoteErr, pageId, targetNoteId, siteId, notesRecordId: notesRecord.id, notesWarnings }, }, 502, ); } /* ---- 1: try append to known page (if we have a valid pageId) ---- */ if (pageId) { const pageAppendHtml = syncMode === 'add_separate' ? separateNoteHtml : appendHtml; const action = syncMode === 'add_separate' ? 'insert' : 'append'; let patchResp: Response | null = null; let targetUsed = ''; if (syncMode === 'add_separate') { const candidates = objectTargetCandidates(targetObjectId); if (!candidates.length) { return c.json({ success: false, message: 'Separate mode could not build target candidates from object anchor' }, 400); } for (const candidate of candidates) { for (const position of ['after', 'before'] as const) { const command = { target: candidate, action: 'insert', position, content: pageAppendHtml, }; const resp = await fetch( `${base}/pages/${pageId}/content`, { method: 'PATCH', headers: { ...authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify([command]), }, ); if (resp.ok) { patchResp = resp; targetUsed = `${candidate} (${position})`; break; } patchResp = resp; } if (patchResp?.ok) { break; } } } else { const command = { target: 'body', action, content: pageAppendHtml, }; patchResp = await fetch( `${base}/pages/${pageId}/content`, { method: 'PATCH', headers: { ...authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify([command]), }, ); } if (!patchResp) { return c.json({ success: false, message: 'OneNote patch failed before request execution' }, 502); } if (patchResp.ok) { const onenoteId = effectiveNoteId; await upsertSyncFields(effectiveNoteId); console.log('onenote_append_success', { mode: 'patch', noteId: effectiveNoteId, siteId }); return c.json({ success: true, mode: syncMode === 'add_separate' ? 'separate_inserted' : 'appended', operation: action, targetUsed: targetUsed || null, pageId, noteId: effectiveNoteId, onenoteId, notesRecordId: notesRecord.id, notesWarnings, 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(() => ({})); const createdPageId = created?.id ? String(created.id) : ''; const onenoteId = effectiveNoteId; await upsertSyncFields(effectiveNoteId); console.log('onenote_create_success', { mode: 'create', noteId: effectiveNoteId, pageId: created?.id, siteId }); return c.json({ success: true, mode: 'created', pageId: createdPageId || null, noteId: effectiveNoteId, onenoteId: onenoteId || null, notesRecordId: notesRecord.id, notesWarnings, 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, notesRecordId: notesRecord.id, notesWarnings }, }, 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 note record to PocketBase Notes collection // --------------------------------------------------------------------------- app.post('/api/submit', async (c) => { try { const body = await c.req.json(); if (!body?.pbToken) return c.json({ success: false, message: 'Missing pbToken' }, 401); try { await validatePbToken(body.pbToken); } catch (e) { return c.json({ success: false, message: 'Invalid token' }, 401); } const user = pb.authStore.model; const bodyPlain = String(body.bodyPlain || '').trim(); const title = String(body.title || 'Untitled').trim(); const noteType = String(body.noteType || 'personal').trim(); const jobNumber = String(body.jobNumber || '').trim(); const noteId = String(body.noteId || '').trim(); const payload = buildNotesPayload({ user, bodyPlain, title, noteType, jobNumber, userEmail: String(body.userEmail || '').trim(), noteId, }); const record = await pb.collection(PB_NOTES_COLLECTION).create(payload); console.log('submission_success', { 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: (err as Error)?.message || 'Internal error' }, 500); } finally { clearPbToken(); } }); // --------------------------------------------------------------------------- // Static assets // --------------------------------------------------------------------------- app.use('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.use('/tools/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.use('/legal/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); 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); }); app.get('/notes', async (c) => { const html = await Bun.file(new URL('./notes.html', import.meta.url)).text(); return c.html(html); }); app.get('/notes-workspace', async (c) => { const html = await Bun.file(new URL('./notes-workspace.html', import.meta.url)).text(); return c.html(html); }); app.get('/tasks', async (c) => { const html = await Bun.file(new URL('./tasks.html', import.meta.url)).text(); return c.html(html); }); app.get('/legal/privacy', async (c) => { const html = await Bun.file(new URL('./legal/privacy.html', import.meta.url)).text(); return c.html(html); }); app.get('/legal/eula', async (c) => { const html = await Bun.file(new URL('./legal/eula.html', import.meta.url)).text(); return c.html(html); }); // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- const PORT = Number(process.env.PORT || 3030); Bun.serve({ port: PORT, fetch: app.fetch }); console.log(`Prism Notes running at http://localhost:${PORT}`);