Add isolated legacy notes route and tasks view with resilient task APIs

This commit is contained in:
2026-03-28 19:17:56 +00:00
parent 8ca2c97e37
commit d6b0e3192a
5 changed files with 1516 additions and 66 deletions
+784 -48
View File
@@ -24,6 +24,10 @@ const ONENOTE_TARGET = {
notebookName: 'Billing Notes',
};
const WORKSPACE_ROOT = new URL('./', import.meta.url).pathname;
const LEGACY_NOTES_COMMIT = '4c6058c';
let legacyNotesHtmlCache: string | null = null;
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
@@ -37,6 +41,8 @@ app.use('/*', cors({ origin: '*', credentials: true }));
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);
@@ -56,6 +62,42 @@ function getBearerToken(header: string | undefined | null): string | null {
return m?.[1] || null;
}
function decorateLegacyNotesHtml(html: string): string {
const nav = `
<div style="position:fixed;top:12px;left:12px;z-index:100000;display:flex;flex-wrap:wrap;gap:8px;align-items:center;max-width:min(90vw,960px);">
<a href="/" style="display:inline-flex;align-items:center;padding:8px 12px;border-radius:999px;background:#0f172a;color:#e2e8f0;text-decoration:none;font:600 13px/1.2 ui-sans-serif,system-ui,sans-serif;border:1px solid #334155;box-shadow:0 8px 20px rgba(15,23,42,.28);">Testing Harness</a>
<a href="/tasks" style="display:inline-flex;align-items:center;padding:8px 12px;border-radius:999px;background:#0f172a;color:#e2e8f0;text-decoration:none;font:600 13px/1.2 ui-sans-serif,system-ui,sans-serif;border:1px solid #334155;box-shadow:0 8px 20px rgba(15,23,42,.28);">Tasks</a>
<span style="display:inline-flex;align-items:center;padding:8px 12px;border-radius:999px;background:rgba(15,23,42,.92);color:#67e8f9;font:600 12px/1.2 ui-sans-serif,system-ui,sans-serif;border:1px solid rgba(103,232,249,.35);box-shadow:0 8px 20px rgba(15,23,42,.28);">Legacy Notes UI · restored from ${LEGACY_NOTES_COMMIT}</span>
</div>`;
return html
.replace('<title>Prism Notes</title>', '<title>Prism Notes Legacy</title>')
.replace(/<body([^>]*)>/i, `<body$1>${nav}`);
}
async function loadLegacyNotesHtml(): Promise<string> {
if (legacyNotesHtmlCache) return legacyNotesHtmlCache;
const proc = Bun.spawn(['git', '--no-pager', 'show', `${LEGACY_NOTES_COMMIT}:index.html`], {
cwd: WORKSPACE_ROOT,
stdout: 'pipe',
stderr: 'pipe',
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(stderr || `git show failed for ${LEGACY_NOTES_COMMIT}:index.html`);
}
legacyNotesHtmlCache = decorateLegacyNotesHtml(stdout);
return legacyNotesHtmlCache;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
@@ -65,6 +107,267 @@ function escapeHtml(s: string): string {
.replace(/'/g, '&#39;');
}
function buildNotesPayload(input: {
user: any;
bodyPlain: string;
title: string;
noteType: string;
jobNumber: string;
userEmail?: string;
noteId?: string;
onenoteId?: string;
}): Record<string, unknown> {
const bodyHtml = input.bodyPlain
.split(/\r\n|\r|\n/)
.map((line) => `<p>${line || '&nbsp;'}</p>`)
.join('');
const payload: Record<string, unknown> = {
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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 = /<pre\b[^>]*>([\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 = /<div\b([^>]*)\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(/<p>\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<string, unknown>, 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)
// ---------------------------------------------------------------------------
@@ -116,6 +419,32 @@ async function resolveSiteId(delegatedToken: string): Promise<string> {
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);
@@ -144,6 +473,133 @@ app.get('/api/auth/msal-config', (c) => {
});
});
// 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);
try {
await validatePbToken(pbToken);
} catch {
return c.json({ success: false, message: 'Invalid PocketBase token' }, 401);
}
try {
const me = pb.authStore.model;
let users: any[] = [];
let warning = '';
try {
users = await pb.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 = me ? [me] : [];
}
return c.json({
success: true,
me: {
id: me?.id || '',
email: me?.email || '',
name: (me?.name || me?.username || me?.email || 'Me') as string,
},
users: users.map((user: any) => ({
id: user.id,
email: user.email || '',
name: user.name || user.username || user.email || user.id,
})),
warning: warning || undefined,
});
} catch (e) {
return c.json({ success: false, message: (e as Error)?.message || 'Failed to load users' }, 500);
} finally {
clearPbToken();
}
});
// GET /api/tasks/list?userId=<id>
// 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);
try {
await validatePbToken(pbToken);
} catch {
return c.json({ success: false, message: 'Invalid PocketBase token' }, 401);
}
try {
const me = pb.authStore.model;
const meId = String(me?.id || '').trim();
const requestedUserId = String(c.req.query('userId') || '').trim();
const targetUserId = requestedUserId || meId;
if (!targetUserId) return c.json({ success: false, message: 'No target user available' }, 400);
const fetchTasks = async (userId: string) => {
const filter = `user = "${userId.replace(/"/g, '\\"')}"`;
return pb.collection(PB_TASKS_COLLECTION).getFullList({
filter,
sort: '-startDate,-created',
expand: 'user',
fields: 'id,user,title,startDate,dueDate,priority,completed,content,size,status,expand.user.id,expand.user.email,expand.user.name,expand.user.username',
});
};
let records: any[] = [];
let warning = '';
let effectiveUserId = targetUserId;
try {
records = await fetchTasks(targetUserId);
} catch (listErr) {
if (targetUserId !== meId && meId) {
try {
records = await fetchTasks(meId);
effectiveUserId = meId;
warning = 'Requested user tasks are restricted by PocketBase rules; showing your tasks instead.';
} catch {
records = [];
effectiveUserId = meId;
warning = 'Task list is restricted by PocketBase rules; no accessible tasks for current user.';
}
} else {
records = [];
effectiveUserId = meId || targetUserId;
warning = 'Task list is restricted by PocketBase rules; no accessible tasks for current user.';
}
}
return c.json({
success: true,
userId: effectiveUserId,
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)
: 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);
} finally {
clearPbToken();
}
});
app.get('/api/graph/status', async (c) => {
try {
const t = await getGraphToken();
@@ -197,19 +653,53 @@ app.get('/api/onenote/section-pages', async (c) => {
}
});
// GET /api/onenote/page-note-blocks?pageId=...
// Requires: Authorization: Bearer <delegated token>
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 <delegated Graph token>
// Body: {
// noteId, title, bodyPlain, noteType?, userEmail?, jobNumber?,
// sectionId?, pageId?, pageTitle?, syncMode?: 'add'|'edit', targetNoteId?
// 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 and pageId: PATCH append to known page
// 4. If syncMode=add and no pageId: POST create new page in known section
// 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 {
@@ -235,8 +725,11 @@ app.post('/api/onenote/append', async (c) => {
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 syncMode = String(body?.syncMode || 'add').trim().toLowerCase() === 'edit' ? 'edit' : 'add';
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);
@@ -248,10 +741,26 @@ app.post('/api/onenote/append', async (c) => {
`type: ${escapeHtml(noteType)}`,
`user: ${escapeHtml(userEmail)}`,
jobNumber ? `job: ${escapeHtml(jobNumber)}` : '',
`note_id: ${escapeHtml(noteId)}`,
`note_id: ${escapeHtml(effectiveNoteId)}`,
].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 noteDataId = `pn-${escapeHtml(effectiveNoteId)}`;
const appendHtml = `<div data-id="${noteDataId}" data-prism-notes-note-id="${escapeHtml(effectiveNoteId)}" data-prism-notes-synced-at="${ts}"><p>${headerLine}</p><p>${safeBody}</p><p>---</p></div>`;
const separateNoteHtml = [
`<table data-id="pn-table-${escapeHtml(effectiveNoteId)}" data-prism-notes-note-id="${escapeHtml(effectiveNoteId)}" style="width:100%;border-collapse:separate;border-spacing:0;margin-top:16px;margin-bottom:8px;">`,
'<tr>',
'<td style="border:2px solid #7c3aed;padding:12px;background-color:#f6f0ff;">',
`<div data-id="${noteDataId}" data-prism-notes-note-id="${escapeHtml(effectiveNoteId)}" data-prism-notes-synced-at="${ts}">`,
'<p><strong>Prism Notes Entry</strong></p>',
`<p>${headerLine}</p>`,
`<p>${safeBody}</p>`,
'<p>---</p>',
'</div>',
'</td>',
'</tr>',
'</table>',
].join('');
const appendToExistingNoteHtml = `<p><strong>update_at:</strong> ${escapeHtml(ts)}</p><p>${safeBody}</p><p>---</p>`;
const pageHtml = [
'<!DOCTYPE html>',
@@ -264,12 +773,70 @@ app.post('/api/onenote/append', async (c) => {
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' && !pageId) {
return c.json({ success: false, message: 'Edit mode requires pageId' }, 400);
if ((syncMode === 'edit' || syncMode === 'add_to') && !pageId) {
return c.json({ success: false, message: 'Add/Edit mode requires pageId' }, 400);
}
if (syncMode === 'edit' && !targetNoteId) {
return c.json({ success: false, message: 'Edit mode requires targetNoteId or noteId' }, 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<string, unknown> = { ...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<string, unknown>, [/^note_?id$/i, /^noteid$/i]);
const oneNoteIdField = findRecordFieldName(notesRecord as Record<string, unknown>, [/^onenote_?id$/i, /^one_?note_?id$/i, /^onenoteid$/i]);
const upsertSyncFields = async (effectiveNoteId: string): Promise<void> => {
try {
const updatePayload: Record<string, unknown> = {};
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}` };
@@ -295,8 +862,19 @@ app.post('/api/onenote/append', async (c) => {
);
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, syncedAt: ts });
return c.json({
success: true,
mode: 'edited',
pageId,
noteId: targetNoteId,
onenoteId,
notesRecordId: notesRecord.id,
notesWarnings,
syncedAt: ts,
});
}
const replaceErr = await replaceResp.text();
@@ -305,7 +883,52 @@ app.post('/api/onenote/append', async (c) => {
{
success: false,
message: `OneNote edit failed (${replaceResp.status})`,
details: { edit: replaceErr, pageId, targetNoteId, siteId },
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,
);
@@ -313,18 +936,81 @@ app.post('/api/onenote/append', async (c) => {
/* ---- 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 }]),
},
);
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) {
console.log('onenote_append_success', { mode: 'patch', noteId, siteId });
return c.json({ success: true, mode: 'appended', syncedAt: ts });
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();
@@ -343,14 +1029,30 @@ app.post('/api/onenote/append', async (c) => {
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 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 } },
{
success: false,
message: `OneNote write failed (create ${postResp.status})`,
details: { create: postErr, siteId, sectionId, notesRecordId: notesRecord.id, notesWarnings },
},
502,
);
} catch (e) {
@@ -364,8 +1066,6 @@ app.post('/api/onenote/append', async (c) => {
// ---------------------------------------------------------------------------
// POST /api/submit — save a note record to PocketBase Notes collection
// ---------------------------------------------------------------------------
const PB_NOTES_COLLECTION = process.env.PB_NOTES_COLLECTION || 'Notes';
app.post('/api/submit', async (c) => {
try {
const body = await c.req.json();
@@ -382,29 +1082,17 @@ app.post('/api/submit', async (c) => {
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();
// Convert plain text to simple HTML (matches what the main app stores)
const bodyHtml = bodyPlain
.split(/\r\n|\r|\n/)
.map((line) => `<p>${line || '&nbsp;'}</p>`)
.join('');
const payload: Record<string, unknown> = {
body_plain: bodyPlain,
body_html: bodyHtml,
const payload = buildNotesPayload({
user,
bodyPlain,
title,
type: noteType,
email: user?.email || body.userEmail || '',
Username: user?.name || user?.username || user?.email || '',
userId: user?.id || '',
job_note: noteType === 'job' || !!jobNumber,
shared: false,
shared_with: [],
};
if (jobNumber) {
payload['Job_Number'] = jobNumber;
}
noteType,
jobNumber,
userEmail: String(body.userEmail || '').trim(),
noteId,
});
const record = await pb.collection(PB_NOTES_COLLECTION).create(payload);
@@ -423,6 +1111,7 @@ app.post('/api/submit', async (c) => {
// ---------------------------------------------------------------------------
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));
@@ -436,6 +1125,53 @@ app.get('/', async (c) => {
return c.html(html);
});
app.get('/notes', async (c) => {
try {
const html = await loadLegacyNotesHtml();
return c.html(html);
} catch (error) {
console.error('Failed to load legacy notes UI', error);
return c.html(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes Legacy</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<main class="mx-auto max-w-3xl px-6 py-16">
<div class="rounded-2xl border border-slate-800 bg-slate-900/80 p-6 shadow-2xl">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Legacy notes UI unavailable</h1>
<p class="mt-4 text-sm text-slate-300">The current server could not load the historical notes interface from git commit <strong>${LEGACY_NOTES_COMMIT}</strong>.</p>
<pre class="mt-4 overflow-x-auto rounded-xl border border-slate-800 bg-slate-950 p-4 text-xs text-rose-200">${escapeHtml(String(error instanceof Error ? error.message : error || 'Unknown error'))}</pre>
<div class="mt-6 flex flex-wrap gap-3">
<a href="/" class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-semibold text-slate-100">Back to Testing Harness</a>
<a href="/tasks" class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-semibold text-slate-100">Open Tasks View</a>
</div>
</div>
</main>
</body>
</html>`, 500);
}
});
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
// ---------------------------------------------------------------------------