Add unified analytics system with GeoIP geo-tracking

Full analytics platform with MaxMind GeoLite2 IP-to-location resolution,
cross-module dashboard (docs, video, photo), user drill-down, volunteer
self-service stats, and ANALYTICS_ADMIN role with feature flag controls.

- ANALYTICS_ADMIN role + ANALYTICS_ROLES group across backend and frontend
- GeoIP service (MaxMind GeoLite2, lazy-loaded, graceful degradation)
- Geo fields (country, region, city, lat/lng) on DocsPageView, VideoView, PhotoView
- IP resolved to geo before SHA-256 hashing (privacy-preserving)
- Unified analytics module: overview, geo, content, user engagement endpoints
- 4 admin dashboard pages: Overview, Geography (Leaflet map), Content, Users
- Volunteer MyAnalyticsPage for self-service activity stats
- Settings UI: enableAnalytics, analyticsGeoEnabled, trackAuthenticatedUsers, retentionDays
- Scheduled cleanup job respecting configurable retention period
- config.sh: Analytics + MaxMind prompt in configure_features()
- Control panel: enableAnalytics flag, template, discovery, wizard, detail page
- Docker: geoip volume mount, MaxMind env vars, entrypoint auto-download
- Nginx: X-Forwarded-For fix ($proxy_add_x_forwarded_for) for real client IP
- Express trust proxy set to 2 for Pangolin/Newt tunnel chain
- CORS updated for docs origin (cmlite.org + docs.cmlite.org)
- Lander page: added docs-analytics tracking snippet
- Prisma migration: 20260402100000_add_analytics_system

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-03 08:47:44 -06:00
parent 0a20444a74
commit 08bd1f92b0
46 changed files with 2718 additions and 24 deletions

View File

@ -420,3 +420,8 @@ GOTIFY_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)
BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server
BUNKER_OPS_REMOTE_WRITE_URL= # VictoriaMetrics remote_write endpoint (e.g., https://ops.example.com/api/v1/write)
# --- GeoIP (MaxMind GeoLite2) ---
# Free account: https://www.maxmind.com/en/geolite2/signup
MAXMIND_ACCOUNT_ID= # MaxMind account ID
MAXMIND_LICENSE_KEY= # MaxMind license key (auto-downloads GeoLite2-City DB at startup)

View File

@ -45,6 +45,10 @@ import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
import AnalyticsOverviewPage from '@/pages/analytics/AnalyticsOverviewPage';
import GeoAnalyticsPage from '@/pages/analytics/GeoAnalyticsPage';
import ContentAnalyticsPage from '@/pages/analytics/ContentAnalyticsPage';
import UserAnalyticsPage from '@/pages/analytics/UserAnalyticsPage';
import DocsCommentsPage from '@/pages/DocsCommentsPage';
import DocsMetadataPage from '@/pages/DocsMetadataPage';
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
@ -62,6 +66,11 @@ import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage';
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
import PetitionsPage from '@/pages/influence/PetitionsPage';
import PetitionSignaturesPage from '@/pages/influence/PetitionSignaturesPage';
import PetitionModerationPage from '@/pages/influence/PetitionModerationPage';
import PetitionsListPage from '@/pages/public/PetitionsListPage';
import PetitionPage from '@/pages/public/PetitionPage';
import PublicLandingPage from '@/pages/public/LandingPage';
import PagesIndexPage from '@/pages/public/PagesIndexPage';
import EventsPage from '@/pages/public/EventsPage';
@ -100,6 +109,7 @@ import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
import DiscoverPage from '@/pages/volunteer/DiscoverPage';
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
import AchievementsPage from '@/pages/volunteer/AchievementsPage';
import MyAnalyticsPage from '@/pages/volunteer/MyAnalyticsPage';
import {
ADMIN_ROLES,
INFLUENCE_ROLES,
@ -113,6 +123,7 @@ import {
SOCIAL_ROLES,
SYSTEM_ROLES,
POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api';
import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage';
@ -240,6 +251,12 @@ export default function App() {
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} />
</Route>
<Route path="/petitions" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
<Route index element={<PetitionsListPage />} />
</Route>
<Route path="/petition/:slug" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
<Route index element={<PetitionPage />} />
</Route>
<Route path="/campaigns/create" element={
<FeatureGate feature="enableInfluence">
<ProtectedRoute>
@ -399,6 +416,7 @@ export default function App() {
<Route path="/volunteer/calendar/shared" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarsPage /></FeatureGate>} />
<Route path="/volunteer/calendar/friend/:userId" element={<FeatureGate feature="enableSocialCalendar"><FriendCalendarPage /></FeatureGate>} />
<Route path="/volunteer/calendar" element={<FeatureGate feature="enableSocialCalendar"><MyCalendarPage /></FeatureGate>} />
<Route path="/volunteer/my-analytics" element={<FeatureGate feature="enableAnalytics"><MyAnalyticsPage /></FeatureGate>} />
<Route path="/volunteer/*" element={<NotFoundPage />} />
</Route>
@ -574,6 +592,30 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="influence/petitions"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionsPage />
</ProtectedRoute>
}
/>
<Route
path="influence/petitions/:id/signatures"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionSignaturesPage />
</ProtectedRoute>
}
/>
<Route
path="influence/petitions/moderation"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionModerationPage />
</ProtectedRoute>
}
/>
<Route
path="influence/straw-polls"
element={
@ -815,6 +857,46 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="analytics"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<AnalyticsOverviewPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/geo"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<GeoAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/content"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<ContentAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/users"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<UserAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/users/:userId"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<UserAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="map"
element={

View File

@ -72,6 +72,7 @@ import {
PAYMENTS_ROLES,
SOCIAL_ROLES,
POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api';
@ -188,6 +189,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
...(settings?.enablePetitions !== false ? [
{ key: '/app/influence/petitions', icon: <FileTextOutlined />, label: 'Petitions' },
{ key: '/app/influence/petitions/moderation', icon: <FileTextOutlined />, label: 'Petition Review' },
] : []),
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
],
});
@ -326,6 +331,20 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
}
if (isSuperAdmin) {
if (settings?.enableAnalytics !== false && can(ANALYTICS_ROLES)) {
items.push({
key: 'analytics-submenu',
icon: <BarChartOutlined />,
label: 'Analytics',
children: [
{ key: '/app/analytics', icon: <DashboardOutlined />, label: 'Overview' },
{ key: '/app/analytics/geo', icon: <GlobalOutlined />, label: 'Geography' },
{ key: '/app/analytics/content', icon: <FileTextOutlined />, label: 'Content' },
{ key: '/app/analytics/users', icon: <TeamOutlined />, label: 'Users' },
],
});
}
items.push({
key: 'services-submenu',
icon: <CloudServerOutlined />,

View File

@ -22,11 +22,13 @@ const FEATURE_LABELS: Record<string, string> = {
enableMeetingPlanner: 'Meeting Planner',
enableTicketedEvents: 'Ticketed Events',
enableSocialCalendar: 'Social Calendar',
enablePetitions: 'Petitions',
enablePolls: 'Straw Polls',
enableAnalytics: 'Analytics Dashboard',
};
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePolls'>;
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePetitions' | 'enablePolls' | 'enableAnalytics'>;
children: ReactNode;
}

View File

@ -14,6 +14,7 @@ import {
TagOutlined,
TeamOutlined,
MessageOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
@ -65,6 +66,9 @@ export default function VolunteerLayout() {
if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' });
}
if (settings?.enableAnalytics) {
items.push({ key: '/volunteer/my-analytics', icon: <BarChartOutlined />, label: 'My Stats' });
}
return items;
}, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]);

View File

@ -17,6 +17,7 @@ const roleColors: Record<UserRole, string> = {
EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue',
ANALYTICS_ADMIN: 'processing',
USER: 'blue',
TEMP: 'default',
};

View File

@ -55,6 +55,7 @@ import {
LoadingOutlined,
ReloadOutlined,
ClockCircleOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { api } from '@/lib/api';
@ -470,6 +471,9 @@ export default function SettingsPage() {
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Petitions" name="enablePetitions" valuePropName="checked" extra="Public petition pages with signature collection, progress tracking, and campaign linking" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Straw Polls" name="enablePolls" valuePropName="checked" extra="Quick opinion polls with public landers and MkDocs widgets" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
@ -565,6 +569,27 @@ export default function SettingsPage() {
</Form.Item>
</Card>
</Col>
{/* Analytics */}
<Col xs={24} lg={12}>
<Card
size="small"
title={<Space><BarChartOutlined /> Analytics</Space>}
>
<Form.Item label="Analytics Dashboard" name="enableAnalytics" valuePropName="checked" extra="Unified cross-module analytics with geo-tracking" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="GeoIP Tracking" name="analyticsGeoEnabled" valuePropName="checked" extra="Resolve visitor locations from IP addresses (requires MaxMind credentials in .env)" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Track Authenticated Users" name="trackAuthenticatedUsers" valuePropName="checked" extra="Include logged-in user activity in analytics" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Retention (days)" name="analyticsRetentionDays" extra="Auto-delete analytics data older than this (7-365)" style={{ marginBottom: 0 }}>
<InputNumber min={7} max={365} style={{ width: '100%' }} />
</Form.Item>
</Card>
</Col>
</Row>
</div>
),
@ -596,7 +621,10 @@ export default function SettingsPage() {
<Form.Item label="Sign Request" name="notifyAdminSignRequested" valuePropName="checked" extra="When a resident requests a yard sign during canvassing" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Shift Cancellation" name="notifyAdminShiftCancellation" valuePropName="checked" extra="When a volunteer cancels their shift signup" style={{ marginBottom: 0 }}>
<Form.Item label="Shift Cancellation" name="notifyAdminShiftCancellation" valuePropName="checked" extra="When a volunteer cancels their shift signup" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Petition Milestones" name="notifyAdminPetitionMilestone" valuePropName="checked" extra="When a petition reaches a signature milestone (100, 500, 1000, etc.)" style={{ marginBottom: 0 }}>
<Switch />
</Form.Item>
</Card>

View File

@ -82,6 +82,7 @@ const roleColors: Record<UserRole, string> = {
EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue',
ANALYTICS_ADMIN: 'processing',
USER: 'blue',
TEMP: 'default',
};

View File

@ -0,0 +1,316 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Row, Col, Card, Statistic, Table, Select, Button, App, Spin, Empty, Tag, Progress } from 'antd';
import {
EyeOutlined,
UserOutlined,
CalendarOutlined,
ReloadOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ModuleBreakdown {
module: string;
views: number;
}
interface DayViews {
date: string;
views: number;
module: string;
}
interface OverviewData {
totalViews: number;
uniqueSessions: number;
avgViewsPerDay: number;
moduleBreakdown: ModuleBreakdown[];
viewsByDay: DayViews[];
}
interface ContentItem {
title: string;
module: string;
views: number;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const MODULE_COLORS: Record<string, string> = {
docs: '#1890ff',
video: '#722ed1',
photo: '#52c41a',
};
const MODULE_LABELS: Record<string, string> = {
docs: 'Docs',
video: 'Video',
photo: 'Photo',
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Pivot day-level rows into { date, docs, video, photo } for recharts */
function pivotByDay(rows: DayViews[]): Record<string, number | string>[] {
const map = new Map<string, Record<string, number | string>>();
for (const r of rows) {
if (!map.has(r.date)) map.set(r.date, { date: r.date });
const entry = map.get(r.date)!;
entry[r.module] = (typeof entry[r.module] === 'number' ? (entry[r.module] as number) : 0) + r.views;
}
return Array.from(map.values()).sort((a, b) =>
String(a.date).localeCompare(String(b.date)),
);
}
/* ------------------------------------------------------------------ */
/* Table columns */
/* ------------------------------------------------------------------ */
const contentColumns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (title: string) => (
<span style={{ fontWeight: 500 }}>{title}</span>
),
},
{
title: 'Module',
dataIndex: 'module',
key: 'module',
width: 100,
render: (mod: string) => (
<Tag color={MODULE_COLORS[mod] ?? 'default'}>
{MODULE_LABELS[mod] ?? mod}
</Tag>
),
},
{
title: 'Views',
dataIndex: 'views',
key: 'views',
width: 90,
sorter: (a: ContentItem, b: ContentItem) => a.views - b.views,
defaultSortOrder: 'descend' as const,
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function AnalyticsOverviewPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [days, setDays] = useState(30);
const [data, setData] = useState<OverviewData | null>(null);
const [content, setContent] = useState<ContentItem[]>([]);
const [loading, setLoading] = useState(true);
const { message } = App.useApp();
useEffect(() => {
setPageHeader({ title: 'Analytics Overview' });
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [overviewRes, contentRes] = await Promise.all([
api.get('/analytics/overview', { params: { days } }),
api.get('/analytics/content', { params: { days, module: 'all' } }),
]);
setData(overviewRes.data);
setContent(Array.isArray(contentRes.data) ? contentRes.data : contentRes.data.items ?? []);
} catch {
message.error('Failed to load analytics data');
} finally {
setLoading(false);
}
}, [days, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const chartData = useMemo(() => (data ? pivotByDay(data.viewsByDay) : []), [data]);
const totalModuleViews = useMemo(
() => (data ? data.moduleBreakdown.reduce((sum, m) => sum + m.views, 0) : 0),
[data],
);
return (
<div>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<BarChartOutlined style={{ fontSize: 18 }} />
<div style={{ flex: 1 }} />
<Select
value={days}
onChange={setDays}
style={{ width: 140 }}
options={[
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 },
{ label: 'Last 365 days', value: 365 },
]}
/>
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
Refresh
</Button>
</div>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : !data ? (
<Empty description="No analytics data available" />
) : (
<>
{/* Stat cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="Total Views"
value={data.totalViews}
prefix={<EyeOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="Unique Sessions"
value={data.uniqueSessions}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="Avg Views / Day"
value={data.avgViewsPerDay}
precision={1}
prefix={<CalendarOutlined />}
/>
</Card>
</Col>
</Row>
{/* Views over time chart */}
<Card title="Views Over Time" style={{ marginBottom: 24 }}>
{chartData.length === 0 ? (
<Empty description="No data for this period" />
) : (
<div style={{ width: '100%', height: 300 }}>
<ResponsiveContainer>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="docs"
name="Docs"
stroke={MODULE_COLORS.docs}
fill={MODULE_COLORS.docs}
fillOpacity={0.3}
stackId="1"
/>
<Area
type="monotone"
dataKey="video"
name="Video"
stroke={MODULE_COLORS.video}
fill={MODULE_COLORS.video}
fillOpacity={0.3}
stackId="1"
/>
<Area
type="monotone"
dataKey="photo"
name="Photo"
stroke={MODULE_COLORS.photo}
fill={MODULE_COLORS.photo}
fillOpacity={0.3}
stackId="1"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Card>
{/* Module breakdown */}
<Card title="Module Breakdown" style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]}>
{data.moduleBreakdown.map((m) => {
const pct = totalModuleViews > 0
? Math.round((m.views / totalModuleViews) * 100)
: 0;
return (
<Col xs={24} sm={8} key={m.module}>
<div style={{ textAlign: 'center' }}>
<Tag color={MODULE_COLORS[m.module] ?? 'default'} style={{ marginBottom: 8, fontSize: 13 }}>
{MODULE_LABELS[m.module] ?? m.module}
</Tag>
<Progress
type="circle"
percent={pct}
size={80}
strokeColor={MODULE_COLORS[m.module]}
format={() => m.views.toLocaleString()}
/>
<div style={{ marginTop: 4, fontSize: 12, opacity: 0.65 }}>
{pct}% of total
</div>
</div>
</Col>
);
})}
{data.moduleBreakdown.length === 0 && (
<Col span={24}>
<Empty description="No module data" />
</Col>
)}
</Row>
</Card>
{/* Top content table */}
<Card title="Top Content">
<Table
dataSource={content}
columns={contentColumns}
rowKey={(r, i) => `${r.module}-${r.title}-${i}`}
pagination={{ pageSize: 10, showSizeChanger: false }}
size="small"
scroll={{ x: 400 }}
/>
</Card>
</>
)}
</div>
);
}

View File

@ -0,0 +1,138 @@
import { useState, useEffect, useCallback } from 'react';
import { Row, Col, Card, Table, Select, Spin, Empty, Tag, Segmented, App } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FileTextOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
interface ContentItem {
id: string;
title: string;
views: number;
module: string;
}
interface ContentAnalyticsData {
content: ContentItem[];
}
const MODULE_TAG_COLORS: Record<string, string> = {
docs: 'blue',
video: 'purple',
photo: 'green',
};
const MODULE_OPTIONS = ['All', 'Docs', 'Video', 'Photo'];
export default function ContentAnalyticsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [days, setDays] = useState(30);
const [moduleFilter, setModuleFilter] = useState('All');
const [data, setData] = useState<ContentAnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const { message } = App.useApp();
useEffect(() => {
setPageHeader({ title: 'Content Analytics' });
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get('/analytics/content', {
params: { days, module: moduleFilter.toLowerCase() },
});
setData(res.data);
} catch {
message.error('Failed to load content analytics');
} finally {
setLoading(false);
}
}, [days, moduleFilter, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const columns: ColumnsType<ContentItem> = [
{
title: '#',
key: 'rank',
width: 50,
render: (_: unknown, __: ContentItem, index: number) => index + 1,
},
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: 'Module',
dataIndex: 'module',
key: 'module',
width: 100,
render: (mod: string) => (
<Tag color={MODULE_TAG_COLORS[mod] ?? 'default'}>
{mod.charAt(0).toUpperCase() + mod.slice(1)}
</Tag>
),
},
{
title: 'Views',
dataIndex: 'views',
key: 'views',
width: 100,
sorter: (a: ContentItem, b: ContentItem) => a.views - b.views,
defaultSortOrder: 'descend',
},
];
return (
<div>
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
<Col xs={24} sm={12}>
<Segmented
value={moduleFilter}
onChange={(val) => setModuleFilter(val as string)}
options={MODULE_OPTIONS}
/>
</Col>
<Col xs={24} sm={12} style={{ textAlign: 'right' }}>
<Select
value={days}
onChange={setDays}
style={{ width: 140 }}
options={[
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 },
{ label: 'Last 365 days', value: 365 },
]}
/>
</Col>
</Row>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : !data || data.content.length === 0 ? (
<Empty description="No content analytics data available" />
) : (
<Card title={<><FileTextOutlined style={{ marginRight: 8 }} />Content Performance</>}>
<Table
dataSource={data.content}
columns={columns}
rowKey="id"
size="small"
scroll={{ x: 400 }}
pagination={{ pageSize: 20, showSizeChanger: false }}
/>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,295 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Row, Col, Card, Table, Select, Button, App, Spin, Empty } from 'antd';
import {
ReloadOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface CountryRow {
country: string;
views: number;
}
interface CityRow {
city: string;
region: string | null;
country: string;
views: number;
}
interface GeoPoint {
latitude: number;
longitude: number;
count: number;
module: string;
}
interface GeoData {
viewsByCountry: CountryRow[];
topCities: CityRow[];
geoPoints: GeoPoint[];
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const MODULE_COLORS: Record<string, string> = {
docs: '#1890ff',
video: '#722ed1',
photo: '#52c41a',
};
const MODULE_LABELS: Record<string, string> = {
docs: 'Docs',
video: 'Video',
photo: 'Photo',
};
function markerRadius(count: number): number {
return Math.max(5, Math.min(20, Math.sqrt(count) * 3));
}
/* ------------------------------------------------------------------ */
/* Table columns */
/* ------------------------------------------------------------------ */
function buildCountryColumns(totalViews: number) {
return [
{
title: 'Country',
dataIndex: 'country',
key: 'country',
width: 100,
},
{
title: 'Views',
dataIndex: 'views',
key: 'views',
width: 90,
sorter: (a: CountryRow, b: CountryRow) => a.views - b.views,
defaultSortOrder: 'descend' as const,
},
{
title: '% of Total',
key: 'pct',
width: 100,
render: (_: unknown, row: CountryRow) => {
const pct = totalViews > 0 ? ((row.views / totalViews) * 100).toFixed(1) : '0.0';
return `${pct}%`;
},
},
];
}
const cityColumns = [
{
title: 'City',
dataIndex: 'city',
key: 'city',
ellipsis: true,
},
{
title: 'Region',
dataIndex: 'region',
key: 'region',
width: 110,
ellipsis: true,
render: (v: string | null) => v ?? '\u2014',
},
{
title: 'Country',
dataIndex: 'country',
key: 'country',
width: 80,
},
{
title: 'Views',
dataIndex: 'views',
key: 'views',
width: 80,
sorter: (a: CityRow, b: CityRow) => a.views - b.views,
defaultSortOrder: 'descend' as const,
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function GeoAnalyticsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [days, setDays] = useState(30);
const [data, setData] = useState<GeoData | null>(null);
const [loading, setLoading] = useState(true);
const { message } = App.useApp();
useEffect(() => {
setPageHeader({ title: 'Geographic Analytics' });
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get('/analytics/geo', { params: { days } });
setData(res.data);
} catch {
message.error('Failed to load geographic analytics');
} finally {
setLoading(false);
}
}, [days, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const totalViews = useMemo(
() => (data ? data.viewsByCountry.reduce((s, r) => s + r.views, 0) : 0),
[data],
);
const countryColumns = useMemo(() => buildCountryColumns(totalViews), [totalViews]);
return (
<div>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<GlobalOutlined style={{ fontSize: 18 }} />
<div style={{ flex: 1 }} />
<Select
value={days}
onChange={setDays}
style={{ width: 140 }}
options={[
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 },
{ label: 'Last 365 days', value: 365 },
]}
/>
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
Refresh
</Button>
</div>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : !data ? (
<Empty description="No geographic data available" />
) : (
<>
{/* Map */}
<Card title="Visitor Locations" style={{ marginBottom: 24 }}>
{data.geoPoints.length === 0 ? (
<Empty description="No location data for this period" />
) : (
<div style={{ height: 400, width: '100%' }}>
<MapContainer
center={[30, 0]}
zoom={2}
style={{ height: '100%', width: '100%', borderRadius: 8 }}
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{data.geoPoints.map((pt, idx) => (
<CircleMarker
key={`${pt.latitude}-${pt.longitude}-${pt.module}-${idx}`}
center={[pt.latitude, pt.longitude]}
radius={markerRadius(pt.count)}
pathOptions={{
color: MODULE_COLORS[pt.module] ?? '#1890ff',
fillColor: MODULE_COLORS[pt.module] ?? '#1890ff',
fillOpacity: 0.55,
weight: 1,
}}
>
<Popup>
<div style={{ fontSize: 12 }}>
<strong>{MODULE_LABELS[pt.module] ?? pt.module}</strong>
<br />
{pt.count.toLocaleString()} view{pt.count !== 1 ? 's' : ''}
</div>
</Popup>
</CircleMarker>
))}
</MapContainer>
</div>
)}
{/* Map legend */}
{data.geoPoints.length > 0 && (
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12 }}>
{Object.entries(MODULE_COLORS).map(([mod, color]) => (
<span key={mod} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: color,
}}
/>
{MODULE_LABELS[mod] ?? mod}
</span>
))}
</div>
)}
</Card>
{/* Two tables side by side */}
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Card title="Views by Country">
{data.viewsByCountry.length === 0 ? (
<Empty description="No country data" />
) : (
<Table
dataSource={data.viewsByCountry}
columns={countryColumns}
rowKey="country"
pagination={{ pageSize: 10, showSizeChanger: false }}
size="small"
scroll={{ x: 280 }}
/>
)}
</Card>
</Col>
<Col xs={24} md={12}>
<Card title="Top Cities">
{data.topCities.length === 0 ? (
<Empty description="No city data" />
) : (
<Table
dataSource={data.topCities}
columns={cityColumns}
rowKey={(r, i) => `${r.city}-${r.country}-${i}`}
pagination={{ pageSize: 10, showSizeChanger: false }}
size="small"
scroll={{ x: 350 }}
/>
)}
</Card>
</Col>
</Row>
</>
)}
</div>
);
}

View File

@ -0,0 +1,396 @@
import { useState, useEffect, useCallback } from 'react';
import { Row, Col, Card, Statistic, Table, Select, Button, Spin, Empty, Tag, App, Grid } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
TeamOutlined,
ArrowLeftOutlined,
PlayCircleOutlined,
ClockCircleOutlined,
PictureOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { useOutletContext, useParams, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
/* ---------- Shared helpers ---------- */
function formatWatchTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s}s`;
}
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
/* ---------- Types ---------- */
interface UserRow {
userId: string;
name: string | null;
email: string;
videoViews: number;
photoViews: number;
totalWatchTime: number;
lastActive: string | null;
}
interface UserDetailSummary {
videoViews: number;
totalWatchTime: number;
photoViews: number;
}
interface RecentVideoView {
videoId: number;
videoTitle: string;
watchTime: number;
completed: boolean;
viewedAt: string;
}
interface RecentPhotoView {
photoId: number;
photoTitle: string;
viewedAt: string;
}
interface UserDetailData {
summary: UserDetailSummary;
recentVideoViews: RecentVideoView[];
recentPhotoViews: RecentPhotoView[];
}
interface UserListData {
users: UserRow[];
pagination: { page: number; limit: number; total: number; totalPages: number };
}
/* ---------- List mode ---------- */
function UserList() {
const { message } = App.useApp();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [days, setDays] = useState(30);
const [page, setPage] = useState(1);
const [data, setData] = useState<UserListData | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get('/analytics/users', {
params: { days, page, limit: 20 },
});
setData(res.data);
} catch {
message.error('Failed to load user analytics');
} finally {
setLoading(false);
}
}, [days, page, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const columns: ColumnsType<UserRow> = [
{
title: 'Name',
key: 'name',
ellipsis: true,
render: (_: unknown, record: UserRow) => record.name || record.email,
},
{
title: 'Video Views',
dataIndex: 'videoViews',
key: 'videoViews',
width: 110,
sorter: (a: UserRow, b: UserRow) => a.videoViews - b.videoViews,
},
{
title: 'Photo Views',
dataIndex: 'photoViews',
key: 'photoViews',
width: 110,
sorter: (a: UserRow, b: UserRow) => a.photoViews - b.photoViews,
},
{
title: 'Watch Time',
dataIndex: 'totalWatchTime',
key: 'totalWatchTime',
width: 110,
render: (val: number) => formatWatchTime(val),
sorter: (a: UserRow, b: UserRow) => a.totalWatchTime - b.totalWatchTime,
},
{
title: 'Last Active',
dataIndex: 'lastActive',
key: 'lastActive',
width: 110,
render: (val: string | null) => formatRelativeTime(val),
},
];
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{ flex: 1 }} />
<Select
value={days}
onChange={(val) => { setDays(val); setPage(1); }}
style={{ width: 140 }}
options={[
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 },
{ label: 'Last 365 days', value: 365 },
]}
/>
</div>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : !data || data.users.length === 0 ? (
<Empty description="No user analytics data available" />
) : (
<Card title={<><TeamOutlined style={{ marginRight: 8 }} />User Engagement</>}>
<Table
dataSource={data.users}
columns={columns}
rowKey="userId"
size="small"
scroll={isMobile ? { x: 600 } : undefined}
loading={loading}
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () => navigate(`/app/analytics/users/${record.userId}`),
})}
pagination={{
current: page,
total: data.pagination.total,
pageSize: 20,
showSizeChanger: false,
onChange: setPage,
}}
/>
</Card>
)}
</div>
);
}
/* ---------- Detail mode ---------- */
function UserDetail({ userId }: { userId: string }) {
const { message } = App.useApp();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [days, setDays] = useState(30);
const [data, setData] = useState<UserDetailData | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get(`/analytics/users/${userId}`, {
params: { days },
});
setData(res.data);
} catch {
message.error('Failed to load user detail');
} finally {
setLoading(false);
}
}, [userId, days, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const videoColumns: ColumnsType<RecentVideoView> = [
{
title: 'Video',
dataIndex: 'videoTitle',
key: 'videoTitle',
ellipsis: true,
},
{
title: 'Watch Time',
dataIndex: 'watchTime',
key: 'watchTime',
width: 110,
render: (val: number) => formatWatchTime(val),
},
{
title: 'Completed',
dataIndex: 'completed',
key: 'completed',
width: 90,
render: (val: boolean) =>
val ? <Tag icon={<CheckCircleOutlined />} color="success">Yes</Tag> : <Tag>No</Tag>,
},
{
title: 'Viewed',
dataIndex: 'viewedAt',
key: 'viewedAt',
width: 110,
render: (val: string) => formatRelativeTime(val),
},
];
const photoColumns: ColumnsType<RecentPhotoView> = [
{
title: 'Photo',
dataIndex: 'photoTitle',
key: 'photoTitle',
ellipsis: true,
},
{
title: 'Viewed',
dataIndex: 'viewedAt',
key: 'viewedAt',
width: 110,
render: (val: string) => formatRelativeTime(val),
},
];
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/analytics/users')}
>
Back to Users
</Button>
<div style={{ flex: 1 }} />
<Select
value={days}
onChange={setDays}
style={{ width: 140 }}
options={[
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 },
{ label: 'Last 365 days', value: 365 },
]}
/>
</div>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : !data ? (
<Empty description="No data available for this user" />
) : (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="Video Views"
value={data.summary.videoViews}
prefix={<PlayCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="Watch Time"
value={formatWatchTime(data.summary.totalWatchTime)}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="Photo Views"
value={data.summary.photoViews}
prefix={<PictureOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={14}>
<Card title="Recent Video Views" style={{ marginBottom: 16 }}>
{data.recentVideoViews.length === 0 ? (
<Empty description="No video views" />
) : (
<Table
dataSource={data.recentVideoViews}
columns={videoColumns}
rowKey="videoId"
size="small"
scroll={isMobile ? { x: 500 } : undefined}
pagination={{ pageSize: 10, showSizeChanger: false }}
/>
)}
</Card>
</Col>
<Col xs={24} lg={10}>
<Card title="Recent Photo Views" style={{ marginBottom: 16 }}>
{data.recentPhotoViews.length === 0 ? (
<Empty description="No photo views" />
) : (
<Table
dataSource={data.recentPhotoViews}
columns={photoColumns}
rowKey="photoId"
size="small"
scroll={isMobile ? { x: 300 } : undefined}
pagination={{ pageSize: 10, showSizeChanger: false }}
/>
)}
</Card>
</Col>
</Row>
</>
)}
</div>
);
}
/* ---------- Main export ---------- */
export default function UserAnalyticsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { userId } = useParams<{ userId: string }>();
useEffect(() => {
setPageHeader({ title: 'User Analytics' });
return () => setPageHeader(null);
}, [setPageHeader]);
if (userId) {
return <UserDetail userId={userId} />;
}
return <UserList />;
}

View File

@ -0,0 +1,228 @@
import { useState, useEffect, useCallback } from 'react';
import { Row, Col, Card, Statistic, Table, Select, Spin, Empty, Tag, Typography, App, Grid } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PlayCircleOutlined,
ClockCircleOutlined,
PictureOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
/* ---------- Helpers ---------- */
function formatWatchTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s}s`;
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
/* ---------- Types ---------- */
interface MyAnalyticsSummary {
videoViews: number;
totalWatchTime: number;
photoViews: number;
}
interface RecentVideoView {
videoId: number;
videoTitle: string;
watchTime: number;
completed: boolean;
viewedAt: string;
}
interface RecentPhotoView {
photoId: number;
photoTitle: string;
viewedAt: string;
}
interface MyAnalyticsData {
summary: MyAnalyticsSummary;
recentVideoViews: RecentVideoView[];
recentPhotoViews: RecentPhotoView[];
}
/* ---------- Component ---------- */
export default function MyAnalyticsPage() {
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [days, setDays] = useState(30);
const [data, setData] = useState<MyAnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get('/analytics/my-activity', { params: { days } });
setData(res.data);
} catch {
message.error('Failed to load your stats');
} finally {
setLoading(false);
}
}, [days, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const videoColumns: ColumnsType<RecentVideoView> = [
{
title: 'Video',
dataIndex: 'videoTitle',
key: 'videoTitle',
ellipsis: true,
},
{
title: 'Watch Time',
dataIndex: 'watchTime',
key: 'watchTime',
width: 100,
render: (val: number) => formatWatchTime(val),
},
{
title: 'Completed',
dataIndex: 'completed',
key: 'completed',
width: 90,
render: (val: boolean) =>
val ? <Tag icon={<CheckCircleOutlined />} color="success">Yes</Tag> : <Tag>No</Tag>,
},
{
title: 'When',
dataIndex: 'viewedAt',
key: 'viewedAt',
width: 100,
render: (val: string) => formatRelativeTime(val),
},
];
const photoColumns: ColumnsType<RecentPhotoView> = [
{
title: 'Photo',
dataIndex: 'photoTitle',
key: 'photoTitle',
ellipsis: true,
},
{
title: 'When',
dataIndex: 'viewedAt',
key: 'viewedAt',
width: 100,
render: (val: string) => formatRelativeTime(val),
},
];
if (loading && !data) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<Typography.Title level={3} style={{ margin: 0 }}>My Stats</Typography.Title>
<div style={{ flex: 1 }} />
<Select
value={days}
onChange={setDays}
style={{ width: 140 }}
options={[
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 },
{ label: 'Last 365 days', value: 365 },
]}
/>
</div>
{!data ? (
<Empty description="No stats available yet" />
) : (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic
title="Videos Watched"
value={data.summary.videoViews}
prefix={<PlayCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic
title="Watch Time"
value={formatWatchTime(data.summary.totalWatchTime)}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic
title="Photos Viewed"
value={data.summary.photoViews}
prefix={<PictureOutlined />}
/>
</Card>
</Col>
</Row>
<Card title="Recent Videos" size="small" style={{ marginBottom: 16 }}>
{data.recentVideoViews.length === 0 ? (
<Empty description="No video views yet" />
) : (
<Table
dataSource={data.recentVideoViews}
columns={videoColumns}
rowKey="videoId"
size="small"
scroll={isMobile ? { x: 500 } : undefined}
loading={loading}
pagination={{ pageSize: 10, showSizeChanger: false }}
/>
)}
</Card>
<Card title="Recent Photos" size="small">
{data.recentPhotoViews.length === 0 ? (
<Empty description="No photo views yet" />
) : (
<Table
dataSource={data.recentPhotoViews}
columns={photoColumns}
rowKey="photoId"
size="small"
scroll={isMobile ? { x: 300 } : undefined}
loading={loading}
pagination={{ pageSize: 10, showSizeChanger: false }}
/>
)}
</Card>
</>
)}
</div>
);
}

View File

@ -13,7 +13,7 @@ export interface AppOutletContext {
setPageHeader: (config: PageHeaderConfig | null) => void;
}
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'POLLS_ADMIN' | 'USER' | 'TEMP';
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'POLLS_ADMIN' | 'ANALYTICS_ADMIN' | 'USER' | 'TEMP';
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
@ -101,7 +101,7 @@ export interface UsersListParams {
status?: UserStatus;
}
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN', 'POLLS_ADMIN'];
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN', 'POLLS_ADMIN', 'ANALYTICS_ADMIN'];
// Module-specific role groups
export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN'];
@ -115,6 +115,7 @@ export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN'];
export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN'];
export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN'];
export const POLLS_ROLES: UserRole[] = ['SUPER_ADMIN', 'POLLS_ADMIN', 'INFLUENCE_ADMIN'];
export const ANALYTICS_ROLES: UserRole[] = ['SUPER_ADMIN', 'ANALYTICS_ADMIN'];
// --- User Provisioning ---
@ -1170,7 +1171,12 @@ export interface SiteSettings {
enableMeetingPlanner: boolean;
enableTicketedEvents: boolean;
enableSocialCalendar: boolean;
enablePetitions: boolean;
enablePolls: boolean;
enableAnalytics: boolean;
analyticsRetentionDays: number;
analyticsGeoEnabled: boolean;
trackAuthenticatedUsers: boolean;
enableDocsCollaboration: boolean;
requireEventApproval: boolean;
autoSyncPeopleToMap: boolean;
@ -3453,3 +3459,88 @@ export interface StrawPoll {
requiresAuth?: boolean;
}
// --- Petitions ---
export type PetitionStatus = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'CLOSED' | 'ARCHIVED';
export type PetitionSignatureStatus = 'PENDING_VERIFICATION' | 'VERIFIED' | 'UNVERIFIED' | 'REJECTED';
export interface Petition {
id: string;
slug: string;
title: string;
description: string | null;
signatureGoal: number | null;
showProgress: boolean;
showSignatureCount: boolean;
showSignerNames: boolean;
signatureCountOffset: number;
requireName: boolean;
requireEmail: boolean;
requirePostalCode: boolean;
requirePhone: boolean;
allowComment: boolean;
commentLabel: string | null;
requireEmailConfirmation: boolean;
coverPhoto: string | null;
coverVideoId: number | null;
callToAction: string | null;
thankYouMessage: string | null;
highlightPetition: boolean;
linkedCampaignId: string | null;
linkedCampaign?: { id: string; slug: string; title: string; description: string | null; status: string } | null;
status: PetitionStatus;
isUserGenerated: boolean;
moderationStatus: CampaignModerationStatus | null;
rejectionReason?: string | null;
moderationNotes?: string | null;
createdByUserId?: string | null;
createdByUserEmail?: string | null;
createdByUserName: string | null;
reviewedByUserId?: string | null;
reviewedAt?: string | null;
createdAt: string;
updatedAt: string;
_count: { signatures: number };
}
export interface PetitionSignature {
id: string;
petitionId: string;
signerName: string | null;
signerEmail: string | null;
signerPostalCode: string | null;
signerPhone: string | null;
signerComment: string | null;
isAnonymous: boolean;
displayName: string | null;
status: PetitionSignatureStatus;
geoCountry: string | null;
geoRegion: string | null;
geoCity: string | null;
verifiedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface PetitionStats {
total: number;
verified: number;
goal: number | null;
percentComplete: number | null;
byCountry: Record<string, number>;
byRegion: Record<string, number>;
recentSigners: { displayName: string | null; geoCity: string | null; geoCountry: string | null; createdAt: string }[];
}
export interface PetitionsListResponse {
petitions: Petition[];
pagination: PaginationMeta;
}
export interface SignPetitionResponse {
id: string;
status: PetitionSignatureStatus;
verificationSent: boolean;
alreadySigned?: boolean;
}

View File

@ -30,6 +30,48 @@ echo "Running database seed..."
npx prisma db seed 2>&1 || echo "WARNING: Seed failed (non-fatal — seed.ts may require source files not present in production image)"
echo "Seed step done."
# Download MaxMind GeoLite2 database if credentials are provided and DB is missing/stale
if [ -n "$MAXMIND_ACCOUNT_ID" ] && [ -n "$MAXMIND_LICENSE_KEY" ]; then
GEOIP_DIR="/data/geoip"
GEOIP_DB="$GEOIP_DIR/GeoLite2-City.mmdb"
mkdir -p "$GEOIP_DIR" 2>/dev/null || true
# Re-download weekly (if file is older than 7 days or missing)
if [ ! -f "$GEOIP_DB" ] || [ "$(find "$GEOIP_DB" -mtime +7 2>/dev/null | wc -l)" -gt 0 ]; then
echo "Downloading MaxMind GeoLite2-City database..."
DOWNLOAD_URL="https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz"
# Use Node.js for download (BusyBox wget leaks auth header on redirects)
if node -e "
const https = require('https');
const fs = require('fs');
const auth = Buffer.from('$MAXMIND_ACCOUNT_ID:$MAXMIND_LICENSE_KEY').toString('base64');
const get = (url, cb) => https.get(url, { headers: url.includes('maxmind.com') ? { Authorization: 'Basic ' + auth } : {} }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return get(res.headers.location, cb);
if (res.statusCode !== 200) { cb(new Error('HTTP ' + res.statusCode)); return; }
const out = fs.createWriteStream('/tmp/geolite2.tar.gz');
res.pipe(out);
out.on('close', () => cb(null));
}).on('error', cb);
get('$DOWNLOAD_URL', (err) => { if (err) { console.error(err.message); process.exit(1); } });
"; then
tar -xzf /tmp/geolite2.tar.gz -C /tmp/ 2>/dev/null
MMDB_FILE=$(find /tmp -name 'GeoLite2-City.mmdb' -type f 2>/dev/null | head -1)
if [ -n "$MMDB_FILE" ]; then
mv "$MMDB_FILE" "$GEOIP_DB"
echo "GeoLite2-City database updated."
else
echo "WARNING: Downloaded archive but no .mmdb file found (non-fatal)"
fi
rm -rf /tmp/geolite2.tar.gz /tmp/GeoLite2-City_* 2>/dev/null
else
echo "WARNING: Failed to download GeoLite2 database (non-fatal — geo features disabled)"
fi
else
echo "GeoLite2-City database is up to date."
fi
else
echo "MaxMind credentials not set — GeoIP lookup disabled."
fi
# If running production mode (node dist/server.js) and dist is stale, recompile
if [ -f "src/server.ts" ] && echo "$@" | grep -q "npm.*start\|node.*dist"; then
if [ ! -f "dist/server.js" ] || [ "src/server.ts" -nt "dist/server.js" ]; then

40
api/package-lock.json generated
View File

@ -12,6 +12,7 @@
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@hocuspocus/server": "^3.4.4",
"@maxmind/geoip2-node": "^6.3.4",
"@prisma/client": "^6.3.0",
"@types/mime-types": "^3.0.1",
"bcryptjs": "^2.4.3",
@ -1675,6 +1676,15 @@
"node": ">=8"
}
},
"node_modules/@maxmind/geoip2-node": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/@maxmind/geoip2-node/-/geoip2-node-6.3.4.tgz",
"integrity": "sha512-BTRFHCX7Uie4wVSPXsWQfg0EVl4eGZgLCts0BTKAP+Eiyt1zmF2UPyuUZkaj0R59XSDYO+84o1THAtaenUoQYg==",
"license": "Apache-2.0",
"dependencies": {
"maxmind": "^5.0.0"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
@ -4285,6 +4295,19 @@
"node": ">= 0.4"
}
},
"node_modules/maxmind": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.6.tgz",
"integrity": "sha512-5bvd/u+kIaTqaGM+xkXjatzQw1dQfSmlLggr2W1EKMyMxSgx2woZyusLpNpZ4DdPmL+1bbJWeo4LXsi6bC0Iew==",
"dependencies": {
"mmdb-lib": "3.0.2",
"tiny-lru": "13.0.0"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -4370,6 +4393,15 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mmdb-lib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz",
"integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==",
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@ -5509,6 +5541,14 @@
"node": ">=20"
}
},
"node_modules/tiny-lru": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz",
"integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==",
"engines": {
"node": ">=14"
}
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",

View File

@ -20,6 +20,7 @@
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@hocuspocus/server": "^3.4.4",
"@maxmind/geoip2-node": "^6.3.4",
"@prisma/client": "^6.3.0",
"@types/mime-types": "^3.0.1",
"bcryptjs": "^2.4.3",

View File

@ -0,0 +1,39 @@
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE 'ANALYTICS_ADMIN';
-- AlterTable
ALTER TABLE "docs_page_views" ADD COLUMN "city" VARCHAR(100),
ADD COLUMN "country" VARCHAR(2),
ADD COLUMN "ip_address_hash" VARCHAR(64),
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION,
ADD COLUMN "region" VARCHAR(100);
-- AlterTable
ALTER TABLE "photo_views" ADD COLUMN "city" VARCHAR(100),
ADD COLUMN "country" VARCHAR(2),
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION,
ADD COLUMN "region" VARCHAR(100);
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "analytics_geo_enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "analytics_retention_days" INTEGER NOT NULL DEFAULT 90,
ADD COLUMN "enable_analytics" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "track_authenticated_users" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "video_views" ADD COLUMN "city" VARCHAR(100),
ADD COLUMN "country" VARCHAR(2),
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION,
ADD COLUMN "region" VARCHAR(100);
-- CreateIndex
CREATE INDEX "docs_page_views_country_createdAt_idx" ON "docs_page_views"("country", "createdAt");
-- CreateIndex
CREATE INDEX "idx_photo_views_country_date" ON "photo_views"("country", "viewed_at");
-- CreateIndex
CREATE INDEX "idx_video_views_country_created" ON "video_views"("country", "created_at");

View File

@ -22,6 +22,7 @@ enum UserRole {
EVENTS_ADMIN
SOCIAL_ADMIN
POLLS_ADMIN
ANALYTICS_ADMIN
USER
TEMP
}
@ -223,6 +224,10 @@ model User {
docShareLinksCreated DocShareLink[] @relation("DocShareLinkCreator")
docWatches DocWatch[] @relation("DocWatcher")
// Petitions
petitionsCreated Petition[] @relation("PetitionCreator")
petitionsReviewed Petition[] @relation("PetitionReviewer")
@@map("users")
}
@ -315,12 +320,140 @@ model Campaign {
stories ImpactStory[] @relation("CampaignStories")
milestones CampaignMilestone[] @relation("CampaignMilestones")
donationOrders Order[] @relation("CampaignDonations")
petitions Petition[] @relation("PetitionLinkedCampaign")
@@index([moderationStatus])
@@index([isUserGenerated])
@@map("campaigns")
}
// ============================================================================
// INFLUENCE — PETITIONS
// ============================================================================
enum PetitionStatus {
DRAFT
ACTIVE
PAUSED
CLOSED
ARCHIVED
}
enum PetitionSignatureStatus {
PENDING_VERIFICATION
VERIFIED
UNVERIFIED
REJECTED
}
model Petition {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
// Goal and progress
signatureGoal Int?
showProgress Boolean @default(true)
showSignatureCount Boolean @default(true)
showSignerNames Boolean @default(true)
signatureCountOffset Int @default(0)
// Form fields
requireName Boolean @default(true)
requireEmail Boolean @default(true)
requirePostalCode Boolean @default(false)
requirePhone Boolean @default(false)
allowComment Boolean @default(true)
commentLabel String?
// Email confirmation
requireEmailConfirmation Boolean @default(false)
confirmationEmailSubject String?
confirmationEmailBody String? @db.Text
// Presentation
coverPhoto String?
coverVideoId Int?
callToAction String? @db.Text
thankYouMessage String? @db.Text
highlightPetition Boolean @default(false)
// Linked campaign (post-sign CTA)
linkedCampaignId String?
linkedCampaign Campaign? @relation("PetitionLinkedCampaign", fields: [linkedCampaignId], references: [id], onDelete: SetNull)
// Status and moderation
status PetitionStatus @default(DRAFT)
isUserGenerated Boolean @default(false)
moderationStatus CampaignModerationStatus?
rejectionReason String? @db.Text
moderationNotes String? @db.Text
// Creator
createdByUserId String?
createdByUser User? @relation("PetitionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserEmail String?
createdByUserName String?
// Reviewer
reviewedByUserId String?
reviewedByUser User? @relation("PetitionReviewer", fields: [reviewedByUserId], references: [id], onDelete: SetNull)
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signatures PetitionSignature[]
@@index([status])
@@index([isUserGenerated])
@@index([highlightPetition])
@@index([linkedCampaignId])
@@map("petitions")
}
model PetitionSignature {
id String @id @default(cuid())
petitionId String
petition Petition @relation(fields: [petitionId], references: [id], onDelete: Cascade)
// Signer info
signerName String?
signerEmail String?
signerPostalCode String?
signerPhone String?
signerComment String? @db.Text
isAnonymous Boolean @default(false)
displayName String?
// Status and verification
status PetitionSignatureStatus @default(UNVERIFIED)
verificationToken String? @unique
verificationSentAt DateTime?
verifiedAt DateTime?
// CRM link
contactId String?
contact Contact? @relation("PetitionSignatureContact", fields: [contactId], references: [id], onDelete: SetNull)
// Geo (from IP via MaxMind)
signerIp String?
geoCountry String?
geoRegion String?
geoCity String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([petitionId, signerEmail])
@@index([petitionId])
@@index([signerEmail])
@@index([petitionId, status])
@@index([contactId])
@@map("petition_signatures")
}
// ============================================================================
// INFLUENCE — REPRESENTATIVES
// ============================================================================
@ -971,6 +1104,11 @@ model SiteSettings {
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
enablePolls Boolean @default(false) @map("enable_polls")
enableAnalytics Boolean @default(false) @map("enable_analytics")
analyticsRetentionDays Int @default(90) @map("analytics_retention_days")
analyticsGeoEnabled Boolean @default(true) @map("analytics_geo_enabled")
trackAuthenticatedUsers Boolean @default(true) @map("track_authenticated_users")
enablePetitions Boolean @default(false) @map("enable_petitions")
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
requireEventApproval Boolean @default(true) @map("require_event_approval")
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
@ -995,6 +1133,7 @@ model SiteSettings {
notifyAdminShiftSignup Boolean @default(true)
notifyAdminResponseSubmitted Boolean @default(true)
notifyAdminSignRequested Boolean @default(true)
notifyAdminPetitionMilestone Boolean @default(false) @map("notify_admin_petition_milestone")
notifyAdminShiftCancellation Boolean @default(true)
notifyVolunteerSessionSummary Boolean @default(true)
notifyVolunteerCancellation Boolean @default(true)
@ -3704,6 +3843,11 @@ model VideoView {
ipAddressHash String? @map("ip_address_hash") @db.VarChar(64) // SHA-256 hash
userAgentHash String? @map("user_agent_hash") @db.VarChar(64) // SHA-256 hash
referer String? @db.Text
country String? @db.VarChar(2) // ISO 3166-1 alpha-2
region String? @db.VarChar(100)
city String? @db.VarChar(100)
latitude Float?
longitude Float?
watchTimeSeconds Int @default(0) @map("watch_time_seconds")
completed Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
@ -3717,6 +3861,7 @@ model VideoView {
@@index([userId], map: "idx_video_views_user")
@@index([createdAt], map: "idx_video_views_created")
@@index([videoId, createdAt], map: "idx_video_views_video_created")
@@index([country, createdAt], map: "idx_video_views_country_created")
@@map("video_views")
}
@ -3765,15 +3910,22 @@ model VideoScheduleHistory {
// ============================================================================
model DocsPageView {
id String @id @default(cuid())
path String // e.g. "/docs/getting-started/"
referrer String? @db.Text // document.referrer
sessionHash String? // anonymous session UUID (sessionStorage)
userAgent String? // for device type breakdown
createdAt DateTime @default(now())
id String @id @default(cuid())
path String // e.g. "/docs/getting-started/"
referrer String? @db.Text // document.referrer
sessionHash String? // anonymous session UUID (sessionStorage)
userAgent String? // for device type breakdown
ipAddressHash String? @map("ip_address_hash") @db.VarChar(64)
country String? @db.VarChar(2) // ISO 3166-1 alpha-2
region String? @db.VarChar(100)
city String? @db.VarChar(100)
latitude Float?
longitude Float?
createdAt DateTime @default(now())
@@index([createdAt])
@@index([path, createdAt])
@@index([country, createdAt])
@@map("docs_page_views")
}
@ -3938,12 +4090,18 @@ model PhotoView {
sessionId String? @map("session_id")
userId String? @map("user_id")
ipAddressHash String? @map("ip_address_hash")
country String? @db.VarChar(2) // ISO 3166-1 alpha-2
region String? @db.VarChar(100)
city String? @db.VarChar(100)
latitude Float?
longitude Float?
viewedAt DateTime @default(now()) @map("viewed_at")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
@@index([photoId, viewedAt], map: "idx_photo_views_photo_date")
@@index([sessionId], map: "idx_photo_views_session")
@@index([country, viewedAt], map: "idx_photo_views_country_date")
@@map("photo_views")
}
@ -4210,6 +4368,7 @@ enum ContactSource {
SMS_CONTACT
DONATION
POLL_VOTE
PETITION_SIGNER
MANUAL
}
@ -4237,6 +4396,7 @@ enum ContactActivityType {
PROFILE_PHOTO_UPDATED
USER_LOGIN
POLL_VOTED
PETITION_SIGNED
}
model Contact {
@ -4294,6 +4454,7 @@ model Contact {
pollVotes SchedulingPollVote[] @relation("PollVoteContact")
strawPollVotes StrawPollVote[] @relation("StrawPollVoteContact")
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
petitionSignatures PetitionSignature[] @relation("PetitionSignatureContact")
@@index([email])
@@index([phone])

View File

@ -248,6 +248,11 @@ const envSchema = z.object({
REDIS_EXPORTER_PORT: z.coerce.number().default(9121),
GOTIFY_URL: z.string().default('http://gotify-changemaker:80'),
GOTIFY_PORT: z.coerce.number().default(8889),
// GeoIP (MaxMind GeoLite2)
MAXMIND_ACCOUNT_ID: z.string().default(''),
MAXMIND_LICENSE_KEY: z.string().default(''),
GEOIP_DB_PATH: z.string().default('/data/geoip/GeoLite2-City.mmdb'),
});
export type Env = z.infer<typeof envSchema>;

View File

@ -277,6 +277,23 @@ export const docsAnalyticsRateLimit = rateLimit({
},
});
export const analyticsAdminRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:analytics-admin:',
}),
message: {
error: {
message: 'Too many analytics requests, please try again later',
code: 'ANALYTICS_RATE_LIMIT_EXCEEDED',
},
},
});
export const docsCommentAnonRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
@ -430,6 +447,23 @@ export const errorReportRateLimit = rateLimit({
},
});
export const petitionSignRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:petition-sign:',
}),
message: {
error: {
message: 'Too many petition signatures, please try again later',
code: 'PETITION_SIGN_RATE_LIMIT_EXCEEDED',
},
},
});
export const healthMetricsRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute

View File

@ -0,0 +1,35 @@
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
export const analyticsCleanupService = {
async cleanupAll(): Promise<void> {
// Read retention setting from SiteSettings
let retentionDays = 90;
try {
const settings = await prisma.siteSettings.findFirst();
if (settings?.analyticsRetentionDays) {
retentionDays = settings.analyticsRetentionDays;
}
} catch {
// Use default if settings unavailable
}
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - retentionDays);
const [docsDeleted, videoDeleted, photoDeleted] = await Promise.all([
prisma.docsPageView.deleteMany({ where: { createdAt: { lt: cutoff } } }),
prisma.videoView.deleteMany({ where: { createdAt: { lt: cutoff } } }),
prisma.photoView.deleteMany({ where: { viewedAt: { lt: cutoff } } }),
]);
const total = docsDeleted.count + videoDeleted.count + photoDeleted.count;
if (total > 0) {
logger.info(`Analytics cleanup: removed ${total} records older than ${retentionDays} days`, {
docs: docsDeleted.count,
video: videoDeleted.count,
photo: photoDeleted.count,
});
}
},
};

View File

@ -0,0 +1,23 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import { analyticsService } from './analytics.service';
import { userDetailQuerySchema } from './analytics.schemas';
export const analyticsUserRouter = Router();
analyticsUserRouter.use(authenticate);
// GET /api/analytics/my-activity?days=30
analyticsUserRouter.get(
'/my-activity',
validate(userDetailQuerySchema, 'query'),
async (req, res, next) => {
try {
const days = Number(req.query.days) || 30;
const activity = await analyticsService.getMyActivity(req.user!.id, days);
res.json(activity);
} catch (err) {
next(err);
}
},
);

View File

@ -0,0 +1,89 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { ANALYTICS_ROLES } from '../../utils/roles';
import { analyticsService } from './analytics.service';
import { analyticsQuerySchema, userAnalyticsQuerySchema, userDetailQuerySchema } from './analytics.schemas';
export const analyticsAdminRouter = Router();
analyticsAdminRouter.use(authenticate);
analyticsAdminRouter.use(requireRole(...ANALYTICS_ROLES));
// GET /api/analytics/overview?days=30
analyticsAdminRouter.get(
'/overview',
validate(analyticsQuerySchema, 'query'),
async (req, res, next) => {
try {
const days = Number(req.query.days) || 30;
const overview = await analyticsService.getOverview(days);
res.json(overview);
} catch (err) {
next(err);
}
},
);
// GET /api/analytics/geo?days=30
analyticsAdminRouter.get(
'/geo',
validate(analyticsQuerySchema, 'query'),
async (req, res, next) => {
try {
const days = Number(req.query.days) || 30;
const geo = await analyticsService.getGeoAnalytics(days);
res.json(geo);
} catch (err) {
next(err);
}
},
);
// GET /api/analytics/content?days=30&module=all
analyticsAdminRouter.get(
'/content',
validate(analyticsQuerySchema, 'query'),
async (req, res, next) => {
try {
const days = Number(req.query.days) || 30;
const module = String(req.query.module || 'all');
const content = await analyticsService.getContentAnalytics(days, module);
res.json(content);
} catch (err) {
next(err);
}
},
);
// GET /api/analytics/users?days=30&page=1&limit=20
analyticsAdminRouter.get(
'/users',
validate(userAnalyticsQuerySchema, 'query'),
async (req, res, next) => {
try {
const days = Number(req.query.days) || 30;
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 20;
const engagement = await analyticsService.getUsersEngagement(days, page, limit);
res.json(engagement);
} catch (err) {
next(err);
}
},
);
// GET /api/analytics/users/:userId?days=30
analyticsAdminRouter.get(
'/users/:userId',
validate(userDetailQuerySchema, 'query'),
async (req, res, next) => {
try {
const days = Number(req.query.days) || 30;
const activity = await analyticsService.getUserActivity(String(req.params.userId), days);
res.json(activity);
} catch (err) {
next(err);
}
},
);

View File

@ -0,0 +1,16 @@
import { z } from 'zod';
export const analyticsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(30),
module: z.enum(['all', 'docs', 'video', 'photo']).default('all'),
});
export const userAnalyticsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(30),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const userDetailQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(30),
});

View File

@ -0,0 +1,338 @@
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
// ── Raw query result types ──────────────────────────────────────────────
type CountRow = { count: bigint };
type ModuleCountRow = { module: string; views: bigint };
type DayViewRow = { day: Date; views: bigint; module: string };
type CountryRow = { country: string; views: bigint };
type CityRow = { city: string; region: string | null; country: string; views: bigint };
type GeoPointRow = { latitude: number; longitude: number; count: bigint; module: string };
type ContentRow = { id: string; title: string; views: bigint; module: string };
type UserEngagementRow = {
user_id: string;
name: string | null;
email: string;
video_views: bigint;
photo_views: bigint;
total_watch_time: bigint;
last_active: Date | null;
};
// ── Helpers ─────────────────────────────────────────────────────────────
function sinceDate(days: number): Date {
const d = new Date();
d.setDate(d.getDate() - days);
return d;
}
function toNum(v: bigint | null | undefined): number {
return Number(v ?? 0);
}
// ── Service ─────────────────────────────────────────────────────────────
export const analyticsService = {
/**
* Cross-module overview: total views, unique sessions, views by day, module breakdown
*/
async getOverview(days: number) {
const since = sinceDate(days);
const [docViews, videoViews, photoViews, docSessions, viewsByDay] = await Promise.all([
prisma.docsPageView.count({ where: { createdAt: { gte: since } } }),
prisma.videoView.count({ where: { createdAt: { gte: since } } }),
prisma.photoView.count({ where: { viewedAt: { gte: since } } }),
prisma.$queryRaw<CountRow[]>`
SELECT COUNT(DISTINCT "sessionHash") as count
FROM docs_page_views
WHERE "createdAt" >= ${since} AND "sessionHash" IS NOT NULL
`,
// Combined views by day from all three modules
prisma.$queryRaw<DayViewRow[]>`
SELECT day, SUM(views)::bigint as views, module FROM (
SELECT DATE("createdAt") as day, COUNT(*) as views, 'docs' as module
FROM docs_page_views WHERE "createdAt" >= ${since} GROUP BY DATE("createdAt")
UNION ALL
SELECT DATE(created_at) as day, COUNT(*) as views, 'video' as module
FROM video_views WHERE created_at >= ${since} GROUP BY DATE(created_at)
UNION ALL
SELECT DATE(viewed_at) as day, COUNT(*) as views, 'photo' as module
FROM photo_views WHERE viewed_at >= ${since} GROUP BY DATE(viewed_at)
) combined
GROUP BY day, module
ORDER BY day ASC
`,
]);
const totalViews = docViews + videoViews + photoViews;
const uniqueSessions = toNum(docSessions[0]?.count);
return {
totalViews,
uniqueSessions,
avgViewsPerDay: days > 0 ? Math.round(totalViews / days) : 0,
moduleBreakdown: [
{ module: 'docs', views: docViews },
{ module: 'video', views: videoViews },
{ module: 'photo', views: photoViews },
],
viewsByDay: viewsByDay.map((r) => ({
date: r.day instanceof Date ? r.day.toISOString().split('T')[0] : String(r.day),
views: toNum(r.views),
module: r.module,
})),
};
},
/**
* Geographic analytics: views by country, top cities, map points
*/
async getGeoAnalytics(days: number) {
const since = sinceDate(days);
const [viewsByCountry, topCities, geoPoints] = await Promise.all([
prisma.$queryRaw<CountryRow[]>`
SELECT country, SUM(views)::bigint as views FROM (
SELECT country, COUNT(*) as views FROM docs_page_views
WHERE "createdAt" >= ${since} AND country IS NOT NULL GROUP BY country
UNION ALL
SELECT country, COUNT(*) as views FROM video_views
WHERE created_at >= ${since} AND country IS NOT NULL GROUP BY country
UNION ALL
SELECT country, COUNT(*) as views FROM photo_views
WHERE viewed_at >= ${since} AND country IS NOT NULL GROUP BY country
) combined
GROUP BY country ORDER BY views DESC LIMIT 30
`,
prisma.$queryRaw<CityRow[]>`
SELECT city, region, country, SUM(views)::bigint as views FROM (
SELECT city, region, country, COUNT(*) as views FROM docs_page_views
WHERE "createdAt" >= ${since} AND city IS NOT NULL GROUP BY city, region, country
UNION ALL
SELECT city, region, country, COUNT(*) as views FROM video_views
WHERE created_at >= ${since} AND city IS NOT NULL GROUP BY city, region, country
UNION ALL
SELECT city, region, country, COUNT(*) as views FROM photo_views
WHERE viewed_at >= ${since} AND city IS NOT NULL GROUP BY city, region, country
) combined
GROUP BY city, region, country ORDER BY views DESC LIMIT 30
`,
prisma.$queryRaw<GeoPointRow[]>`
SELECT latitude, longitude, SUM(count)::bigint as count, module FROM (
SELECT latitude, longitude, COUNT(*) as count, 'docs' as module FROM docs_page_views
WHERE "createdAt" >= ${since} AND latitude IS NOT NULL GROUP BY latitude, longitude
UNION ALL
SELECT latitude, longitude, COUNT(*) as count, 'video' as module FROM video_views
WHERE created_at >= ${since} AND latitude IS NOT NULL GROUP BY latitude, longitude
UNION ALL
SELECT latitude, longitude, COUNT(*) as count, 'photo' as module FROM photo_views
WHERE viewed_at >= ${since} AND latitude IS NOT NULL GROUP BY latitude, longitude
) combined
GROUP BY latitude, longitude, module
ORDER BY count DESC LIMIT 500
`,
]);
return {
viewsByCountry: viewsByCountry.map((r) => ({
country: r.country,
views: toNum(r.views),
})),
topCities: topCities.map((r) => ({
city: r.city,
region: r.region,
country: r.country,
views: toNum(r.views),
})),
geoPoints: geoPoints.map((r) => ({
latitude: r.latitude,
longitude: r.longitude,
count: toNum(r.count),
module: r.module,
})),
};
},
/**
* Content analytics: top pages/videos/photos by views
*/
async getContentAnalytics(days: number, module: string) {
const since = sinceDate(days);
const results: { id: string; title: string; views: number; module: string }[] = [];
if (module === 'all' || module === 'docs') {
const docs = await prisma.$queryRaw<ContentRow[]>`
SELECT path as id, path as title, COUNT(*)::bigint as views, 'docs' as module
FROM docs_page_views
WHERE "createdAt" >= ${since}
GROUP BY path
ORDER BY views DESC
LIMIT 20
`;
results.push(...docs.map((r) => ({ id: r.id, title: r.title, views: toNum(r.views), module: 'docs' })));
}
if (module === 'all' || module === 'video') {
const videos = await prisma.$queryRaw<{ id: number; title: string; views: bigint }[]>`
SELECT v.id, v.title, COUNT(vv.id)::bigint as views
FROM videos v
LEFT JOIN video_views vv ON vv.video_id = v.id AND vv.created_at >= ${since}
GROUP BY v.id, v.title
HAVING COUNT(vv.id) > 0
ORDER BY views DESC
LIMIT 20
`;
results.push(...videos.map((r) => ({ id: String(r.id), title: r.title, views: toNum(r.views), module: 'video' })));
}
if (module === 'all' || module === 'photo') {
const photos = await prisma.$queryRaw<{ id: number; title: string; views: bigint }[]>`
SELECT p.id, COALESCE(p.title, p.filename) as title, COUNT(pv.id)::bigint as views
FROM photos p
LEFT JOIN photo_views pv ON pv.photo_id = p.id AND pv.viewed_at >= ${since}
GROUP BY p.id, p.title, p.filename
HAVING COUNT(pv.id) > 0
ORDER BY views DESC
LIMIT 20
`;
results.push(...photos.map((r) => ({ id: String(r.id), title: r.title, views: toNum(r.views), module: 'photo' })));
}
// Sort combined results by views descending
results.sort((a, b) => b.views - a.views);
return { content: results.slice(0, 30) };
},
/**
* User engagement list (paginated)
*/
async getUsersEngagement(days: number, page: number, limit: number) {
const since = sinceDate(days);
const offset = (page - 1) * limit;
const [users, totalResult] = await Promise.all([
prisma.$queryRaw<UserEngagementRow[]>`
SELECT
u.id as user_id,
u.name,
u.email,
COALESCE(vv.cnt, 0)::bigint as video_views,
COALESCE(pv.cnt, 0)::bigint as photo_views,
COALESCE(vv.watch_time, 0)::bigint as total_watch_time,
GREATEST(vv.last_active, pv.last_active) as last_active
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) as cnt,
SUM(watch_time_seconds) as watch_time,
MAX(created_at) as last_active
FROM video_views WHERE created_at >= ${since} AND user_id IS NOT NULL
GROUP BY user_id
) vv ON vv.user_id = u.id
LEFT JOIN (
SELECT user_id, COUNT(*) as cnt,
MAX(viewed_at) as last_active
FROM photo_views WHERE viewed_at >= ${since} AND user_id IS NOT NULL
GROUP BY user_id
) pv ON pv.user_id = u.id
WHERE COALESCE(vv.cnt, 0) + COALESCE(pv.cnt, 0) > 0
ORDER BY (COALESCE(vv.cnt, 0) + COALESCE(pv.cnt, 0)) DESC
LIMIT ${limit} OFFSET ${offset}
`,
prisma.$queryRaw<CountRow[]>`
SELECT COUNT(DISTINCT u.id)::bigint as count
FROM users u
LEFT JOIN video_views vv ON vv.user_id = u.id AND vv.created_at >= ${since}
LEFT JOIN photo_views pv ON pv.user_id = u.id AND pv.viewed_at >= ${since}
WHERE vv.id IS NOT NULL OR pv.id IS NOT NULL
`,
]);
return {
users: users.map((r) => ({
userId: r.user_id,
name: r.name,
email: r.email,
videoViews: toNum(r.video_views),
photoViews: toNum(r.photo_views),
totalWatchTime: toNum(r.total_watch_time),
lastActive: r.last_active ? r.last_active.toISOString() : null,
})),
pagination: {
page,
limit,
total: toNum(totalResult[0]?.count),
totalPages: Math.ceil(toNum(totalResult[0]?.count) / limit),
},
};
},
/**
* Individual user activity detail
*/
async getUserActivity(userId: string, days: number) {
const since = sinceDate(days);
const [videoViews, photoViews, videoStats, photoStats] = await Promise.all([
prisma.videoView.findMany({
where: { userId, createdAt: { gte: since } },
include: { video: { select: { id: true, title: true } } },
orderBy: { createdAt: 'desc' },
take: 50,
}),
prisma.photoView.findMany({
where: { userId, viewedAt: { gte: since } },
include: { photo: { select: { id: true, title: true, filename: true } } },
orderBy: { viewedAt: 'desc' },
take: 50,
}),
prisma.videoView.aggregate({
where: { userId, createdAt: { gte: since } },
_count: true,
_sum: { watchTimeSeconds: true },
}),
prisma.photoView.aggregate({
where: { userId, viewedAt: { gte: since } },
_count: true,
}),
]);
return {
summary: {
videoViews: videoStats._count,
totalWatchTime: videoStats._sum.watchTimeSeconds ?? 0,
photoViews: photoStats._count,
},
recentVideoViews: videoViews.map((v) => ({
videoId: v.videoId,
videoTitle: v.video?.title ?? 'Untitled',
watchTime: v.watchTimeSeconds,
completed: v.completed,
viewedAt: v.createdAt.toISOString(),
})),
recentPhotoViews: photoViews.map((v) => ({
photoId: v.photoId,
photoTitle: v.photo?.title ?? v.photo?.filename ?? 'Untitled',
viewedAt: v.viewedAt.toISOString(),
})),
};
},
/**
* Self-service: authenticated user's own activity
*/
async getMyActivity(userId: string, days: number) {
return this.getUserActivity(userId, days);
},
};

View File

@ -12,9 +12,19 @@ export const docsAnalyticsPublicRouter = Router();
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
import { env } from '../../config/env';
const DOCS_ORIGIN = env.DOMAIN ? `https://docs.${env.DOMAIN}` : (env.ADMIN_URL || 'http://localhost:4003');
docsAnalyticsPublicRouter.use((_req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', DOCS_ORIGIN);
const DOCS_ORIGINS = new Set([
env.DOMAIN ? `https://${env.DOMAIN}` : '',
env.DOMAIN ? `https://docs.${env.DOMAIN}` : '',
env.DOMAIN ? `http://${env.DOMAIN}` : '',
env.ADMIN_URL || 'http://localhost:4003',
'http://localhost:4003',
'http://localhost:4004',
].filter(Boolean));
docsAnalyticsPublicRouter.use((req, res, next) => {
const origin = req.headers.origin || '';
if (DOCS_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@ -34,9 +44,10 @@ docsAnalyticsPublicRouter.post(
async (req, res) => {
const { path, referrer, sessionHash } = req.body;
const userAgent = req.headers['user-agent'] || undefined;
const ipAddress = req.ip || req.socket.remoteAddress || undefined;
// Fire-and-forget: don't await, respond immediately
docsAnalyticsService.recordPageView({ path, referrer, sessionHash, userAgent }).catch(() => {});
docsAnalyticsService.recordPageView({ path, referrer, sessionHash, userAgent, ipAddress }).catch(() => {});
res.sendStatus(204);
},

View File

@ -1,11 +1,14 @@
import { createHash } from 'crypto';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { geoipService } from '../../services/geoip.service';
interface PageViewData {
path: string;
referrer?: string;
sessionHash?: string;
userAgent?: string;
ipAddress?: string;
}
interface TopPage {
@ -25,27 +28,53 @@ interface TopReferrer {
count: number;
}
interface CountryViews {
country: string;
views: number;
}
interface GeoPoint {
latitude: number;
longitude: number;
count: number;
}
interface AnalyticsSummary {
totalViews: number;
uniqueSessions: number;
topPages: TopPage[];
viewsByDay: DayViews[];
topReferrers: TopReferrer[];
viewsByCountry: CountryViews[];
geoPoints: GeoPoint[];
}
type UniqueCountRow = { count: bigint };
type TopPageRow = { path: string; views: bigint; unique_sessions: bigint };
type DayViewRow = { day: Date; views: bigint; unique_sessions: bigint };
type ReferrerRow = { referrer: string; count: bigint };
type CountryRow = { country: string; views: bigint };
type GeoPointRow = { latitude: number; longitude: number; count: bigint };
export const docsAnalyticsService = {
async recordPageView(data: PageViewData): Promise<void> {
const geo = data.ipAddress ? await geoipService.lookup(data.ipAddress) : null;
const ipAddressHash = data.ipAddress
? createHash('sha256').update(data.ipAddress).digest('hex')
: null;
await prisma.docsPageView.create({
data: {
path: data.path,
referrer: data.referrer || null,
sessionHash: data.sessionHash || null,
userAgent: data.userAgent || null,
ipAddressHash,
country: geo?.country ?? null,
region: geo?.region ?? null,
city: geo?.city ?? null,
latitude: geo?.latitude ?? null,
longitude: geo?.longitude ?? null,
},
});
},
@ -98,8 +127,29 @@ export const docsAnalyticsService = {
LIMIT 10
`;
const [totalViews, uniqueSessionsResult, topPagesRaw, viewsByDayRaw, topReferrersRaw] =
await Promise.all([totalViewsP, uniqueSessionsP, topPagesP, viewsByDayP, topReferrersP]);
const viewsByCountryP = prisma.$queryRaw<CountryRow[]>`
SELECT country,
COUNT(*) as views
FROM docs_page_views
WHERE "createdAt" >= ${since}
AND country IS NOT NULL
GROUP BY country
ORDER BY views DESC
LIMIT 20
`;
const geoPointsP = prisma.$queryRaw<GeoPointRow[]>`
SELECT latitude, longitude, COUNT(*) as count
FROM docs_page_views
WHERE "createdAt" >= ${since}
AND latitude IS NOT NULL
GROUP BY latitude, longitude
ORDER BY count DESC
LIMIT 500
`;
const [totalViews, uniqueSessionsResult, topPagesRaw, viewsByDayRaw, topReferrersRaw, viewsByCountryRaw, geoPointsRaw] =
await Promise.all([totalViewsP, uniqueSessionsP, topPagesP, viewsByDayP, topReferrersP, viewsByCountryP, geoPointsP]);
return {
totalViews,
@ -120,6 +170,15 @@ export const docsAnalyticsService = {
referrer: r.referrer,
count: Number(r.count),
})),
viewsByCountry: viewsByCountryRaw.map((r) => ({
country: r.country,
views: Number(r.views),
})),
geoPoints: geoPointsRaw.map((r) => ({
latitude: r.latitude,
longitude: r.longitude,
count: Number(r.count),
})),
};
},

View File

@ -3,6 +3,7 @@ import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
import { createHash } from 'crypto';
import { logger } from '../../../utils/logger';
import { geoipService } from '../../../services/geoip.service';
/**
* Photo engagement routes upvotes, comments, reactions, views (prefix: /api)
@ -227,9 +228,10 @@ export async function photoEngagementRoutes(fastify: FastifyInstance) {
return reply.code(400).send({ message: 'photoId is required' });
}
// Hash IP for privacy
// Resolve geo before hashing IP (IP is discarded after this block)
const ipRaw = request.ip || request.headers['x-forwarded-for'] || '';
const ipStr = Array.isArray(ipRaw) ? ipRaw[0] : ipRaw;
const geo = ipStr ? await geoipService.lookup(ipStr) : null;
const ipHash = createHash('sha256').update(ipStr).digest('hex').slice(0, 16);
await prisma.photoView.create({
@ -238,6 +240,11 @@ export async function photoEngagementRoutes(fastify: FastifyInstance) {
sessionId: sessionId || null,
userId: request.user?.id || null,
ipAddressHash: ipHash,
country: geo?.country ?? null,
region: geo?.region ?? null,
city: geo?.city ?? null,
latitude: geo?.latitude ?? null,
longitude: geo?.longitude ?? null,
},
});

View File

@ -2,6 +2,7 @@ import { prisma } from '../../../config/database';
import { logger } from '../../../utils/logger';
import { createHash } from 'crypto';
import { Decimal } from '@prisma/client/runtime/library';
import { geoipService } from '../../../services/geoip.service';
export class VideoAnalyticsService {
/**
@ -35,6 +36,9 @@ export class VideoAnalyticsService {
const { videoId, userId, ipAddress, userAgent, referer } = params;
try {
// Resolve geo before hashing (IP is discarded after this block)
const geo = ipAddress ? await geoipService.lookup(ipAddress) : null;
const view = await prisma.videoView.create({
data: {
videoId,
@ -42,6 +46,11 @@ export class VideoAnalyticsService {
ipAddressHash: ipAddress ? this.hashIpAddress(ipAddress) : null,
userAgentHash: userAgent ? this.hashUserAgent(userAgent) : null,
referer: referer || null,
country: geo?.country ?? null,
region: geo?.region ?? null,
city: geo?.city ?? null,
latitude: geo?.latitude ?? null,
longitude: geo?.longitude ?? null,
watchTimeSeconds: 0,
completed: false,
},

View File

@ -59,7 +59,12 @@ export const updateSiteSettingsSchema = z.object({
enableMeetingPlanner: z.boolean().optional(),
enableTicketedEvents: z.boolean().optional(),
enablePolls: z.boolean().optional(),
enableAnalytics: z.boolean().optional(),
analyticsRetentionDays: z.number().int().min(7).max(365).optional(),
analyticsGeoEnabled: z.boolean().optional(),
trackAuthenticatedUsers: z.boolean().optional(),
enableSocialCalendar: z.boolean().optional(),
enablePetitions: z.boolean().optional(),
enableDocsCollaboration: z.boolean().optional(),
requireEventApproval: z.boolean().optional(),
autoSyncPeopleToMap: z.boolean().optional(),
@ -125,6 +130,7 @@ export const updateSiteSettingsSchema = z.object({
}).optional(),
// Notification settings
notifyAdminPetitionMilestone: z.boolean().optional(),
notifyAdminShiftSignup: z.boolean().optional(),
notifyAdminResponseSubmitted: z.boolean().optional(),
notifyAdminSignRequested: z.boolean().optional(),

View File

@ -83,8 +83,12 @@ import { donationPagesAdminRouter } from './modules/payments/donation-pages-admi
import { webhookService } from './modules/payments/webhook.service';
import { galleryAdsPublicRouter } from './modules/gallery-ads/gallery-ads-public.routes';
import { galleryAdsAdminRouter } from './modules/gallery-ads/gallery-ads-admin.routes';
import { petitionsPublicRouter, petitionVerifyRouter } from './modules/influence/petitions/petitions-public.routes';
import { petitionsAdminRouter } from './modules/influence/petitions/petitions.routes';
import { effectivenessRouter } from './modules/influence/effectiveness/effectiveness.routes';
import { docsAnalyticsPublicRouter, docsAnalyticsAdminRouter } from './modules/docs-analytics/docs-analytics.routes';
import { analyticsUserRouter } from './modules/analytics/analytics-user.routes';
import { analyticsAdminRouter } from './modules/analytics/analytics.routes';
import { docsCommentsPublicRouter, docsCommentsAdminRouter } from './modules/docs-comments/docs-comments.routes';
import { volunteerInviteRouter } from './modules/volunteer-invite/volunteer-invite.routes';
import { docsAnalyticsService } from './modules/docs-analytics/docs-analytics.service';
@ -140,8 +144,8 @@ import { eventBus } from './services/event-bus.service';
const app = express();
// Trust proxy for reverse proxy (nginx adds X-Forwarded-For)
app.set('trust proxy', 1);
// Trust proxy chain: Pangolin/Newt → Nginx → API (2 hops in production)
app.set('trust proxy', 2);
// --- Middleware Stack ---
app.use(correlationId);
@ -343,11 +347,16 @@ app.use('/api/donation-pages', donationPagesPublicRouter); // Public donation
app.use('/api/payments', paymentsPublicRouter); // Public payment routes (plans, checkout, my subscription)
app.use('/api/payments/admin/donation-pages', donationPagesAdminRouter); // Admin donation page CRUD (SUPER_ADMIN)
app.use('/api/payments/admin', paymentsAdminRouter); // Admin payment management (SUPER_ADMIN)
app.use('/api/petitions', petitionsPublicRouter); // Public petition listing + signing (no auth)
app.use('/api/petitions', petitionVerifyRouter); // Petition email verification (no auth)
app.use('/api/petitions', petitionsAdminRouter); // Admin petition CRUD (INFLUENCE_ROLES)
app.use('/api/influence/effectiveness', effectivenessRouter); // Campaign effectiveness analytics (ADMIN)
app.use('/api/gallery-ads', galleryAdsPublicRouter); // Public gallery ads (optional auth)
app.use('/api/gallery-ads/admin', galleryAdsAdminRouter); // Admin gallery ad CRUD (SUPER_ADMIN)
app.use('/api/docs-analytics', docsAnalyticsPublicRouter); // Public docs page view tracking (no auth)
app.use('/api/docs-analytics', docsAnalyticsAdminRouter); // Admin docs analytics (ADMIN roles)
app.use('/api/analytics', analyticsUserRouter); // User self-service analytics (any auth)
app.use('/api/analytics', analyticsAdminRouter); // Admin unified analytics (ANALYTICS_ROLES)
app.use('/api/docs-comments', docsCommentsPublicRouter); // Public docs comments (CORS override for docs origin)
app.use('/api/docs-comments', docsCommentsAdminRouter); // Admin docs comment moderation (ADMIN roles)
app.use('/api/volunteer-invite', volunteerInviteRouter); // Quick join invite (admin generate + public redeem)

View File

@ -0,0 +1,69 @@
import { Reader } from '@maxmind/geoip2-node';
import fs from 'fs';
import { env } from '../config/env';
import { logger } from '../utils/logger';
export interface GeoResult {
country: string | null; // ISO 3166-1 alpha-2
region: string | null;
city: string | null;
latitude: number | null;
longitude: number | null;
}
class GeoIPService {
private reader: Reader | null = null;
private loadAttempted = false;
private async ensureReader(): Promise<Reader | null> {
if (this.reader) return this.reader;
if (this.loadAttempted) return null;
this.loadAttempted = true;
const dbPath = env.GEOIP_DB_PATH;
if (!fs.existsSync(dbPath)) {
logger.info('GeoIP database not found — geo lookups disabled');
return null;
}
try {
this.reader = await Reader.open(dbPath);
logger.info('GeoIP database loaded successfully');
return this.reader;
} catch (err) {
logger.warn('Failed to load GeoIP database', { error: (err as Error).message });
return null;
}
}
async lookup(ip: string): Promise<GeoResult | null> {
if (!ip) return null;
const reader = await this.ensureReader();
if (!reader) return null;
try {
const response = (reader as any).city(ip);
return {
country: response?.country?.isoCode ?? null,
region: response?.subdivisions?.[0]?.names?.en ?? null,
city: response?.city?.names?.en ?? null,
latitude: response?.location?.latitude ?? null,
longitude: response?.location?.longitude ?? null,
};
} catch {
// Private IPs, invalid addresses, or not-found entries
return null;
}
}
/** Force-reload the database (e.g., after a fresh download) */
async reload(): Promise<void> {
this.reader = null;
this.loadAttempted = false;
await this.ensureReader();
}
}
export const geoipService = new GeoIPService();

View File

@ -11,6 +11,7 @@ type ScheduledJobType =
| 'close-stale-tracking-sessions'
| 'cleanup-tracking-data'
| 'cleanup-docs-analytics'
| 'cleanup-analytics-data'
| 'cleanup-verification-tokens'
| 'listmonk-full-sync'
| 'validate-mkdocs-exports'
@ -30,6 +31,7 @@ const JOB_DEFINITIONS: Array<{ type: ScheduledJobType; every: number; conditiona
{ type: 'close-stale-tracking-sessions', every: HOUR },
{ type: 'cleanup-tracking-data', every: 24 * HOUR },
{ type: 'cleanup-docs-analytics', every: 24 * HOUR },
{ type: 'cleanup-analytics-data', every: 24 * HOUR },
{ type: 'cleanup-verification-tokens', every: HOUR },
{ type: 'listmonk-full-sync', every: 6 * HOUR, conditional: true },
{ type: 'validate-mkdocs-exports', every: 24 * HOUR },
@ -65,8 +67,14 @@ async function executeJob(type: ScheduledJobType): Promise<void> {
break;
}
case 'cleanup-docs-analytics': {
const { docsAnalyticsService } = await import('../modules/docs-analytics/docs-analytics.service');
await docsAnalyticsService.cleanupOldData(90);
// Delegated to unified analytics cleanup
const { analyticsCleanupService } = await import('../modules/analytics/analytics-cleanup.service');
await analyticsCleanupService.cleanupAll();
break;
}
case 'cleanup-analytics-data': {
const { analyticsCleanupService } = await import('../modules/analytics/analytics-cleanup.service');
await analyticsCleanupService.cleanupAll();
break;
}
case 'cleanup-verification-tokens': {

View File

@ -11,6 +11,7 @@ const ROLE_PRIORITY: Record<string, number> = {
EVENTS_ADMIN: 4,
SOCIAL_ADMIN: 4,
POLLS_ADMIN: 4,
ANALYTICS_ADMIN: 4,
USER: 2,
TEMP: 1,
};
@ -27,6 +28,7 @@ export const ADMIN_ROLES: UserRole[] = [
UserRole.EVENTS_ADMIN,
UserRole.SOCIAL_ADMIN,
UserRole.POLLS_ADMIN,
UserRole.ANALYTICS_ADMIN,
];
// Module-specific role groups
@ -41,6 +43,7 @@ export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_A
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
export const POLLS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.POLLS_ADMIN, UserRole.INFLUENCE_ADMIN];
export const ANALYTICS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.ANALYTICS_ADMIN];
/** Check if the user has any of the specified roles */
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {

View File

@ -287,6 +287,12 @@ export default function CreateWizardPage() {
<span>Unified people management, contact linking (no additional containers)</span>
</Space>
</Card>
<Card size="small" title="Analytics & GeoIP">
<Space>
<Switch checked={data.enableAnalytics} onChange={(v) => update({ enableAnalytics: v })} />
<span>Unified analytics dashboard, visitor geo-tracking</span>
</Space>
</Card>
</Space>
),
},

View File

@ -587,6 +587,9 @@ export default function InstanceDetailPage() {
<Tag color={instance.enablePeople ? 'red' : 'default'}>
People CRM {instance.enablePeople ? 'ON' : 'OFF'}
</Tag>
<Tag color={instance.enableAnalytics ? 'geekblue' : 'default'}>
Analytics {instance.enableAnalytics ? 'ON' : 'OFF'}
</Tag>
</Space>
</Card>
@ -980,6 +983,19 @@ export default function InstanceDetailPage() {
disabled={isRegistered}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Text strong>Analytics & GeoIP</Typography.Text>
<br />
<Typography.Text type="secondary">Unified analytics, visitor geography, user drill-down</Typography.Text>
</div>
<Switch
checked={featureFlags.enableAnalytics}
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableAnalytics: v }))}
disabled={isRegistered}
/>
</div>
</Space>
</Card>

View File

@ -90,6 +90,7 @@ model Instance {
enableSms Boolean @default(false) @map("enable_sms")
enableSocial Boolean @default(false) @map("enable_social")
enablePeople Boolean @default(false) @map("enable_people")
enableAnalytics Boolean @default(false) @map("enable_analytics")
jvbAdvertiseIp String? @map("jvb_advertise_ip")
// Admin config

View File

@ -100,6 +100,7 @@ function extractFeatureFlags(envVars: Record<string, string>) {
enableSms: isTrue(envVars.ENABLE_SMS),
enableSocial: isTrue(envVars.ENABLE_SOCIAL),
enablePeople: isTrue(envVars.ENABLE_PEOPLE),
enableAnalytics: isTrue(envVars.ENABLE_ANALYTICS),
emailTestMode: isTrue(envVars.EMAIL_TEST_MODE),
};
}

View File

@ -79,6 +79,7 @@ export interface TemplateContext {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
enableAnalytics: boolean;
jvbAdvertiseIp: string;
enablePangolin: boolean;
@ -190,6 +191,7 @@ export function buildTemplateContext(
enableSms: instance.enableSms,
enableSocial: instance.enableSocial,
enablePeople: instance.enablePeople,
enableAnalytics: instance.enableAnalytics,
jvbAdvertiseIp: instance.jvbAdvertiseIp || '',
enablePangolin: !!(instance.pangolinEndpoint && instance.pangolinNewtId && instance.pangolinNewtSecret),
pangolin: {

View File

@ -236,6 +236,11 @@ ENABLE_SOCIAL={{#if enableSocial}}true{{else}}false{{/if}}
# People CRM
ENABLE_PEOPLE={{#if enablePeople}}true{{else}}false{{/if}}
# Analytics & GeoIP
ENABLE_ANALYTICS={{#if enableAnalytics}}true{{else}}false{{/if}}
MAXMIND_ACCOUNT_ID=
MAXMIND_LICENSE_KEY=
# Monitoring
GRAFANA_ADMIN_PASSWORD={{secrets.grafanaAdminPassword}}
GRAFANA_ROOT_URL=https://grafana.{{domain}}

View File

@ -705,6 +705,29 @@ configure_features() {
else
BUNKER_OPS_ENABLED="no"
fi
echo ""
if prompt_yes_no "Enable Analytics & GeoIP tracking (visitor geography)?"; then
update_env_var "ENABLE_ANALYTICS" "true"
success "Analytics enabled"
echo ""
info "GeoIP tracking requires a free MaxMind account."
info "Sign up at: https://www.maxmind.com/en/geolite2/signup"
read -rp " MaxMind Account ID [leave blank to set later]: " maxmind_id
if [[ -n "$maxmind_id" ]]; then
update_env_var "MAXMIND_ACCOUNT_ID" "$maxmind_id"
read -rp " MaxMind License Key: " maxmind_key
if [[ -n "$maxmind_key" ]]; then
update_env_var "MAXMIND_LICENSE_KEY" "$maxmind_key"
success "MaxMind GeoIP credentials configured"
fi
else
info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking."
fi
else
info "Analytics disabled (can enable later in admin Settings)"
fi
}
configure_pangolin() {

View File

@ -117,10 +117,14 @@ services:
- GITEA_REGISTRY=${GITEA_REGISTRY:-gitea.bnkops.com/admin}
- GITEA_REGISTRY_USER=${GITEA_REGISTRY_USER:-}
- GITEA_REGISTRY_PASS=${GITEA_REGISTRY_PASS:-}
# GeoIP (MaxMind GeoLite2)
- MAXMIND_ACCOUNT_ID=${MAXMIND_ACCOUNT_ID:-}
- MAXMIND_LICENSE_KEY=${MAXMIND_LICENSE_KEY:-}
volumes:
- ./assets/uploads:/app/uploads
- ./mkdocs:/mkdocs:rw
- ./data:/data:ro
- ./data/geoip:/data/geoip:rw
- ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro
- ./logs/api:/app/logs

View File

@ -125,12 +125,16 @@ services:
- GITEA_DOCS_REPO=${GITEA_DOCS_REPO:-admin/changemaker.lite}
- GITEA_DOCS_PREFIX=${GITEA_DOCS_PREFIX:-mkdocs/docs}
- GITEA_DOCS_BRANCH=${GITEA_DOCS_BRANCH:-v2}
# GeoIP (MaxMind GeoLite2)
- MAXMIND_ACCOUNT_ID=${MAXMIND_ACCOUNT_ID:-}
- MAXMIND_LICENSE_KEY=${MAXMIND_LICENSE_KEY:-}
volumes:
- ./api:/app
- /app/node_modules
- ./assets/uploads:/app/uploads
- ./mkdocs:/mkdocs:rw
- ./data:/data:ro
- ./data/geoip:/data/geoip:rw
- ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro
- ./logs/api:/app/logs

View File

@ -4705,5 +4705,27 @@
})();
</script>
<!-- Docs Analytics Tracking -->
<script>
(function() {
var apiUrl = "{{ config.extra.api_url }}";
if (!apiUrl) return;
var trackUrl = apiUrl + "/api/docs-analytics/track";
function getSessionHash() {
var key = "__docs_sh";
var hash = sessionStorage.getItem(key);
if (!hash) {
hash = crypto.randomUUID ? crypto.randomUUID() : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
var r = (Math.random() * 16) | 0; return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
sessionStorage.setItem(key, hash);
}
return hash;
}
var payload = JSON.stringify({ path: location.pathname, referrer: document.referrer || undefined, sessionHash: getSessionHash() });
if (navigator.sendBeacon) { navigator.sendBeacon(trackUrl, new Blob([payload], { type: "application/json" })); }
else { fetch(trackUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload, keepalive: true }).catch(function() {}); }
})();
</script>
</body>
</html>

View File

@ -12,7 +12,7 @@ server {
proxy_pass $upstream_media;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Large upload support
@ -33,7 +33,7 @@ server {
proxy_pass $upstream_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
@ -46,7 +46,7 @@ server {
proxy_pass $upstream_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;