diff --git a/.env.example b/.env.example index eca3d63b..c9be760c 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/admin/src/App.tsx b/admin/src/App.tsx index c81b5217..be4c2401 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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() { }> } /> + }> + } /> + + }> + } /> + @@ -399,6 +416,7 @@ export default function App() { } /> } /> } /> + } /> } /> @@ -574,6 +592,30 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> , label: badges?.pendingResponses ? Responses : 'Responses' }, { key: '/app/influence/effectiveness', icon: , label: 'Effectiveness' }, { key: '/app/influence/stories', icon: , label: 'Impact Stories' }, + ...(settings?.enablePetitions !== false ? [ + { key: '/app/influence/petitions', icon: , label: 'Petitions' }, + { key: '/app/influence/petitions/moderation', icon: , label: 'Petition Review' }, + ] : []), ...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: , 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: , + label: 'Analytics', + children: [ + { key: '/app/analytics', icon: , label: 'Overview' }, + { key: '/app/analytics/geo', icon: , label: 'Geography' }, + { key: '/app/analytics/content', icon: , label: 'Content' }, + { key: '/app/analytics/users', icon: , label: 'Users' }, + ], + }); + } + items.push({ key: 'services-submenu', icon: , diff --git a/admin/src/components/FeatureGate.tsx b/admin/src/components/FeatureGate.tsx index 3c76118f..9f959e26 100644 --- a/admin/src/components/FeatureGate.tsx +++ b/admin/src/components/FeatureGate.tsx @@ -22,11 +22,13 @@ const FEATURE_LABELS: Record = { enableMeetingPlanner: 'Meeting Planner', enableTicketedEvents: 'Ticketed Events', enableSocialCalendar: 'Social Calendar', + enablePetitions: 'Petitions', enablePolls: 'Straw Polls', + enableAnalytics: 'Analytics Dashboard', }; interface FeatureGateProps { - feature: keyof Pick; + feature: keyof Pick; children: ReactNode; } diff --git a/admin/src/components/VolunteerLayout.tsx b/admin/src/components/VolunteerLayout.tsx index dffc808c..843a936a 100644 --- a/admin/src/components/VolunteerLayout.tsx +++ b/admin/src/components/VolunteerLayout.tsx @@ -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: , label: 'Chat' }); } + if (settings?.enableAnalytics) { + items.push({ key: '/volunteer/my-analytics', icon: , label: 'My Stats' }); + } return items; }, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]); diff --git a/admin/src/components/people/UserAccountStatusPanel.tsx b/admin/src/components/people/UserAccountStatusPanel.tsx index a856f66f..6bbab995 100644 --- a/admin/src/components/people/UserAccountStatusPanel.tsx +++ b/admin/src/components/people/UserAccountStatusPanel.tsx @@ -17,6 +17,7 @@ const roleColors: Record = { EVENTS_ADMIN: 'cyan', SOCIAL_ADMIN: 'magenta', POLLS_ADMIN: 'geekblue', + ANALYTICS_ADMIN: 'processing', USER: 'blue', TEMP: 'default', }; diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index ccd2e043..9abab2d7 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -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() { + + + @@ -565,6 +569,27 @@ export default function SettingsPage() { + + {/* Analytics */} + + Analytics} + > + + + + + + + + + + + + + + ), @@ -596,7 +621,10 @@ export default function SettingsPage() { - + + + + diff --git a/admin/src/pages/UsersPage.tsx b/admin/src/pages/UsersPage.tsx index c0d715ed..7fce54a3 100644 --- a/admin/src/pages/UsersPage.tsx +++ b/admin/src/pages/UsersPage.tsx @@ -82,6 +82,7 @@ const roleColors: Record = { EVENTS_ADMIN: 'cyan', SOCIAL_ADMIN: 'magenta', POLLS_ADMIN: 'geekblue', + ANALYTICS_ADMIN: 'processing', USER: 'blue', TEMP: 'default', }; diff --git a/admin/src/pages/analytics/AnalyticsOverviewPage.tsx b/admin/src/pages/analytics/AnalyticsOverviewPage.tsx new file mode 100644 index 00000000..ad84834e --- /dev/null +++ b/admin/src/pages/analytics/AnalyticsOverviewPage.tsx @@ -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 = { + docs: '#1890ff', + video: '#722ed1', + photo: '#52c41a', +}; + +const MODULE_LABELS: Record = { + docs: 'Docs', + video: 'Video', + photo: 'Photo', +}; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** Pivot day-level rows into { date, docs, video, photo } for recharts */ +function pivotByDay(rows: DayViews[]): Record[] { + const map = new Map>(); + 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) => ( + {title} + ), + }, + { + title: 'Module', + dataIndex: 'module', + key: 'module', + width: 100, + render: (mod: string) => ( + + {MODULE_LABELS[mod] ?? mod} + + ), + }, + { + 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(); + const [days, setDays] = useState(30); + const [data, setData] = useState(null); + const [content, setContent] = useState([]); + 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 ( +
+ {/* Header row */} +
+ +
+ + + + + {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 */} +
+ +
+
+ )} + + + + + {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 ( +
+
+
+
({ + 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 ( +
+
+ +
+
+ )} + + + + + {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 ( +
+
+ My Stats +
+
+ )} + + + + {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 @@ })(); + + \ No newline at end of file diff --git a/nginx/conf.d/api.conf.template b/nginx/conf.d/api.conf.template index 4615f395..217a96bc 100644 --- a/nginx/conf.d/api.conf.template +++ b/nginx/conf.d/api.conf.template @@ -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;