@zeruxjs/reports 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ export function reportDashboard(prefix) {
2
+ const p = JSON.stringify(prefix);
3
+ return `<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Zerux Reports</title><style>
4
+ :root{font:14px/1.5 system-ui,sans-serif;color-scheme:light dark}body{max-width:1280px;margin:auto;padding:24px}h1{margin-bottom:3px}nav{display:flex;flex-wrap:wrap;gap:10px;margin:18px 0}button,input,select,textarea{font:inherit;padding:8px 12px;border:1px solid #9998;border-radius:6px;background:transparent;color:inherit}button{cursor:pointer}input,select,textarea{max-width:100%;box-sizing:border-box}textarea{width:100%;height:200px;font-family:monospace}main{display:grid;grid-template-columns:minmax(230px,1fr) 3fr;gap:18px}aside,article,.chart{padding:15px;border:1px solid #8885;border-radius:10px}aside label{display:block;margin:8px 0}aside input,aside select{width:100%}#charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}.chart{min-height:180px}canvas{width:100%;height:200px}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:5px;border-bottom:1px solid #8885}.hint{color:#888}.warn{color:#b76a00}pre{white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:720px){main{display:block}aside{margin-bottom:12px}}
5
+ </style></head><body><h1>Zerux Reports</h1><p class="hint">Internal analytics reporting • authenticated workspace • page &amp; chart designer</p>
6
+ <nav><button id="reload">Reload</button><button id="newReport">New report</button><button id="newPage">Add page</button><button id="newChart">Add chart</button><button id="save">Save report</button><button id="csv">Export CSV</button><button id="json">Export JSON</button></nav><p class="hint">A saved report is private to your application-authorized tenant. Downloaded exports have their own permission check.</p><section aria-label="Report date filters"><label>From UTC <input id="since" type="date"></label> <label>To UTC <input id="until" type="date"></label> <button id="refreshCharts">Refresh charts</button> <span class="hint">An optional 366-day maximum date window; blank dates use each chart's saved filters.</span></section>
7
+ <main><aside><label>Report<select id="report"></select></label><label>Page<select id="page"></select></label><label>Report title<input id="reportName"></label><label>Page title<input id="pageName"></label>
8
+ <label>Chart<select id="chart"></select></label><label>Chart title<input id="chartTitle"></label>
9
+ <label>Chart type<select id="chartType"><option>bar</option><option>horizontal-bar</option><option>line</option><option>area</option><option>pie</option><option>doughnut</option><option>scatter</option><option>stacked-bar</option><option>table</option><option>kpi</option></select></label>
10
+ <label>Metric<select id="chartMetric"><option>count</option><option>sum</option><option>average</option><option>min</option><option>max</option><option>distinct</option></select></label>
11
+ <label>Group by<input id="chartGroupBy" placeholder="event, day, source, property:plan"></label>
12
+ <label>Numeric value field<input id="chartValueField" placeholder="price"></label><label>Filter event<input id="chartEvent" placeholder="purchase"></label>
13
+ <button id="deleteChart">Delete chart</button>
14
+ <p class="hint">Chart editor (JSON). Types: bar, horizontal-bar, line, area, pie, doughnut, scatter, stacked-bar, table, kpi.</p><textarea id="chartsJson" spellcheck="false"></textarea><button id="applyCharts">Apply charts</button><p><button id="deletePage">Delete page</button></p></aside><article><h2 id="pageTitle">Report preview</h2><p class="hint" id="notice"></p><div id="charts"></div></article></main><section aria-label="Marketing campaign reports"><h2>Campaign analytics</h2><p class="hint">Optional integration. SMTP acceptance is not inbox delivery; tracking events are not unique recipients.</p><label>Campaign code <input id="campaignCode" maxlength="96"></label> <label>Report <select id="campaignKind"><option value="delivery">Recipient delivery page</option><option value="performance">All-time durable states / provider notices</option><option value="daily">Daily outcome trends</option><option value="activity">Observed open / click events</option></select></label> <button id="loadCampaign">Load campaign</button> <button id="nextCampaign">Next recipient page</button> <div id="campaignCharts" aria-live="polite"></div><p id="campaignNotice" class="hint"></p></section><pre role="status" aria-live="polite" id="status"></pre>
15
+ <script>(function(){'use strict';const prefix=${p},$=id=>document.getElementById(id);let doc,ridx=0,pidx=0,cidx=0,campaignCursor=null;
16
+ const say=s=>$('status').textContent=s;
17
+ function opt(select,list,field){select.replaceChildren();list.forEach((v,i)=>{const el=document.createElement('option');el.value=String(i);el.textContent=v[field];select.append(el);});}
18
+ function selected(){return doc&&doc.reports[ridx]}function page(){return selected()&&selected().pages[pidx]}function chart(){return page()?.charts[cidx]}
19
+ async function load(){try{const response=await fetch(prefix+'/api',{credentials:'same-origin',cache:'no-store'});if(!response.ok)throw Error('HTTP '+response.status);doc=await response.json();ridx=0;pidx=0;render();say('Reports loaded');}catch(e){say('Load failed: '+e.message);}}
20
+ function render(){opt($('report'),doc.reports,'name');$('report').value=String(ridx);opt($('page'),selected()?.pages||[],'name');$('page').value=String(pidx);$('reportName').value=selected()?.name||'';$('pageName').value=page()?.name||'';opt($('chart'),page()?.charts||[],'title');$('chart').value=String(cidx);
21
+ for(const [input,key] of [['chartTitle','title'],['chartType','type'],['chartMetric','metric'],['chartGroupBy','groupBy'],['chartValueField','valueField'],['chartEvent','eventName']])$(''+input).value=chart()?.[key]||'';
22
+ $('chartsJson').value=JSON.stringify(page()?.charts||[],null,2);void preview();}
23
+ $('report').onchange=e=>{ridx=Number(e.target.value);pidx=0;cidx=0;render();};$('page').onchange=e=>{pidx=Number(e.target.value);cidx=0;render();};
24
+ $('chart').onchange=e=>{cidx=Number(e.target.value);render();};
25
+ for(const [input,key] of [['chartTitle','title'],['chartType','type'],['chartMetric','metric'],['chartGroupBy','groupBy'],['chartValueField','valueField'],['chartEvent','eventName']])$(''+input).onchange=e=>{if(!chart())return;if(e.target.value)chart()[key]=e.target.value;else delete chart()[key];render();};
26
+ $('deleteChart').onclick=()=>{if(!chart())return;page().charts.splice(cidx,1);cidx=0;render();};
27
+ $('reportName').onchange=e=>{if(selected()){selected().name=e.target.value;render();}};
28
+ $('pageName').onchange=e=>{if(page()){page().name=e.target.value;render();}};
29
+ function uid(prefix){return prefix+'_'+Date.now().toString(36)+'_'+Math.floor(Math.random()*9999);}
30
+ $('newReport').onclick=()=>{doc.reports.push({id:uid('report'),name:'New report',pages:[{id:uid('page'),name:'Overview',charts:[]}]});ridx=doc.reports.length-1;pidx=0;render();};
31
+ $('newPage').onclick=()=>{if(!selected())return;selected().pages.push({id:uid('page'),name:'New page',charts:[]});pidx=selected().pages.length-1;cidx=0;render();};
32
+ $('newChart').onclick=()=>{if(!page())return;page().charts.push({id:uid('chart'),title:'Events',type:'bar',groupBy:'event',metric:'count',limit:100});cidx=page().charts.length-1;render();};
33
+ $('applyCharts').onclick=()=>{try{if(!page())return;page().charts=JSON.parse($('chartsJson').value);cidx=0;render();say('Applied chart definitions (publish to save)');}catch(e){say('Invalid JSON: '+e.message);}};
34
+ $('deletePage').onclick=()=>{if(!selected()||!page())return;selected().pages.splice(pidx,1);pidx=0;render();};
35
+ $('reload').onclick=load;
36
+ $('refreshCharts').onclick=()=>void preview();
37
+ function dateWindow(){const a=$('since').value,b=$('until').value;if(!a&&!b)return null;if(!a||!b)throw Error('Select both UTC dates');const since=Date.parse(a+'T00:00:00.000Z'),until=Date.parse(b+'T23:59:59.999Z');if(!Number.isFinite(since)||!Number.isFinite(until)||until<since||until-since>366*86400000)throw Error('UTC range must be within 366 days');return {since,until};}
38
+ function applyWindow(url){const filter=dateWindow();if(filter){url.searchParams.set('since',String(filter.since));url.searchParams.set('until',String(filter.until));}}
39
+ function exportFile(format){try{if(!page())throw Error('Select a report page');const url=new URL(prefix+'/export',location.origin);url.searchParams.set('report',selected().id);url.searchParams.set('page',page().id);url.searchParams.set('format',format);applyWindow(url);window.location.assign(url.toString());say('Requesting '+format.toUpperCase()+' export (requires export permission)');}catch(e){say('Export unavailable: '+e.message);}}
40
+ $('csv').onclick=()=>exportFile('csv');$('json').onclick=()=>exportFile('json');
41
+ async function loadCampaign(next=false){try{const code=$('campaignCode').value,kind=$('campaignKind').value;if(!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$/.test(code))throw Error('Invalid campaign code');if(!next)campaignCursor=null;const url=new URL(prefix+'/campaign',location.origin);url.searchParams.set('code',code);url.searchParams.set('kind',kind);if(kind==='delivery'&&campaignCursor)url.searchParams.set('cursor',campaignCursor);if(kind==='daily'||kind==='activity'){const a=$('since').value,b=$('until').value;if(!a||!b)throw Error('Choose both campaign UTC dates');url.searchParams.set('from',a);url.searchParams.set('to',b);}const response=await fetch(url,{credentials:'same-origin',cache:'no-store'});const result=await response.json();if(!response.ok)throw Error(result.error||response.status);const board=$('campaignCharts');board.replaceChildren();const values=result.charts?Object.values(result.charts):result.activity?[result.activity]:result.statuses?[result.statuses]:[];for(const item of values){if(!item)continue;const section=document.createElement('section');section.className='chart';const title=document.createElement('h3');title.textContent=item.title;section.append(title);draw(section,item);board.append(section);}campaignCursor=kind==='delivery'?result.coverage.nextCursor:null;$('nextCampaign').disabled=!campaignCursor;$('campaignNotice').textContent=kind==='delivery'?result.coverage.disclaimer+' Page size: '+result.coverage.scanned+'; scope: '+result.coverage.scope:result.disclaimer||JSON.stringify(result.coverage||{});say('Campaign report loaded');}catch(e){say('Campaign report unavailable: '+e.message);}}
42
+ $('loadCampaign').onclick=()=>void loadCampaign();$('nextCampaign').onclick=()=>void loadCampaign(true);$('campaignKind').onchange=()=>{campaignCursor=null;$('nextCampaign').disabled=true;};$('nextCampaign').disabled=true;
43
+ $('save').onclick=async()=>{try{const next=JSON.parse(JSON.stringify(doc));next.version++;const res=await fetch(prefix+'/api',{method:'PUT',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(next)});const result=await res.json();if(!res.ok)throw Error(result.error||res.status);doc=next;render();say('Saved version '+next.version);}catch(e){say('Save failed: '+e.message);}};
44
+ async function preview(){const board=$('charts');board.replaceChildren();$('pageTitle').textContent=page()?.name||'No page';$('notice').textContent='';if(!page())return;
45
+ if(page().charts.length>12)$('notice').textContent='Preview is limited to the first 12 charts to protect database performance.';
46
+ for(const chart of page().charts.slice(0,12)){const section=document.createElement('div');section.className='chart';const h=document.createElement('h3');h.textContent=chart.title;section.append(h);board.append(section);
47
+ try{const url=new URL(prefix+'/query',location.origin);url.searchParams.set('report',selected().id);url.searchParams.set('page',page().id);url.searchParams.set('chart',chart.id);applyWindow(url);
48
+ const response=await fetch(url,{credentials:'same-origin',cache:'no-store'});if(!response.ok)throw Error('HTTP '+response.status);const result=await response.json();
49
+ if(['pie','doughnut'].includes(chart.type)&&result.values.some(v=>v<0)){const note=document.createElement('p');note.className='warn';note.textContent='Pie and doughnut charts cannot display negative values. Choose bar, line or table.';section.append(note);continue;}
50
+ if(result.truncated||result.queryMode==='materialized'){const note=document.createElement('p');note.className='warn';note.textContent=result.queryMode==='materialized'?'Indexed numeric facts only ('+result.matchedFacts+' matching facts); older or failed writes may be missing.'+(result.truncated?' Group display limited.':''):result.queryMode==='database'?'Group display limited; total is a database event count.':'Sampled at most '+result.sampledRows+' source events; not a complete aggregate.';section.append(note);}draw(section,result);
51
+ }catch(e){const error=document.createElement('p');error.textContent='Query unavailable: '+e.message;section.append(error);}}
52
+ }
53
+ function draw(section,d){const labels=d.labels||[],v=d.values||[];
54
+ if(d.type==='kpi'){const h=document.createElement('h1');h.textContent=Number(d.total).toLocaleString();section.append(h);return;}
55
+ if(d.type==='table'){const table=document.createElement('table'),head=document.createElement('tr');for(const label of ['Dimension','Value']){const th=document.createElement('th');th.textContent=label;head.append(th);}table.append(head);labels.forEach((l,i)=>{const tr=document.createElement('tr');for(const text of [l,String(v[i])]){const cell=document.createElement('td');cell.textContent=text;tr.append(cell);}table.append(tr);});section.append(table);return;}
56
+ const canvas=document.createElement('canvas');canvas.width=640;canvas.height=320;section.append(canvas);const c=canvas.getContext('2d');if(!c)return;
57
+ const palette=['#2563eb','#0891b2','#d97706','#059669','#9333ea','#dc2626','#4f46e5','#64748b'];c.font='12px sans-serif';c.fillStyle='#777';
58
+ if(d.type==='pie'||d.type==='doughnut'){let angle=-Math.PI/2;const total=v.reduce((a,b)=>a+Math.max(0,b),0)||1;v.slice(0,15).forEach((value,i)=>{const sweep=Math.PI*2*Math.max(0,value)/total;c.beginPath();c.moveTo(210,155);c.arc(210,155,125,angle,angle+sweep);c.closePath();c.fillStyle=palette[i%palette.length];c.fill();angle+=sweep;});if(d.type==='doughnut'){c.beginPath();c.arc(210,155,62,0,Math.PI*2);c.fillStyle=getComputedStyle(document.body).backgroundColor||'#fff';c.fill();}labels.slice(0,8).forEach((l,i)=>{c.fillStyle=palette[i%palette.length];c.fillRect(370,20+i*28,12,12);c.fillStyle='#888';c.fillText(String(l).slice(0,25),390,31+i*28);});return;}
59
+ const lowest=Math.min(0,...v),highest=Math.max(0,...v),range=Math.max(1,highest-lowest),count=Math.max(v.length,1),w=520/count;
60
+ const yValue=value=>278-230*(value-lowest)/range,zeroY=yValue(0);
61
+ c.strokeStyle='#aaa';c.beginPath();c.moveTo(50,zeroY);c.lineTo(610,zeroY);c.stroke();
62
+ if(d.type==='horizontal-bar'){const height=Math.min(28,240/Math.max(1,count)),zeroX=145+430*(-lowest)/range;
63
+ v.slice(0,30).forEach((value,i)=>{const y=12+i*height,x=145+430*(value-lowest)/range;c.fillStyle=palette[i%palette.length];c.fillRect(Math.min(zeroX,x),y,Math.abs(x-zeroX),Math.max(2,height-4));c.fillStyle='#888';c.fillText(String(labels[i]).slice(0,18),4,y+height-5);});return;}
64
+ if(d.type==='line'||d.type==='area'||d.type==='scatter'){c.strokeStyle=palette[0];c.fillStyle=palette[0];c.beginPath();v.forEach((value,i)=>{const x=65+i*(520/Math.max(1,count-1)),y=yValue(value);if(i===0)c.moveTo(x,y);else c.lineTo(x,y);});if(d.type==='area'){c.lineTo(65+(count-1)*(520/Math.max(1,count-1)),zeroY);c.lineTo(65,zeroY);c.closePath();c.globalAlpha=.2;c.fillStyle=palette[0];c.fill();c.globalAlpha=1;}if(d.type!=='scatter')c.stroke();v.forEach((value,i)=>{const x=65+i*(520/Math.max(1,count-1)),y=yValue(value);c.beginPath();c.arc(x,y,3,0,Math.PI*2);c.fill();});}
65
+ else v.forEach((value,i)=>{c.fillStyle=palette[i%palette.length];const y=yValue(value);c.fillRect(60+i*w,Math.min(zeroY,y),Math.max(2,w-5),Math.abs(zeroY-y));});
66
+ c.fillStyle='#888';labels.slice(0,8).forEach((l,i)=>c.fillText(String(l).slice(0,13),60+i*(520/Math.max(1,Math.min(count,8))),304));}
67
+ load();})();</script></body></html>`;
68
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './model.js';
3
+ export * from './runtime.js';
4
+ export * from './campaign-delivery.js';
5
+ export * from './campaign-reports.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './model.js';
3
+ export * from './runtime.js';
4
+ export * from './campaign-delivery.js';
5
+ export * from './campaign-reports.js';
@@ -0,0 +1,5 @@
1
+ import type { ReportsDocument, ReportsStore, ChartDefinition, ChartData, AnalyticsReportSource } from './types.js';
2
+ export declare const isReportId: (v: unknown) => v is string;
3
+ export declare function validateReports(input: unknown): ReportsDocument;
4
+ export declare function createMemoryReportsStore(initial?: ReportsDocument): ReportsStore;
5
+ export declare function queryChart(source: AnalyticsReportSource, chart: ChartDefinition, maxSourceRows?: number, site?: string): Promise<ChartData>;
package/dist/model.js ADDED
@@ -0,0 +1,124 @@
1
+ const ident = /^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/;
2
+ const forbidden = new Set(['__proto__', 'constructor', 'prototype']);
3
+ const chartTypes = ['bar', 'horizontal-bar', 'line', 'area', 'pie', 'doughnut', 'scatter', 'stacked-bar', 'table', 'kpi'];
4
+ const metrics = ['count', 'sum', 'average', 'min', 'max', 'distinct'];
5
+ export const isReportId = (v) => typeof v === 'string' && ident.test(v) && !v.split(/[.:-]/).some(k => forbidden.has(k));
6
+ export function validateReports(input) {
7
+ if (!input || typeof input !== 'object' || Array.isArray(input))
8
+ throw new TypeError('Invalid reports document');
9
+ const doc = input;
10
+ if (!Number.isSafeInteger(doc.version) || doc.version < 1 || !Array.isArray(doc.reports) || doc.reports.length > 100)
11
+ throw new TypeError('Invalid reports version or size');
12
+ const ids = new Set();
13
+ const unique = (id) => { if (!isReportId(id) || ids.has(id))
14
+ throw new TypeError('Invalid or repeated report ID'); ids.add(id); };
15
+ for (const report of doc.reports) {
16
+ unique(report.id);
17
+ if (typeof report.name !== 'string' || report.name.length < 1 || report.name.length > 128 || report.description !== undefined && (typeof report.description !== 'string' || report.description.length > 1024) || !Array.isArray(report.pages) || report.pages.length > 50)
18
+ throw new TypeError('Invalid report');
19
+ for (const page of report.pages) {
20
+ unique(page.id);
21
+ if (typeof page.name !== 'string' || page.name.length < 1 || page.name.length > 128 || !Array.isArray(page.charts) || page.charts.length > 50)
22
+ throw new TypeError('Invalid report page');
23
+ for (const chart of page.charts) {
24
+ unique(chart.id);
25
+ if (typeof chart.title !== 'string' || chart.title.length < 1 || chart.title.length > 128 || !chartTypes.includes(chart.type) || !metrics.includes(chart.metric) || !['event', 'day', 'source', 'site', 'pathname'].includes(chart.groupBy) && !/^property:[A-Za-z][A-Za-z0-9_.:-]{0,95}$/.test(chart.groupBy) || chart.groupBy.startsWith('property:') && !isReportId(chart.groupBy.slice(9)))
26
+ throw new TypeError('Invalid chart');
27
+ // eslint-disable-next-line no-control-regex -- Intentionally matches control characters for validation or output sanitization.
28
+ if (chart.valueField !== undefined && !isReportId(chart.valueField) || chart.eventName !== undefined && !isReportId(chart.eventName) || chart.source !== undefined && !['web', 'server', 'email', 'system'].includes(chart.source) || chart.site !== undefined && (typeof chart.site !== 'string' || chart.site.length > 200 || /[\x00-\x1f]/.test(chart.site)) || chart.limit !== undefined && (!Number.isSafeInteger(chart.limit) || chart.limit < 1 || chart.limit > 500) || ['since', 'until'].some(k => { const n = chart[k]; return n !== undefined && (!Number.isSafeInteger(n) || n < 0); }) || chart.since !== undefined && chart.until !== undefined && chart.since > chart.until)
29
+ throw new TypeError('Invalid chart query');
30
+ }
31
+ }
32
+ }
33
+ return structuredClone(doc);
34
+ }
35
+ export function createMemoryReportsStore(initial = { version: 1, reports: [] }) {
36
+ let current = validateReports(initial);
37
+ return { async load() { return structuredClone(current); }, async save(next, expected) { if (expected !== current.version || next.version !== expected + 1)
38
+ return false; current = validateReports(next); return true; } };
39
+ }
40
+ export async function queryChart(source, chart, maxSourceRows = 500, site) {
41
+ if (!Number.isSafeInteger(maxSourceRows) || maxSourceRows < 1 || maxSourceRows > 500)
42
+ throw new RangeError('Invalid source row limit');
43
+ // Direct programmatic callers receive the same chart validation as editor-saved definitions.
44
+ const reportId = chart.id === 'ReportValidation' ? 'ReportValidationTwo' : 'ReportValidation';
45
+ const pageId = [chart.id, reportId].includes('PageValidation') ? 'PageValidationTwo' : 'PageValidation';
46
+ validateReports({ version: 1, reports: [{ id: reportId, name: 'Validation', pages: [{ id: pageId, name: 'Preview', charts: [chart] }] }] });
47
+ const take = Math.min(chart.limit ?? maxSourceRows, maxSourceRows);
48
+ const scopedSite = site ?? chart.site;
49
+ if (chart.metric === 'count' && typeof source.aggregate === 'function' && (['event', 'source', 'site', 'pathname'].includes(chart.groupBy) || chart.groupBy === 'day' && chart.since !== undefined && chart.until !== undefined)) {
50
+ const result = await source.aggregate({ groupBy: chart.groupBy,
51
+ name: chart.eventName, source: chart.source, site: scopedSite, since: chart.since, until: chart.until, limit: chart.limit ?? 100 });
52
+ if (result.mode !== 'database' || result.groupBy !== chart.groupBy || !Number.isSafeInteger(result.total) || result.total < 0 ||
53
+ typeof result.truncated !== 'boolean' || !Array.isArray(result.groups) || result.groups.length > (chart.limit ?? 100) ||
54
+ result.groups.some(g => typeof g.category !== 'string' || g.category.length > 150 || !Number.isSafeInteger(g.value) || g.value < 0) ||
55
+ result.groups.reduce((n, g) => n + g.value, 0) > result.total)
56
+ throw new Error('Invalid native report count result');
57
+ const output = chart.groupBy === 'day' ? [...result.groups].sort((a, b) => a.category.localeCompare(b.category)) : result.groups;
58
+ const visible = output.slice(0, 60);
59
+ return { id: chart.id, title: chart.title, type: chart.type, labels: visible.map(g => g.category), values: visible.map(g => g.value),
60
+ total: result.total, sampledRows: 0, truncated: result.truncated || output.length > 60, queryMode: 'database', coverage: 'exact-persisted-event-count' };
61
+ }
62
+ if (chart.metric !== 'count' && chart.metric !== 'distinct' && chart.valueField && typeof source.aggregateNumeric === 'function' && (typeof source.supportsNumericField !== 'function' || source.supportsNumericField(chart.valueField)) && (['event', 'source', 'site', 'pathname'].includes(chart.groupBy) || chart.groupBy === 'day' && chart.since !== undefined && chart.until !== undefined)) {
63
+ const result = await source.aggregateNumeric({ groupBy: chart.groupBy,
64
+ metric: chart.metric, valueField: chart.valueField, name: chart.eventName, source: chart.source,
65
+ site: scopedSite, since: chart.since, until: chart.until, limit: chart.limit ?? 100 });
66
+ if (result.mode !== 'materialized' || result.groupBy !== chart.groupBy || result.metric !== chart.metric || result.valueField !== chart.valueField ||
67
+ result.coverage !== 'indexed-only' || typeof result.truncated !== 'boolean' || !Number.isSafeInteger(result.matchedFacts) || result.matchedFacts < 0 ||
68
+ !Number.isFinite(result.total) || !Array.isArray(result.groups) || result.groups.length > (chart.limit ?? 100) ||
69
+ result.groups.some(g => typeof g.category !== 'string' || g.category.length > 150 || !Number.isFinite(g.value)))
70
+ throw new Error('Invalid indexed numeric report result');
71
+ const output = chart.groupBy === 'day' ? [...result.groups].sort((a, b) => a.category.localeCompare(b.category)) : result.groups;
72
+ const visible = output.slice(0, 60);
73
+ return { id: chart.id, title: chart.title, type: chart.type, labels: visible.map(g => g.category), values: visible.map(g => g.value),
74
+ total: result.total, sampledRows: 0, truncated: result.truncated || output.length > 60, queryMode: 'materialized',
75
+ numericCoverage: result.coverage, matchedFacts: result.matchedFacts, coverage: 'indexed-numeric-facts-only' };
76
+ }
77
+ const rows = await source.events({ name: chart.eventName, source: chart.source, site: scopedSite, since: chart.since, until: chart.until, limit: take });
78
+ if (!Array.isArray(rows) || rows.length > take)
79
+ throw new Error('Source returned more than the bounded event page');
80
+ const groups = new Map();
81
+ const all = { count: 0, numericCount: 0, sum: 0, min: Infinity, max: -Infinity, unique: new Set() };
82
+ for (const row of rows) {
83
+ let props = {};
84
+ try {
85
+ const parsed = JSON.parse(row.properties_json);
86
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
87
+ props = parsed;
88
+ }
89
+ catch { /* This optional failure is intentionally ignored. */ }
90
+ if (scopedSite !== undefined && row.site !== scopedSite)
91
+ throw new Error('Analytics source violated enforced site scope');
92
+ const raw = chart.groupBy === 'event' ? row.event_name : chart.groupBy === 'day' ? new Date(Number(row.occurred_at)).toISOString().slice(0, 10) : chart.groupBy === 'source' ? row.source : chart.groupBy === 'site' ? row.site : chart.groupBy === 'pathname' ? row.pathname : props[chart.groupBy.slice(9)];
93
+ const category = String(raw ?? '(not set)').slice(0, 150);
94
+ const value = chart.valueField ? props[chart.valueField] : 1;
95
+ let group = groups.get(category);
96
+ if (!group) {
97
+ group = { count: 0, numericCount: 0, sum: 0, min: Infinity, max: -Infinity, unique: new Set() };
98
+ groups.set(category, group);
99
+ }
100
+ group.count++;
101
+ all.count++;
102
+ if (typeof value === 'number' && Number.isFinite(value)) {
103
+ for (const item of [group, all]) {
104
+ item.numericCount++;
105
+ item.sum += value;
106
+ item.min = Math.min(item.min, value);
107
+ item.max = Math.max(item.max, value);
108
+ }
109
+ }
110
+ const distinct = String(value ?? '');
111
+ if (group.unique.size < 10000)
112
+ group.unique.add(distinct);
113
+ if (all.unique.size < 10000)
114
+ all.unique.add(distinct);
115
+ }
116
+ const metric = (group, method) => ({ count: group.count, sum: group.sum, average: group.numericCount ? group.sum / group.numericCount : 0, min: Number.isFinite(group.min) ? group.min : 0, max: Number.isFinite(group.max) ? group.max : 0, distinct: group.unique.size })[method];
117
+ const items = [...groups].map(([category, group]) => ({ category, value: metric(group, chart.metric) }));
118
+ if (chart.groupBy === 'day')
119
+ items.sort((a, b) => a.category.localeCompare(b.category));
120
+ else
121
+ items.sort((a, b) => b.value - a.value);
122
+ const output = items.slice(0, 60);
123
+ return { id: chart.id, title: chart.title, type: chart.type, labels: output.map(i => i.category), values: output.map(i => i.value), total: metric(all, chart.metric), sampledRows: rows.length, truncated: rows.length >= take, queryMode: 'sampled', coverage: 'bounded-source-event-page' };
124
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReportsOptions, ReportsStore, ChartData } from './types.js';
2
+ export declare function createReports(options: ReportsOptions): {
3
+ store: ReportsStore;
4
+ query: (reportId: string, pageId: string, chartId: string) => Promise<ChartData>;
5
+ routes(): {
6
+ pattern: string;
7
+ handler: (req: Request) => Promise<Response>;
8
+ }[];
9
+ };
@@ -0,0 +1,316 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createMemoryReportsStore, validateReports, queryChart, isReportId } from './model.js';
3
+ import { campaignPerformanceCharts, campaignDailyTrendsCharts } from './campaign-delivery.js';
4
+ import { marketingDeliveryPageCharts, campaignActivityCharts } from './campaign-reports.js';
5
+ import { reportDashboard } from './dashboard.js';
6
+ const headers = { 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'vary': 'Cookie, Authorization' };
7
+ const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { ...headers, 'content-type': 'application/json; charset=utf-8' } });
8
+ const error = (message, status) => json({ error: message }, status);
9
+ // eslint-disable-next-line no-control-regex -- Intentionally matches control characters for validation or output sanitization.
10
+ const validSite = (site) => typeof site === 'string' && site.length > 0 && site.length <= 200 && !/[\x00-\x1f]/.test(site);
11
+ const date = (s) => {
12
+ if (!s || !/^\d{4}-\d{2}-\d{2}$/.test(s) || new Date(s + 'T00:00:00Z').toISOString().slice(0, 10) !== s)
13
+ throw new TypeError('Invalid UTC date');
14
+ return Date.parse(s + 'T00:00:00Z');
15
+ };
16
+ function utcWindow(params, required = false) {
17
+ const since = params.get('since'), until = params.get('until');
18
+ if (!since && !until && !required)
19
+ return {};
20
+ if (!since || !until || !/^\d+$/.test(since) || !/^\d+$/.test(until))
21
+ throw new TypeError('Both numeric since and until must be supplied');
22
+ const a = Number(since), b = Number(until);
23
+ if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b) || a < 0 || b < a || b - a > 366 * 86400000)
24
+ throw new RangeError('Date range must be bounded to 366 days');
25
+ return { since: a, until: b };
26
+ }
27
+ function campaignDays(params) {
28
+ const from = params.get('from'), to = params.get('to');
29
+ const first = date(from), last = date(to);
30
+ if (last < first || last - first > 365 * 86400000)
31
+ throw new RangeError('Campaign date range exceeds 366 days');
32
+ return { from: from, to: to };
33
+ }
34
+ const sha = (source) => "'sha256-" + createHash('sha256').update(source).digest('base64') + "'";
35
+ const escapeCsv = (item) => {
36
+ const value = String(item ?? '');
37
+ // Spreadsheet formulas must remain literal data, even when preceded by whitespace.
38
+ const safe = /^[\s\uFEFF]*[=+@\-\t\r]/.test(value) ? "'" + value : value;
39
+ return '"' + safe.replace(/"/g, '""') + '"';
40
+ };
41
+ const csv = (items) => [
42
+ ['chartId', 'title', 'dimension', 'value', 'total', 'mode', 'coverage', 'truncated'].map(escapeCsv).join(','),
43
+ ...items.flatMap(item => item.labels.map((label, index) => [item.id, item.title, label, item.values[index], item.total, item.queryMode ?? 'unknown', item.coverage ?? item.numericCoverage ?? '', item.truncated].map(escapeCsv).join(',')))
44
+ ].join('\r\n') + '\r\n';
45
+ function exported(data, format, filename) {
46
+ const payload = format === 'csv' ? csv(Array.isArray(data) ? data : [data]) : JSON.stringify(data);
47
+ if (Buffer.byteLength(payload) > 256 * 1024)
48
+ return error('Export exceeds 256 KiB limit; narrow your selection', 413);
49
+ return new Response(payload, { headers: { ...headers, 'content-type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8',
50
+ 'content-disposition': `attachment; filename="${filename}.${format}"` } });
51
+ }
52
+ export function createReports(options) {
53
+ if (!options?.analytics || typeof options.authorize !== 'function')
54
+ throw new TypeError('Reports requires analytics and an application authorizer');
55
+ if (options.resolveScope && typeof options.resolveScope !== 'function')
56
+ throw new TypeError('Invalid tenant resolver');
57
+ if (options.site !== undefined && !validSite(options.site))
58
+ throw new TypeError('Invalid enforced site');
59
+ const prefix = options.prefix ?? '/_reports';
60
+ if (!/^\/[A-Za-z0-9/_-]+$/.test(prefix) || prefix.includes('//') || prefix.endsWith('/'))
61
+ throw new TypeError('Invalid reports path');
62
+ if (options.dashboardHost && !/^[A-Za-z0-9.-]+(?::\d{2,5})?$/.test(options.dashboardHost))
63
+ throw new TypeError('Invalid reports dashboard host');
64
+ if (options.maxSourceRows !== undefined && (!Number.isSafeInteger(options.maxSourceRows) || options.maxSourceRows < 1 || options.maxSourceRows > 500))
65
+ throw new RangeError('Invalid report row cap');
66
+ const store = options.store ?? createMemoryReportsStore();
67
+ const source = options.source ?? options.analytics;
68
+ const authorized = async (req, action, id) => {
69
+ if (options.dashboardHost && new URL(req.url).host !== options.dashboardHost)
70
+ return false;
71
+ try {
72
+ return await options.authorize(req, action, id) === true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ };
78
+ const scoped = async (req) => {
79
+ if (!options.resolveScope)
80
+ return { site: options.site, store, source };
81
+ const scope = await options.resolveScope(req);
82
+ if (!scope || !validSite(scope.site) || !scope.store || typeof scope.store.load !== 'function' || typeof scope.store.save !== 'function' ||
83
+ !scope.source && typeof source.events !== 'function' || scope.source && typeof scope.source.events !== 'function' ||
84
+ options.site !== undefined && scope.site !== options.site)
85
+ throw new Error('Untrusted reports tenant scope');
86
+ return { site: scope.site, store: scope.store, source: scope.source ?? source };
87
+ };
88
+ const main = async (req) => {
89
+ if (!await authorized(req, 'read'))
90
+ return new Response('Not found', { status: 404 });
91
+ const html = reportDashboard(prefix);
92
+ const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1] ?? '';
93
+ const style = html.match(/<style>([\s\S]*?)<\/style>/)?.[1] ?? '';
94
+ return new Response(html, { headers: { ...headers, 'content-type': 'text/html; charset=utf-8',
95
+ 'content-security-policy': `default-src 'none'; script-src ${sha(script)}; style-src ${sha(style)}; connect-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none'` } });
96
+ };
97
+ const api = async (req) => {
98
+ const action = req.method === 'PUT' ? 'edit' : 'read';
99
+ if (!await authorized(req, action))
100
+ return error('Forbidden', 403);
101
+ if (!['GET', 'PUT'].includes(req.method))
102
+ return error('Method not allowed', 405);
103
+ let tenantStore;
104
+ try {
105
+ tenantStore = (await scoped(req)).store;
106
+ }
107
+ catch {
108
+ return error('Report storage unavailable', 503);
109
+ }
110
+ if (req.method === 'GET') {
111
+ try {
112
+ return json(validateReports(await tenantStore.load()));
113
+ }
114
+ catch {
115
+ return error('Report storage unavailable', 503);
116
+ }
117
+ }
118
+ if (req.headers.get('origin') !== new URL(req.url).origin)
119
+ return error('Origin required', 403);
120
+ let next;
121
+ try {
122
+ const raw = await req.text();
123
+ if (Buffer.byteLength(raw) > 256 * 1024)
124
+ return error('Report document too large', 413);
125
+ next = validateReports(JSON.parse(raw));
126
+ }
127
+ catch {
128
+ return error('Invalid report document', 422);
129
+ }
130
+ try {
131
+ if (!await tenantStore.save(next, next.version - 1))
132
+ return error('Version conflict', 409);
133
+ return json({ ok: true, version: next.version });
134
+ }
135
+ catch {
136
+ return error('Report storage unavailable', 503);
137
+ }
138
+ };
139
+ const catalog = async (req) => {
140
+ if (!await authorized(req, 'read'))
141
+ return error('Forbidden', 403);
142
+ if (req.method !== 'GET')
143
+ return error('Method not allowed', 405);
144
+ try {
145
+ const params = new URL(req.url).searchParams;
146
+ const rawLimit = params.get('limit') ?? '20', rawCursor = params.get('cursor');
147
+ if (!/^\d{1,2}$/.test(rawLimit) || Number(rawLimit) < 1 || Number(rawLimit) > 50)
148
+ return error('Invalid page size', 422);
149
+ const doc = validateReports(await (await scoped(req)).store.load());
150
+ let offset = 0;
151
+ if (rawCursor) {
152
+ const match = /^(\d+):(\d{1,3})$/.exec(rawCursor);
153
+ if (!match)
154
+ return error('Invalid page cursor', 422);
155
+ if (Number(match[1]) !== doc.version)
156
+ return error('Report catalog changed; reload from first page', 409);
157
+ offset = Number(match[2]);
158
+ if (offset > doc.reports.length)
159
+ return error('Invalid page cursor', 422);
160
+ }
161
+ const take = Number(rawLimit), items = doc.reports.slice(offset, offset + take).map(r => ({ id: r.id, name: r.name, description: r.description ?? '', pages: r.pages.length }));
162
+ return json({ version: doc.version, items, nextCursor: offset + take < doc.reports.length ? `${doc.version}:${offset + take}` : null, total: doc.reports.length });
163
+ }
164
+ catch {
165
+ return error('Report catalog unavailable', 503);
166
+ }
167
+ };
168
+ const locate = (doc, params) => {
169
+ const reportId = params.get('report'), pageId = params.get('page'), chartId = params.get('chart');
170
+ const report = doc.reports.find(r => r.id === reportId), page = report?.pages.find(p => p.id === pageId);
171
+ if (!page)
172
+ return null;
173
+ if (chartId && !page.charts.some(c => c.id === chartId))
174
+ return null;
175
+ return { report, page, charts: chartId ? page.charts.filter(c => c.id === chartId) : page.charts };
176
+ };
177
+ const charts = async (req, kind) => {
178
+ if (req.method !== 'GET')
179
+ return error('Method not allowed', 405);
180
+ const params = new URL(req.url).searchParams;
181
+ if (kind === 'export' && !['json', 'csv'].includes(params.get('format') ?? 'json'))
182
+ return error('Invalid export format', 422);
183
+ let window;
184
+ try {
185
+ window = utcWindow(params);
186
+ }
187
+ catch {
188
+ return error('Invalid or excessive UTC date window', 422);
189
+ }
190
+ try {
191
+ const scope = await scoped(req), doc = validateReports(await scope.store.load()), target = locate(doc, params);
192
+ if (!target)
193
+ return error('Unknown report page or chart', 404);
194
+ if (kind === 'query' && target.charts.length !== 1)
195
+ return error('Choose exactly one chart', 422);
196
+ if (target.charts.length > 12)
197
+ return error('Page export is limited to 12 charts', 422);
198
+ // 366-day native grouping issues one COUNT per UTC day; bound multi-chart amplification.
199
+ let estimatedQueries = 0;
200
+ for (const chart of target.charts) {
201
+ const merged = { ...chart, ...window };
202
+ if (merged.groupBy === 'day' && merged.since !== undefined && merged.until !== undefined) {
203
+ const from = Date.UTC(new Date(merged.since).getUTCFullYear(), new Date(merged.since).getUTCMonth(), new Date(merged.since).getUTCDate());
204
+ const to = Date.UTC(new Date(merged.until).getUTCFullYear(), new Date(merged.until).getUTCMonth(), new Date(merged.until).getUTCDate());
205
+ estimatedQueries += 2 + Math.floor((to - from) / 86400000);
206
+ }
207
+ else
208
+ estimatedQueries++;
209
+ }
210
+ if (estimatedQueries > 500)
211
+ return error('Export exceeds query budget; narrow the UTC date range or select one chart', 422);
212
+ for (const chart of target.charts)
213
+ if (chart.groupBy.startsWith('property:') && !await authorized(req, 'sensitive', chart.id))
214
+ return error('Sensitive chart permission required', 403);
215
+ const results = [];
216
+ for (const chart of target.charts) {
217
+ const merged = { ...chart, ...window };
218
+ results.push(await queryChart(scope.source, merged, options.maxSourceRows ?? 500, scope.site));
219
+ }
220
+ if (kind === 'query')
221
+ return json(results[0]);
222
+ const format = params.get('format') ?? 'json';
223
+ return exported(format === 'csv' ? results : { report: target.report.id, page: target.page.id,
224
+ generatedAt: new Date().toISOString(), site: scope.site ?? null, charts: results,
225
+ disclaimer: 'Coverage is chart-specific. SMTP acceptance and provider notifications do not confirm inbox delivery.' }, format, `reports-${target.report.id}-${target.page.id}`);
226
+ }
227
+ catch {
228
+ return error('Report query unavailable', 503);
229
+ }
230
+ };
231
+ const query = async (req) => {
232
+ if (!await authorized(req, 'read'))
233
+ return error('Forbidden', 403);
234
+ return charts(req, 'query');
235
+ };
236
+ const exportRoute = async (req) => {
237
+ if (!await authorized(req, 'export'))
238
+ return error('Forbidden', 403);
239
+ return charts(req, 'export');
240
+ };
241
+ const campaign = async (req) => {
242
+ if (req.method !== 'GET')
243
+ return error('Method not allowed', 405);
244
+ if (!options.campaigns)
245
+ return error('Campaign reporting unavailable', 404);
246
+ const params = new URL(req.url).searchParams;
247
+ const code = params.get('code');
248
+ if (!isReportId(code))
249
+ return error('Invalid campaign code', 422);
250
+ if (!await authorized(req, 'campaign', code))
251
+ return error('Forbidden', 403);
252
+ const kind = params.get('kind') ?? 'delivery';
253
+ if (!['delivery', 'performance', 'daily', 'activity'].includes(kind))
254
+ return error('Invalid campaign report kind', 422);
255
+ let days;
256
+ if (kind === 'daily' || kind === 'activity')
257
+ try {
258
+ days = campaignDays(params);
259
+ }
260
+ catch {
261
+ return error('Invalid or excessive campaign UTC date window', 422);
262
+ }
263
+ const cursor = params.get('cursor');
264
+ const rawLimit = params.get('limit') ?? '200';
265
+ if (kind === 'delivery' && (!/^\d{1,3}$/.test(rawLimit) || Number(rawLimit) < 1 || Number(rawLimit) > 200 || cursor !== null && (cursor.length > 1024 || !cursor)))
266
+ return error('Invalid campaign recipient page', 422);
267
+ try {
268
+ // Scope resolution and the campaign service independently enforce the same application-selected tenant.
269
+ const site = (await scoped(req)).site;
270
+ if (kind === 'delivery') {
271
+ const page = await options.campaigns.report(code, { cursor, limit: Number(rawLimit), site });
272
+ if (page.campaignCode !== code)
273
+ throw new Error('Campaign mismatch');
274
+ return json({ kind, code, ...marketingDeliveryPageCharts(page) });
275
+ }
276
+ if (kind === 'performance') {
277
+ if (!options.campaigns.performance)
278
+ return error('Native campaign performance not configured', 404);
279
+ const snapshot = await options.campaigns.performance(code, site);
280
+ if (snapshot.campaignId !== code)
281
+ throw new Error('Campaign mismatch');
282
+ return json({ kind, code, charts: campaignPerformanceCharts(snapshot), coverage: { smtp: 'durable-delivery-states', provider: 'reported-events-only' } });
283
+ }
284
+ if (kind === 'daily') {
285
+ if (!options.campaigns.dailyTrends)
286
+ return error('Daily campaign trends not configured', 404);
287
+ const snapshot = await options.campaigns.dailyTrends(code, days.from, days.to, site);
288
+ if (snapshot.campaignId !== code || snapshot.from !== days.from || snapshot.to !== days.to)
289
+ throw new Error('Campaign mismatch');
290
+ return json({ kind, code, charts: campaignDailyTrendsCharts(snapshot), coverage: { delivery: 'last-state-update-day', snapshotConsistent: false } });
291
+ }
292
+ if (!options.campaigns.activity)
293
+ return error('Campaign tracking reports not configured', 404);
294
+ const activity = await options.campaigns.activity(code, days.from, days.to, site);
295
+ if (activity.campaignCode !== code || activity.from !== days.from || activity.to !== days.to)
296
+ throw new Error('Campaign mismatch');
297
+ return json({ kind, code, ...campaignActivityCharts(activity) });
298
+ }
299
+ catch {
300
+ return error('Campaign report unavailable', 503);
301
+ }
302
+ };
303
+ return { store, query: async (reportId, pageId, chartId) => {
304
+ if (options.resolveScope)
305
+ throw new Error('Tenant-scoped reports require an authorized HTTP request');
306
+ const doc = validateReports(await store.load());
307
+ const chart = doc.reports.find(r => r.id === reportId)?.pages.find(p => p.id === pageId)?.charts.find(c => c.id === chartId);
308
+ if (!chart)
309
+ throw new Error('Unknown report chart');
310
+ return queryChart(source, chart, options.maxSourceRows ?? 500, options.site);
311
+ }, routes() {
312
+ return [{ pattern: prefix, handler: main }, { pattern: prefix + '/api', handler: api },
313
+ { pattern: prefix + '/catalog', handler: catalog }, { pattern: prefix + '/query', handler: query },
314
+ { pattern: prefix + '/export', handler: exportRoute }, { pattern: prefix + '/campaign', handler: campaign }];
315
+ } };
316
+ }