// SnapZilla Reports — server-side proxy // Holds NOTION_TOKEN, ADMIN_PASSWORD, and SMTP_* credentials; the browser never sees any of them. // // Modes (POST body { mode, password, ... }): // "read" -> query all sales entries (paginated) + staff rate pages // "link" -> set the Staff (rate) relation on one sales entry // "getPage" -> fetch a single sales entry page by id (for post-link polling) // "setCommission" -> set the Manual commission (£) number on one sales entry // "setExtraHours" -> set the Extra hours number on one sales entry // "setPaid" -> mark a PAYE payroll period paid for one person. // Update-or-create a row in the Payroll log DB, keyed by Ref. // "setShiftPaid" -> toggle the Paid checkbox on one Sales Entries shift row. // "setShiftsPaid" -> batch toggle the Paid checkbox on many shift rows. // "saveVenueCommission" -> log a venue commission run (update-or-create by Ref) // in the Venue commission log DB. // "saveStatement" -> log a freelance earnings statement (update-or-create by Ref) // in the Freelance Statements DB. Optionally stamps // Emailed at / Emailed to / Status on the same row. // "updateStaffPassword" -> set a new password on one Login Credentials row. // "emailTemplates" / "emailTemplateSave" / "emailStaffList" -> the Emails tab. // "sendEmail" -> send a pre-rendered HTML email via Google Workspace SMTP. // body: { to, subject, htmlBody } // Requires SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM env vars. // "rebuildNights" -> group sales entries into nights by Night key, create any // missing Nights pages, and set each entry's Night relation. // DRY RUN by default: returns a plan and writes nothing. // Pass { confirm: true } to actually write. // // NOTE (12 July 2026): the old "addStaff" mode was removed. It was dead code — // no current Reports screen calls it — and it referenced undefined variables // (nick, effectiveFrom) so it would have thrown if ever wired up. Staff are // added directly in Notion; the invite/password screens act on EXISTING rows // via updateStaffPassword + sendEmail. If a create-staff screen is ever built, // add a fresh, tested mode rather than resurrecting the old one. // // Every request must include the correct password or it is rejected 401. import nodemailer from "nodemailer"; const SALES_DB = "36f69937e3f4800a8bfae26b7d106a73"; const STAFF_DB = "e20931e2595c42119f2690179bb567dd"; const EMAIL_TEMPLATES_DB = "d044435636254b76af1c1e03d1f70565"; // SnapZilla Email Templates — share it with the integration! const STAFF_LOGIN_DB = "7b954742-d16a-4518-b51d-6ef5a6448065"; const VENUE_DB = "0e10122eab124be98ee52af3200849cf"; const NIGHTS_DB = "37b69937e3f480c7a42ec275786b0c16"; // SnapZilla Clock Log (owned by the Hub) — read-only here for the Clock log tab. const CLOCK_DB = "f53226bcc7164ebbb55da6345cc4a67a"; // Payroll log: one row per PAYE person per pay period, with a Paid checkbox. // >>> PASTE the new Payroll log database id here once you've created it in Notion. <<< // Until it's set, the "Mark paid" feature returns a clear message instead of failing. const PAYROLL_LOG_DB = "d1fb5e5ac2d04f98b555c4bdd0444f31"; const VENUE_LOG_DB = "20f4b5efea92415eae235dddf5f653cb"; // Freelance statements log: one row per earnings statement, keyed by Ref. const STATEMENTS_DB = "7be5ab85fbef4fc1b60e0efb911472c6"; // ⚙️ SnapZilla Settings (shared with the integration) — the rota's tunables // database also carries the stock unit costs, editable in Notion. const SETTINGS_DB = "7240222cb79347b48f64cd55aa3f2bcc"; // Equipment Check In/Out — feeds the Kit status tab. The Kits database is // resolved by title search at read time (same trick the Kit Checker uses), // so there's no hardcoded id to go stale. const EQUIP_DB = "cd21e16232c94a46a58d05efa4a1a324"; // Stock system (Stock Feature Spec v1.0, 3 July 2026). Create both databases // in the SnapZilla Data Entry & Stock teamspace, share them with the // integration, then paste the IDs here (or set STOCK_ITEMS_DB_ID / // STOCK_MOVEMENTS_DB_ID env vars on the Reports Vercel project). Until then // the Stock tab replies 'stock-not-configured'. const STOCK_ITEMS_DB = process.env.STOCK_ITEMS_DB_ID || "REPLACE_STOCK_ITEMS_DB_ID"; const STOCK_MOVES_DB = process.env.STOCK_MOVEMENTS_DB_ID || "REPLACE_STOCK_MOVEMENTS_DB_ID"; const PENTHOUSE = "Penthouse (Office)"; const stockReady = () => !/^REPLACE_/.test(STOCK_ITEMS_DB) && !/^REPLACE_/.test(STOCK_MOVES_DB); function stockMoveRow(pg) { const p = pg.properties || {}; const txt = arr => (arr || []).map(t => t.plain_text).join(""); return { id: pg.id, item: txt(p.Item?.rich_text), type: p.Type?.select?.name || "", qty: p.Quantity?.number ?? 0, location: txt(p.Location?.rich_text), toLocation: txt(p["To location"]?.rich_text), staff: txt(p.Staff?.rich_text), when: p.When?.date?.start || "", note: txt(p.Note?.rich_text), flag: txt(p.Flag?.rich_text), photos: (p.Photo?.files || []).map(f => ({ name: f.name, url: f.file?.url || f.external?.url || "" })), }; } // COUNT and FLAG rows are records, not movements — never move a level. function stockMoveEffect(m, item, location) { if (m.item !== item) return 0; const q = Math.abs(m.qty || 0); switch (m.type) { case "IN": return m.location === location ? q : 0; case "OUT": case "WASTE": return m.location === location ? -q : 0; case "TRANSFER": if (m.location === location && m.toLocation === location) return 0; // pool-internal return m.location === location ? -q : (m.toLocation === location ? q : 0); case "ADJUSTMENT": return m.location === location ? (/adj-remove/.test(m.flag) ? -q : q) : 0; default: return 0; } } const NOTION_VERSION = "2022-06-28"; // Night-key separator must match the Notion "Night key" formula EXACTLY: // date + " — " + Staff name (space, EM DASH U+2014, space). const NIGHT_SEP = " — "; export default async function handler(req, res) { if (req.method !== "POST") { return res.status(405).json({ error: "Method not allowed" }); } const token = process.env.NOTION_TOKEN; const adminPw = process.env.ADMIN_PASSWORD; if (!token) return res.status(500).json({ error: "NOTION_TOKEN not configured" }); if (!adminPw) return res.status(500).json({ error: "ADMIN_PASSWORD not configured" }); let body = req.body; if (typeof body === "string") { try { body = JSON.parse(body); } catch { return res.status(400).json({ error: "Bad JSON" }); } } body = body || {}; // --- password gate (server-side, constant-ish time) --- // Failed-attempt limiter: 20 wrong passwords per IP per 10 minutes. // In-memory per instance (best effort on serverless) — enough to blunt // dumb brute force without ever locking out a fat-fingered owner. const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || 'unknown'; globalThis.__authFails = globalThis.__authFails || {}; const rec = globalThis.__authFails[ip]; if (rec && rec.count >= 20 && Date.now() - rec.first < 10 * 60 * 1000) { return res.status(429).json({ error: 'Too many attempts — try again in a few minutes.' }); } if (!body.password || body.password !== adminPw) { const r = globalThis.__authFails[ip]; if (r && Date.now() - r.first < 10 * 60 * 1000) r.count++; else globalThis.__authFails[ip] = { first: Date.now(), count: 1 }; return res.status(401).json({ error: "Unauthorized" }); } const notion = (path, options = {}) => fetch(`https://api.notion.com/v1/${path}`, { ...options, headers: { Authorization: `Bearer ${token}`, "Notion-Version": NOTION_VERSION, "Content-Type": "application/json", ...(options.headers || {}), }, }); try { if (body.mode === "read") { const entries = await queryAll(notion, SALES_DB); const staff = await queryAll(notion, STAFF_DB); let loginCreds = []; try { loginCreds = await queryAll(notion, STAFF_LOGIN_DB); } catch(e) { console.error('Login creds fetch failed:', e.message); } // Venue rates are optional — if the integration isn't connected to that // DB yet, don't fail the whole load; just return an empty list. let venues = []; try { venues = await queryAll(notion, VENUE_DB); } catch (e) { venues = []; } // Payroll log is optional — only loads if the DB id is set and connected. let payrollLog = []; if (PAYROLL_LOG_DB) { try { payrollLog = await queryAll(notion, PAYROLL_LOG_DB); } catch (e) { payrollLog = []; } } let venueLog = []; if (VENUE_LOG_DB) { try { venueLog = await queryAll(notion, VENUE_LOG_DB); } catch (e) { venueLog = []; } } // Equipment check in/out log — optional. Needs the Equipment Check In/Out // database shared with this integration in Notion, otherwise it loads empty // and the Kit status tab explains what to connect. let equipLog = []; try { equipLog = await queryAll(notion, EQUIP_DB); } catch (e) { equipLog = []; } // Kits list — used to validate the free-text kit codes staff type on the // form. Resolved by title search so unknown codes can be flagged. let kits = []; try { const sr = await notion("search", { method: "POST", body: JSON.stringify({ query: "Kits", filter: { value: "database", property: "object" } }), }); const sd = await sr.json(); const kdb = (sd.results || []).find(d => (d.title?.[0]?.plain_text || "").toLowerCase() === "kits"); if (kdb) kits = await queryAll(notion, kdb.id); } catch (e) { kits = []; } // Compact equipment events + kit metadata for the live "Kits out right // now" strip on the Clock log tab. Camera body per kit is resolved from // the Kits "Equipment items (auto)" relation against the SnapZilla // Equipment database (one query, matched by page id). Best-effort: if // either database isn't shared, the strip simply hides itself. let equip = [], kitMeta = []; try { const etxt = a => (a || []).map(t => t.plain_text).join(""); equip = equipLog.map(pg => { const p = pg.properties || {}; return { created: pg.created_time || "", type: p.Type?.select?.name || "", date: p.Date?.date?.start || "", staff: p["Staff name"]?.select?.name || etxt(p["Staff name"]?.rich_text) || etxt(p["Staff name"]?.title), kit: etxt(p["Kit number/Code"]?.rich_text) || etxt(p["Kit number/Code"]?.title), }; }).filter(r => r.type); let eqItems = {}; try { const sr2 = await notion("search", { method: "POST", body: JSON.stringify({ query: "SnapZilla Equipment", filter: { value: "database", property: "object" } }), }); const sd2 = await sr2.json(); const edb = (sd2.results || []).find(d => (d.title?.[0]?.plain_text || "").toLowerCase() === "snapzilla equipment"); if (edb) { const pages = await queryAll(notion, edb.id); for (const pg of pages) { const p = pg.properties || {}; eqItems[pg.id] = { name: etxt(p.Item?.title), brand: etxt(p.Brand?.rich_text), model: etxt(p.Model?.rich_text), category: p.Category?.select?.name || "", }; } } } catch (e) { eqItems = {}; } kitMeta = kits.map(pg => { const p = pg.properties || {}; let name = ""; for (const k in p) { if (p[k]?.type === "title") { name = etxt(p[k].title).trim(); break; } } const rels = (p["Equipment items (auto)"]?.relation || []).map(r => eqItems[r.id]).filter(Boolean); const cam = rels.find(i => /camera/i.test(i.category)) || rels.find(i => /\b(body|camera)\b/i.test(i.name + " " + i.model)); return { name, flagged: p["Flagged"]?.checkbox === true, flagNote: etxt(p["Flag note"]?.rich_text), body: cam ? ([cam.brand, cam.model].filter(Boolean).join(" ") || cam.name) : "", }; }).filter(k => k.name); } catch (e) { equip = []; kitMeta = []; } // Stock unit costs (COGS) — from the Settings database. The Notion // value always wins when present; blanks fall back to the code // defaults so a blank cell can never zero a cost silently... except // Magnet, whose default IS 0 until Ashleigh fills it in. let stockCosts = { keyring: 0.60, dsKeyring: 0.60, magnet: 0 }; let vatPct = 11; try { const t = arr => (arr || []).map(x => x.plain_text).join(""); const rows = await queryAll(notion, SETTINGS_DB); const map = {}; for (const pg of rows) { const p = pg.properties || {}; let name = ""; for (const k of Object.keys(p)) { if (p[k]?.type === "title") { name = t(p[k].title).trim(); break; } } const val = t(p.Value?.rich_text).trim(); if (name && val !== "" && isFinite(Number(val))) map[name] = Number(val); } const pick = (k, f) => (k in map) ? map[k] : f; stockCosts = { keyring: pick("Keyring unit cost", 0.60), dsKeyring: pick("DS keyring unit cost", 0.60), magnet: pick("Magnet unit cost", 0), }; vatPct = pick("VAT rate (%)", 11); } catch (e) { /* Settings unreachable → code defaults */ } return res.status(200).json({ ok: true, entries, staff, venues, payrollLog, venueLog, loginCreds, equipLog, kits, equip, kitMeta, stockCosts, vatPct }); } if (body.mode === "link") { const { pageId, staffPageId } = body; if (!pageId || !staffPageId) { return res.status(400).json({ error: "pageId and staffPageId required" }); } const r = await notion(`pages/${pageId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Staff (rate)": { relation: [{ id: staffPageId }] } }, }), }); const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Link failed" }); return res.status(200).json({ ok: true }); } if (body.mode === "autoLink") { // Batch-link multiple entries to their staff rate pages. // pairs: [{ pageId, staffPageId }, ...] const { pairs } = body; if (!Array.isArray(pairs) || !pairs.length) { return res.status(400).json({ error: "pairs array required" }); } const results = await Promise.all(pairs.map(async ({ pageId, staffPageId }) => { try { const r = await notion(`pages/${pageId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Staff (rate)": { relation: [{ id: staffPageId }] } }, }), }); const data = await r.json(); return { pageId, ok: r.ok, error: r.ok ? null : (data.message || "failed") }; } catch(err) { return { pageId, ok: false, error: err.message }; } })); return res.status(200).json({ ok: true, results }); } if (body.mode === "getPage") { // Fetch a single page directly — returns freshest formula/rollup values. const { pageId } = body; if (!pageId) return res.status(400).json({ error: "pageId required" }); const r = await notion(`pages/${pageId}`); const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Fetch failed" }); return res.status(200).json({ ok: true, id: data.id, properties: data.properties }); } if (body.mode === "setCommission") { const { pageId } = body; let { amount } = body; if (!pageId) return res.status(400).json({ error: "pageId required" }); // amount: a number to set, or null/empty to clear the field let numberValue = null; if (amount !== null && amount !== undefined && amount !== "") { numberValue = Number(amount); if (!isFinite(numberValue)) { return res.status(400).json({ error: "amount must be a number" }); } } const r = await notion(`pages/${pageId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Manual commission (£)": { number: numberValue } }, }), }); const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Update failed" }); return res.status(200).json({ ok: true }); } if (body.mode === "setExtraHours") { const { pageId } = body; let { hours } = body; if (!pageId) return res.status(400).json({ error: "pageId required" }); // hours: a number to set, or null/empty to clear the field let numberValue = null; if (hours !== null && hours !== undefined && hours !== "") { numberValue = Number(hours); if (!isFinite(numberValue)) { return res.status(400).json({ error: "hours must be a number" }); } if (numberValue < 0) { return res.status(400).json({ error: "hours cannot be negative" }); } } const r = await notion(`pages/${pageId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Extra hours": { number: numberValue } }, }), }); const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Update failed" }); return res.status(200).json({ ok: true }); } if (body.mode === "setPaid") { // Mark a PAYE payroll period paid (or unpaid) for one person. // Update-or-create a Payroll log row keyed by Ref. // body: { ref, person, period, totalPaid, paid (bool) } if (!PAYROLL_LOG_DB) { return res.status(400).json({ error: "Payroll log database not configured. Paste the Payroll log DB id into proxy.js (PAYROLL_LOG_DB)." }); } const { ref, person, period, totalPaid } = body; const paid = body.paid !== false; // default true if (!ref) return res.status(400).json({ error: "ref required" }); // Find an existing row with this Ref (title equals ref). const q = await notion(`databases/${PAYROLL_LOG_DB}/query`, { method: "POST", body: JSON.stringify({ filter: { property: "Ref", title: { equals: ref } }, page_size: 1, }), }); const qData = await q.json(); if (!q.ok) return res.status(q.status).json({ error: qData.message || "Payroll log query failed" }); const props = { "Ref": { title: [{ text: { content: ref } }] }, "Person": { rich_text: [{ text: { content: String(person || "") } }] }, "Period": { rich_text: [{ text: { content: String(period || "") } }] }, "Total paid": { number: (totalPaid != null && isFinite(Number(totalPaid))) ? Number(totalPaid) : null }, "Paid": { checkbox: !!paid }, "Paid date": paid ? { date: { start: new Date().toISOString().slice(0,10) } } : { date: null }, }; let r, data; if (qData.results && qData.results.length) { const pageId = qData.results[0].id; r = await notion(`pages/${pageId}`, { method: "PATCH", body: JSON.stringify({ properties: props }) }); } else { r = await notion(`pages`, { method: "POST", body: JSON.stringify({ parent: { database_id: PAYROLL_LOG_DB }, properties: props }), }); } data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Save failed" }); return res.status(200).json({ ok: true, ref, paid }); } if (body.mode === "setShiftPaid") { // Toggle the Paid checkbox on one Sales Entries shift row. // body: { pageId, paid (bool) } const { pageId } = body; const paid = body.paid !== false; // default true if (!pageId) return res.status(400).json({ error: "pageId required" }); const r = await notion(`pages/${pageId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Paid": { checkbox: !!paid } } }), }); const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Update failed" }); return res.status(200).json({ ok: true, pageId, paid }); } if (body.mode === "setShiftsPaid") { // Batch toggle the Paid checkbox on many Sales Entries rows. // body: { pageIds: [...], paid (bool) } const { pageIds } = body; const paid = body.paid !== false; // default true if (!Array.isArray(pageIds) || !pageIds.length) { return res.status(400).json({ error: "pageIds array required" }); } const results = await Promise.all(pageIds.map(async (pageId) => { try { const r = await notion(`pages/${pageId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Paid": { checkbox: !!paid } } }), }); const data = await r.json(); return { pageId, ok: r.ok, error: r.ok ? null : (data.message || "failed") }; } catch(err) { return { pageId, ok: false, error: err.message }; } })); return res.status(200).json({ ok: true, paid, results }); } if (body.mode === "saveVenueCommission") { // Log a venue commission run. Update-or-create by Ref in the Venue commission log DB. // body: { ref, venue, period, net, pct, owed, paid } if (!VENUE_LOG_DB) { return res.status(400).json({ error: "Venue commission log database not configured. Paste the Venue log DB id into proxy.js (VENUE_LOG_DB)." }); } const { ref, venue, period, net, pct, owed } = body; const paid = body.paid === true; // default false (recording the run, not necessarily paid) if (!ref) return res.status(400).json({ error: "ref required" }); const q = await notion(`databases/${VENUE_LOG_DB}/query`, { method: "POST", body: JSON.stringify({ filter: { property: "Ref", title: { equals: ref } }, page_size: 1 }), }); const qData = await q.json(); if (!q.ok) return res.status(q.status).json({ error: qData.message || "Venue log query failed" }); const num = v => (v != null && isFinite(Number(v))) ? Number(v) : null; const props = { "Ref": { title: [{ text: { content: ref } }] }, "Venue": { rich_text: [{ text: { content: String(venue || "") } }] }, "Period": { rich_text: [{ text: { content: String(period || "") } }] }, "Net revenue": { number: num(net) }, "Commission %": { number: num(pct) }, "Commission owed": { number: num(owed) }, "Paid": { checkbox: !!paid }, "Logged date": { date: { start: new Date().toISOString().slice(0,10) } }, }; let r; if (qData.results && qData.results.length) { r = await notion(`pages/${qData.results[0].id}`, { method: "PATCH", body: JSON.stringify({ properties: props }) }); } else { r = await notion(`pages`, { method: "POST", body: JSON.stringify({ parent: { database_id: VENUE_LOG_DB }, properties: props }) }); } const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Save failed" }); return res.status(200).json({ ok: true, ref }); } if (body.mode === "updateStaffPassword") { const { pageId, password } = body; if (!pageId || !password) return res.status(400).json({ error: "pageId and password required" }); const r = await notion("pages/" + pageId, { method: "PATCH", body: JSON.stringify({ properties: { Password: { rich_text: [{ text: { content: password } }] } } }) }); const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Update failed" }); return res.status(200).json({ ok: true }); } // Email template library (SnapZilla Email Templates in Notion) — the // copy is DATA, editable in Reports or directly in Notion, no deploys. if (body.mode === "emailTemplates") { try { const t = arr => (arr || []).map(x => x.plain_text).join(""); const pages = await queryAll(notion, EMAIL_TEMPLATES_DB); const templates = pages.map(pg => { const p = pg.properties || {}; let name = ""; for (const k of Object.keys(p)) { if (p[k]?.type === "title") { name = t(p[k].title).trim(); break; } } return { id: pg.id, name, desc: t(p.Description?.rich_text), subject: t(p.Subject?.rich_text), bodyText: t(p.Body?.rich_text), sort: p.Sort?.number ?? 99, active: p.Active?.checkbox === true }; }).filter(x => x.name && x.active).sort((a, b) => a.sort - b.sort); return res.status(200).json({ ok: true, templates }); } catch (e) { return res.status(502).json({ error: e.message }); } } if (body.mode === "emailTemplateSave") { const { templateId, subject, bodyText } = body; if (!templateId || typeof subject !== "string" || typeof bodyText !== "string") { return res.status(400).json({ error: "templateId, subject, and bodyText are required" }); } try { const r = await notion(`pages/${templateId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Subject": { rich_text: [{ text: { content: subject.slice(0, 300) } }] }, "Body": { rich_text: [{ text: { content: bodyText.slice(0, 1900) } }] }, }})}); if (!r.ok) { const d = await r.json(); return res.status(502).json({ error: d.message || "save failed" }); } return res.status(200).json({ ok: true }); } catch (e) { return res.status(502).json({ error: e.message }); } } // emailStaffList: legal names + email addresses from Staff Pay Rates, // for the Emails tab's recipient picker. Read-only; the actual send // rides the existing sendEmail mode one recipient at a time. if (body.mode === "emailStaffList") { try { const t = arr => (arr || []).map(x => x.plain_text).join(""); const pages = await queryAll(notion, STAFF_DB); const staff = pages.map(pg => { const p = pg.properties || {}; let name = ""; for (const k of Object.keys(p)) { if (p[k]?.type === "title") { name = t(p[k].title).trim(); break; } } return { name, email: (p.Email?.email || "").trim() }; }).filter(s => s.name).sort((a, b) => a.name.localeCompare(b.name)); return res.status(200).json({ ok: true, staff }); } catch (e) { return res.status(502).json({ error: e.message }); } } if (body.mode === "sendEmail") { // Send a pre-rendered HTML email via Google Workspace SMTP. // Requires env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM // body: { to, subject, htmlBody } // // Safety rules: // - Recipient must be a non-empty string with an @ (basic sanity check) // - Subject and htmlBody must be non-empty strings // - SMTP credentials are read from env, never from the request body // - This is the highest-stakes mode — it sends an irreversible email. // The browser MUST show a preview + confirm dialog before calling this. const smtpHost = process.env.SMTP_HOST; const smtpPort = parseInt(process.env.SMTP_PORT || "465", 10); const smtpUser = process.env.SMTP_USER; const smtpPass = process.env.SMTP_PASS; const smtpFrom = process.env.SMTP_FROM; if (!smtpHost || !smtpUser || !smtpPass || !smtpFrom) { return res.status(400).json({ error: "Email not configured. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM in Vercel environment variables.", }); } const { to, subject, htmlBody, fromOverride } = body; const effectiveFrom = (fromOverride && typeof fromOverride === 'string' && fromOverride.includes('@')) ? fromOverride : smtpFrom; if (!to || typeof to !== "string" || !to.includes("@")) { return res.status(400).json({ error: "to must be a valid email address" }); } if (!subject || typeof subject !== "string" || !subject.trim()) { return res.status(400).json({ error: "subject is required" }); } if (!htmlBody || typeof htmlBody !== "string" || !htmlBody.trim()) { return res.status(400).json({ error: "htmlBody is required" }); } // Determine connection security. Google Workspace typically uses: // port 465 → implicit TLS (secure: true) // port 587 → STARTTLS (secure: false, requireTLS: true) const useImplicitTLS = smtpPort === 465; const transporter = nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: useImplicitTLS, requireTLS: !useImplicitTLS, auth: { user: smtpUser, pass: smtpPass }, }); try { const info = await transporter.sendMail({ from: effectiveFrom, to: to.trim(), subject: subject.trim(), html: htmlBody, }); return res.status(200).json({ ok: true, messageId: info.messageId }); } catch (smtpErr) { // Don't expose internal SMTP detail; log server-side, return safe message. console.error("SMTP error:", smtpErr); return res.status(502).json({ error: "Email send failed: " + (smtpErr.message || "SMTP error") }); } } if (body.mode === "saveStatement") { // Log a freelance earnings statement. Update-or-create by Ref in the Freelance Statements DB. // body: { ref, for, statementDate, lineSubtotal, roundingAdj, cameraRental, totalPaid, // emailedTo (optional), emailedAt (optional, ISO date string) } if (!STATEMENTS_DB) { return res.status(400).json({ error: "Freelance Statements database not configured." }); } const { ref, statementDate, lineSubtotal, roundingAdj, cameraRental, totalPaid, emailedTo, emailedAt } = body; const forName = body["for"] || ""; if (!ref) return res.status(400).json({ error: "ref required" }); const q = await notion(`databases/${STATEMENTS_DB}/query`, { method: "POST", body: JSON.stringify({ filter: { property: "Ref", title: { equals: ref } }, page_size: 1 }), }); const qData = await q.json(); if (!q.ok) return res.status(q.status).json({ error: qData.message || "Statements query failed" }); const num = v => (v != null && isFinite(Number(v))) ? Number(v) : null; const props = { "Ref": { title: [{ text: { content: ref } }] }, "For": { rich_text: [{ text: { content: String(forName) } }] }, "Statement date": statementDate ? { date: { start: statementDate } } : { date: null }, "Line subtotal (£)": { number: num(lineSubtotal) }, "Rounding adjustment (£)": { number: num(roundingAdj) }, "Camera rental total (£)": { number: num(cameraRental) }, "Total paid (£)": { number: num(totalPaid) }, "Status": { select: { name: emailedTo ? "Sent" : "Draft" } }, }; if (emailedTo) props["Emailed to"] = { rich_text: [{ text: { content: String(emailedTo) } }] }; if (emailedAt) props["Emailed at"] = { date: { start: emailedAt } }; let r; if (qData.results && qData.results.length) { r = await notion(`pages/${qData.results[0].id}`, { method: "PATCH", body: JSON.stringify({ properties: props }) }); } else { r = await notion(`pages`, { method: "POST", body: JSON.stringify({ parent: { database_id: STATEMENTS_DB }, properties: props }) }); } const data = await r.json(); if (!r.ok) return res.status(r.status).json({ error: data.message || "Save failed" }); return res.status(200).json({ ok: true, ref }); } if (body.mode === "rebuildNights") { return await rebuildNights(notion, body, res); } if (body.mode === "clockLog") { // Everything from the Clock Log within the window, newest first. const days = Math.min(365, Math.max(1, Number(body.days) || 60)); const cutoff = new Date(Date.now() - days * 24 * 3600e3).toISOString(); const rows = []; let cursor = undefined; do { const r = await notion(`databases/${CLOCK_DB}/query`, { method: "POST", body: JSON.stringify({ page_size: 100, filter: { property: "Clock in", date: { on_or_after: cutoff } }, sorts: [{ property: "Clock in", direction: "descending" }], ...(cursor ? { start_cursor: cursor } : {}), }), }); const data = await r.json(); if (!r.ok) throw new Error(data.message || "Clock Log query failed"); rows.push(...(data.results || [])); cursor = data.has_more ? data.next_cursor : undefined; } while (cursor); const txt = arr => (arr || []).map(t => t.plain_text).join(""); const fileUrl = p => { const f = (p?.files || [])[0]; return f?.file?.url || f?.external?.url || null; }; const sessions = rows.map(pg => { const p = pg.properties || {}; return { id: pg.id, notionUrl: pg.url, staff: txt(p.Staff?.rich_text), venue: txt(p.Venue?.rich_text), clockIn: p["Clock in"]?.date?.start || null, clockOut: p["Clock out"]?.date?.start || null, withinFence: p["Within fence"]?.checkbox === true, noLocation: p["No location"]?.checkbox === true, distance: p["Distance (m)"]?.number ?? null, adjusted: p.Adjusted?.checkbox === true, adjustmentNote: txt(p["Adjustment note"]?.rich_text), originalIn: p["Original clock in"]?.date?.start || null, originalOut: p["Original clock out"]?.date?.start || null, photoIn: fileUrl(p["Clock-in photo"]), photoOut: fileUrl(p["Clock-out photo"]), breaks: txt(p.Breaks?.rich_text), breakMinutes: p["Break minutes"]?.number ?? 0, }; }); return res.status(200).json({ sessions, days }); } // ── Manual clock re-sync for one shift (owner-triggered) ────────── // The automatic triple (reconcile at submission, at clock-out, and at // adjustment) covers every ORDER of events — but not every EDIT. If an // entry's date was wrong at submission (the classic: submitted after // midnight with the form date defaulting to "today", pushing Start time // ~18 hours into the future), the clock-out reconcile can't see it, and // correcting the date afterwards re-triggers nothing. This mode is the // heal button: given one Sales Entry, find the person's closed clock // session for that night and restamp the whole night with the exact // same partition maths as reconcileNight. Idempotent; overwrites the // clocked fields only, never commission. if (body.mode === "reconcileEntry") { const pageId = String(body.pageId || ""); if (!pageId) return res.status(400).json({ ok: false, error: "Missing pageId" }); const txt = a => (a || []).map(t => t.plain_text).join(""); const clean = s => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim(); // 1. The clicked entry. const egRes = await notion(`pages/${pageId}`, {}); const eg = await egRes.json(); if (!egRes.ok) return res.status(404).json({ ok: false, error: "Entry not found" }); const eP = eg.properties || {}; const entryStaff = txt(eP["Staff name"]?.rich_text); const entryStart = eP["Start time"]?.date?.start ? new Date(eP["Start time"].date.start).getTime() : null; const entryFinish = eP["Finish time"]?.date?.start ? new Date(eP["Finish time"].date.start).getTime() : null; if (!entryStaff || entryStart == null || entryFinish == null) return res.status(200).json({ ok: false, error: "Entry has no staff name or Start/Finish times — fix those in Notion first." }); // 2. Candidate CLOSED sessions for this person, ±26h around the // entry's Start time (wide enough to catch a date-mangled entry // sitting a day away from its true session). const sRes = await notion(`databases/${CLOCK_DB}/query`, { method: "POST", body: JSON.stringify({ filter: { and: [ { property: "Clock in", date: { on_or_after: new Date(entryStart - 26 * 3600e3).toISOString() } }, { property: "Clock in", date: { on_or_before: new Date(entryStart + 26 * 3600e3).toISOString() } }, ]}, sorts: [{ property: "Clock in", direction: "ascending" }], page_size: 100, }), }); const sData = await sRes.json(); if (!sRes.ok) return res.status(502).json({ ok: false, error: sData.message || "Clock query failed" }); // Legal names only (nickname system removed 4 July 2026): the clock // row's Staff and the entry's Staff name are both legal names, so // matching is plain equality after normalisation — lowercase, trimmed, // accents stripped (Zoë ≡ Zoe). If a session doesn't match, fix the // name at the source (Staff Login Credentials), not in code. const namesMatch = (sessionStaff, entryName) => { const cs = clean(sessionStaff), ce = clean(entryName); return !!cs && cs === ce; }; const sessions = (sData.results || []).map(pg => { const p = pg.properties || {}; return { staff: txt(p.Staff?.rich_text), venue: txt(p.Venue?.rich_text), cin: p["Clock in"]?.date?.start ? new Date(p["Clock in"].date.start).getTime() : null, cout: p["Clock out"]?.date?.start ? new Date(p["Clock out"].date.start).getTime() : null, }; }).filter(s => { if (s.cin == null || s.cout == null || !(s.cout > s.cin)) return false; // closed sessions only return namesMatch(s.staff, entryStaff); }); if (!sessions.length) return res.status(200).json({ ok: false, error: `No closed clock session found for ${entryStaff} within a day of this entry. If they never clocked out, close the session in the Hub first (adjust clock), then re-sync.` }); // 3. Best session: greatest overlap with the entry's selling window; // when nothing overlaps (mangled dates), nearest clock-in wins. const overlap = s => Math.max(0, Math.min(s.cout, entryFinish) - Math.max(s.cin, entryStart)); sessions.sort((a, b) => (overlap(b) - overlap(a)) || (Math.abs(a.cin - entryStart) - Math.abs(b.cin - entryStart))); const ses = sessions[0]; const entryOverlapsSession = overlap(ses) > 0; // 4. The person's entries in the session window (reconcileNight's // filter), force-including the clicked entry even if its Start // time sits outside the window (that is the case being healed). const wRes = await notion(`databases/${SALES_DB}/query`, { method: "POST", body: JSON.stringify({ filter: { and: [ { or: [ { property: "Staff name", rich_text: { equals: entryStaff } }, { property: "Staff name", rich_text: { equals: ses.staff } }, ]}, { property: "Start time", date: { on_or_after: new Date(ses.cin - 3 * 3600e3).toISOString() } }, { property: "Start time", date: { on_or_before: new Date(ses.cout).toISOString() } }, ]}, sorts: [{ property: "Start time", direction: "ascending" }], page_size: 100, }), }); const wData = await wRes.json(); let entries = (wData.results || []).map(pg => ({ id: pg.id, start: pg.properties?.["Start time"]?.date?.start ? new Date(pg.properties["Start time"].date.start).getTime() : null, finish: pg.properties?.["Finish time"]?.date?.start ? new Date(pg.properties["Finish time"].date.start).getTime() : null, })).filter(e => e.start != null && e.finish != null); if (!entries.some(e => e.id === pageId)) entries.push({ id: pageId, start: entryStart, finish: entryFinish }); entries.sort((a, b) => a.start - b.start); // 5. Partition and stamp — identical maths to reconcileNight. const r2 = n => Math.round(n * 100) / 100; const stamped = []; for (let i = 0; i < entries.length; i++) { const partStart = i === 0 ? ses.cin : entries[i - 1].finish; const partEnd = i === entries.length - 1 ? ses.cout : entries[i].finish; const clockedH = Math.max(0, (partEnd - partStart) / 3600e3); const sellingH = Math.max(0, (entries[i].finish - entries[i].start) / 3600e3); const extraH = Math.max(0, clockedH - sellingH); const pr = await notion(`pages/${entries[i].id}`, { method: "PATCH", body: JSON.stringify({ properties: { "Clocked in at": { date: { start: new Date(partStart).toISOString(), end: null } }, "Clocked out at": { date: { start: new Date(partEnd).toISOString(), end: null } }, "Clocked hours": { number: r2(clockedH) }, "Extra hours": { number: r2(extraH) }, }}), }); if (pr.ok) stamped.push({ id: entries[i].id, clockedHours: r2(clockedH), extraHours: r2(extraH) }); } return res.status(200).json({ ok: true, stamped, session: { staff: ses.staff, venue: ses.venue, in: new Date(ses.cin).toISOString(), out: new Date(ses.cout).toISOString() }, warning: entryOverlapsSession ? null : "This entry's Start/Finish datetimes don't overlap the clock session (probably still carrying the wrong date). Hours are stamped correctly, but fix the entry's Start time and Finish time datetimes in Notion so future windows and reports line up.", }); } // ── STOCK TAB (read-only mirror of hub.snapzilla.app/stock.html) ── if (typeof body.mode === "string" && body.mode.startsWith("stock")) { if (!stockReady()) return res.status(200).json({ ok: false, error: "stock-not-configured" }); const stockAll = async (filter, sorts) => { const out = []; let cursor; do { const r = await notion(`databases/${STOCK_MOVES_DB}/query`, { method: "POST", body: JSON.stringify({ page_size: 100, ...(filter ? { filter } : {}), ...(sorts ? { sorts } : {}), ...(cursor ? { start_cursor: cursor } : {}) }), }); const data = await r.json(); if (!r.ok) throw new Error(data.message || "Stock query failed"); out.push(...(data.results || [])); cursor = data.has_more ? data.next_cursor : undefined; } while (cursor); return out.map(stockMoveRow); }; if (body.mode === "stockBoard") { const txt = arr => (arr || []).map(t => t.plain_text).join(""); const itemPages = await queryAll(notion, STOCK_ITEMS_DB); const items = itemPages.map(pg => { const p = pg.properties || {}; let name = ""; for (const k of Object.keys(p)) { if (p[k]?.type === "title") { name = txt(p[k].title); break; } } return { name, unit: txt(p.Unit?.rich_text), vThr: p["Venue threshold"]?.number ?? null, pThr: p["Penthouse threshold"]?.number ?? null, counted: p.Counted?.checkbox === true, active: p.Active?.checkbox === true, }; }).filter(i => i.name && i.active) .sort((a, b) => { const O = ['Keyring fronts','Keyring backs','Fridge magnets','Rechargeable Battery x 4','Ink (ribbon)','Paper (stack)','Disposable Batteries x4','SD cards','Logo backs','Pens','Die cut']; const r = n => { const i = O.indexOf(n); return i !== -1 ? i : 6.5; }; return r(a.name) - r(b.name); }); const rawMoves = await stockAll(); // Stock pools: venues sharing a "Stock group" in the Venues DB fold // into one column, mirroring the Hub board exactly. const poolMap = {}; try { const vp = await queryAll(notion, '518a2864647847d69ff45fcf0313bab5'); for (const pg of vp) { const p = pg.properties || {}; let nm = ''; for (const k of Object.keys(p)) { if (p[k]?.type === 'title') { nm = txt(p[k].title).trim(); break; } } const grp = txt(p['Stock group']?.rich_text).trim(); if (nm && grp) poolMap[nm] = grp; } } catch (e) {} const pOf = l => (l && poolMap[l]) || l || ''; const moves = Object.keys(poolMap).length ? rawMoves.map(m => ({ ...m, location: pOf(m.location), toLocation: pOf(m.toLocation) })) : rawMoves; const locations = [...new Set(moves.flatMap(m => [m.location, m.toLocation]).filter(Boolean))] .filter(l => l !== PENTHOUSE).sort().concat([PENTHOUSE]); const levels = {}; for (const it of items) { levels[it.name] = {}; for (const loc of locations) levels[it.name][loc] = moves.reduce((s, m) => s + stockMoveEffect(m, it.name, loc), 0); } const lows = moves.filter(m => /low-open/.test(m.flag) && !/cleared/.test(m.flag)) .map(m => ({ item: m.item, location: m.location, by: m.staff, when: m.when })); const pInk = levels["Ink (ribbon)"]?.[PENTHOUSE] ?? 0, pPaper = levels["Paper (stack)"]?.[PENTHOUSE] ?? 0; return res.status(200).json({ ok: true, items, locations, levels, lows, penthouseBoxes: { ink: pInk, paper: pPaper, boxes: Math.min(Math.floor(pInk / 3), Math.floor(pPaper / 6)) } }); } if (body.mode === "stockLog") { const days = Math.min(365, Math.max(1, Number(body.days) || 30)); const moves = await stockAll( { property: "When", date: { on_or_after: new Date(Date.now() - days * 24 * 3600e3).toISOString() } }, [{ property: "When", direction: "descending" }]); return res.status(200).json({ ok: true, moves: moves.slice(0, 500) }); } if (body.mode === "stockWastage") { const days = Math.min(365, Math.max(1, Number(body.days) || 30)); const moves = await stockAll({ and: [ { property: "Type", select: { equals: "WASTE" } }, { property: "When", date: { on_or_after: new Date(Date.now() - days * 24 * 3600e3).toISOString() } }, ]}, [{ property: "When", direction: "descending" }]); return res.status(200).json({ ok: true, waste: moves }); } if (body.mode === "stockMismatch") { const counts = (await stockAll({ property: "Type", select: { equals: "COUNT" } }, [{ property: "When", direction: "descending" }])).map(m => { const vm = /variance:(-?\d+)/.exec(m.note || ""); return { ...m, variance: vm ? Number(vm[1]) : 0, resolved: /resolved/.test(m.flag) }; }); return res.status(200).json({ ok: true, counts: counts.slice(0, 300) }); } if (body.mode === "stockCashup") { const days = Math.min(30, Math.max(1, Number(body.days) || 3)); const sinceIso = new Date(Date.now() - days * 24 * 3600e3).toISOString(); const txt = arr => (arr || []).map(t => t.plain_text).join(""); const salesRows = []; let cursor; do { const r = await notion(`databases/${SALES_DB}/query`, { method: "POST", body: JSON.stringify({ page_size: 100, filter: { property: "Start time", date: { on_or_after: sinceIso } }, sorts: [{ property: "Start time", direction: "descending" }], ...(cursor ? { start_cursor: cursor } : {}) }), }); const data = await r.json(); if (!r.ok) throw new Error(data.message || "Sales query failed"); salesRows.push(...(data.results || [])); cursor = data.has_more ? data.next_cursor : undefined; } while (cursor); const moves = await stockAll({ property: "When", date: { on_or_after: sinceIso } }); const recSet = new Set(moves.filter(m => /cashup-received/.test(m.flag)).map(m => m.note)); const endChecks = moves.filter(m => /check-end/.test(m.flag)); const sdReuse = moves.filter(m => /sd-reuse/.test(m.flag)); const lines = salesRows.map(pg => { const p = pg.properties || {}; const staff = txt(p["Staff name"]?.rich_text), venue = txt(p.Venue?.rich_text); const start = p["Start time"]?.date?.start || ""; const sd = txt(p["SD card"]?.rich_text); const batteryTaps = moves.filter(m => /battery-tap|battery-check/.test(m.flag)); const battery = endChecks.some(m => m.staff === staff && m.location === venue && /batteries:rechargeable/.test(m.note) && Math.abs(new Date(m.when) - new Date(start)) < 14 * 3600e3) || batteryTaps.some(m => m.staff === staff && m.location === venue && Math.abs(new Date(m.when) - new Date(start)) < 14 * 3600e3); return { salesId: pg.id, staff, venue, start, sd, cash: p["Total cash (£)"]?.number ?? 0, battery, sdReused: sdReuse.some(m => m.location === venue && String(m.qty) === String(sd).trim() && Math.abs(new Date(m.when) - new Date(start)) < 14 * 3600e3), recSd: recSet.has(pg.id + "|sd"), recCash: recSet.has(pg.id + "|cash"), recBattery: recSet.has(pg.id + "|battery"), }; }); return res.status(200).json({ ok: true, lines }); } if (body.mode === "stockCats") { const rows = await stockAll({ property: "Type", select: { equals: "FLAG" } }, [{ property: "When", direction: "descending" }]); let total = 0, latest = null; for (const m of rows) { const mm = /cats:(\d+)/.exec(m.note || ""); const n = mm ? Number(mm[1]) : 0; if (n > 0) { total += n; if (!latest) latest = { n, staff: m.staff, venue: m.location, when: m.when }; } } return res.status(200).json({ ok: true, total, latest }); } } return res.status(400).json({ error: "Unknown mode" }); } catch (e) { return res.status(500).json({ error: e.message || "Server error" }); } } // --------------------------------------------------------------------------- // rebuildNights // --------------------------------------------------------------------------- async function rebuildNights(notion, body, res) { const confirm = body.confirm === true; const entries = await queryAll(notion, SALES_DB); const nights = await queryAll(notion, NIGHTS_DB); const nightByKey = new Map(); for (const n of nights) { const key = titleText(n.properties).trim(); if (key) nightByKey.set(key, n.id); } const skipped = []; const toLink = []; const keysNeeded = new Set(); for (const e of entries) { const props = e.properties || {}; const start = dateStart(props["Start time"]); const staff = richText(props["Staff name"]).trim(); if (!start || !staff) { skipped.push({ id: e.id, reason: !start ? "no Start time" : "no Staff name" }); continue; } const key = nightKey(start, staff); if (!key) { skipped.push({ id: e.id, reason: "could not build key" }); continue; } keysNeeded.add(key); const currentRel = relationIds(props["Night"]); toLink.push({ entryId: e.id, key, currentRel }); } const keysToCreate = [...keysNeeded].filter(k => !nightByKey.has(k)).sort(); const willLink = toLink.filter(t => { const targetId = nightByKey.get(t.key); return !(targetId && t.currentRel.length === 1 && t.currentRel[0] === targetId); }); const plan = { entriesScanned: entries.length, nightsExisting: nights.length, nightsToCreate: keysToCreate.length, nightsToCreateKeys: keysToCreate, entriesToLink: willLink.length, entriesSkipped: skipped.length, skipped, }; if (!confirm) { return res.status(200).json({ ok: true, dryRun: true, plan }); } let created = 0, linked = 0; const errors = []; for (const key of keysToCreate) { try { const r = await notion("pages", { method: "POST", body: JSON.stringify({ parent: { database_id: NIGHTS_DB }, properties: { "Night key": { title: [{ text: { content: key } }] } }, }), }); const data = await r.json(); if (!r.ok) { errors.push({ key, error: data.message || "create failed" }); continue; } nightByKey.set(key, data.id); created++; } catch (err) { errors.push({ key, error: err.message || "create error" }); } } for (const t of toLink) { const targetId = nightByKey.get(t.key); if (!targetId) continue; if (t.currentRel.length === 1 && t.currentRel[0] === targetId) continue; try { const r = await notion(`pages/${t.entryId}`, { method: "PATCH", body: JSON.stringify({ properties: { "Night": { relation: [{ id: targetId }] } }, }), }); const data = await r.json(); if (!r.ok) { errors.push({ entryId: t.entryId, error: data.message || "link failed" }); continue; } linked++; } catch (err) { errors.push({ entryId: t.entryId, error: err.message || "link error" }); } } return res.status(200).json({ ok: errors.length === 0, dryRun: false, result: { nightsCreated: created, entriesLinked: linked, errors, skipped }, }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function nightKey(startIso, staffName) { if (!startIso) return null; const d = new Date(startIso); if (isNaN(d.getTime())) return null; // Notion stores datetimes as UTC. The 6am cutoff is a Europe/London local-time // rule, so we must convert to UK time before applying it. const fmt = new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/London", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }); const parts = Object.fromEntries( fmt.formatToParts(d) .filter(p => p.type !== "literal") .map(p => [p.type, +p.value]) ); let year = parts.year, month = parts.month, day = parts.day; const hour = parts.hour; // local hour in Europe/London (0-23) if (hour < 6) { // Roll back one calendar day — shift belongs to the previous night const rolled = new Date(Date.UTC(year, month - 1, day)); rolled.setUTCDate(rolled.getUTCDate() - 1); year = rolled.getUTCFullYear(); month = rolled.getUTCMonth() + 1; day = rolled.getUTCDate(); } const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; return dateStr + NIGHT_SEP + staffName; } function titleText(props) { if (!props) return ""; for (const k of Object.keys(props)) { const p = props[k]; if (p && p.type === "title") return (p.title || []).map(t => t.plain_text).join(""); } return ""; } function richText(p) { if (!p) return ""; if (p.type === "title") return (p.title || []).map(t => t.plain_text).join(""); if (p.type === "rich_text") return (p.rich_text || []).map(t => t.plain_text).join(""); return ""; } function dateStart(p) { if (!p || p.type !== "date") return null; return p.date?.start || null; } function relationIds(p) { if (!p || p.type !== "relation") return []; return (p.relation || []).map(r => r.id); } async function queryAll(notion, dbId) { const out = []; let cursor = undefined; do { const r = await notion(`databases/${dbId}/query`, { method: "POST", body: JSON.stringify({ page_size: 100, ...(cursor ? { start_cursor: cursor } : {}) }), }); const data = await r.json(); if (!r.ok) throw new Error(data.message || "Notion query failed"); out.push(...(data.results || [])); cursor = data.has_more ? data.next_cursor : undefined; } while (cursor); return out; }