Compare commits

...
Author SHA1 Message Date
joshua cd42ca375e Merge branch 'feat/impersonate-user' 2026-10-19 18:25:10 +02:00
joshuaandClaude Opus 5.5 ccb4db919a Bump to 1.23.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:25:08 +02:00
joshuaandClaude Opus 5.5 2b453b00e6 Add read-only admin impersonation
Admins (with 2FA) can view the site as a non-admin user for up to 30
minutes, with a required reason and a "Viewing as" banner. It is
strictly read-only: a guard mounted before every route refuses any
write made with an impersonation token, protect/optionalAuth check the
session is live, and GET handlers skip the price-refresh writes. Start,
end, reason and every page/API call (including blocked attempts) are
audited; optional email to the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:25:02 +02:00
joshua fb8ce5f86d Merge branch 'feat/funnel-stats' 2026-10-19 18:14:29 +02:00
joshuaandClaude Opus 5.5 4cd89b1a60 Bump to 1.22.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:14:24 +02:00
joshuaandClaude Opus 5.5 cc62f52ab3 Add registration funnel stats
First-party, consent-based funnel tracking: a cookie notice asks once,
and only visitors who allow it get an anonymous visit id. Event page
views and registration starts are tracked from the browser; submit and
paid come from the registration. Adds the Registration funnel report
(conversion per stage, daily trend, by source/device/ticket option), a
conversion tile on the supervisor/admin dashboards, short-link source
tagging, and a daily rollup of raw rows older than 90 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:14:18 +02:00
joshua 238fcd6fd7 Merge branch 'feat/feedback-forms' 2026-10-19 15:28:48 +02:00
48 changed files with 1444 additions and 27 deletions
+34
View File
@@ -7,6 +7,40 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.23.0] - 2026-10-19
### Added
- Impersonate user (Admin → Manage users → Impersonate): an admin can see the site exactly as a user does, to help with problems such as "my ticket isn't showing".
- A reason is required, and the session lasts at most 30 minutes.
- A bright "Viewing as [name]" banner with a countdown and "End impersonation" shows on every page.
- Impersonation is strictly read-only.
- A server-side guard, mounted before every route, refuses any change (registrations, payments, refunds, cancellations, profile, consent, messages and everything else) made with an impersonation token.
- Viewing pages doesn't trigger the price refresh it normally does.
- Admins can't be impersonated, and the admin must have two-factor authentication on.
- Impersonation is recorded in the audit log: start, end, reason, and every page and API call, including blocked attempts.
- The audit log page lists recent sessions with their full trail.
- New setting: email users when an admin views their account (Site Settings → Legal → Support access; off by default).
- New `/api/admin/impersonate` endpoints.
- Requires the `add_impersonation` database migration.
## [1.22.0] - 2026-10-19
### Added
- Registration funnel stats (first-party, no third-party analytics), showing where people drop off between seeing an event and paying.
- A new cookie notice asks visitors once. Only those who choose "Allow" are counted, using a random anonymous visit id.
- We record the event page view and the start of registration. The submit and paid stages come from the registration itself.
- Only the stage, the traffic source and the device type are stored. No name, IP address or User-Agent is kept.
- New report, **Registration funnel**: counts and conversion % per stage over a date range, a daily trend, and breakdowns by traffic source, device and ticket option.
- Selecting several events compares them.
- Sources come from `utm_source` links and from short links, which now add `?src=<alias>` when redirecting.
- A "Conversion (past month)" tile on the supervisor and admin dashboards covers live events.
- Raw visit records are rolled up into daily totals after 90 days by a daily job (`ANALYTICS_ROLLUP_ENABLED=false` to turn it off).
- The privacy policy's cookies section now describes these statistics.
- New `/api/analytics` endpoints.
- Requires the `add_funnel_analytics` database migration.
## [1.21.0] - 2026-10-19
### Added
+24 -1
View File
@@ -171,6 +171,28 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
Each payment records **who recorded it** as well as who it's for.
- **Refunds** — negative payments against an original payment, netted against
the original method.
- **Impersonate user** (Admin → Manage users) — an admin with 2FA can view the site as a
user, to debug problems.
- It needs a reason, lasts at most 30 minutes, and shows a bright "Viewing as" banner
on every page.
- It is strictly read-only. A server-side guard refuses every write made with the
impersonation token before it reaches any route, and pages don't even refresh
prices while viewing.
- Admins can't be impersonated.
- The start, end, reason and every page and API call are in the audit log, with each
session's trail.
- Optional email to the user (`notify_user_on_impersonation`).
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
- A cookie notice asks once. Only visitors who allow it get a random visit id.
- We record event page views and registration starts. Submits and payments come
from the registration itself.
- Each record holds only the stage, the traffic source (utm_source, or the short-link
alias, which short links now pass as `?src=`) and the device type.
- The Registration funnel report shows counts and conversion per stage over a date
range, a daily trend, and breakdowns by source, device and ticket option.
- A "Conversion (past month)" tile on the supervisor/admin dashboards covers live
events.
- Raw rows are rolled up into daily totals after 90 days.
- **Post-event feedback** — a Feedback step in the event wizard.
- Questions use the form builder, with new **star rating (1–5)** and **multiple
choice** types (also available on attendee forms).
@@ -404,7 +426,7 @@ a viewer with its own filters, stat tiles and bar charts.
| Category | Reports |
|----------|---------|
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
| Registration | Registration funnel (views → started → submitted → paid, daily trend, by source / device / ticket option) · Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
| Cashup | Cashup reconciliation · Cashup audit trail |
@@ -477,6 +499,7 @@ falling back to a general guide.
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
| Daily event summary email | 07:00 daily |
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
+1
View File
@@ -66,6 +66,7 @@ or Branding tab has been saved even once — the wizard always writes
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
+33
View File
@@ -26,6 +26,8 @@ Tokens are issued by `POST /api/users/login`.
- [Rooms & groups](#rooms--groups)
- [Sponsor a spot](#sponsor-a-spot)
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -283,6 +285,37 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Impersonation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
Read-only enforcement:
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
## Registration funnel
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent |
| GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` |
How the stages are recorded:
- `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage.
- `paid` is read from that registration's status.
- Counts are sessions that reached a stage or a later one.
Data handling:
- Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`).
- `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days.
## Post-event feedback
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.21.0",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.21.0",
"version": "1.23.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.21.0",
"version": "1.23.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "analyticsSessionId" TEXT;
-- CreateTable
CREATE TABLE "AnalyticsEvent" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnalyticsDaily" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"day" DATE NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'direct',
"device" TEXT NOT NULL DEFAULT 'unknown',
"sessions" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "AnalyticsDaily_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AnalyticsEvent_eventId_createdAt_idx" ON "AnalyticsEvent"("eventId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsEvent_sessionId_eventId_stage_key" ON "AnalyticsEvent"("sessionId", "eventId", "stage");
-- CreateIndex
CREATE INDEX "AnalyticsDaily_eventId_day_idx" ON "AnalyticsDaily"("eventId", "day");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsDaily_eventId_day_stage_source_device_key" ON "AnalyticsDaily"("eventId", "day", "stage", "source", "device");
-- AddForeignKey
ALTER TABLE "AnalyticsEvent" ADD CONSTRAINT "AnalyticsEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnalyticsDaily" ADD CONSTRAINT "AnalyticsDaily_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,55 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_started';
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
-- CreateTable
CREATE TABLE "ImpersonationSession" (
"id" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"endedReason" TEXT,
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImpersonationRequestLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
-- CreateIndex
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
-- CreateIndex
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+71
View File
@@ -111,6 +111,8 @@ model User {
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
feedbackInvites FeedbackInvite[]
feedbackGiven FeedbackResponse[]
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
@@ -153,6 +155,8 @@ model Event {
sponsorshipGoalSpots Int?
showSponsorshipProgress Boolean @default(true)
feedbackForm FeedbackForm?
analyticsEvents AnalyticsEvent[]
analyticsDaily AnalyticsDaily[]
allocationSets AllocationSet[]
waitlistOfferHours Int @default(24)
waitlistPartialOffers Boolean @default(true)
@@ -266,6 +270,7 @@ model Registration {
discountTotal Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
analyticsSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -496,6 +501,8 @@ enum AdminAuditAction {
scan_lock_force_released
offline_sync
ticket_transferred
impersonation_started
impersonation_ended
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -851,6 +858,70 @@ enum TicketDelivery {
// One named person on a group registration, for one main-ticket place. Gets their
// own quantity-1 ticket and their own attendee form response.
/// An admin viewing the site as another user (strictly read-only, at most 30 minutes).
model ImpersonationSession {
id String @id @default(uuid())
adminId String
targetUserId String
reason String
startedAt DateTime @default(now())
expiresAt DateTime
endedAt DateTime?
endedReason String? // 'ended' | 'expired'
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade)
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
requests ImpersonationRequestLog[]
@@index([adminId])
@@index([targetUserId])
}
/// Every page viewed and API call made during an impersonation session.
model ImpersonationRequestLog {
id String @id @default(uuid())
sessionId String
method String
path String
blocked Boolean @default(false)
createdAt DateTime @default(now())
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId, createdAt])
}
/// First-party funnel tracking (only after cookie consent): one row per anonymous
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
/// registration that carries the session id. Rows older than 90 days are rolled up
/// into AnalyticsDaily and deleted.
model AnalyticsEvent {
id String @id @default(uuid())
sessionId String
eventId String
stage String
source String?
device String?
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([sessionId, eventId, stage])
@@index([eventId, createdAt])
}
/// Rolled-up funnel counts: sessions that reached `stage`, per day, source and device.
model AnalyticsDaily {
id String @id @default(uuid())
eventId String
day DateTime @db.Date
stage String
source String @default("direct")
device String @default("unknown")
sessions Int @default(0)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([eventId, day, stage, source, device])
@@index([eventId, day])
}
/// Post-event survey. Sent at sendAt to checked-in attendees (or everyone registered),
/// with one reminder 3 days later. anonymous responses aren't linked to a person.
model FeedbackForm {
@@ -0,0 +1,44 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { track, funnelFor } = require('../utils/analytics');
// @desc Record a funnel step (event page view, registration started)
// @route POST /api/analytics/track { sessionId, eventId, stage: 'view'|'start', source? }
// @access Public — only sent by browsers whose visitor accepted cookies
const trackStage = async (req, res) => {
try {
const { sessionId, eventId, stage, source } = req.body || {};
await track({ sessionId, eventId, stage, source, userAgent: req.headers['user-agent'] });
} catch (e) {
console.warn('[analytics] track failed:', e?.message || e);
}
// Always a quiet 204 — tracking must never get in a visitor's way.
res.status(204).end();
};
// @desc Registration funnel for one or more events
// @route GET /api/analytics/funnel?eventIds=a,b&from=YYYY-MM-DD&to=YYYY-MM-DD
// @access Private/Supervisor
const getFunnel = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
// Per event too, so events can be compared.
const perEvent = [];
if (events.length > 1) {
for (const ev of events) {
const f = await funnelFor([ev.id], { from: req.query.from, to: req.query.to });
perEvent.push({ eventId: ev.id, title: ev.title, totals: f.totals, conversion: f.conversion });
}
} else if (events[0]) {
perEvent.push({ eventId: events[0].id, title: events[0].title, totals: total.totals, conversion: total.conversion });
}
res.json({ ...total, perEvent });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { trackStage, getFunnel };
@@ -0,0 +1,108 @@
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
// @desc Start viewing the site as a user (read-only, 30 minutes)
// @route POST /api/admin/impersonate/:userId { reason }
// @access Private/Admin (with 2FA)
const startImpersonation = async (req, res) => {
try {
if (req.readOnly) throw err('End the current impersonation first.', 403);
const reason = String(req.body?.reason || '').trim();
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
const blocker = impersonationBlocker(req.user, target);
if (blocker) throw err(blocker, target ? 403 : 404);
// One live session per admin.
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
});
// Optional: tell the user (Site Settings → Security).
const { getSettingSync } = require('../utils/settingsCache');
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
const { sendMail } = require('../utils/email');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
sendMail({
to: target.email,
subject: `An administrator viewed your account — ${orgName}`,
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
}).catch(() => {});
}
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
} catch (error) {
fail(res, error);
}
};
// @desc End impersonation (called with the impersonation token)
// @route POST /api/admin/impersonate/end
// @access Impersonation token
const endImpersonation = async (req, res) => {
try {
const sessionId = req.impersonation?.id;
if (!sessionId) throw err('Not impersonating.', 400);
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
logAdminAction({
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
});
res.json({ message: 'Impersonation ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Record a page the admin viewed while impersonating
// @route POST /api/admin/impersonate/log { path }
// @access Impersonation token
const logPageView = async (req, res) => {
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
res.status(204).end();
};
// @desc The current impersonation session (for the banner)
// @route GET /api/admin/impersonate/current
// @access Impersonation token
const currentImpersonation = async (req, res) => {
const s = req.impersonation;
if (!s) return res.json(null);
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
};
// @desc Impersonation sessions with their request trail (audit log page)
// @route GET /api/admin/impersonation-sessions
// @access Private/Admin
const listSessions = async (req, res) => {
try {
const sessions = await prisma.impersonationSession.findMany({
orderBy: { startedAt: 'desc' },
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
include: {
admin: { select: { name: true, email: true } },
target: { select: { name: true, email: true } },
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
},
});
res.json(sessions);
} catch (error) {
fail(res, error);
}
};
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
@@ -110,7 +110,7 @@ async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
// @access Private
const createRegistration = async (req, res) => {
try {
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
// Check if event exists
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -503,6 +503,12 @@ const createRegistration = async (req, res) => {
}
})();
// Funnel: this registration came from a tracked (cookie-consented) visit.
if (analyticsSessionId) {
const { recordSubmit } = require('../utils/analytics');
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
}
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -580,7 +586,7 @@ const getUserRegistrations = async (req, res) => {
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -657,7 +663,7 @@ const getRegistrationById = async (req, res) => {
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
if ((registration.status === 'pending' || registration.status === 'partial_paid') && !req.readOnly) {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
@@ -870,7 +876,7 @@ const getRegistrationsByEvent = async (req, res) => {
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -267,7 +267,21 @@ const getOverviewStats = async (req, res) => {
ticketsSold: ticketSumById[r.eventId] || 0,
}));
// Funnel conversion (event page views → paid) across live events, last 30 days.
let funnel = null;
try {
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
if (live.length) {
const { funnelFor } = require('../utils/analytics');
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
if (f.totals.view > 0) funnel = { views: f.totals.view, paid: f.totals.paid, conversion: f.conversion.paid };
}
} catch (e) {
console.warn('[stats] funnel tile failed:', e?.message || e);
}
res.json({
funnel,
activeEvents,
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
+40
View File
@@ -103,6 +103,10 @@ const globalLimiter = rateLimit({
});
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
@@ -182,6 +186,7 @@ app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
app.use('/api/allocations', require('./routes/allocationRoutes'));
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
@@ -638,6 +643,18 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
]},
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
]},
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
@@ -1399,6 +1416,29 @@ app.listen(PORT, () => {
console.warn('[waitlist] Not scheduled:', e?.message || e);
}
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
try {
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { rollupOldEvents } = require('./utils/analytics');
const run = async () => {
try {
const n = await rollupOldEvents();
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
} catch (e) {
console.warn('[analytics] Rollup failed:', e?.message || e);
}
};
setTimeout(run, 5 * 60 * 1000);
setInterval(run, 24 * 60 * 60 * 1000);
console.log('[analytics] Rollup daily.');
} else {
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
}
} catch (e) {
console.warn('[analytics] Not scheduled:', e?.message || e);
}
// Feedback job — send post-event feedback forms at their send time, and the one
// reminder 3 days later.
try {
+24 -2
View File
@@ -48,6 +48,17 @@ const protect = async (req, res, next) => {
return next(new Error('User account is deactivated'));
}
// An admin viewing as this user: only while the session is live, and read-only.
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
if (!session || session.targetUserId !== req.user.id) {
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
}
req.impersonation = session;
req.readOnly = true;
}
// Revocation check — tokenVersion in JWT must match DB
// Old tokens without tokenVersion are treated as version 0
const tokenVer = decoded.tokenVersion ?? 0;
@@ -60,7 +71,8 @@ const protect = async (req, res, next) => {
// requirement (or the user's promotion) is sent back to log in, where they
// enrol. The setup wizard's own token gets a short grace period.
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
// (An impersonating admin has already passed 2FA themselves.)
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
return res.status(401).json({
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
code: 'TWO_FACTOR_REQUIRED',
@@ -128,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
});
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
req.user = user;
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
req.readOnly = true;
if (session && session.targetUserId === user.id) {
req.user = user;
req.impersonation = session;
}
} else {
req.user = user;
}
}
} catch {
// Token invalid or expired — proceed without user
+7
View File
@@ -1,8 +1,15 @@
const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
router.post('/impersonate/log', protect, logPageView);
router.get('/impersonate/current', protect, currentImpersonation);
router.post('/impersonate/:userId', protect, admin, startImpersonation);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { trackStage, getFunnel } = require('../controllers/analyticsController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const trackLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => res.status(204).end(), // silently drop floods
});
router.post('/track', trackLimiter, trackStage);
router.get('/funnel', protect, supervisor, getFunnel);
module.exports = router;
+220
View File
@@ -0,0 +1,220 @@
/**
* Registration funnel — first-party, consent-based tracking.
*
* The browser only tracks after the visitor accepts cookies: it keeps a random session
* id and reports 'view' (event page) and 'start' (registration page). 'submit' is
* recorded here when a registration arrives carrying that session id, and 'paid' is
* read from that registration's status, so no personal data is stored with the events —
* just a stage, a traffic source and a device type (worked out from the User-Agent,
* which isn't kept).
*
* Counts are "reached": a session counts at a stage if it got that far or further, so a
* visitor who went straight to the registration page still counts as a view. Raw rows
* older than 90 days are rolled up into AnalyticsDaily and deleted.
*/
const prisma = require('../config/db');
const STAGES = ['view', 'start', 'submit', 'paid'];
const TRACKED = ['view', 'start'];
const RAW_DAYS = 90;
const SESSION_RE = /^[a-zA-Z0-9-]{16,64}$/;
const dayOf = (d) => new Date(d).toISOString().slice(0, 10);
// ─── Pure helpers ─────────────────────────────────────────────────────────────
const validSession = (sid) => typeof sid === 'string' && SESSION_RE.test(sid);
/** 'mobile' | 'tablet' | 'desktop', or null for bots. Pure. */
function deviceFromUA(ua) {
const s = String(ua || '');
if (!s || /bot|crawl|spider|slurp|preview|headless|lighthouse/i.test(s)) return null;
if (/iPad|Tablet|PlayBook|Silk|(Android(?!.*Mobile))/i.test(s)) return 'tablet';
if (/Mobi|iPhone|iPod|Android|Windows Phone/i.test(s)) return 'mobile';
return 'desktop';
}
/** A traffic source label: utm_source / short-link alias, tidied; 'direct' when none. Pure. */
function cleanSource(src) {
const s = String(src || '').trim().toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 50);
return s || 'direct';
}
/**
* One entry per session: the furthest stage reached, its source/device, and the day each
* stage was reached. Pure.
* @param {Array} events - [{ sessionId, stage, createdAt, source, device }]
* @param {Set} paidSessions - session ids whose registration is paid
*/
function sessionsFrom(events, paidSessions = new Set()) {
const map = new Map();
for (const e of [...events].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) {
const s = map.get(e.sessionId) || { sessionId: e.sessionId, reach: -1, source: null, device: null, days: {} };
const idx = STAGES.indexOf(e.stage);
if (idx > s.reach) s.reach = idx;
if (!s.days[e.stage]) s.days[e.stage] = dayOf(e.createdAt);
if (!s.source && e.source) s.source = e.source;
if (!s.device && e.device) s.device = e.device;
map.set(e.sessionId, s);
}
for (const s of map.values()) {
if (paidSessions.has(s.sessionId) && s.reach >= 2) { s.reach = 3; s.days.paid = s.days.submit; }
// A stage reached without its own event (e.g. no 'view' for a direct link) takes the
// day of the next stage that was recorded.
for (let i = STAGES.length - 1; i >= 0; i--) {
if (i <= s.reach && !s.days[STAGES[i]]) s.days[STAGES[i]] = s.days[STAGES[i + 1]];
}
s.source = s.source || 'direct';
s.device = s.device || 'unknown';
}
return [...map.values()];
}
/** Rows for AnalyticsDaily: sessions reaching each stage per day/source/device. Pure. */
function rollupRows(sessions) {
const by = new Map();
for (const s of sessions) {
for (let i = 0; i <= s.reach; i++) {
const key = [s.days[STAGES[i]], STAGES[i], s.source, s.device].join('|');
by.set(key, (by.get(key) || 0) + 1);
}
}
return [...by.entries()].map(([key, n]) => {
const [day, stage, source, device] = key.split('|');
return { day, stage, source, device, sessions: n };
});
}
const emptyCounts = () => ({ view: 0, start: 0, submit: 0, paid: 0 });
/**
* The funnel report from raw sessions plus rolled-up rows. Pure.
* @returns {{ totals, conversion, trend, bySource, byDevice }}
*/
function funnelReport(sessions, daily = []) {
const rows = [...rollupRows(sessions), ...daily.map(d => ({ ...d, day: typeof d.day === 'string' ? d.day.slice(0, 10) : dayOf(d.day) }))];
const totals = emptyCounts();
const trend = new Map(), bySource = new Map(), byDevice = new Map();
const add = (map, key, stage, n) => { const c = map.get(key) || emptyCounts(); c[stage] += n; map.set(key, c); };
for (const r of rows) {
totals[r.stage] += r.sessions;
add(trend, r.day, r.stage, r.sessions);
add(bySource, r.source, r.stage, r.sessions);
add(byDevice, r.device, r.stage, r.sessions);
}
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
const list = (map) => [...map.entries()].map(([key, c]) => ({ key, ...c, conversion: pct(c.paid, c.view) })).sort((a, b) => b.view - a.view);
return {
totals,
conversion: {
start: pct(totals.start, totals.view), submit: pct(totals.submit, totals.view), paid: pct(totals.paid, totals.view),
startToSubmit: pct(totals.submit, totals.start), submitToPaid: pct(totals.paid, totals.submit),
},
trend: [...trend.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, c]) => ({ day, ...c })),
bySource: list(bySource),
byDevice: list(byDevice),
};
}
// ─── Database ─────────────────────────────────────────────────────────────────
/** Records a 'view' or 'start'. Repeats from the same session are ignored. */
async function track({ sessionId, eventId, stage, source, userAgent }) {
if (!validSession(sessionId) || !TRACKED.includes(stage) || typeof eventId !== 'string') return false;
const device = deviceFromUA(userAgent);
if (!device) return false; // bots
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return false;
await prisma.analyticsEvent.createMany({ data: [{ sessionId, eventId, stage, source: cleanSource(source), device }], skipDuplicates: true });
return true;
}
/** 'submit' for a registration made on the public registration page. */
async function recordSubmit(registration, sessionId) {
if (!validSession(sessionId) || !registration?.id) return;
const first = await prisma.analyticsEvent.findFirst({ where: { sessionId, eventId: registration.eventId }, orderBy: { createdAt: 'asc' } });
await prisma.registration.updateMany({ where: { id: registration.id, analyticsSessionId: null }, data: { analyticsSessionId: sessionId } });
await prisma.analyticsEvent.createMany({
data: [{ sessionId, eventId: registration.eventId, stage: 'submit', source: first?.source || 'direct', device: first?.device || null }],
skipDuplicates: true,
});
}
async function paidSessionsFor(eventIds, sessionIds) {
if (!sessionIds.length) return new Set();
const regs = await prisma.registration.findMany({
where: { eventId: { in: eventIds }, status: 'paid', analyticsSessionId: { in: sessionIds } },
select: { analyticsSessionId: true },
});
return new Set(regs.map(r => r.analyticsSessionId));
}
/**
* The funnel for some events between two dates (by the day each stage was reached).
* Adds a by-ticket-option breakdown from the tracked registrations.
*/
async function funnelFor(eventIds, { from, to } = {}) {
const range = {};
if (from) range.gte = new Date(from);
if (to) { const t = new Date(to); t.setHours(23, 59, 59, 999); range.lte = t; }
const where = { eventId: { in: eventIds }, ...(from || to ? { createdAt: range } : {}) };
const events = await prisma.analyticsEvent.findMany({ where, select: { sessionId: true, stage: true, createdAt: true, source: true, device: true, eventId: true } });
// A session is per event (the same browser can look at several events).
const keyed = events.map(e => ({ ...e, sessionId: `${e.eventId}:${e.sessionId}` }));
const paidRaw = await paidSessionsFor(eventIds, [...new Set(events.map(e => e.sessionId))]);
const paid = new Set(events.filter(e => paidRaw.has(e.sessionId)).map(e => `${e.eventId}:${e.sessionId}`));
const daily = await prisma.analyticsDaily.findMany({
where: { eventId: { in: eventIds }, ...(from || to ? { day: range } : {}) },
select: { day: true, stage: true, source: true, device: true, sessions: true },
});
const report = funnelReport(sessionsFrom(keyed, paid), daily);
const regs = await prisma.registration.findMany({
where: { eventId: { in: eventIds }, analyticsSessionId: { not: null }, status: { not: 'cancelled' }, ...(from || to ? { createdAt: range } : {}) },
select: { status: true, registrationOptions: { select: { quantity: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } } },
});
const byOption = new Map();
for (const r of regs) {
for (const ro of r.registrationOptions) {
const key = ro.variant ? `${ro.eventOption.name} — ${ro.variant.name}` : ro.eventOption.name;
const c = byOption.get(key) || { key, submitted: 0, paid: 0 };
c.submitted += 1;
if (r.status === 'paid') c.paid += 1;
byOption.set(key, c);
}
}
return { ...report, byOption: [...byOption.values()].sort((a, b) => b.submitted - a.submitted) };
}
/** Rolls up sessions whose last activity is older than 90 days, then deletes their rows. */
async function rollupOldEvents(now = new Date()) {
const cutoff = new Date(now.getTime() - RAW_DAYS * 864e5);
const groups = await prisma.analyticsEvent.groupBy({ by: ['eventId', 'sessionId'], _max: { createdAt: true }, having: { createdAt: { _max: { lt: cutoff } } } });
if (groups.length === 0) return 0;
const byEvent = new Map();
for (const g of groups) byEvent.set(g.eventId, [...(byEvent.get(g.eventId) || []), g.sessionId]);
let rolled = 0;
for (const [eventId, sessionIds] of byEvent) {
for (let i = 0; i < sessionIds.length; i += 500) {
const batch = sessionIds.slice(i, i + 500);
const events = await prisma.analyticsEvent.findMany({ where: { eventId, sessionId: { in: batch } } });
const paid = await paidSessionsFor([eventId], batch);
const rows = rollupRows(sessionsFrom(events, paid));
await prisma.$transaction(async (tx) => {
for (const r of rows) {
await tx.analyticsDaily.upsert({
where: { eventId_day_stage_source_device: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device } },
update: { sessions: { increment: r.sessions } },
create: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions },
});
}
await tx.analyticsEvent.deleteMany({ where: { eventId, sessionId: { in: batch } } });
});
rolled += batch.length;
}
}
return rolled;
}
module.exports = { STAGES, RAW_DAYS, validSession, deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport, track, recordSubmit, funnelFor, rollupOldEvents };
+80
View File
@@ -0,0 +1,80 @@
/**
* Admin "view as user" — strictly read-only.
*
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
* logging page views. No controller can be reached for a write.
* 2. protect / optionalAuth check the session is live and set req.readOnly.
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
* req.readOnly is set.
* The only rows an impersonation session writes are its own session and request log.
*/
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return null;
try {
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return decoded.imp ? String(decoded.imp) : null;
} catch {
return null;
}
}
/** Whether this request may go ahead while impersonating. Pure. */
function allowedWhileImpersonating(method, path) {
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
}
function logRequest(sessionId, method, path, blocked = false) {
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
}
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
function readOnlyGuard(req, res, next) {
const sessionId = impersonationClaim(req);
if (!sessionId) return next();
const path = req.originalUrl || req.url;
if (!allowedWhileImpersonating(req.method, path)) {
logRequest(sessionId, req.method, path, true);
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
}
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
next();
}
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
async function liveSession(sessionId) {
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
if (!s || s.endedAt) return null;
if (s.expiresAt <= new Date()) {
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
return null;
}
return s;
}
/** Why `admin` can't view as `target`, or null. Pure. */
function impersonationBlocker(admin, target) {
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
if (!target) return 'User not found.';
if (target.id === admin.id) return "You can't view as yourself.";
if (target.role === 'admin') return "Admins can't be impersonated.";
if (!target.isActive) return 'That account is deactivated.';
return null;
}
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
+70
View File
@@ -0,0 +1,70 @@
const { deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport } = require('../src/utils/analytics');
const ev = (sessionId, stage, day, extra = {}) => ({ sessionId, stage, createdAt: `${day}T10:00:00Z`, ...extra });
describe('deviceFromUA', () => {
test('mobile, tablet, desktop and bots', () => {
expect(deviceFromUA('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile/15E148')).toBe('mobile');
expect(deviceFromUA('Mozilla/5.0 (Linux; Android 14; SM-X200) Safari')).toBe('tablet');
expect(deviceFromUA('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe('desktop');
expect(deviceFromUA('Googlebot/2.1')).toBeNull();
expect(deviceFromUA('')).toBeNull();
});
});
describe('cleanSource', () => {
test('tidies, defaulting to direct', () => {
expect(cleanSource(' Facebook ')).toBe('facebook');
expect(cleanSource('camp-2025<script>')).toBe('camp-2025script');
expect(cleanSource('')).toBe('direct');
});
});
describe('sessionsFrom', () => {
test('a session counts as having reached every earlier stage', () => {
const [s] = sessionsFrom([ev('a', 'start', '2026-01-02')]);
expect(s.reach).toBe(1);
expect(s.days.view).toBe('2026-01-02');
});
test('paid only counts for sessions that submitted', () => {
const out = sessionsFrom([ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'submit', '2026-01-03'), ev('b', 'view', '2026-01-01')], new Set(['a', 'b']));
const a = out.find(s => s.sessionId === 'a');
expect(a.reach).toBe(3);
expect(a.days.paid).toBe('2026-01-03');
expect(a.source).toBe('fb');
expect(out.find(s => s.sessionId === 'b').reach).toBe(0);
});
});
describe('funnelReport', () => {
const sessions = sessionsFrom([
ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'start', '2026-01-01'), ev('a', 'submit', '2026-01-01'),
ev('b', 'view', '2026-01-01', { source: 'fb', device: 'desktop' }), ev('b', 'start', '2026-01-02'),
ev('c', 'view', '2026-01-02', { device: 'mobile' }),
ev('d', 'view', '2026-01-02', { device: 'mobile' }),
], new Set(['a']));
test('counts and conversion per stage', () => {
const r = funnelReport(sessions);
expect(r.totals).toEqual({ view: 4, start: 2, submit: 1, paid: 1 });
expect(r.conversion.start).toBe(50);
expect(r.conversion.paid).toBe(25);
expect(r.conversion.startToSubmit).toBe(50);
});
test('daily trend and breakdowns', () => {
const r = funnelReport(sessions);
expect(r.trend).toEqual([{ day: '2026-01-01', view: 2, start: 1, submit: 1, paid: 1 }, { day: '2026-01-02', view: 2, start: 1, submit: 0, paid: 0 }]);
expect(r.bySource.find(s => s.key === 'fb')).toMatchObject({ view: 2, paid: 1, conversion: 50 });
expect(r.bySource.find(s => s.key === 'direct').view).toBe(2);
expect(r.byDevice.find(s => s.key === 'mobile').view).toBe(3);
});
test('rolled-up rows add to the raw counts', () => {
const daily = rollupRows(sessionsFrom([ev('x', 'view', '2025-10-01'), ev('x', 'submit', '2025-10-01')], new Set(['x'])));
expect(daily.find(r => r.stage === 'paid').sessions).toBe(1);
const r = funnelReport(sessions, daily);
expect(r.totals).toEqual({ view: 5, start: 3, submit: 2, paid: 2 });
});
});
+43
View File
@@ -0,0 +1,43 @@
const { allowedWhileImpersonating, impersonationBlocker } = require('../src/utils/impersonation');
describe('allowedWhileImpersonating', () => {
test('reads are allowed', () => {
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
});
test('every write is refused', () => {
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
expect(allowedWhileImpersonating(m, '/api/registrations')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/payments/yoco-checkout')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/users/profile')).toBe(false);
}
});
test('except ending the session and logging page views', () => {
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/log?x=1')).toBe(true);
expect(allowedWhileImpersonating('DELETE', '/api/admin/impersonate/end')).toBe(false);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end/../../users')).toBe(false);
});
});
describe('impersonationBlocker', () => {
const admin = { id: 'a', role: 'admin', twoFactorEnabled: true };
const user = { id: 'u', role: 'user', isActive: true };
test('an admin with 2FA can view a user', () => {
expect(impersonationBlocker(admin, user)).toBeNull();
expect(impersonationBlocker(admin, { ...user, role: 'supervisor' })).toBeNull();
});
test('refusals', () => {
expect(impersonationBlocker({ ...admin, role: 'supervisor' }, user)).toMatch(/Only admins/);
expect(impersonationBlocker({ ...admin, twoFactorEnabled: false }, user)).toMatch(/two-factor/);
expect(impersonationBlocker(admin, { ...user, role: 'admin' })).toMatch(/Admins can't/);
expect(impersonationBlocker(admin, { ...admin })).toMatch(/yourself/);
expect(impersonationBlocker(admin, { ...user, isActive: false })).toMatch(/deactivated/);
expect(impersonationBlocker(admin, null)).toMatch(/not found/);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.21.0",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.21.0",
"version": "1.23.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.21.0",
"version": "1.23.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+1 -1
View File
@@ -40,7 +40,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
}
if (event.isLive || event.isActive) {
redirect(`/events/${event.id}`);
redirect(`/events/${event.id}?src=${encodeURIComponent(redirectUrl)}`);
}
return (
@@ -30,6 +30,8 @@ const ACTIONS = [
"scan_lock_force_released",
"offline_sync",
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -54,6 +56,8 @@ const ACTION_LABELS: Record<string, string> = {
scan_lock_force_released: "Offline lock force-released",
offline_sync: "Offline scans synced",
ticket_transferred: "Ticket transferred",
impersonation_started: "Impersonation started",
impersonation_ended: "Impersonation ended",
};
interface AuditLogEntry {
@@ -81,6 +85,49 @@ function formatMetadata(metadata: Record<string, unknown> | null): string {
}
}
type ImpSession = {
id: string; reason: string; startedAt: string; expiresAt: string; endedAt: string | null; endedReason: string | null;
admin: { name: string; email: string }; target: { name: string; email: string };
requests: { method: string; path: string; blocked: boolean; createdAt: string }[];
};
/** "View as" sessions, each with the trail of pages and API calls made during it. */
function ImpersonationSessions({ token }: { token: string }) {
const [sessions, setSessions] = useState<ImpSession[]>([]);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
}, [token]);
if (sessions.length === 0) return null;
return (
<div className="border rounded-xl bg-white shadow-sm p-4 mb-4">
<div className="font-semibold mb-2">Impersonation sessions</div>
<ul className="divide-y text-sm">
{sessions.map(s => (
<li key={s.id} className="py-2">
<button className="w-full text-left flex flex-wrap items-baseline justify-between gap-2" onClick={() => setOpen(open === s.id ? null : s.id)}>
<span><strong>{s.admin.name}</strong> viewed as <strong>{s.target.name}</strong> — “{s.reason}”</span>
<span className="text-xs text-gray-500">
{new Date(s.startedAt).toLocaleString()} · {s.endedAt ? `${s.endedReason || "ended"} ${new Date(s.endedAt).toLocaleTimeString()}` : new Date(s.expiresAt) > new Date() ? "active" : "expired"} · {s.requests.length} request(s){s.requests.some(r => r.blocked) ? ` · ${s.requests.filter(r => r.blocked).length} blocked` : ""}
</span>
</button>
{open === s.id && (
<ul className="mt-2 text-xs font-mono bg-gray-50 rounded p-2 max-h-64 overflow-auto space-y-0.5">
{s.requests.map((r, i) => (
<li key={i} className={r.blocked ? "text-red-700" : "text-gray-700"}>
{new Date(r.createdAt).toLocaleTimeString()} {r.method} {r.path}{r.blocked ? " (blocked — read-only)" : ""}
</li>
))}
{s.requests.length === 0 && <li className="text-gray-500">No requests recorded.</li>}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
export default function AdminAuditLogPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
@@ -148,6 +195,7 @@ export default function AdminAuditLogPage() {
You need admin access to view the audit log.
</div>
)}
{isAdmin && token && <ImpersonationSessions token={token} />}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
+6 -2
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -43,6 +43,7 @@ const QUICK_ACTIONS = [
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
funnel?: { views: number; paid: number; conversion: number | null } | null;
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
@@ -120,12 +121,15 @@ export default function AdminDashboardPage() {
</div>
)}
<StatCardRow>
<StatCardRow cols={overview?.funnel ? 6 : 5}>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
{overview?.funnel && (
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
)}
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
@@ -137,6 +137,7 @@ function SiteSettingsPageInner() {
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
const [notifyOnImpersonation, setNotifyOnImpersonation] = useState(false);
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
@@ -173,6 +174,7 @@ function SiteSettingsPageInner() {
setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || "");
setConsentRetentionDays(s.consent_medical_retention_days || "30");
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
@@ -322,6 +324,7 @@ function SiteSettingsPageInner() {
legal_website_url: legalWebsiteUrl.trim(),
legal_effective_date: legalEffectiveDate.trim(),
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
notify_user_on_impersonation: notifyOnImpersonation ? "true" : "false",
});
const handleTestSmtp = async () => {
@@ -670,6 +673,14 @@ function SiteSettingsPageInner() {
<p className="text-xs text-gray-500 mt-1">Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.</p>
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Support access</p>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={notifyOnImpersonation} onChange={e => setNotifyOnImpersonation(e.target.checked)} />
<span>Email a user when an admin views their account with &quot;Impersonate&quot; (read-only support access), including the reason given</span>
</label>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
@@ -187,6 +188,23 @@ export default function AdminUsersPage() {
}
};
const [impTarget, setImpTarget] = useState<{ id: string; name: string } | null>(null);
const [impReason, setImpReason] = useState("");
const [impBusy, setImpBusy] = useState(false);
const [impError, setImpError] = useState<string | null>(null);
const startImpersonation = async () => {
if (!impTarget || !token) return;
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
beginImpersonation(res);
} catch (e: any) {
setImpError(e?.message || "Couldn't start impersonation");
setImpBusy(false);
}
};
const resetTwoFactor = async (id: string, name: string, role: string) => {
// Admins and supervisors can't go without 2FA: a reset just makes them set it up
// again (on a channel that works) at their next login.
@@ -222,6 +240,24 @@ export default function AdminUsersPage() {
}
};
const impersonateModal = impTarget && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => !impBusy && setImpTarget(null)}>
<div className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold">View as {impTarget.name}</h3>
<p className="text-sm text-gray-600">You&apos;ll see the site exactly as they do, for up to 30 minutes. It&apos;s strictly read-only: nothing can be registered, paid, changed or sent. The session, your reason and every page you open are recorded in the audit log.</p>
<div>
<label className="block text-xs text-gray-600 mb-1">Reason (required)</label>
<textarea className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} value={impReason} onChange={e => setImpReason(e.target.value)} placeholder="e.g. Says their ticket isn't showing in My Events" maxLength={500} autoFocus />
</div>
{impError && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{impError}</div>}
<div className="flex justify-end gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => setImpTarget(null)} disabled={impBusy}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50" onClick={startImpersonation} disabled={impBusy || impReason.trim().length < 5}>{impBusy ? "Starting…" : "Start viewing"}</button>
</div>
</div>
</div>
);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
@@ -367,6 +403,9 @@ export default function AdminUsersPage() {
<td className="p-2">
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
{user?.role === "admin" && u.role !== "admin" && u.isActive && (
<button className="px-2 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700" onClick={() => { setImpTarget({ id: u.id, name: u.name }); setImpReason(""); setImpError(null); }} title="See the site exactly as this user sees it (read-only, 30 minutes, audited)">Impersonate</button>
)}
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
{u.twoFactorEnabled && (
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name, u.role)} title={u.role === "admin" || u.role === "supervisor" ? "Reset two-factor authentication — they set it up again at their next login" : "Turn off two-factor authentication for a user who can no longer receive their codes"}>Reset 2FA</button>
@@ -498,6 +537,7 @@ export default function AdminUsersPage() {
</div>
</div>
)}
{impersonateModal}
</div>
);
}
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -39,6 +39,7 @@ const QUICK_ACTIONS = [
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
funnel?: { views: number; paid: number; conversion: number | null } | null;
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
@@ -118,12 +119,15 @@ export default function SupervisorDashboardPage() {
</div>
)}
<StatCardRow>
<StatCardRow cols={overview?.funnel ? 6 : 5}>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
{overview?.funnel && (
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
)}
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
+2
View File
@@ -7,6 +7,7 @@ import { ContactButton } from "@/components/events/ContactButton";
import { LocationMap } from "@/components/events/LocationMap";
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
import { SponsorSpot } from "@/components/events/SponsorSpot";
import { FunnelTracker } from "@/components/events/FunnelTracker";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -166,6 +167,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
<FunnelTracker eventId={event.id} stage="view" />
<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
{event.picture ? (
+4
View File
@@ -8,6 +8,8 @@ import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { CookieConsent } from "@/components/shared/CookieConsent";
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
@@ -93,9 +95,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<ToastProvider>
<SetupGuard />
<ImpersonationBanner />
<BannerBar />
{children}
<HelpFab />
<CookieConsent />
</ToastProvider>
</AuthProvider>
</SiteSettingsProvider>
+1
View File
@@ -192,6 +192,7 @@ function Section({
],
cookies: [
"This website uses browser localStorage to store your login session token and user interface preferences (such as dismissed notifications). No third-party cookies, tracking pixels, or analytics tools are used.",
"If you choose \"Allow\" on the cookie notice, we also keep a random, anonymous visit identifier so we can count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link) and the type of device — never your name, IP address or browsing history. Detailed records are combined into daily totals after 90 days. If you choose \"No thanks\", nothing is counted.",
"You can clear your browser's local storage at any time through your browser settings, which will log you out of the site.",
],
security: [
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { analyticsSessionId } from "@/lib/analytics";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
@@ -393,7 +394,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
setCreatingRegistration(true);
const res = await apiFetch<{ id: string; totalDueComputed?: number }>("/api/registrations", {
method: "POST",
body: { eventId, options: items, waitlistToken, ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
});
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
} catch (e: any) {
@@ -419,6 +420,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
eventId,
options: items,
...(coupon ? { couponCode: coupon.code } : {}),
analyticsSessionId: analyticsSessionId(),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
...(waitlistToken ? { waitlistToken } : {}),
},
@@ -463,6 +465,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
method: "POST",
body: {
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
analyticsSessionId: analyticsSessionId(),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
},
});
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import RegisterForm from "./RegisterForm";
import { FunnelTracker } from "@/components/events/FunnelTracker";
export const revalidate = 60;
@@ -28,6 +29,7 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<FunnelTracker eventId={event.id} stage="start" />
<RegisterForm event={event} />
</main>
<Footer />
@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import { CONSENT_EVENT, trackFunnel } from "@/lib/analytics";
/** Records a funnel step for this page (once consent is given, even if given later). */
export function FunnelTracker({ eventId, stage }: { eventId: string; stage: "view" | "start" }) {
useEffect(() => {
trackFunnel(eventId, stage);
const onConsent = () => trackFunnel(eventId, stage);
window.addEventListener(CONSENT_EVENT, onConsent);
return () => window.removeEventListener(CONSENT_EVENT, onConsent);
}, [eventId, stage]);
return null;
}
@@ -17,6 +17,7 @@ import {
ShieldCheck,
HeartPulse,
MessageSquareHeart,
Filter,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -38,6 +39,7 @@ export const REPORTS = [
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
{ key: "consentStatus", label: "Parental consent status", description: "Youth events: consent per attendee", category: "Registration", icon: ShieldCheck, fields: 5 },
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
+114 -1
View File
@@ -5,12 +5,13 @@ import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send } from "lucide-react";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
import { StatTile, StatTileRow } from "./StatTile";
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
@@ -179,6 +180,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [medicalRows, setMedicalRows] = useState<any[]>([]);
// Post-event feedback, one entry per event that had a feedback form.
const [feedbackRows, setFeedbackRows] = useState<any[]>([]);
// Registration funnel for the selected events (null until viewed).
const [funnel, setFunnel] = useState<any | null>(null);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
@@ -288,6 +291,18 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
return byEv;
};
const loadFunnel = async (eventIds: string[]) => {
if (!token || !eventIds.length) return null;
const query = new URLSearchParams({ eventIds: eventIds.join(",") });
if (dateFrom) query.set("from", dateFrom);
if (dateTo) query.set("to", dateTo);
try {
return await apiFetch<any>(`/api/analytics/funnel?${query.toString()}`, { authToken: token });
} catch {
return null;
}
};
const loadFeedback = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [] as any[];
try {
@@ -443,6 +458,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
setDiscountRows(await loadDiscounts(selectedEventIds));
} else if (report === "consentStatus") {
setConsentRows(await loadConsent(selectedEventIds, "status"));
} else if (report === "funnel") {
setFunnel(await loadFunnel(selectedEventIds));
} else if (report === "feedbackSummary") {
setFeedbackRows(await loadFeedback(selectedEventIds));
} else if (report === "medicalList") {
@@ -1040,6 +1057,31 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
},
};
}
if (report === "funnel" && funnel) {
const pct = (v: number | null) => (v != null ? `${v}%` : "—");
const line = (label: string, c: any) => [label, c.view, c.start, c.submit, c.paid, pct(c.conversion ?? (c.view ? Math.round((c.paid / c.view) * 1000) / 10 : null))];
const rows: (string | number)[][] = [
line("All selected events", funnel.totals),
...(funnel.perEvent || []).length > 1 ? funnel.perEvent.map((p: any) => line(p.title, p.totals)) : [],
...funnel.bySource.map((s: any) => line(`Source: ${s.key}`, s)),
...funnel.byDevice.map((d: any) => line(`Device: ${d.key}`, d)),
...funnel.byOption.map((o: any) => [`Ticket: ${o.key}`, "", "", o.submitted, o.paid, ""]),
];
return {
title: 'Registration funnel', subtitle, kind: 'table', orientation: 'landscape',
stats: [
{ label: 'Event page visits', value: String(funnel.totals.view), tone: 'blue' },
{ label: 'Paid', value: String(funnel.totals.paid), tone: 'green' },
{ label: 'Conversion', value: pct(funnel.conversion.paid), tone: 'violet' },
],
chart: { title: 'Visitors reaching each stage', data: [
{ label: 'Viewed event', value: funnel.totals.view }, { label: 'Started', value: funnel.totals.start },
{ label: 'Submitted', value: funnel.totals.submit }, { label: 'Paid', value: funnel.totals.paid },
] },
note: 'Counts only visitors who allowed cookies. A visitor counts at a stage if they got that far or further.',
table: { columns: ["Breakdown", "Viewed", "Started", "Submitted", "Paid", "Conversion"], rows },
};
}
if (report === "feedbackSummary") {
const rows: (string | number)[][] = [];
for (const f of feedbackSummary.perEvent) {
@@ -1727,6 +1769,77 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
)}
</div>
)}
{report === "funnel" && (
<div>
{!funnel || funnel.totals.view === 0 ? (
<div className="text-sm text-gray-500">No tracked visits for the selected events and dates yet. Only visitors who allow cookies are counted.</div>
) : (() => {
const pct = (v: number | null) => (v != null ? `${v}%` : "—");
const stages = [
{ label: "Viewed event", value: funnel.totals.view },
{ label: `Started (${pct(funnel.conversion.start)})`, value: funnel.totals.start },
{ label: `Submitted (${pct(funnel.conversion.submit)})`, value: funnel.totals.submit },
{ label: `Paid (${pct(funnel.conversion.paid)})`, value: funnel.totals.paid },
];
const table = (title: string, rows: any[]) => rows.length === 0 ? null : (
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[560px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">{title}</th><th className="py-2 px-3 text-right">Viewed</th><th className="py-2 px-3 text-right">Started</th>
<th className="py-2 px-3 text-right">Submitted</th><th className="py-2 px-3 text-right">Paid</th><th className="py-2 px-3 text-right">Conversion</th>
</tr>
</thead>
<tbody>
{rows.map((r: any) => (
<tr key={r.key} className="border-t border-gray-100">
<td className="py-2 px-3">{r.key}</td><td className="py-2 px-3 text-right">{r.view}</td><td className="py-2 px-3 text-right">{r.start}</td>
<td className="py-2 px-3 text-right">{r.submit}</td><td className="py-2 px-3 text-right">{r.paid}</td><td className="py-2 px-3 text-right">{pct(r.conversion)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
return (
<>
<StatTileRow>
<StatTile icon={Eye} label="Event page visits" value={String(funnel.totals.view)} tone="blue" />
<StatTile icon={MousePointerClick} label="Started registering" value={String(funnel.totals.start)} tone="amber" />
<StatTile icon={BadgeCheck} label="Paid" value={`${funnel.totals.paid} (${pct(funnel.conversion.paid)})`} tone="green" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Visitors reaching each stage (% of event page visits)</div>
<HorizontalBarChart data={stages} valueFormatter={v => String(v)} />
<div className="text-xs text-gray-500 mt-2">Started → submitted: {pct(funnel.conversion.startToSubmit)} · Submitted → paid: {pct(funnel.conversion.submitToPaid)}</div>
</div>
{funnel.trend.length > 1 && (
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Event page visits per day</div>
<AreaTrendChart data={funnel.trend.map((t: any) => ({ label: t.day.slice(5), value: t.view }))} />
</div>
)}
{(funnel.perEvent || []).length > 1 && table("Event", funnel.perEvent.map((p: any) => ({ key: p.title, ...p.totals, conversion: p.conversion.paid })))}
{table("Source", funnel.bySource)}
{table("Device", funnel.byDevice)}
{funnel.byOption.length > 0 && (
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[400px] w-full">
<thead><tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50"><th className="py-2 px-3">Ticket option</th><th className="py-2 px-3 text-right">Registrations</th><th className="py-2 px-3 text-right">Paid</th></tr></thead>
<tbody>
{funnel.byOption.map((o: any) => (
<tr key={o.key} className="border-t border-gray-100"><td className="py-2 px-3">{o.key}</td><td className="py-2 px-3 text-right">{o.submitted}</td><td className="py-2 px-3 text-right">{o.paid}</td></tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-gray-500 mt-3">Only visitors who allowed cookies are counted. A visitor counts at a stage if they got that far or further. Sources come from utm_source links and short links.</p>
</>
);
})()}
</div>
)}
{report === "feedbackSummary" && (
<div>
{feedbackSummary.perEvent.length === 0 ? (
@@ -0,0 +1,32 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { Cookie } from "lucide-react";
import { getConsent, setConsent } from "@/lib/analytics";
/**
* Asks once whether we may use a cookie-like identifier to count visits (our own
* registration-funnel stats). Declining changes nothing else about the site.
*/
export function CookieConsent() {
const [show, setShow] = useState(false);
useEffect(() => { setShow(getConsent() === null); }, []);
if (!show) return null;
const choose = (v: "accepted" | "declined") => { setConsent(v); setShow(false); };
return (
<div className="fixed bottom-0 inset-x-0 z-40 p-3 sm:p-4 pointer-events-none" role="region" aria-label="Cookie consent">
<div className="pointer-events-auto max-w-3xl mx-auto bg-white border shadow-lg rounded-xl p-4 flex flex-col sm:flex-row sm:items-center gap-3">
<Cookie className="w-6 h-6 text-brand-600 shrink-0 hidden sm:block" />
<p className="text-sm text-gray-700 flex-1">
May we count your visit? We use our own anonymous statistics (no third-party analytics) to see how people find and register for events.{" "}
<Link href="/legal/privacy#cookies" className="text-brand-700 underline">Privacy policy</Link>
</p>
<div className="flex gap-2 shrink-0">
<button onClick={() => choose("declined")} className="px-3 py-1.5 text-sm rounded-lg border hover:bg-gray-50">No thanks</button>
<button onClick={() => choose("accepted")} className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700">Allow</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import React, { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { Eye } from "lucide-react";
import { currentImpersonation, endImpersonation, logImpersonatedPage, type Impersonation } from "@/lib/impersonation";
/** Bright bar on every page while an admin is viewing the site as someone else. */
export function ImpersonationBanner() {
const pathname = usePathname();
const [imp, setImp] = useState<Impersonation | null>(null);
const [left, setLeft] = useState("");
const [ending, setEnding] = useState(false);
useEffect(() => { setImp(currentImpersonation()); }, []);
// Every page viewed goes into the session's audit trail.
useEffect(() => {
if (imp && pathname) logImpersonatedPage(pathname + (typeof window !== "undefined" ? window.location.search : ""));
}, [imp, pathname]);
useEffect(() => {
if (!imp) return;
const tick = () => {
const ms = new Date(imp.expiresAt).getTime() - Date.now();
if (ms <= 0) { endImpersonation(); return; }
const m = Math.floor(ms / 60000), s = Math.floor((ms % 60000) / 1000);
setLeft(`${m}:${String(s).padStart(2, "0")}`);
};
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [imp]);
if (!imp) return null;
return (
<div className="sticky top-0 z-[60] bg-amber-400 text-amber-950 px-4 py-2 text-sm flex flex-wrap items-center justify-center gap-x-3 gap-y-1 shadow" role="status">
<Eye className="w-4 h-4 shrink-0" />
<span><strong>Viewing as {imp.name}</strong> ({imp.email}) — read-only, nothing can be changed.</span>
<span className="tabular-nums">{left} left</span>
<button
onClick={() => { setEnding(true); endImpersonation(); }}
disabled={ending}
className="px-2.5 py-1 rounded bg-amber-950 text-amber-50 text-xs font-medium hover:bg-black disabled:opacity-60"
>
{ending ? "Ending…" : "End impersonation"}
</button>
</div>
);
}
+2 -2
View File
@@ -66,6 +66,6 @@ export function StatCard({
);
}
export function StatCardRow({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5 gap-4">{children}</div>;
export function StatCardRow({ children, cols = 5 }: { children: React.ReactNode; cols?: 5 | 6 }) {
return <div className={`grid grid-cols-1 sm:grid-cols-3 ${cols === 6 ? "lg:grid-cols-6" : "lg:grid-cols-5"} gap-4`}>{children}</div>;
}
@@ -1,5 +1,5 @@
import React from "react";
import { Users, UserPlus, ShieldAlert } from "lucide-react";
import { Users, UserPlus, ShieldAlert, Eye } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -30,6 +30,9 @@ export const dashboardAdminUsersHelpContent: HelpContent = {
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Eye} title="Impersonate" tone="violet">
See the site exactly as a user does, to help with a problem (&quot;my ticket isn&apos;t showing&quot;). Give a reason. A bright banner shows while you&apos;re viewing, for up to 30 minutes. It&apos;s strictly read-only: nothing can be registered, paid, changed or sent. The reason and every page you open are recorded in the audit log. You need two-factor authentication on, and admins can&apos;t be impersonated. Optionally, users get an email when this happens (Site Settings → Legal → Support access).
</GuideItem>
<GuideItem icon={Users} title="Sign out everywhere" tone="amber">
Revokes all of that user&apos;s active sessions — useful if a device was lost or access needs to be cut immediately.
</GuideItem>
+1 -1
View File
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026).
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source (short links and utm_source links) and device. It counts only visitors who allowed cookies.
</GuideItem>
</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
"use client";
/**
* First-party registration-funnel tracking. Nothing is tracked until the visitor accepts
* cookies (CookieConsent); then a random session id is kept in localStorage and the
* event page ("view") and registration page ("start") report to our own API. No
* third-party analytics, and no personal data — the server records only the stage, the
* traffic source and a device type.
*/
import { API_BASE } from "@/lib/api";
const CONSENT_KEY = "hope_events_cookie_consent";
const SESSION_KEY = "hope_events_analytics_sid";
export const CONSENT_EVENT = "hope-events-consent";
export type Consent = "accepted" | "declined" | null;
export function getConsent(): Consent {
try {
const v = localStorage.getItem(CONSENT_KEY);
return v === "accepted" || v === "declined" ? v : null;
} catch {
return null;
}
}
export function setConsent(v: "accepted" | "declined") {
try {
localStorage.setItem(CONSENT_KEY, v);
if (v === "declined") localStorage.removeItem(SESSION_KEY);
} catch { /* storage unavailable */ }
try { window.dispatchEvent(new Event(CONSENT_EVENT)); } catch { /* no window */ }
}
/** The anonymous session id — only when cookies were accepted. */
export function analyticsSessionId(): string | undefined {
if (getConsent() !== "accepted") return undefined;
try {
if (localStorage.getItem("hope_events_impersonation")) return undefined;
let id = localStorage.getItem(SESSION_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(SESSION_KEY, id);
}
return id;
} catch {
return undefined;
}
}
/** Where the visitor came from: ?utm_source=… or ?src=<short-link alias>. */
function sourceFromUrl(): string | undefined {
try {
const p = new URLSearchParams(window.location.search);
return p.get("utm_source") || p.get("src") || undefined;
} catch {
return undefined;
}
}
/** Reports a funnel step. Silently does nothing without consent or on any error. */
export function trackFunnel(eventId: string, stage: "view" | "start") {
const sessionId = analyticsSessionId();
if (!sessionId || !eventId) return;
const body = JSON.stringify({ sessionId, eventId, stage, source: sourceFromUrl() });
try {
fetch(`${API_BASE}/api/analytics/track`, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {});
} catch { /* ignore */ }
}
+5
View File
@@ -119,6 +119,11 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
} catch { /* not JSON — use raw text */ }
// An admin/supervisor session from before 2FA was required for their role:
// end it and send them to log in, where they enrol.
// The admin's "view as" session ended or expired: put their own session back.
if (res.status === 401 && data?.code === "IMPERSONATION_ENDED" && typeof window !== "undefined") {
const { restoreAdmin } = await import("@/lib/impersonation");
restoreAdmin();
}
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
localStorage.removeItem("hope_events_token");
localStorage.removeItem("hope_events_user_cache");
+72
View File
@@ -0,0 +1,72 @@
"use client";
/**
* Admin "view as user". The admin's own token (and cached profile) are put aside while
* the impersonation token is the active one, and restored when it ends. Everything is
* read-only on the server (any write is refused), so this is only about which token the
* browser sends.
*/
import { API_BASE } from "@/lib/api";
const TOKEN_KEY = "hope_events_token";
const USER_CACHE_KEY = "hope_events_user_cache";
const ADMIN_TOKEN_KEY = "hope_events_admin_token";
const ADMIN_CACHE_KEY = "hope_events_admin_user_cache";
const IMP_KEY = "hope_events_impersonation";
export type Impersonation = { sessionId: string; expiresAt: string; name: string; email: string };
export function currentImpersonation(): Impersonation | null {
try {
const raw = localStorage.getItem(IMP_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export const isImpersonating = () => !!currentImpersonation();
/** Switches this browser to the impersonation token and opens the user's dashboard. */
export function beginImpersonation(res: { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } }) {
try {
localStorage.setItem(ADMIN_TOKEN_KEY, localStorage.getItem(TOKEN_KEY) || "");
localStorage.setItem(ADMIN_CACHE_KEY, localStorage.getItem(USER_CACHE_KEY) || "");
localStorage.setItem(TOKEN_KEY, res.token);
localStorage.removeItem(USER_CACHE_KEY);
localStorage.setItem(IMP_KEY, JSON.stringify({ sessionId: res.session.id, expiresAt: res.session.expiresAt, name: res.session.target.name, email: res.session.target.email }));
} catch { /* storage unavailable */ }
window.location.href = "/dashboard/user";
}
/** Puts the admin's own session back (no server call). */
export function restoreAdmin(redirect = true) {
try {
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY);
const adminCache = localStorage.getItem(ADMIN_CACHE_KEY);
if (adminToken) localStorage.setItem(TOKEN_KEY, adminToken); else localStorage.removeItem(TOKEN_KEY);
if (adminCache) localStorage.setItem(USER_CACHE_KEY, adminCache); else localStorage.removeItem(USER_CACHE_KEY);
localStorage.removeItem(ADMIN_TOKEN_KEY);
localStorage.removeItem(ADMIN_CACHE_KEY);
localStorage.removeItem(IMP_KEY);
} catch { /* storage unavailable */ }
if (redirect) window.location.href = "/dashboard/admin/users";
}
/** Ends the session on the server (audited), then restores the admin. */
export async function endImpersonation() {
try {
const token = localStorage.getItem(TOKEN_KEY);
if (token) await fetch(`${API_BASE}/api/admin/impersonate/end`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch { /* the session expires by itself anyway */ }
restoreAdmin();
}
/** Records a page view in the session's audit trail. */
export function logImpersonatedPage(path: string) {
try {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
fetch(`${API_BASE}/api/admin/impersonate/log`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ path }), keepalive: true }).catch(() => {});
} catch { /* ignore */ }
}
+1
View File
@@ -30,6 +30,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
### For staff on the day
- **QR code scanner** — check people in by scanning their ticket with any phone camera. A single ticket can cover a whole family and be checked in a few people at a time.
- **See where people drop off** — a privacy-friendly funnel (no Google Analytics) shows how many people viewed an event, started registering and paid, and which links and short URLs brought them.
- **Feedback after every event** — a short survey with star ratings goes out automatically the morning after, with one reminder. See response rates and compare this year's camp with last year's.
- **Sponsor a spot** — let your congregation fund places for people who can't afford to come, with an optional "12 of 20 spots sponsored" progress bar. You decide who receives the help; sponsors never see who.
- **Rooms & groups for camps** — drag campers into cabins, small groups or buses (or auto-fill by capacity and gender), print lists for leaders, and show the cabin on each ticket.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.21.0",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.21.0",
"version": "1.23.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.21.0",
"version": "1.23.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",