+ {/* Header row */}
+
+
+
+
+
} onClick={fetchData} loading={loading}>
+ Refresh
+
+
+
+ {loading && !data ? (
+
+
+
+ ) : !data ? (
+
+ ) : (
+ <>
+ {/* Stat cards */}
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ {/* Views over time chart */}
+
+ {chartData.length === 0 ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Module breakdown */}
+
+
+ {data.moduleBreakdown.map((m) => {
+ const pct = totalModuleViews > 0
+ ? Math.round((m.views / totalModuleViews) * 100)
+ : 0;
+ return (
+
+
+
+ {MODULE_LABELS[m.module] ?? m.module}
+
+
+
+ );
+ })}
+ {data.moduleBreakdown.length === 0 && (
+
+
+
+ )}
+
+
+
+ {/* Top content table */}
+
+ `${r.module}-${r.title}-${i}`}
+ pagination={{ pageSize: 10, showSizeChanger: false }}
+ size="small"
+ scroll={{ x: 400 }}
+ />
+
+ >
+ )}
+
+ );
+}
diff --git a/admin/src/pages/analytics/ContentAnalyticsPage.tsx b/admin/src/pages/analytics/ContentAnalyticsPage.tsx
new file mode 100644
index 00000000..94d2db00
--- /dev/null
+++ b/admin/src/pages/analytics/ContentAnalyticsPage.tsx
@@ -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 = {
+ docs: 'blue',
+ video: 'purple',
+ photo: 'green',
+};
+
+const MODULE_OPTIONS = ['All', 'Docs', 'Video', 'Photo'];
+
+export default function ContentAnalyticsPage() {
+ const { setPageHeader } = useOutletContext();
+ const [days, setDays] = useState(30);
+ const [moduleFilter, setModuleFilter] = useState('All');
+ const [data, setData] = useState(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 = [
+ {
+ 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) => (
+
+ {mod.charAt(0).toUpperCase() + mod.slice(1)}
+
+ ),
+ },
+ {
+ title: 'Views',
+ dataIndex: 'views',
+ key: 'views',
+ width: 100,
+ sorter: (a: ContentItem, b: ContentItem) => a.views - b.views,
+ defaultSortOrder: 'descend',
+ },
+ ];
+
+ return (
+
+
+
+ setModuleFilter(val as string)}
+ options={MODULE_OPTIONS}
+ />
+
+
+
+
+
+
+ {loading && !data ? (
+
+
+
+ ) : !data || data.content.length === 0 ? (
+
+ ) : (
+
Content Performance>}>
+
+
+ )}
+
+ );
+}
diff --git a/admin/src/pages/analytics/GeoAnalyticsPage.tsx b/admin/src/pages/analytics/GeoAnalyticsPage.tsx
new file mode 100644
index 00000000..5f655f58
--- /dev/null
+++ b/admin/src/pages/analytics/GeoAnalyticsPage.tsx
@@ -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 = {
+ docs: '#1890ff',
+ video: '#722ed1',
+ photo: '#52c41a',
+};
+
+const MODULE_LABELS: Record = {
+ 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();
+ const [days, setDays] = useState(30);
+ const [data, setData] = useState(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 (
+
+ {/* Header row */}
+
+
+
+
+
} onClick={fetchData} loading={loading}>
+ Refresh
+
+
+
+ {loading && !data ? (
+
+
+
+ ) : !data ? (
+
+ ) : (
+ <>
+ {/* Map */}
+
+ {data.geoPoints.length === 0 ? (
+
+ ) : (
+
+
+
+ {data.geoPoints.map((pt, idx) => (
+
+
+
+ {MODULE_LABELS[pt.module] ?? pt.module}
+
+ {pt.count.toLocaleString()} view{pt.count !== 1 ? 's' : ''}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Map legend */}
+ {data.geoPoints.length > 0 && (
+
+ {Object.entries(MODULE_COLORS).map(([mod, color]) => (
+
+
+ {MODULE_LABELS[mod] ?? mod}
+
+ ))}
+
+ )}
+
+
+ {/* Two tables side by side */}
+
+
+
+ {data.viewsByCountry.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {data.topCities.length === 0 ? (
+
+ ) : (
+ `${r.city}-${r.country}-${i}`}
+ pagination={{ pageSize: 10, showSizeChanger: false }}
+ size="small"
+ scroll={{ x: 350 }}
+ />
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/admin/src/pages/analytics/UserAnalyticsPage.tsx b/admin/src/pages/analytics/UserAnalyticsPage.tsx
new file mode 100644
index 00000000..d8dc33e0
--- /dev/null
+++ b/admin/src/pages/analytics/UserAnalyticsPage.tsx
@@ -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(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 = [
+ {
+ 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 (
+
+
+
+
+
+ {loading && !data ? (
+
+
+
+ ) : !data || data.users.length === 0 ? (
+
+ ) : (
+
User Engagement>}>
+ ({
+ style: { cursor: 'pointer' },
+ onClick: () => navigate(`/app/analytics/users/${record.userId}`),
+ })}
+ pagination={{
+ current: page,
+ total: data.pagination.total,
+ pageSize: 20,
+ showSizeChanger: false,
+ onChange: setPage,
+ }}
+ />
+
+ )}
+
+ );
+}
+
+/* ---------- 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(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 = [
+ {
+ 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 ? } color="success">Yes : No,
+ },
+ {
+ title: 'Viewed',
+ dataIndex: 'viewedAt',
+ key: 'viewedAt',
+ width: 110,
+ render: (val: string) => formatRelativeTime(val),
+ },
+ ];
+
+ const photoColumns: ColumnsType = [
+ {
+ title: 'Photo',
+ dataIndex: 'photoTitle',
+ key: 'photoTitle',
+ ellipsis: true,
+ },
+ {
+ title: 'Viewed',
+ dataIndex: 'viewedAt',
+ key: 'viewedAt',
+ width: 110,
+ render: (val: string) => formatRelativeTime(val),
+ },
+ ];
+
+ return (
+
+
+
}
+ onClick={() => navigate('/app/analytics/users')}
+ >
+ Back to Users
+
+
+
+
+
+ {loading && !data ? (
+
+
+
+ ) : !data ? (
+
+ ) : (
+ <>
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+ {data.recentVideoViews.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {data.recentPhotoViews.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
+
+/* ---------- Main export ---------- */
+
+export default function UserAnalyticsPage() {
+ const { setPageHeader } = useOutletContext();
+ const { userId } = useParams<{ userId: string }>();
+
+ useEffect(() => {
+ setPageHeader({ title: 'User Analytics' });
+ return () => setPageHeader(null);
+ }, [setPageHeader]);
+
+ if (userId) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/admin/src/pages/volunteer/MyAnalyticsPage.tsx b/admin/src/pages/volunteer/MyAnalyticsPage.tsx
new file mode 100644
index 00000000..781dd296
--- /dev/null
+++ b/admin/src/pages/volunteer/MyAnalyticsPage.tsx
@@ -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(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 = [
+ {
+ 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 ? } color="success">Yes : No,
+ },
+ {
+ title: 'When',
+ dataIndex: 'viewedAt',
+ key: 'viewedAt',
+ width: 100,
+ render: (val: string) => formatRelativeTime(val),
+ },
+ ];
+
+ const photoColumns: ColumnsType = [
+ {
+ 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
;
+ }
+
+ return (
+
+
+
+ {!data ? (
+
+ ) : (
+ <>
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+
+ {data.recentVideoViews.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {data.recentPhotoViews.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/admin/src/types/api.ts b/admin/src/types/api.ts
index d8b7274a..832b3785 100644
--- a/admin/src/types/api.ts
+++ b/admin/src/types/api.ts
@@ -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;
+ byRegion: Record;
+ 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;
+}
+
diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh
index 4c87daf8..f1951d9c 100755
--- a/api/docker-entrypoint.sh
+++ b/api/docker-entrypoint.sh
@@ -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
diff --git a/api/package-lock.json b/api/package-lock.json
index 8acfb808..9f94078e 100644
--- a/api/package-lock.json
+++ b/api/package-lock.json
@@ -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",
diff --git a/api/package.json b/api/package.json
index 45700b92..4de18361 100644
--- a/api/package.json
+++ b/api/package.json
@@ -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",
diff --git a/api/prisma/migrations/20260402100000_add_analytics_system/migration.sql b/api/prisma/migrations/20260402100000_add_analytics_system/migration.sql
new file mode 100644
index 00000000..e30c51ee
--- /dev/null
+++ b/api/prisma/migrations/20260402100000_add_analytics_system/migration.sql
@@ -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");
diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma
index c7172b33..20c52cb6 100644
--- a/api/prisma/schema.prisma
+++ b/api/prisma/schema.prisma
@@ -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])
diff --git a/api/src/config/env.ts b/api/src/config/env.ts
index 909c0e0f..8fd388f9 100644
--- a/api/src/config/env.ts
+++ b/api/src/config/env.ts
@@ -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;
diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts
index 5a8d9f29..b869aed5 100644
--- a/api/src/middleware/rate-limit.ts
+++ b/api/src/middleware/rate-limit.ts
@@ -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,
+ 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,
+ 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
diff --git a/api/src/modules/analytics/analytics-cleanup.service.ts b/api/src/modules/analytics/analytics-cleanup.service.ts
new file mode 100644
index 00000000..d751988a
--- /dev/null
+++ b/api/src/modules/analytics/analytics-cleanup.service.ts
@@ -0,0 +1,35 @@
+import { prisma } from '../../config/database';
+import { logger } from '../../utils/logger';
+
+export const analyticsCleanupService = {
+ async cleanupAll(): Promise {
+ // 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,
+ });
+ }
+ },
+};
diff --git a/api/src/modules/analytics/analytics-user.routes.ts b/api/src/modules/analytics/analytics-user.routes.ts
new file mode 100644
index 00000000..6ab632b5
--- /dev/null
+++ b/api/src/modules/analytics/analytics-user.routes.ts
@@ -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);
+ }
+ },
+);
diff --git a/api/src/modules/analytics/analytics.routes.ts b/api/src/modules/analytics/analytics.routes.ts
new file mode 100644
index 00000000..716ae84d
--- /dev/null
+++ b/api/src/modules/analytics/analytics.routes.ts
@@ -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);
+ }
+ },
+);
diff --git a/api/src/modules/analytics/analytics.schemas.ts b/api/src/modules/analytics/analytics.schemas.ts
new file mode 100644
index 00000000..041ba794
--- /dev/null
+++ b/api/src/modules/analytics/analytics.schemas.ts
@@ -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),
+});
diff --git a/api/src/modules/analytics/analytics.service.ts b/api/src/modules/analytics/analytics.service.ts
new file mode 100644
index 00000000..84c3def6
--- /dev/null
+++ b/api/src/modules/analytics/analytics.service.ts
@@ -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`
+ 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`
+ 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`
+ 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`
+ 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`
+ 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`
+ 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`
+ 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`
+ 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);
+ },
+};
diff --git a/api/src/modules/docs-analytics/docs-analytics.routes.ts b/api/src/modules/docs-analytics/docs-analytics.routes.ts
index c2484707..0b9317b7 100644
--- a/api/src/modules/docs-analytics/docs-analytics.routes.ts
+++ b/api/src/modules/docs-analytics/docs-analytics.routes.ts
@@ -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);
},
diff --git a/api/src/modules/docs-analytics/docs-analytics.service.ts b/api/src/modules/docs-analytics/docs-analytics.service.ts
index 8b51677d..2260a8c7 100644
--- a/api/src/modules/docs-analytics/docs-analytics.service.ts
+++ b/api/src/modules/docs-analytics/docs-analytics.service.ts
@@ -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 {
+ 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`
+ 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`
+ 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),
+ })),
};
},
diff --git a/api/src/modules/media/routes/photo-engagement.routes.ts b/api/src/modules/media/routes/photo-engagement.routes.ts
index 88d60206..22045805 100644
--- a/api/src/modules/media/routes/photo-engagement.routes.ts
+++ b/api/src/modules/media/routes/photo-engagement.routes.ts
@@ -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,
},
});
diff --git a/api/src/modules/media/services/video-analytics.service.ts b/api/src/modules/media/services/video-analytics.service.ts
index 30adc7d2..6259f2ec 100644
--- a/api/src/modules/media/services/video-analytics.service.ts
+++ b/api/src/modules/media/services/video-analytics.service.ts
@@ -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,
},
diff --git a/api/src/modules/settings/settings.schemas.ts b/api/src/modules/settings/settings.schemas.ts
index e86fd4cc..195a3138 100644
--- a/api/src/modules/settings/settings.schemas.ts
+++ b/api/src/modules/settings/settings.schemas.ts
@@ -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(),
diff --git a/api/src/server.ts b/api/src/server.ts
index ca8829e2..aa04d0f0 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -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)
diff --git a/api/src/services/geoip.service.ts b/api/src/services/geoip.service.ts
new file mode 100644
index 00000000..9ed90cf7
--- /dev/null
+++ b/api/src/services/geoip.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ this.reader = null;
+ this.loadAttempted = false;
+ await this.ensureReader();
+ }
+}
+
+export const geoipService = new GeoIPService();
diff --git a/api/src/services/scheduled-jobs-queue.service.ts b/api/src/services/scheduled-jobs-queue.service.ts
index 6abf6fa7..24ead8dc 100644
--- a/api/src/services/scheduled-jobs-queue.service.ts
+++ b/api/src/services/scheduled-jobs-queue.service.ts
@@ -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 {
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': {
diff --git a/api/src/utils/roles.ts b/api/src/utils/roles.ts
index 35306392..14de6598 100644
--- a/api/src/utils/roles.ts
+++ b/api/src/utils/roles.ts
@@ -11,6 +11,7 @@ const ROLE_PRIORITY: Record = {
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 {
diff --git a/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx b/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx
index ff82240a..c23d518a 100644
--- a/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx
+++ b/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx
@@ -287,6 +287,12 @@ export default function CreateWizardPage() {
Unified people management, contact linking (no additional containers)
+
+
+ update({ enableAnalytics: v })} />
+ Unified analytics dashboard, visitor geo-tracking
+
+
),
},
diff --git a/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx
index 78195bff..2caa2b29 100644
--- a/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx
+++ b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx
@@ -587,6 +587,9 @@ export default function InstanceDetailPage() {
People CRM {instance.enablePeople ? 'ON' : 'OFF'}
+
+ Analytics {instance.enableAnalytics ? 'ON' : 'OFF'}
+
@@ -980,6 +983,19 @@ export default function InstanceDetailPage() {
disabled={isRegistered}
/>
+
+
+
+ Analytics & GeoIP
+
+ Unified analytics, visitor geography, user drill-down
+
+
setFeatureFlags((f) => ({ ...f, enableAnalytics: v }))}
+ disabled={isRegistered}
+ />
+
diff --git a/changemaker-control-panel/api/prisma/schema.prisma b/changemaker-control-panel/api/prisma/schema.prisma
index 820ee27d..9972f42b 100644
--- a/changemaker-control-panel/api/prisma/schema.prisma
+++ b/changemaker-control-panel/api/prisma/schema.prisma
@@ -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
diff --git a/changemaker-control-panel/api/src/services/discovery.service.ts b/changemaker-control-panel/api/src/services/discovery.service.ts
index d6a6c8e6..9cdd1349 100644
--- a/changemaker-control-panel/api/src/services/discovery.service.ts
+++ b/changemaker-control-panel/api/src/services/discovery.service.ts
@@ -100,6 +100,7 @@ function extractFeatureFlags(envVars: Record) {
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),
};
}
diff --git a/changemaker-control-panel/api/src/services/template-engine.ts b/changemaker-control-panel/api/src/services/template-engine.ts
index 9f84399e..d8020717 100644
--- a/changemaker-control-panel/api/src/services/template-engine.ts
+++ b/changemaker-control-panel/api/src/services/template-engine.ts
@@ -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: {
diff --git a/changemaker-control-panel/templates/env.hbs b/changemaker-control-panel/templates/env.hbs
index f8996c95..93099437 100644
--- a/changemaker-control-panel/templates/env.hbs
+++ b/changemaker-control-panel/templates/env.hbs
@@ -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}}
diff --git a/config.sh b/config.sh
index 39b3ce78..8532052b 100755
--- a/config.sh
+++ b/config.sh
@@ -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() {
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 01687b09..1edca97d 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index f5e8e5be..e4ac8d8a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/mkdocs/docs/overrides/lander.html b/mkdocs/docs/overrides/lander.html
index 6a9a4c2b..eeada5aa 100644
--- a/mkdocs/docs/overrides/lander.html
+++ b/mkdocs/docs/overrides/lander.html
@@ -4705,5 +4705,27 @@
})();
+
+