Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions public/app.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,23 @@
</form>
<div id="success" class="panel success" hidden><span class="eyebrow">IT’S A DATE</span><h2 id="success-title">Your event is ready.</h2><p id="success-time"></p><button class="primary wide" id="share-event">Invite your people ↗</button><button class="secondary wide" id="open-chat">Open event in chat</button><button class="text-button wide" id="another-event">Back to my events</button></div>
</section>
<section id="admin-analytics-view" class="view" hidden>
<span class="eyebrow">SUPER ADMIN</span><h1>Daily activity.</h1>
<div class="event-actions"><button type="button" class="secondary" data-tab="admin">← Admin panel</button><button type="button" class="secondary" id="analytics-refresh">↻ Refresh</button></div>
<label for="analytics-range">Date range</label><select id="analytics-range"><option value="7">Last 7 days</option><option value="30" selected>Last 30 days</option><option value="90">Last 90 days</option></select>
<p id="analytics-zone" class="small muted"></p>
<p id="analytics-status" role="status" aria-live="polite"></p><p id="analytics-error" class="error" role="alert" hidden></p>
<div id="analytics-content" hidden>
<div class="panel analytics-total"><span class="eyebrow">ALL TIME</span><h2>Total users</h2><strong id="analytics-total-users"></strong><p class="small muted">All known users, including older accounts without a recorded join date.</p></div>
<div id="analytics-summary" class="counts"></div>
<article class="panel analytics-panel"><h2>New users</h2><p class="small muted">People first seen by XEvents each day.</p><div id="analytics-users-chart"></div></article>
<article class="panel analytics-panel"><h2>Events created</h2><p class="small muted">Creation date, including cancelled events still saved.</p><div id="analytics-events-chart"></div></article>
<details class="panel"><summary>Daily counts</summary><div class="analytics-table-wrap"><table class="analytics-table"><caption>Daily new users and events created</caption><thead><tr><th scope="col">Date</th><th scope="col">Users</th><th scope="col">Events</th></tr></thead><tbody id="analytics-daily"></tbody></table></div></details>
<p id="analytics-note" class="small muted"></p>
</div>
</section>
<section id="admin-view" class="view" hidden><span class="eyebrow">SUPER ADMIN</span><h1>All plans.<br>All people.</h1><p class="muted">Browse every event and everyone who has used XEvents.</p>
<button type="button" class="secondary wide" data-tab="admin-analytics">📊 Daily activity</button>
<details id="admin-whitelist" class="panel" hidden>
<summary id="whitelist-summary">🔒 Dev access</summary>
<p id="whitelist-access" role="status" aria-live="polite"></p>
Expand Down
2 changes: 2 additions & 0 deletions public/style.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions scripts/check-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,26 @@ async function checkTestAccess() {
assert.equal((await worker.dispatchFetch('https://access.test/api/bootstrap')).status,401);
assert.equal((await api('bootstrap',seedUser)).status,200);
assert.equal((await api('bootstrap',admin)).status,200,'The configured admin must remain able to manage testers.');
assert.equal((await api('admin/analytics',seedUser)).status,403,'Analytics must require server-side super admin access.');
assert.equal((await worker.dispatchFetch('https://access.test/api/admin/analytics')).status,401);
assert.equal((await api('admin/analytics?days=365',admin)).status,400);
assert.equal((await api('admin/analytics?timezone=Bad%2FZone',admin)).status,400);
const analyticsResponse=await api('admin/analytics?days=7&timezone=UTC',admin);
assert.equal(analyticsResponse.status,200);assert.equal(analyticsResponse.headers.get('Cache-Control'),'no-store');
const analytics=await analyticsResponse.json();
assert.equal(analytics.daily.length,7);assert.equal(analytics.totals.users,2);
assert.equal(analytics.totalUsers,2);
assert.doesNotMatch(JSON.stringify(analytics),/Access tester|firstName|username|phone|"id"/);
// Hold delivery so command retries can be checked against the exact durable outbox.
await db.prepare("INSERT INTO delivery_lease(id,owner,expires) VALUES (1,'access-fixture',unixepoch()+3600)").run();
const eventId='7100000000000001',inviteToken='71000000000000000000000000000001',cohostToken='71000000000000000000000000000002',mediaId='710000000001';
const event={id:eventId,owner:admin,title:'Private access fixture',when:'Future test event',startsAt:'2099-12-05T18:00:00Z',timezone:'UTC',location:'Fictional venue',description:'',invitationMode:'named',oneTimeInvite:true,guests:{[blockedUser]:{name:'Existing tester',status:'yes',invitationToken:inviteToken}},invitees:{[inviteToken]:{name:'Named tester',participants:1,participantMode:'preset'}},permissions:{guestList:true,uploadMedia:true,viewMedia:true},banner:'access-banner',media:[{id:mediaId,fileId:'access-image',type:'photo',name:'fictional.jpg',user:blockedUser}],cohostLinks:[{id:'7100000000000002',token:cohostToken,label:'Test co-host',status:'pending',cohost:null,createdAt:'2026-01-01T00:00:00Z'}]};
await db.prepare("INSERT INTO records(kind,id,data) VALUES ('events',?,?)").bind(eventId,JSON.stringify(event)).run();
await db.prepare("INSERT INTO records(kind,id,data) VALUES ('preferences',?,?)").bind(String(blockedUser),JSON.stringify({profilePhoto:'access-profile',timezone:'UTC'})).run();
const beforeAnalytics=await records();
const totalsByRange=await Promise.all([7,30,90].map(async days=>(await (await api('admin/analytics?days='+days,admin)).json()).totalUsers));
assert.deepEqual(totalsByRange,[3,3,3],'All-time total includes legacy owners/guests and is independent of date range.');
assert.deepEqual(await records(),beforeAnalytics,'Analytics must not mutate user/event records.');
const beforeDenied=await records(),beforeCalls=calls.length;
for(const [path,method,input] of [
['bootstrap','GET'],['explore?timezone=UTC','GET'],['profile/photo','GET'],['profile/photo','DELETE'],['profile/photo','POST',{}],['branding/icon','GET'],
Expand Down
31 changes: 31 additions & 0 deletions src/admin.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { eventGroup } from './reminders.js';
import { Temporal } from '@js-temporal/polyfill';
import { timezone, InputError } from './time.js';
import { permissions, responseCounts, participantCount,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';

export const isSuperAdmin = (user, env = {}) => {
Expand All @@ -15,6 +17,35 @@ export async function rememberUser(env, user) {
await env.DB.prepare("INSERT INTO records(kind,id,data) VALUES ('users',?,?) ON CONFLICT(kind,id) DO UPDATE SET data=json_set(excluded.data,'$.firstSeen',coalesce(json_extract(records.data,'$.firstSeen'),json_extract(excluded.data,'$.firstSeen')))").bind(String(user.id), JSON.stringify(profile)).run();
}

// Match the admin directory, including users from before first-seen tracking.
export function adminUserCount(rows) {
return new Set(rows.map(row => Number(row.id)).filter(id => Number.isSafeInteger(id) && id > 0)).size;
}

// Read-only counts: never infer a registration date from a guest's RSVP or last visit.
export function adminAnalytics(rows, { days = '30', zone = 'UTC', now = Date.now() } = {}) {
days = Number(days);
if (![7, 30, 90].includes(days)) throw new InputError('Choose 7, 30 or 90 days.');
zone = timezone(zone);
const today = Temporal.Instant.fromEpochMilliseconds(now).toZonedDateTimeISO(zone).toPlainDate();
const start = today.subtract({ days: days - 1 });
const daily = Array.from({ length: days }, (_, i) => ({ date: start.add({ days: i }).toString(), users: 0, events: 0 }));
const buckets = new Map(daily.map(day => [day.date, day]));
const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' });
const undated = { users: 0, events: 0 };
for (const row of rows) {
const key = row.kind === 'users' ? 'users' : row.kind === 'events' ? 'events' : null;
if (!key) continue;
const stamp = typeof row.timestamp === 'string' && row.timestamp.trim() ? Date.parse(row.timestamp) : NaN;
if (!Number.isFinite(stamp)) { undated[key]++; continue; }
if (stamp > now) continue;
const parts = Object.fromEntries(formatter.formatToParts(new Date(stamp)).map(p => [p.type, p.value]));
const bucket = buckets.get(`${parts.year}-${parts.month}-${parts.day}`);
if (bucket) bucket[key]++;
}
return { timezone: zone, days, daily, totals: daily.reduce((sum, day) => ({ users: sum.users + day.users, events: sum.events + day.events }), { users: 0, events: 0 }), undated };
}

export function adminOverview(rows) {
const users = new Map();
const ensure = uid => { const id = Number(uid); if (!Number.isSafeInteger(id) || id <= 0) return null; if (!users.has(id)) users.set(id, { id, firstName: '', lastName: '', username: '', timezone: '', firstSeen: null, lastSeen: null, names: [], organised: [], invited: [] }); return users.get(id); };
Expand Down
Loading
Loading