1387 lines
50 KiB
TypeScript
1387 lines
50 KiB
TypeScript
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<void> {
|
|
setPbToken(token);
|
|
await pb.collection(PB_AUTH_COLLECTION).authRefresh();
|
|
}
|
|
|
|
async function createValidatedPbClient(token: string): Promise<PocketBase> {
|
|
const client = new PocketBase(PB_BASE_URL);
|
|
client.authStore.save(token, null);
|
|
await client.collection(PB_AUTH_COLLECTION).authRefresh();
|
|
return client;
|
|
}
|
|
|
|
function getRequestPbToken(c: any): string {
|
|
const headerToken = String(c.req.header('x-pb-token') || '').trim();
|
|
if (headerToken) return headerToken;
|
|
const authHeader = String(c.req.header('authorization') || '').trim();
|
|
const bearer = getBearerToken(authHeader);
|
|
return String(bearer || '').trim();
|
|
}
|
|
|
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
try {
|
|
const parts = String(token || '').split('.');
|
|
if (parts.length < 2) return null;
|
|
const base = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
const padded = base + '='.repeat((4 - (base.length % 4 || 4)) % 4);
|
|
const payload = Buffer.from(padded, 'base64').toString('utf8');
|
|
const parsed = JSON.parse(payload);
|
|
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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, ''');
|
|
}
|
|
|
|
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 || ' '}</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(/ /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 = /<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)
|
|
// ---------------------------------------------------------------------------
|
|
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<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: 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,
|
|
});
|
|
});
|
|
|
|
app.post('/api/auth/validate-pb-token', async (c) => {
|
|
try {
|
|
const body = await c.req.json().catch(() => ({}));
|
|
const pbToken = String(body?.pbToken || '').trim();
|
|
if (!pbToken) {
|
|
return c.json({ valid: false, error: 'No token provided' }, 400);
|
|
}
|
|
|
|
const tokenPayload = decodeJwtPayload(pbToken);
|
|
let taskPb: PocketBase;
|
|
try {
|
|
taskPb = await createValidatedPbClient(pbToken);
|
|
} catch (error) {
|
|
const message = (error as Error)?.message || String(error);
|
|
return c.json({
|
|
valid: false,
|
|
error: 'Invalid PocketBase token',
|
|
details: message,
|
|
tokenContext: {
|
|
type: String(tokenPayload?.type || ''),
|
|
collectionId: String(tokenPayload?.collectionId || ''),
|
|
collectionName: String(tokenPayload?.collectionName || ''),
|
|
},
|
|
}, 401);
|
|
}
|
|
|
|
const user = taskPb.authStore.record || taskPb.authStore.model || null;
|
|
return c.json({
|
|
valid: true,
|
|
user: user ? {
|
|
id: user.id || '',
|
|
email: user.email || '',
|
|
name: user.name || user.username || user.email || '',
|
|
} : null,
|
|
tokenContext: {
|
|
type: String(tokenPayload?.type || ''),
|
|
collectionId: String(tokenPayload?.collectionId || ''),
|
|
collectionName: String(tokenPayload?.collectionName || ''),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
return c.json({ valid: false, error: (error as Error)?.message || 'Validation failed' }, 500);
|
|
}
|
|
});
|
|
|
|
// GET /api/tasks/users
|
|
// Requires: x-pb-token
|
|
app.get('/api/tasks/users', async (c) => {
|
|
const pbToken = getRequestPbToken(c);
|
|
if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401);
|
|
|
|
let taskPb: PocketBase;
|
|
const tokenPayload = decodeJwtPayload(pbToken);
|
|
try {
|
|
taskPb = await createValidatedPbClient(pbToken);
|
|
} catch (error) {
|
|
const message = (error as Error)?.message || String(error);
|
|
return c.json({
|
|
success: false,
|
|
message: 'Invalid PocketBase token',
|
|
details: message,
|
|
tokenContext: {
|
|
type: String(tokenPayload?.type || ''),
|
|
collectionId: String(tokenPayload?.collectionId || ''),
|
|
collectionName: String(tokenPayload?.collectionName || ''),
|
|
},
|
|
}, 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=<id>
|
|
// Requires: x-pb-token
|
|
app.get('/api/tasks/list', async (c) => {
|
|
const pbToken = getRequestPbToken(c);
|
|
if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401);
|
|
|
|
let taskPb: PocketBase;
|
|
const tokenPayload = decodeJwtPayload(pbToken);
|
|
try {
|
|
taskPb = await createValidatedPbClient(pbToken);
|
|
} catch (error) {
|
|
const message = (error as Error)?.message || String(error);
|
|
return c.json({
|
|
success: false,
|
|
message: 'Invalid PocketBase token',
|
|
details: message,
|
|
tokenContext: {
|
|
type: String(tokenPayload?.type || ''),
|
|
collectionId: String(tokenPayload?.collectionId || ''),
|
|
collectionName: String(tokenPayload?.collectionName || ''),
|
|
},
|
|
}, 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 uniqueById = (records: any[]) => {
|
|
const seen = new Map<string, any>();
|
|
for (const record of Array.isArray(records) ? records : []) {
|
|
const id = String(record?.id || '').trim();
|
|
if (!id || seen.has(id)) continue;
|
|
seen.set(id, record);
|
|
}
|
|
return Array.from(seen.values());
|
|
};
|
|
|
|
const taskMatchesTarget = (task: any, userIds: string[], nameHints: string[]) => {
|
|
const idSet = new Set(userIds.map((id) => String(id || '').trim()).filter(Boolean));
|
|
const lowerHints = nameHints.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
|
|
|
|
const directUsers = Array.isArray(task?.user)
|
|
? task.user
|
|
: (task?.user ? [task.user] : []);
|
|
if (directUsers.some((value: any) => idSet.has(String(value || '').trim()))) {
|
|
return true;
|
|
}
|
|
|
|
const expandedUsers = Array.isArray(task?.expand?.user)
|
|
? task.expand.user
|
|
: (task?.expand?.user ? [task.expand.user] : []);
|
|
if (expandedUsers.some((user: any) => idSet.has(String(user?.id || '').trim()))) {
|
|
return true;
|
|
}
|
|
|
|
const textFields = [task?.user_name, task?.Username, task?.owner, task?.assigned_to]
|
|
.map((value) => String(value || '').trim().toLowerCase())
|
|
.filter(Boolean);
|
|
|
|
return lowerHints.some((hint) => textFields.some((value) => value === hint || value.includes(hint)));
|
|
};
|
|
|
|
const fetchTasksByRelationIds = async (userIds: string[], nameHints: string[]) => {
|
|
const ids = Array.from(new Set(userIds.map((id) => String(id || '').trim()).filter(Boolean)));
|
|
if (!ids.length) return { records: [], lookupMethod: 'no_user_ids' };
|
|
|
|
const filterAttempts = [
|
|
{
|
|
lookupMethod: 'relation_eq',
|
|
filter: ids.map((id) => `user = "${id.replace(/"/g, '\\"')}"`).join(' || '),
|
|
},
|
|
{
|
|
lookupMethod: 'relation_any_eq',
|
|
filter: ids.map((id) => `user ?= "${id.replace(/"/g, '\\"')}"`).join(' || '),
|
|
},
|
|
];
|
|
|
|
let lastError: unknown = null;
|
|
for (const attempt of filterAttempts) {
|
|
try {
|
|
const records = await taskPb.collection(PB_TASKS_COLLECTION).getFullList({
|
|
filter: attempt.filter,
|
|
sort: '-startDate,-created',
|
|
expand: 'user',
|
|
fields: baseFields,
|
|
});
|
|
if (records.length) {
|
|
return { records: uniqueById(records), lookupMethod: attempt.lookupMethod };
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const accessibleTasks = await taskPb.collection(PB_TASKS_COLLECTION).getFullList({
|
|
sort: '-startDate,-created',
|
|
expand: 'user',
|
|
fields: baseFields,
|
|
});
|
|
const filtered = accessibleTasks.filter((task: any) => taskMatchesTarget(task, ids, nameHints));
|
|
return {
|
|
records: uniqueById(filtered),
|
|
lookupMethod: 'accessible_fallback',
|
|
};
|
|
} catch (error) {
|
|
if (lastError) throw lastError;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const resolveUserIdsByName = async (name: string): Promise<string[]> => {
|
|
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);
|
|
}
|
|
|
|
try {
|
|
const allUsers = await taskPb.collection(PB_AUTH_COLLECTION).getFullList({
|
|
fields: 'id,name,email,username',
|
|
});
|
|
const lowerTarget = target.toLowerCase();
|
|
const fuzzy = allUsers.filter((user: any) => {
|
|
const values = [user?.name, user?.username, user?.email]
|
|
.map((value) => String(value || '').trim().toLowerCase())
|
|
.filter(Boolean);
|
|
return values.includes(lowerTarget);
|
|
});
|
|
if (fuzzy.length) {
|
|
return fuzzy.map((user: any) => String(user.id || '').trim()).filter(Boolean);
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
if (meId && target === meName) {
|
|
return [meId];
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
let records: any[] = [];
|
|
let warning = '';
|
|
let effectiveUserId = requestedUserId || meId;
|
|
let effectiveUserName = targetUserName;
|
|
let lookupMethod = 'unresolved';
|
|
try {
|
|
if (targetUserName) {
|
|
const targetUserIds = Array.from(new Set([
|
|
...(await resolveUserIdsByName(targetUserName)),
|
|
...(requestedUserId ? [requestedUserId] : []),
|
|
...(meId && targetUserName === meName ? [meId] : []),
|
|
].map((id) => String(id || '').trim()).filter(Boolean)));
|
|
if (!targetUserIds.length) {
|
|
warning = `No Users record matched name "${targetUserName}".`;
|
|
records = [];
|
|
} else {
|
|
const result = await fetchTasksByRelationIds(targetUserIds, [targetUserName, meName]);
|
|
records = result.records;
|
|
lookupMethod = result.lookupMethod;
|
|
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();
|
|
const result = await fetchTasksByRelationIds([fallbackId], [meName]);
|
|
records = result.records;
|
|
lookupMethod = result.lookupMethod;
|
|
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,
|
|
authUser: {
|
|
id: meId,
|
|
email: meRecord?.email || '',
|
|
name: meName,
|
|
},
|
|
tokenContext: {
|
|
type: String(tokenPayload?.type || ''),
|
|
collectionId: String(tokenPayload?.collectionId || ''),
|
|
collectionName: String(tokenPayload?.collectionName || ''),
|
|
},
|
|
count: records.length,
|
|
lookupMethod,
|
|
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 <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);
|
|
}
|
|
});
|
|
|
|
// GET /api/onenote/section-pages?sectionId=...
|
|
// Requires: Authorization: Bearer <delegated token>
|
|
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 <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_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, '<br/>');
|
|
const headerLine = [
|
|
`<strong>${escapeHtml(title)}</strong>`,
|
|
`type: ${escapeHtml(noteType)}`,
|
|
`user: ${escapeHtml(userEmail)}`,
|
|
jobNumber ? `job: ${escapeHtml(jobNumber)}` : '',
|
|
`note_id: ${escapeHtml(effectiveNoteId)}`,
|
|
].filter(Boolean).join(' | ');
|
|
|
|
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>',
|
|
'<html>',
|
|
`<head><title>${escapeHtml(pageTitle)}</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);
|
|
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<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}` };
|
|
|
|
/* ---- 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}`);
|