Add unified analytics system with GeoIP geo-tracking
Full analytics platform with MaxMind GeoLite2 IP-to-location resolution, cross-module dashboard (docs, video, photo), user drill-down, volunteer self-service stats, and ANALYTICS_ADMIN role with feature flag controls. - ANALYTICS_ADMIN role + ANALYTICS_ROLES group across backend and frontend - GeoIP service (MaxMind GeoLite2, lazy-loaded, graceful degradation) - Geo fields (country, region, city, lat/lng) on DocsPageView, VideoView, PhotoView - IP resolved to geo before SHA-256 hashing (privacy-preserving) - Unified analytics module: overview, geo, content, user engagement endpoints - 4 admin dashboard pages: Overview, Geography (Leaflet map), Content, Users - Volunteer MyAnalyticsPage for self-service activity stats - Settings UI: enableAnalytics, analyticsGeoEnabled, trackAuthenticatedUsers, retentionDays - Scheduled cleanup job respecting configurable retention period - config.sh: Analytics + MaxMind prompt in configure_features() - Control panel: enableAnalytics flag, template, discovery, wizard, detail page - Docker: geoip volume mount, MaxMind env vars, entrypoint auto-download - Nginx: X-Forwarded-For fix ($proxy_add_x_forwarded_for) for real client IP - Express trust proxy set to 2 for Pangolin/Newt tunnel chain - CORS updated for docs origin (cmlite.org + docs.cmlite.org) - Lander page: added docs-analytics tracking snippet - Prisma migration: 20260402100000_add_analytics_system Bunker Admin
This commit is contained in:
parent
0a20444a74
commit
08bd1f92b0
@ -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)
|
||||
|
||||
@ -45,6 +45,10 @@ import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
|
||||
import PangolinPage from '@/pages/PangolinPage';
|
||||
import ObservabilityPage from '@/pages/ObservabilityPage';
|
||||
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
||||
import AnalyticsOverviewPage from '@/pages/analytics/AnalyticsOverviewPage';
|
||||
import GeoAnalyticsPage from '@/pages/analytics/GeoAnalyticsPage';
|
||||
import ContentAnalyticsPage from '@/pages/analytics/ContentAnalyticsPage';
|
||||
import UserAnalyticsPage from '@/pages/analytics/UserAnalyticsPage';
|
||||
import DocsCommentsPage from '@/pages/DocsCommentsPage';
|
||||
import DocsMetadataPage from '@/pages/DocsMetadataPage';
|
||||
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
||||
@ -62,6 +66,11 @@ import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
|
||||
import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage';
|
||||
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
|
||||
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
|
||||
import PetitionsPage from '@/pages/influence/PetitionsPage';
|
||||
import PetitionSignaturesPage from '@/pages/influence/PetitionSignaturesPage';
|
||||
import PetitionModerationPage from '@/pages/influence/PetitionModerationPage';
|
||||
import PetitionsListPage from '@/pages/public/PetitionsListPage';
|
||||
import PetitionPage from '@/pages/public/PetitionPage';
|
||||
import PublicLandingPage from '@/pages/public/LandingPage';
|
||||
import PagesIndexPage from '@/pages/public/PagesIndexPage';
|
||||
import EventsPage from '@/pages/public/EventsPage';
|
||||
@ -100,6 +109,7 @@ import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
|
||||
import DiscoverPage from '@/pages/volunteer/DiscoverPage';
|
||||
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
|
||||
import AchievementsPage from '@/pages/volunteer/AchievementsPage';
|
||||
import MyAnalyticsPage from '@/pages/volunteer/MyAnalyticsPage';
|
||||
import {
|
||||
ADMIN_ROLES,
|
||||
INFLUENCE_ROLES,
|
||||
@ -113,6 +123,7 @@ import {
|
||||
SOCIAL_ROLES,
|
||||
SYSTEM_ROLES,
|
||||
POLLS_ROLES,
|
||||
ANALYTICS_ROLES,
|
||||
} from '@/types/api';
|
||||
import { isAdmin } from '@/utils/roles';
|
||||
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
||||
@ -240,6 +251,12 @@ export default function App() {
|
||||
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<CampaignsListPage />} />
|
||||
</Route>
|
||||
<Route path="/petitions" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<PetitionsListPage />} />
|
||||
</Route>
|
||||
<Route path="/petition/:slug" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<PetitionPage />} />
|
||||
</Route>
|
||||
<Route path="/campaigns/create" element={
|
||||
<FeatureGate feature="enableInfluence">
|
||||
<ProtectedRoute>
|
||||
@ -399,6 +416,7 @@ export default function App() {
|
||||
<Route path="/volunteer/calendar/shared" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarsPage /></FeatureGate>} />
|
||||
<Route path="/volunteer/calendar/friend/:userId" element={<FeatureGate feature="enableSocialCalendar"><FriendCalendarPage /></FeatureGate>} />
|
||||
<Route path="/volunteer/calendar" element={<FeatureGate feature="enableSocialCalendar"><MyCalendarPage /></FeatureGate>} />
|
||||
<Route path="/volunteer/my-analytics" element={<FeatureGate feature="enableAnalytics"><MyAnalyticsPage /></FeatureGate>} />
|
||||
<Route path="/volunteer/*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@ -574,6 +592,30 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="influence/petitions"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||
<PetitionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="influence/petitions/:id/signatures"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||
<PetitionSignaturesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="influence/petitions/moderation"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||
<PetitionModerationPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="influence/straw-polls"
|
||||
element={
|
||||
@ -815,6 +857,46 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="analytics"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
|
||||
<AnalyticsOverviewPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="analytics/geo"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
|
||||
<GeoAnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="analytics/content"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
|
||||
<ContentAnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="analytics/users"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
|
||||
<UserAnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="analytics/users/:userId"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
|
||||
<UserAnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map"
|
||||
element={
|
||||
|
||||
@ -72,6 +72,7 @@ import {
|
||||
PAYMENTS_ROLES,
|
||||
SOCIAL_ROLES,
|
||||
POLLS_ROLES,
|
||||
ANALYTICS_ROLES,
|
||||
} from '@/types/api';
|
||||
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
|
||||
import type { NavItem } from '@/types/api';
|
||||
@ -188,6 +189,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
||||
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
|
||||
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
|
||||
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
|
||||
...(settings?.enablePetitions !== false ? [
|
||||
{ key: '/app/influence/petitions', icon: <FileTextOutlined />, label: 'Petitions' },
|
||||
{ key: '/app/influence/petitions/moderation', icon: <FileTextOutlined />, label: 'Petition Review' },
|
||||
] : []),
|
||||
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
|
||||
],
|
||||
});
|
||||
@ -326,6 +331,20 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
||||
}
|
||||
|
||||
if (isSuperAdmin) {
|
||||
if (settings?.enableAnalytics !== false && can(ANALYTICS_ROLES)) {
|
||||
items.push({
|
||||
key: 'analytics-submenu',
|
||||
icon: <BarChartOutlined />,
|
||||
label: 'Analytics',
|
||||
children: [
|
||||
{ key: '/app/analytics', icon: <DashboardOutlined />, label: 'Overview' },
|
||||
{ key: '/app/analytics/geo', icon: <GlobalOutlined />, label: 'Geography' },
|
||||
{ key: '/app/analytics/content', icon: <FileTextOutlined />, label: 'Content' },
|
||||
{ key: '/app/analytics/users', icon: <TeamOutlined />, label: 'Users' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'services-submenu',
|
||||
icon: <CloudServerOutlined />,
|
||||
|
||||
@ -22,11 +22,13 @@ const FEATURE_LABELS: Record<string, string> = {
|
||||
enableMeetingPlanner: 'Meeting Planner',
|
||||
enableTicketedEvents: 'Ticketed Events',
|
||||
enableSocialCalendar: 'Social Calendar',
|
||||
enablePetitions: 'Petitions',
|
||||
enablePolls: 'Straw Polls',
|
||||
enableAnalytics: 'Analytics Dashboard',
|
||||
};
|
||||
|
||||
interface FeatureGateProps {
|
||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePolls'>;
|
||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePetitions' | 'enablePolls' | 'enableAnalytics'>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
TagOutlined,
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@ -65,6 +66,9 @@ export default function VolunteerLayout() {
|
||||
if (settings?.enableChat) {
|
||||
items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' });
|
||||
}
|
||||
if (settings?.enableAnalytics) {
|
||||
items.push({ key: '/volunteer/my-analytics', icon: <BarChartOutlined />, label: 'My Stats' });
|
||||
}
|
||||
return items;
|
||||
}, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]);
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ const roleColors: Record<UserRole, string> = {
|
||||
EVENTS_ADMIN: 'cyan',
|
||||
SOCIAL_ADMIN: 'magenta',
|
||||
POLLS_ADMIN: 'geekblue',
|
||||
ANALYTICS_ADMIN: 'processing',
|
||||
USER: 'blue',
|
||||
TEMP: 'default',
|
||||
};
|
||||
|
||||
@ -55,6 +55,7 @@ import {
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { api } from '@/lib/api';
|
||||
@ -470,6 +471,9 @@ export default function SettingsPage() {
|
||||
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Petitions" name="enablePetitions" valuePropName="checked" extra="Public petition pages with signature collection, progress tracking, and campaign linking" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Straw Polls" name="enablePolls" valuePropName="checked" extra="Quick opinion polls with public landers and MkDocs widgets" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
@ -565,6 +569,27 @@ export default function SettingsPage() {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Analytics */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><BarChartOutlined /> Analytics</Space>}
|
||||
>
|
||||
<Form.Item label="Analytics Dashboard" name="enableAnalytics" valuePropName="checked" extra="Unified cross-module analytics with geo-tracking" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="GeoIP Tracking" name="analyticsGeoEnabled" valuePropName="checked" extra="Resolve visitor locations from IP addresses (requires MaxMind credentials in .env)" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Track Authenticated Users" name="trackAuthenticatedUsers" valuePropName="checked" extra="Include logged-in user activity in analytics" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Retention (days)" name="analyticsRetentionDays" extra="Auto-delete analytics data older than this (7-365)" style={{ marginBottom: 0 }}>
|
||||
<InputNumber min={7} max={365} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
@ -596,7 +621,10 @@ export default function SettingsPage() {
|
||||
<Form.Item label="Sign Request" name="notifyAdminSignRequested" valuePropName="checked" extra="When a resident requests a yard sign during canvassing" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Shift Cancellation" name="notifyAdminShiftCancellation" valuePropName="checked" extra="When a volunteer cancels their shift signup" style={{ marginBottom: 0 }}>
|
||||
<Form.Item label="Shift Cancellation" name="notifyAdminShiftCancellation" valuePropName="checked" extra="When a volunteer cancels their shift signup" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Petition Milestones" name="notifyAdminPetitionMilestone" valuePropName="checked" extra="When a petition reaches a signature milestone (100, 500, 1000, etc.)" style={{ marginBottom: 0 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
@ -82,6 +82,7 @@ const roleColors: Record<UserRole, string> = {
|
||||
EVENTS_ADMIN: 'cyan',
|
||||
SOCIAL_ADMIN: 'magenta',
|
||||
POLLS_ADMIN: 'geekblue',
|
||||
ANALYTICS_ADMIN: 'processing',
|
||||
USER: 'blue',
|
||||
TEMP: 'default',
|
||||
};
|
||||
|
||||
316
admin/src/pages/analytics/AnalyticsOverviewPage.tsx
Normal file
316
admin/src/pages/analytics/AnalyticsOverviewPage.tsx
Normal file
@ -0,0 +1,316 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Row, Col, Card, Statistic, Table, Select, Button, App, Spin, Empty, Tag, Progress } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
UserOutlined,
|
||||
CalendarOutlined,
|
||||
ReloadOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ModuleBreakdown {
|
||||
module: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
interface DayViews {
|
||||
date: string;
|
||||
views: number;
|
||||
module: string;
|
||||
}
|
||||
|
||||
interface OverviewData {
|
||||
totalViews: number;
|
||||
uniqueSessions: number;
|
||||
avgViewsPerDay: number;
|
||||
moduleBreakdown: ModuleBreakdown[];
|
||||
viewsByDay: DayViews[];
|
||||
}
|
||||
|
||||
interface ContentItem {
|
||||
title: string;
|
||||
module: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const MODULE_COLORS: Record<string, string> = {
|
||||
docs: '#1890ff',
|
||||
video: '#722ed1',
|
||||
photo: '#52c41a',
|
||||
};
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
docs: 'Docs',
|
||||
video: 'Video',
|
||||
photo: 'Photo',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Pivot day-level rows into { date, docs, video, photo } for recharts */
|
||||
function pivotByDay(rows: DayViews[]): Record<string, number | string>[] {
|
||||
const map = new Map<string, Record<string, number | string>>();
|
||||
for (const r of rows) {
|
||||
if (!map.has(r.date)) map.set(r.date, { date: r.date });
|
||||
const entry = map.get(r.date)!;
|
||||
entry[r.module] = (typeof entry[r.module] === 'number' ? (entry[r.module] as number) : 0) + r.views;
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date)),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Table columns */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const contentColumns = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
render: (title: string) => (
|
||||
<span style={{ fontWeight: 500 }}>{title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Module',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 100,
|
||||
render: (mod: string) => (
|
||||
<Tag color={MODULE_COLORS[mod] ?? 'default'}>
|
||||
{MODULE_LABELS[mod] ?? mod}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Views',
|
||||
dataIndex: 'views',
|
||||
key: 'views',
|
||||
width: 90,
|
||||
sorter: (a: ContentItem, b: ContentItem) => a.views - b.views,
|
||||
defaultSortOrder: 'descend' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function AnalyticsOverviewPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<OverviewData | null>(null);
|
||||
const [content, setContent] = useState<ContentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { message } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Analytics Overview' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [overviewRes, contentRes] = await Promise.all([
|
||||
api.get('/analytics/overview', { params: { days } }),
|
||||
api.get('/analytics/content', { params: { days, module: 'all' } }),
|
||||
]);
|
||||
setData(overviewRes.data);
|
||||
setContent(Array.isArray(contentRes.data) ? contentRes.data : contentRes.data.items ?? []);
|
||||
} catch {
|
||||
message.error('Failed to load analytics data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [days, message]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const chartData = useMemo(() => (data ? pivotByDay(data.viewsByDay) : []), [data]);
|
||||
|
||||
const totalModuleViews = useMemo(
|
||||
() => (data ? data.moduleBreakdown.reduce((sum, m) => sum + m.views, 0) : 0),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<BarChartOutlined style={{ fontSize: 18 }} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<Select
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: 'Last 7 days', value: 7 },
|
||||
{ label: 'Last 30 days', value: 30 },
|
||||
{ label: 'Last 90 days', value: 90 },
|
||||
{ label: 'Last 365 days', value: 365 },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !data ? (
|
||||
<Empty description="No analytics data available" />
|
||||
) : (
|
||||
<>
|
||||
{/* Stat cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Total Views"
|
||||
value={data.totalViews}
|
||||
prefix={<EyeOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Unique Sessions"
|
||||
value={data.uniqueSessions}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Avg Views / Day"
|
||||
value={data.avgViewsPerDay}
|
||||
precision={1}
|
||||
prefix={<CalendarOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Views over time chart */}
|
||||
<Card title="Views Over Time" style={{ marginBottom: 24 }}>
|
||||
{chartData.length === 0 ? (
|
||||
<Empty description="No data for this period" />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="docs"
|
||||
name="Docs"
|
||||
stroke={MODULE_COLORS.docs}
|
||||
fill={MODULE_COLORS.docs}
|
||||
fillOpacity={0.3}
|
||||
stackId="1"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="video"
|
||||
name="Video"
|
||||
stroke={MODULE_COLORS.video}
|
||||
fill={MODULE_COLORS.video}
|
||||
fillOpacity={0.3}
|
||||
stackId="1"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="photo"
|
||||
name="Photo"
|
||||
stroke={MODULE_COLORS.photo}
|
||||
fill={MODULE_COLORS.photo}
|
||||
fillOpacity={0.3}
|
||||
stackId="1"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Module breakdown */}
|
||||
<Card title="Module Breakdown" style={{ marginBottom: 24 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{data.moduleBreakdown.map((m) => {
|
||||
const pct = totalModuleViews > 0
|
||||
? Math.round((m.views / totalModuleViews) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<Col xs={24} sm={8} key={m.module}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Tag color={MODULE_COLORS[m.module] ?? 'default'} style={{ marginBottom: 8, fontSize: 13 }}>
|
||||
{MODULE_LABELS[m.module] ?? m.module}
|
||||
</Tag>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={pct}
|
||||
size={80}
|
||||
strokeColor={MODULE_COLORS[m.module]}
|
||||
format={() => m.views.toLocaleString()}
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 12, opacity: 0.65 }}>
|
||||
{pct}% of total
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
{data.moduleBreakdown.length === 0 && (
|
||||
<Col span={24}>
|
||||
<Empty description="No module data" />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Top content table */}
|
||||
<Card title="Top Content">
|
||||
<Table
|
||||
dataSource={content}
|
||||
columns={contentColumns}
|
||||
rowKey={(r, i) => `${r.module}-${r.title}-${i}`}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
size="small"
|
||||
scroll={{ x: 400 }}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
admin/src/pages/analytics/ContentAnalyticsPage.tsx
Normal file
138
admin/src/pages/analytics/ContentAnalyticsPage.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Row, Col, Card, Table, Select, Spin, Empty, Tag, Segmented, App } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
|
||||
interface ContentItem {
|
||||
id: string;
|
||||
title: string;
|
||||
views: number;
|
||||
module: string;
|
||||
}
|
||||
|
||||
interface ContentAnalyticsData {
|
||||
content: ContentItem[];
|
||||
}
|
||||
|
||||
const MODULE_TAG_COLORS: Record<string, string> = {
|
||||
docs: 'blue',
|
||||
video: 'purple',
|
||||
photo: 'green',
|
||||
};
|
||||
|
||||
const MODULE_OPTIONS = ['All', 'Docs', 'Video', 'Photo'];
|
||||
|
||||
export default function ContentAnalyticsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const [days, setDays] = useState(30);
|
||||
const [moduleFilter, setModuleFilter] = useState('All');
|
||||
const [data, setData] = useState<ContentAnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { message } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Content Analytics' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/analytics/content', {
|
||||
params: { days, module: moduleFilter.toLowerCase() },
|
||||
});
|
||||
setData(res.data);
|
||||
} catch {
|
||||
message.error('Failed to load content analytics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [days, moduleFilter, message]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const columns: ColumnsType<ContentItem> = [
|
||||
{
|
||||
title: '#',
|
||||
key: 'rank',
|
||||
width: 50,
|
||||
render: (_: unknown, __: ContentItem, index: number) => index + 1,
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Module',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 100,
|
||||
render: (mod: string) => (
|
||||
<Tag color={MODULE_TAG_COLORS[mod] ?? 'default'}>
|
||||
{mod.charAt(0).toUpperCase() + mod.slice(1)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Views',
|
||||
dataIndex: 'views',
|
||||
key: 'views',
|
||||
width: 100,
|
||||
sorter: (a: ContentItem, b: ContentItem) => a.views - b.views,
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Segmented
|
||||
value={moduleFilter}
|
||||
onChange={(val) => setModuleFilter(val as string)}
|
||||
options={MODULE_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} style={{ textAlign: 'right' }}>
|
||||
<Select
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: 'Last 7 days', value: 7 },
|
||||
{ label: 'Last 30 days', value: 30 },
|
||||
{ label: 'Last 90 days', value: 90 },
|
||||
{ label: 'Last 365 days', value: 365 },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !data || data.content.length === 0 ? (
|
||||
<Empty description="No content analytics data available" />
|
||||
) : (
|
||||
<Card title={<><FileTextOutlined style={{ marginRight: 8 }} />Content Performance</>}>
|
||||
<Table
|
||||
dataSource={data.content}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 400 }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
admin/src/pages/analytics/GeoAnalyticsPage.tsx
Normal file
295
admin/src/pages/analytics/GeoAnalyticsPage.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Row, Col, Card, Table, Select, Button, App, Spin, Empty } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
GlobalOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface CountryRow {
|
||||
country: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
interface CityRow {
|
||||
city: string;
|
||||
region: string | null;
|
||||
country: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
interface GeoPoint {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
module: string;
|
||||
}
|
||||
|
||||
interface GeoData {
|
||||
viewsByCountry: CountryRow[];
|
||||
topCities: CityRow[];
|
||||
geoPoints: GeoPoint[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const MODULE_COLORS: Record<string, string> = {
|
||||
docs: '#1890ff',
|
||||
video: '#722ed1',
|
||||
photo: '#52c41a',
|
||||
};
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
docs: 'Docs',
|
||||
video: 'Video',
|
||||
photo: 'Photo',
|
||||
};
|
||||
|
||||
function markerRadius(count: number): number {
|
||||
return Math.max(5, Math.min(20, Math.sqrt(count) * 3));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Table columns */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildCountryColumns(totalViews: number) {
|
||||
return [
|
||||
{
|
||||
title: 'Country',
|
||||
dataIndex: 'country',
|
||||
key: 'country',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Views',
|
||||
dataIndex: 'views',
|
||||
key: 'views',
|
||||
width: 90,
|
||||
sorter: (a: CountryRow, b: CountryRow) => a.views - b.views,
|
||||
defaultSortOrder: 'descend' as const,
|
||||
},
|
||||
{
|
||||
title: '% of Total',
|
||||
key: 'pct',
|
||||
width: 100,
|
||||
render: (_: unknown, row: CountryRow) => {
|
||||
const pct = totalViews > 0 ? ((row.views / totalViews) * 100).toFixed(1) : '0.0';
|
||||
return `${pct}%`;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const cityColumns = [
|
||||
{
|
||||
title: 'City',
|
||||
dataIndex: 'city',
|
||||
key: 'city',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Region',
|
||||
dataIndex: 'region',
|
||||
key: 'region',
|
||||
width: 110,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v ?? '\u2014',
|
||||
},
|
||||
{
|
||||
title: 'Country',
|
||||
dataIndex: 'country',
|
||||
key: 'country',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: 'Views',
|
||||
dataIndex: 'views',
|
||||
key: 'views',
|
||||
width: 80,
|
||||
sorter: (a: CityRow, b: CityRow) => a.views - b.views,
|
||||
defaultSortOrder: 'descend' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function GeoAnalyticsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<GeoData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { message } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Geographic Analytics' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/analytics/geo', { params: { days } });
|
||||
setData(res.data);
|
||||
} catch {
|
||||
message.error('Failed to load geographic analytics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [days, message]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const totalViews = useMemo(
|
||||
() => (data ? data.viewsByCountry.reduce((s, r) => s + r.views, 0) : 0),
|
||||
[data],
|
||||
);
|
||||
|
||||
const countryColumns = useMemo(() => buildCountryColumns(totalViews), [totalViews]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<GlobalOutlined style={{ fontSize: 18 }} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<Select
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: 'Last 7 days', value: 7 },
|
||||
{ label: 'Last 30 days', value: 30 },
|
||||
{ label: 'Last 90 days', value: 90 },
|
||||
{ label: 'Last 365 days', value: 365 },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !data ? (
|
||||
<Empty description="No geographic data available" />
|
||||
) : (
|
||||
<>
|
||||
{/* Map */}
|
||||
<Card title="Visitor Locations" style={{ marginBottom: 24 }}>
|
||||
{data.geoPoints.length === 0 ? (
|
||||
<Empty description="No location data for this period" />
|
||||
) : (
|
||||
<div style={{ height: 400, width: '100%' }}>
|
||||
<MapContainer
|
||||
center={[30, 0]}
|
||||
zoom={2}
|
||||
style={{ height: '100%', width: '100%', borderRadius: 8 }}
|
||||
scrollWheelZoom
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{data.geoPoints.map((pt, idx) => (
|
||||
<CircleMarker
|
||||
key={`${pt.latitude}-${pt.longitude}-${pt.module}-${idx}`}
|
||||
center={[pt.latitude, pt.longitude]}
|
||||
radius={markerRadius(pt.count)}
|
||||
pathOptions={{
|
||||
color: MODULE_COLORS[pt.module] ?? '#1890ff',
|
||||
fillColor: MODULE_COLORS[pt.module] ?? '#1890ff',
|
||||
fillOpacity: 0.55,
|
||||
weight: 1,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<strong>{MODULE_LABELS[pt.module] ?? pt.module}</strong>
|
||||
<br />
|
||||
{pt.count.toLocaleString()} view{pt.count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map legend */}
|
||||
{data.geoPoints.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12 }}>
|
||||
{Object.entries(MODULE_COLORS).map(([mod, color]) => (
|
||||
<span key={mod} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
{MODULE_LABELS[mod] ?? mod}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Two tables side by side */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="Views by Country">
|
||||
{data.viewsByCountry.length === 0 ? (
|
||||
<Empty description="No country data" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={data.viewsByCountry}
|
||||
columns={countryColumns}
|
||||
rowKey="country"
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
size="small"
|
||||
scroll={{ x: 280 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="Top Cities">
|
||||
{data.topCities.length === 0 ? (
|
||||
<Empty description="No city data" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={data.topCities}
|
||||
columns={cityColumns}
|
||||
rowKey={(r, i) => `${r.city}-${r.country}-${i}`}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
size="small"
|
||||
scroll={{ x: 350 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
396
admin/src/pages/analytics/UserAnalyticsPage.tsx
Normal file
396
admin/src/pages/analytics/UserAnalyticsPage.tsx
Normal file
@ -0,0 +1,396 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Row, Col, Card, Statistic, Table, Select, Button, Spin, Empty, Tag, App, Grid } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
TeamOutlined,
|
||||
ArrowLeftOutlined,
|
||||
PlayCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
PictureOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useOutletContext, useParams, useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
|
||||
/* ---------- Shared helpers ---------- */
|
||||
|
||||
function formatWatchTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface UserRow {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
videoViews: number;
|
||||
photoViews: number;
|
||||
totalWatchTime: number;
|
||||
lastActive: string | null;
|
||||
}
|
||||
|
||||
interface UserDetailSummary {
|
||||
videoViews: number;
|
||||
totalWatchTime: number;
|
||||
photoViews: number;
|
||||
}
|
||||
|
||||
interface RecentVideoView {
|
||||
videoId: number;
|
||||
videoTitle: string;
|
||||
watchTime: number;
|
||||
completed: boolean;
|
||||
viewedAt: string;
|
||||
}
|
||||
|
||||
interface RecentPhotoView {
|
||||
photoId: number;
|
||||
photoTitle: string;
|
||||
viewedAt: string;
|
||||
}
|
||||
|
||||
interface UserDetailData {
|
||||
summary: UserDetailSummary;
|
||||
recentVideoViews: RecentVideoView[];
|
||||
recentPhotoViews: RecentPhotoView[];
|
||||
}
|
||||
|
||||
interface UserListData {
|
||||
users: UserRow[];
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||
}
|
||||
|
||||
/* ---------- List mode ---------- */
|
||||
|
||||
function UserList() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [days, setDays] = useState(30);
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<UserListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/analytics/users', {
|
||||
params: { days, page, limit: 20 },
|
||||
});
|
||||
setData(res.data);
|
||||
} catch {
|
||||
message.error('Failed to load user analytics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [days, page, message]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const columns: ColumnsType<UserRow> = [
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, record: UserRow) => record.name || record.email,
|
||||
},
|
||||
{
|
||||
title: 'Video Views',
|
||||
dataIndex: 'videoViews',
|
||||
key: 'videoViews',
|
||||
width: 110,
|
||||
sorter: (a: UserRow, b: UserRow) => a.videoViews - b.videoViews,
|
||||
},
|
||||
{
|
||||
title: 'Photo Views',
|
||||
dataIndex: 'photoViews',
|
||||
key: 'photoViews',
|
||||
width: 110,
|
||||
sorter: (a: UserRow, b: UserRow) => a.photoViews - b.photoViews,
|
||||
},
|
||||
{
|
||||
title: 'Watch Time',
|
||||
dataIndex: 'totalWatchTime',
|
||||
key: 'totalWatchTime',
|
||||
width: 110,
|
||||
render: (val: number) => formatWatchTime(val),
|
||||
sorter: (a: UserRow, b: UserRow) => a.totalWatchTime - b.totalWatchTime,
|
||||
},
|
||||
{
|
||||
title: 'Last Active',
|
||||
dataIndex: 'lastActive',
|
||||
key: 'lastActive',
|
||||
width: 110,
|
||||
render: (val: string | null) => formatRelativeTime(val),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Select
|
||||
value={days}
|
||||
onChange={(val) => { setDays(val); setPage(1); }}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: 'Last 7 days', value: 7 },
|
||||
{ label: 'Last 30 days', value: 30 },
|
||||
{ label: 'Last 90 days', value: 90 },
|
||||
{ label: 'Last 365 days', value: 365 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !data || data.users.length === 0 ? (
|
||||
<Empty description="No user analytics data available" />
|
||||
) : (
|
||||
<Card title={<><TeamOutlined style={{ marginRight: 8 }} />User Engagement</>}>
|
||||
<Table
|
||||
dataSource={data.users}
|
||||
columns={columns}
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 600 } : undefined}
|
||||
loading={loading}
|
||||
onRow={(record) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: () => navigate(`/app/analytics/users/${record.userId}`),
|
||||
})}
|
||||
pagination={{
|
||||
current: page,
|
||||
total: data.pagination.total,
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
onChange: setPage,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Detail mode ---------- */
|
||||
|
||||
function UserDetail({ userId }: { userId: string }) {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<UserDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/analytics/users/${userId}`, {
|
||||
params: { days },
|
||||
});
|
||||
setData(res.data);
|
||||
} catch {
|
||||
message.error('Failed to load user detail');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, days, message]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const videoColumns: ColumnsType<RecentVideoView> = [
|
||||
{
|
||||
title: 'Video',
|
||||
dataIndex: 'videoTitle',
|
||||
key: 'videoTitle',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Watch Time',
|
||||
dataIndex: 'watchTime',
|
||||
key: 'watchTime',
|
||||
width: 110,
|
||||
render: (val: number) => formatWatchTime(val),
|
||||
},
|
||||
{
|
||||
title: 'Completed',
|
||||
dataIndex: 'completed',
|
||||
key: 'completed',
|
||||
width: 90,
|
||||
render: (val: boolean) =>
|
||||
val ? <Tag icon={<CheckCircleOutlined />} color="success">Yes</Tag> : <Tag>No</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Viewed',
|
||||
dataIndex: 'viewedAt',
|
||||
key: 'viewedAt',
|
||||
width: 110,
|
||||
render: (val: string) => formatRelativeTime(val),
|
||||
},
|
||||
];
|
||||
|
||||
const photoColumns: ColumnsType<RecentPhotoView> = [
|
||||
{
|
||||
title: 'Photo',
|
||||
dataIndex: 'photoTitle',
|
||||
key: 'photoTitle',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Viewed',
|
||||
dataIndex: 'viewedAt',
|
||||
key: 'viewedAt',
|
||||
width: 110,
|
||||
render: (val: string) => formatRelativeTime(val),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/app/analytics/users')}
|
||||
>
|
||||
Back to Users
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Select
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: 'Last 7 days', value: 7 },
|
||||
{ label: 'Last 30 days', value: 30 },
|
||||
{ label: 'Last 90 days', value: 90 },
|
||||
{ label: 'Last 365 days', value: 365 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !data ? (
|
||||
<Empty description="No data available for this user" />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Video Views"
|
||||
value={data.summary.videoViews}
|
||||
prefix={<PlayCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Watch Time"
|
||||
value={formatWatchTime(data.summary.totalWatchTime)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Photo Views"
|
||||
value={data.summary.photoViews}
|
||||
prefix={<PictureOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card title="Recent Video Views" style={{ marginBottom: 16 }}>
|
||||
{data.recentVideoViews.length === 0 ? (
|
||||
<Empty description="No video views" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={data.recentVideoViews}
|
||||
columns={videoColumns}
|
||||
rowKey="videoId"
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 500 } : undefined}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title="Recent Photo Views" style={{ marginBottom: 16 }}>
|
||||
{data.recentPhotoViews.length === 0 ? (
|
||||
<Empty description="No photo views" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={data.recentPhotoViews}
|
||||
columns={photoColumns}
|
||||
rowKey="photoId"
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 300 } : undefined}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main export ---------- */
|
||||
|
||||
export default function UserAnalyticsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'User Analytics' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
if (userId) {
|
||||
return <UserDetail userId={userId} />;
|
||||
}
|
||||
|
||||
return <UserList />;
|
||||
}
|
||||
228
admin/src/pages/volunteer/MyAnalyticsPage.tsx
Normal file
228
admin/src/pages/volunteer/MyAnalyticsPage.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Row, Col, Card, Statistic, Table, Select, Spin, Empty, Tag, Typography, App, Grid } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
PictureOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
|
||||
function formatWatchTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface MyAnalyticsSummary {
|
||||
videoViews: number;
|
||||
totalWatchTime: number;
|
||||
photoViews: number;
|
||||
}
|
||||
|
||||
interface RecentVideoView {
|
||||
videoId: number;
|
||||
videoTitle: string;
|
||||
watchTime: number;
|
||||
completed: boolean;
|
||||
viewedAt: string;
|
||||
}
|
||||
|
||||
interface RecentPhotoView {
|
||||
photoId: number;
|
||||
photoTitle: string;
|
||||
viewedAt: string;
|
||||
}
|
||||
|
||||
interface MyAnalyticsData {
|
||||
summary: MyAnalyticsSummary;
|
||||
recentVideoViews: RecentVideoView[];
|
||||
recentPhotoViews: RecentPhotoView[];
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export default function MyAnalyticsPage() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<MyAnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/analytics/my-activity', { params: { days } });
|
||||
setData(res.data);
|
||||
} catch {
|
||||
message.error('Failed to load your stats');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [days, message]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const videoColumns: ColumnsType<RecentVideoView> = [
|
||||
{
|
||||
title: 'Video',
|
||||
dataIndex: 'videoTitle',
|
||||
key: 'videoTitle',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Watch Time',
|
||||
dataIndex: 'watchTime',
|
||||
key: 'watchTime',
|
||||
width: 100,
|
||||
render: (val: number) => formatWatchTime(val),
|
||||
},
|
||||
{
|
||||
title: 'Completed',
|
||||
dataIndex: 'completed',
|
||||
key: 'completed',
|
||||
width: 90,
|
||||
render: (val: boolean) =>
|
||||
val ? <Tag icon={<CheckCircleOutlined />} color="success">Yes</Tag> : <Tag>No</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'When',
|
||||
dataIndex: 'viewedAt',
|
||||
key: 'viewedAt',
|
||||
width: 100,
|
||||
render: (val: string) => formatRelativeTime(val),
|
||||
},
|
||||
];
|
||||
|
||||
const photoColumns: ColumnsType<RecentPhotoView> = [
|
||||
{
|
||||
title: 'Photo',
|
||||
dataIndex: 'photoTitle',
|
||||
key: 'photoTitle',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'When',
|
||||
dataIndex: 'viewedAt',
|
||||
key: 'viewedAt',
|
||||
width: 100,
|
||||
render: (val: string) => formatRelativeTime(val),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && !data) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>My Stats</Typography.Title>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Select
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: 'Last 7 days', value: 7 },
|
||||
{ label: 'Last 30 days', value: 30 },
|
||||
{ label: 'Last 90 days', value: 90 },
|
||||
{ label: 'Last 365 days', value: 365 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!data ? (
|
||||
<Empty description="No stats available yet" />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Videos Watched"
|
||||
value={data.summary.videoViews}
|
||||
prefix={<PlayCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Watch Time"
|
||||
value={formatWatchTime(data.summary.totalWatchTime)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Photos Viewed"
|
||||
value={data.summary.photoViews}
|
||||
prefix={<PictureOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="Recent Videos" size="small" style={{ marginBottom: 16 }}>
|
||||
{data.recentVideoViews.length === 0 ? (
|
||||
<Empty description="No video views yet" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={data.recentVideoViews}
|
||||
columns={videoColumns}
|
||||
rowKey="videoId"
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 500 } : undefined}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Recent Photos" size="small">
|
||||
{data.recentPhotoViews.length === 0 ? (
|
||||
<Empty description="No photo views yet" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={data.recentPhotoViews}
|
||||
columns={photoColumns}
|
||||
rowKey="photoId"
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 300 } : undefined}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -13,7 +13,7 @@ export interface AppOutletContext {
|
||||
setPageHeader: (config: PageHeaderConfig | null) => void;
|
||||
}
|
||||
|
||||
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'POLLS_ADMIN' | 'USER' | 'TEMP';
|
||||
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'POLLS_ADMIN' | 'ANALYTICS_ADMIN' | 'USER' | 'TEMP';
|
||||
|
||||
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
|
||||
|
||||
@ -101,7 +101,7 @@ export interface UsersListParams {
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN', 'POLLS_ADMIN'];
|
||||
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN', 'POLLS_ADMIN', 'ANALYTICS_ADMIN'];
|
||||
|
||||
// Module-specific role groups
|
||||
export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN'];
|
||||
@ -115,6 +115,7 @@ export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN'];
|
||||
export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN'];
|
||||
export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN'];
|
||||
export const POLLS_ROLES: UserRole[] = ['SUPER_ADMIN', 'POLLS_ADMIN', 'INFLUENCE_ADMIN'];
|
||||
export const ANALYTICS_ROLES: UserRole[] = ['SUPER_ADMIN', 'ANALYTICS_ADMIN'];
|
||||
|
||||
// --- User Provisioning ---
|
||||
|
||||
@ -1170,7 +1171,12 @@ export interface SiteSettings {
|
||||
enableMeetingPlanner: boolean;
|
||||
enableTicketedEvents: boolean;
|
||||
enableSocialCalendar: boolean;
|
||||
enablePetitions: boolean;
|
||||
enablePolls: boolean;
|
||||
enableAnalytics: boolean;
|
||||
analyticsRetentionDays: number;
|
||||
analyticsGeoEnabled: boolean;
|
||||
trackAuthenticatedUsers: boolean;
|
||||
enableDocsCollaboration: boolean;
|
||||
requireEventApproval: boolean;
|
||||
autoSyncPeopleToMap: boolean;
|
||||
@ -3453,3 +3459,88 @@ export interface StrawPoll {
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
// --- Petitions ---
|
||||
|
||||
export type PetitionStatus = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'CLOSED' | 'ARCHIVED';
|
||||
export type PetitionSignatureStatus = 'PENDING_VERIFICATION' | 'VERIFIED' | 'UNVERIFIED' | 'REJECTED';
|
||||
|
||||
export interface Petition {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
signatureGoal: number | null;
|
||||
showProgress: boolean;
|
||||
showSignatureCount: boolean;
|
||||
showSignerNames: boolean;
|
||||
signatureCountOffset: number;
|
||||
requireName: boolean;
|
||||
requireEmail: boolean;
|
||||
requirePostalCode: boolean;
|
||||
requirePhone: boolean;
|
||||
allowComment: boolean;
|
||||
commentLabel: string | null;
|
||||
requireEmailConfirmation: boolean;
|
||||
coverPhoto: string | null;
|
||||
coverVideoId: number | null;
|
||||
callToAction: string | null;
|
||||
thankYouMessage: string | null;
|
||||
highlightPetition: boolean;
|
||||
linkedCampaignId: string | null;
|
||||
linkedCampaign?: { id: string; slug: string; title: string; description: string | null; status: string } | null;
|
||||
status: PetitionStatus;
|
||||
isUserGenerated: boolean;
|
||||
moderationStatus: CampaignModerationStatus | null;
|
||||
rejectionReason?: string | null;
|
||||
moderationNotes?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
createdByUserEmail?: string | null;
|
||||
createdByUserName: string | null;
|
||||
reviewedByUserId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count: { signatures: number };
|
||||
}
|
||||
|
||||
export interface PetitionSignature {
|
||||
id: string;
|
||||
petitionId: string;
|
||||
signerName: string | null;
|
||||
signerEmail: string | null;
|
||||
signerPostalCode: string | null;
|
||||
signerPhone: string | null;
|
||||
signerComment: string | null;
|
||||
isAnonymous: boolean;
|
||||
displayName: string | null;
|
||||
status: PetitionSignatureStatus;
|
||||
geoCountry: string | null;
|
||||
geoRegion: string | null;
|
||||
geoCity: string | null;
|
||||
verifiedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PetitionStats {
|
||||
total: number;
|
||||
verified: number;
|
||||
goal: number | null;
|
||||
percentComplete: number | null;
|
||||
byCountry: Record<string, number>;
|
||||
byRegion: Record<string, number>;
|
||||
recentSigners: { displayName: string | null; geoCity: string | null; geoCountry: string | null; createdAt: string }[];
|
||||
}
|
||||
|
||||
export interface PetitionsListResponse {
|
||||
petitions: Petition[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface SignPetitionResponse {
|
||||
id: string;
|
||||
status: PetitionSignatureStatus;
|
||||
verificationSent: boolean;
|
||||
alreadySigned?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,48 @@ echo "Running database seed..."
|
||||
npx prisma db seed 2>&1 || echo "WARNING: Seed failed (non-fatal — seed.ts may require source files not present in production image)"
|
||||
echo "Seed step done."
|
||||
|
||||
# Download MaxMind GeoLite2 database if credentials are provided and DB is missing/stale
|
||||
if [ -n "$MAXMIND_ACCOUNT_ID" ] && [ -n "$MAXMIND_LICENSE_KEY" ]; then
|
||||
GEOIP_DIR="/data/geoip"
|
||||
GEOIP_DB="$GEOIP_DIR/GeoLite2-City.mmdb"
|
||||
mkdir -p "$GEOIP_DIR" 2>/dev/null || true
|
||||
# Re-download weekly (if file is older than 7 days or missing)
|
||||
if [ ! -f "$GEOIP_DB" ] || [ "$(find "$GEOIP_DB" -mtime +7 2>/dev/null | wc -l)" -gt 0 ]; then
|
||||
echo "Downloading MaxMind GeoLite2-City database..."
|
||||
DOWNLOAD_URL="https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz"
|
||||
# Use Node.js for download (BusyBox wget leaks auth header on redirects)
|
||||
if node -e "
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const auth = Buffer.from('$MAXMIND_ACCOUNT_ID:$MAXMIND_LICENSE_KEY').toString('base64');
|
||||
const get = (url, cb) => https.get(url, { headers: url.includes('maxmind.com') ? { Authorization: 'Basic ' + auth } : {} }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return get(res.headers.location, cb);
|
||||
if (res.statusCode !== 200) { cb(new Error('HTTP ' + res.statusCode)); return; }
|
||||
const out = fs.createWriteStream('/tmp/geolite2.tar.gz');
|
||||
res.pipe(out);
|
||||
out.on('close', () => cb(null));
|
||||
}).on('error', cb);
|
||||
get('$DOWNLOAD_URL', (err) => { if (err) { console.error(err.message); process.exit(1); } });
|
||||
"; then
|
||||
tar -xzf /tmp/geolite2.tar.gz -C /tmp/ 2>/dev/null
|
||||
MMDB_FILE=$(find /tmp -name 'GeoLite2-City.mmdb' -type f 2>/dev/null | head -1)
|
||||
if [ -n "$MMDB_FILE" ]; then
|
||||
mv "$MMDB_FILE" "$GEOIP_DB"
|
||||
echo "GeoLite2-City database updated."
|
||||
else
|
||||
echo "WARNING: Downloaded archive but no .mmdb file found (non-fatal)"
|
||||
fi
|
||||
rm -rf /tmp/geolite2.tar.gz /tmp/GeoLite2-City_* 2>/dev/null
|
||||
else
|
||||
echo "WARNING: Failed to download GeoLite2 database (non-fatal — geo features disabled)"
|
||||
fi
|
||||
else
|
||||
echo "GeoLite2-City database is up to date."
|
||||
fi
|
||||
else
|
||||
echo "MaxMind credentials not set — GeoIP lookup disabled."
|
||||
fi
|
||||
|
||||
# If running production mode (node dist/server.js) and dist is stale, recompile
|
||||
if [ -f "src/server.ts" ] && echo "$@" | grep -q "npm.*start\|node.*dist"; then
|
||||
if [ ! -f "dist/server.js" ] || [ "src/server.ts" -nt "dist/server.js" ]; then
|
||||
|
||||
40
api/package-lock.json
generated
40
api/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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])
|
||||
|
||||
@ -248,6 +248,11 @@ const envSchema = z.object({
|
||||
REDIS_EXPORTER_PORT: z.coerce.number().default(9121),
|
||||
GOTIFY_URL: z.string().default('http://gotify-changemaker:80'),
|
||||
GOTIFY_PORT: z.coerce.number().default(8889),
|
||||
|
||||
// GeoIP (MaxMind GeoLite2)
|
||||
MAXMIND_ACCOUNT_ID: z.string().default(''),
|
||||
MAXMIND_LICENSE_KEY: z.string().default(''),
|
||||
GEOIP_DB_PATH: z.string().default('/data/geoip/GeoLite2-City.mmdb'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
@ -277,6 +277,23 @@ export const docsAnalyticsRateLimit = rateLimit({
|
||||
},
|
||||
});
|
||||
|
||||
export const analyticsAdminRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:analytics-admin:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many analytics requests, please try again later',
|
||||
code: 'ANALYTICS_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const docsCommentAnonRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 5,
|
||||
@ -430,6 +447,23 @@ export const errorReportRateLimit = rateLimit({
|
||||
},
|
||||
});
|
||||
|
||||
export const petitionSignRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:petition-sign:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many petition signatures, please try again later',
|
||||
code: 'PETITION_SIGN_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const healthMetricsRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 30, // 30 requests per minute
|
||||
|
||||
35
api/src/modules/analytics/analytics-cleanup.service.ts
Normal file
35
api/src/modules/analytics/analytics-cleanup.service.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export const analyticsCleanupService = {
|
||||
async cleanupAll(): Promise<void> {
|
||||
// Read retention setting from SiteSettings
|
||||
let retentionDays = 90;
|
||||
try {
|
||||
const settings = await prisma.siteSettings.findFirst();
|
||||
if (settings?.analyticsRetentionDays) {
|
||||
retentionDays = settings.analyticsRetentionDays;
|
||||
}
|
||||
} catch {
|
||||
// Use default if settings unavailable
|
||||
}
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - retentionDays);
|
||||
|
||||
const [docsDeleted, videoDeleted, photoDeleted] = await Promise.all([
|
||||
prisma.docsPageView.deleteMany({ where: { createdAt: { lt: cutoff } } }),
|
||||
prisma.videoView.deleteMany({ where: { createdAt: { lt: cutoff } } }),
|
||||
prisma.photoView.deleteMany({ where: { viewedAt: { lt: cutoff } } }),
|
||||
]);
|
||||
|
||||
const total = docsDeleted.count + videoDeleted.count + photoDeleted.count;
|
||||
if (total > 0) {
|
||||
logger.info(`Analytics cleanup: removed ${total} records older than ${retentionDays} days`, {
|
||||
docs: docsDeleted.count,
|
||||
video: videoDeleted.count,
|
||||
photo: photoDeleted.count,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
23
api/src/modules/analytics/analytics-user.routes.ts
Normal file
23
api/src/modules/analytics/analytics-user.routes.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { analyticsService } from './analytics.service';
|
||||
import { userDetailQuerySchema } from './analytics.schemas';
|
||||
|
||||
export const analyticsUserRouter = Router();
|
||||
analyticsUserRouter.use(authenticate);
|
||||
|
||||
// GET /api/analytics/my-activity?days=30
|
||||
analyticsUserRouter.get(
|
||||
'/my-activity',
|
||||
validate(userDetailQuerySchema, 'query'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const days = Number(req.query.days) || 30;
|
||||
const activity = await analyticsService.getMyActivity(req.user!.id, days);
|
||||
res.json(activity);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
89
api/src/modules/analytics/analytics.routes.ts
Normal file
89
api/src/modules/analytics/analytics.routes.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { ANALYTICS_ROLES } from '../../utils/roles';
|
||||
import { analyticsService } from './analytics.service';
|
||||
import { analyticsQuerySchema, userAnalyticsQuerySchema, userDetailQuerySchema } from './analytics.schemas';
|
||||
|
||||
export const analyticsAdminRouter = Router();
|
||||
analyticsAdminRouter.use(authenticate);
|
||||
analyticsAdminRouter.use(requireRole(...ANALYTICS_ROLES));
|
||||
|
||||
// GET /api/analytics/overview?days=30
|
||||
analyticsAdminRouter.get(
|
||||
'/overview',
|
||||
validate(analyticsQuerySchema, 'query'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const days = Number(req.query.days) || 30;
|
||||
const overview = await analyticsService.getOverview(days);
|
||||
res.json(overview);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/analytics/geo?days=30
|
||||
analyticsAdminRouter.get(
|
||||
'/geo',
|
||||
validate(analyticsQuerySchema, 'query'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const days = Number(req.query.days) || 30;
|
||||
const geo = await analyticsService.getGeoAnalytics(days);
|
||||
res.json(geo);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/analytics/content?days=30&module=all
|
||||
analyticsAdminRouter.get(
|
||||
'/content',
|
||||
validate(analyticsQuerySchema, 'query'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const days = Number(req.query.days) || 30;
|
||||
const module = String(req.query.module || 'all');
|
||||
const content = await analyticsService.getContentAnalytics(days, module);
|
||||
res.json(content);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/analytics/users?days=30&page=1&limit=20
|
||||
analyticsAdminRouter.get(
|
||||
'/users',
|
||||
validate(userAnalyticsQuerySchema, 'query'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const days = Number(req.query.days) || 30;
|
||||
const page = Number(req.query.page) || 1;
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
const engagement = await analyticsService.getUsersEngagement(days, page, limit);
|
||||
res.json(engagement);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/analytics/users/:userId?days=30
|
||||
analyticsAdminRouter.get(
|
||||
'/users/:userId',
|
||||
validate(userDetailQuerySchema, 'query'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const days = Number(req.query.days) || 30;
|
||||
const activity = await analyticsService.getUserActivity(String(req.params.userId), days);
|
||||
res.json(activity);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
16
api/src/modules/analytics/analytics.schemas.ts
Normal file
16
api/src/modules/analytics/analytics.schemas.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const analyticsQuerySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(365).default(30),
|
||||
module: z.enum(['all', 'docs', 'video', 'photo']).default('all'),
|
||||
});
|
||||
|
||||
export const userAnalyticsQuerySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(365).default(30),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const userDetailQuerySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(365).default(30),
|
||||
});
|
||||
338
api/src/modules/analytics/analytics.service.ts
Normal file
338
api/src/modules/analytics/analytics.service.ts
Normal file
@ -0,0 +1,338 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ── Raw query result types ──────────────────────────────────────────────
|
||||
|
||||
type CountRow = { count: bigint };
|
||||
type ModuleCountRow = { module: string; views: bigint };
|
||||
type DayViewRow = { day: Date; views: bigint; module: string };
|
||||
type CountryRow = { country: string; views: bigint };
|
||||
type CityRow = { city: string; region: string | null; country: string; views: bigint };
|
||||
type GeoPointRow = { latitude: number; longitude: number; count: bigint; module: string };
|
||||
type ContentRow = { id: string; title: string; views: bigint; module: string };
|
||||
type UserEngagementRow = {
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
video_views: bigint;
|
||||
photo_views: bigint;
|
||||
total_watch_time: bigint;
|
||||
last_active: Date | null;
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function sinceDate(days: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toNum(v: bigint | null | undefined): number {
|
||||
return Number(v ?? 0);
|
||||
}
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const analyticsService = {
|
||||
/**
|
||||
* Cross-module overview: total views, unique sessions, views by day, module breakdown
|
||||
*/
|
||||
async getOverview(days: number) {
|
||||
const since = sinceDate(days);
|
||||
|
||||
const [docViews, videoViews, photoViews, docSessions, viewsByDay] = await Promise.all([
|
||||
prisma.docsPageView.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.videoView.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.photoView.count({ where: { viewedAt: { gte: since } } }),
|
||||
|
||||
prisma.$queryRaw<CountRow[]>`
|
||||
SELECT COUNT(DISTINCT "sessionHash") as count
|
||||
FROM docs_page_views
|
||||
WHERE "createdAt" >= ${since} AND "sessionHash" IS NOT NULL
|
||||
`,
|
||||
|
||||
// Combined views by day from all three modules
|
||||
prisma.$queryRaw<DayViewRow[]>`
|
||||
SELECT day, SUM(views)::bigint as views, module FROM (
|
||||
SELECT DATE("createdAt") as day, COUNT(*) as views, 'docs' as module
|
||||
FROM docs_page_views WHERE "createdAt" >= ${since} GROUP BY DATE("createdAt")
|
||||
UNION ALL
|
||||
SELECT DATE(created_at) as day, COUNT(*) as views, 'video' as module
|
||||
FROM video_views WHERE created_at >= ${since} GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT DATE(viewed_at) as day, COUNT(*) as views, 'photo' as module
|
||||
FROM photo_views WHERE viewed_at >= ${since} GROUP BY DATE(viewed_at)
|
||||
) combined
|
||||
GROUP BY day, module
|
||||
ORDER BY day ASC
|
||||
`,
|
||||
]);
|
||||
|
||||
const totalViews = docViews + videoViews + photoViews;
|
||||
const uniqueSessions = toNum(docSessions[0]?.count);
|
||||
|
||||
return {
|
||||
totalViews,
|
||||
uniqueSessions,
|
||||
avgViewsPerDay: days > 0 ? Math.round(totalViews / days) : 0,
|
||||
moduleBreakdown: [
|
||||
{ module: 'docs', views: docViews },
|
||||
{ module: 'video', views: videoViews },
|
||||
{ module: 'photo', views: photoViews },
|
||||
],
|
||||
viewsByDay: viewsByDay.map((r) => ({
|
||||
date: r.day instanceof Date ? r.day.toISOString().split('T')[0] : String(r.day),
|
||||
views: toNum(r.views),
|
||||
module: r.module,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Geographic analytics: views by country, top cities, map points
|
||||
*/
|
||||
async getGeoAnalytics(days: number) {
|
||||
const since = sinceDate(days);
|
||||
|
||||
const [viewsByCountry, topCities, geoPoints] = await Promise.all([
|
||||
prisma.$queryRaw<CountryRow[]>`
|
||||
SELECT country, SUM(views)::bigint as views FROM (
|
||||
SELECT country, COUNT(*) as views FROM docs_page_views
|
||||
WHERE "createdAt" >= ${since} AND country IS NOT NULL GROUP BY country
|
||||
UNION ALL
|
||||
SELECT country, COUNT(*) as views FROM video_views
|
||||
WHERE created_at >= ${since} AND country IS NOT NULL GROUP BY country
|
||||
UNION ALL
|
||||
SELECT country, COUNT(*) as views FROM photo_views
|
||||
WHERE viewed_at >= ${since} AND country IS NOT NULL GROUP BY country
|
||||
) combined
|
||||
GROUP BY country ORDER BY views DESC LIMIT 30
|
||||
`,
|
||||
|
||||
prisma.$queryRaw<CityRow[]>`
|
||||
SELECT city, region, country, SUM(views)::bigint as views FROM (
|
||||
SELECT city, region, country, COUNT(*) as views FROM docs_page_views
|
||||
WHERE "createdAt" >= ${since} AND city IS NOT NULL GROUP BY city, region, country
|
||||
UNION ALL
|
||||
SELECT city, region, country, COUNT(*) as views FROM video_views
|
||||
WHERE created_at >= ${since} AND city IS NOT NULL GROUP BY city, region, country
|
||||
UNION ALL
|
||||
SELECT city, region, country, COUNT(*) as views FROM photo_views
|
||||
WHERE viewed_at >= ${since} AND city IS NOT NULL GROUP BY city, region, country
|
||||
) combined
|
||||
GROUP BY city, region, country ORDER BY views DESC LIMIT 30
|
||||
`,
|
||||
|
||||
prisma.$queryRaw<GeoPointRow[]>`
|
||||
SELECT latitude, longitude, SUM(count)::bigint as count, module FROM (
|
||||
SELECT latitude, longitude, COUNT(*) as count, 'docs' as module FROM docs_page_views
|
||||
WHERE "createdAt" >= ${since} AND latitude IS NOT NULL GROUP BY latitude, longitude
|
||||
UNION ALL
|
||||
SELECT latitude, longitude, COUNT(*) as count, 'video' as module FROM video_views
|
||||
WHERE created_at >= ${since} AND latitude IS NOT NULL GROUP BY latitude, longitude
|
||||
UNION ALL
|
||||
SELECT latitude, longitude, COUNT(*) as count, 'photo' as module FROM photo_views
|
||||
WHERE viewed_at >= ${since} AND latitude IS NOT NULL GROUP BY latitude, longitude
|
||||
) combined
|
||||
GROUP BY latitude, longitude, module
|
||||
ORDER BY count DESC LIMIT 500
|
||||
`,
|
||||
]);
|
||||
|
||||
return {
|
||||
viewsByCountry: viewsByCountry.map((r) => ({
|
||||
country: r.country,
|
||||
views: toNum(r.views),
|
||||
})),
|
||||
topCities: topCities.map((r) => ({
|
||||
city: r.city,
|
||||
region: r.region,
|
||||
country: r.country,
|
||||
views: toNum(r.views),
|
||||
})),
|
||||
geoPoints: geoPoints.map((r) => ({
|
||||
latitude: r.latitude,
|
||||
longitude: r.longitude,
|
||||
count: toNum(r.count),
|
||||
module: r.module,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Content analytics: top pages/videos/photos by views
|
||||
*/
|
||||
async getContentAnalytics(days: number, module: string) {
|
||||
const since = sinceDate(days);
|
||||
const results: { id: string; title: string; views: number; module: string }[] = [];
|
||||
|
||||
if (module === 'all' || module === 'docs') {
|
||||
const docs = await prisma.$queryRaw<ContentRow[]>`
|
||||
SELECT path as id, path as title, COUNT(*)::bigint as views, 'docs' as module
|
||||
FROM docs_page_views
|
||||
WHERE "createdAt" >= ${since}
|
||||
GROUP BY path
|
||||
ORDER BY views DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
results.push(...docs.map((r) => ({ id: r.id, title: r.title, views: toNum(r.views), module: 'docs' })));
|
||||
}
|
||||
|
||||
if (module === 'all' || module === 'video') {
|
||||
const videos = await prisma.$queryRaw<{ id: number; title: string; views: bigint }[]>`
|
||||
SELECT v.id, v.title, COUNT(vv.id)::bigint as views
|
||||
FROM videos v
|
||||
LEFT JOIN video_views vv ON vv.video_id = v.id AND vv.created_at >= ${since}
|
||||
GROUP BY v.id, v.title
|
||||
HAVING COUNT(vv.id) > 0
|
||||
ORDER BY views DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
results.push(...videos.map((r) => ({ id: String(r.id), title: r.title, views: toNum(r.views), module: 'video' })));
|
||||
}
|
||||
|
||||
if (module === 'all' || module === 'photo') {
|
||||
const photos = await prisma.$queryRaw<{ id: number; title: string; views: bigint }[]>`
|
||||
SELECT p.id, COALESCE(p.title, p.filename) as title, COUNT(pv.id)::bigint as views
|
||||
FROM photos p
|
||||
LEFT JOIN photo_views pv ON pv.photo_id = p.id AND pv.viewed_at >= ${since}
|
||||
GROUP BY p.id, p.title, p.filename
|
||||
HAVING COUNT(pv.id) > 0
|
||||
ORDER BY views DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
results.push(...photos.map((r) => ({ id: String(r.id), title: r.title, views: toNum(r.views), module: 'photo' })));
|
||||
}
|
||||
|
||||
// Sort combined results by views descending
|
||||
results.sort((a, b) => b.views - a.views);
|
||||
|
||||
return { content: results.slice(0, 30) };
|
||||
},
|
||||
|
||||
/**
|
||||
* User engagement list (paginated)
|
||||
*/
|
||||
async getUsersEngagement(days: number, page: number, limit: number) {
|
||||
const since = sinceDate(days);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [users, totalResult] = await Promise.all([
|
||||
prisma.$queryRaw<UserEngagementRow[]>`
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
COALESCE(vv.cnt, 0)::bigint as video_views,
|
||||
COALESCE(pv.cnt, 0)::bigint as photo_views,
|
||||
COALESCE(vv.watch_time, 0)::bigint as total_watch_time,
|
||||
GREATEST(vv.last_active, pv.last_active) as last_active
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT user_id, COUNT(*) as cnt,
|
||||
SUM(watch_time_seconds) as watch_time,
|
||||
MAX(created_at) as last_active
|
||||
FROM video_views WHERE created_at >= ${since} AND user_id IS NOT NULL
|
||||
GROUP BY user_id
|
||||
) vv ON vv.user_id = u.id
|
||||
LEFT JOIN (
|
||||
SELECT user_id, COUNT(*) as cnt,
|
||||
MAX(viewed_at) as last_active
|
||||
FROM photo_views WHERE viewed_at >= ${since} AND user_id IS NOT NULL
|
||||
GROUP BY user_id
|
||||
) pv ON pv.user_id = u.id
|
||||
WHERE COALESCE(vv.cnt, 0) + COALESCE(pv.cnt, 0) > 0
|
||||
ORDER BY (COALESCE(vv.cnt, 0) + COALESCE(pv.cnt, 0)) DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`,
|
||||
|
||||
prisma.$queryRaw<CountRow[]>`
|
||||
SELECT COUNT(DISTINCT u.id)::bigint as count
|
||||
FROM users u
|
||||
LEFT JOIN video_views vv ON vv.user_id = u.id AND vv.created_at >= ${since}
|
||||
LEFT JOIN photo_views pv ON pv.user_id = u.id AND pv.viewed_at >= ${since}
|
||||
WHERE vv.id IS NOT NULL OR pv.id IS NOT NULL
|
||||
`,
|
||||
]);
|
||||
|
||||
return {
|
||||
users: users.map((r) => ({
|
||||
userId: r.user_id,
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
videoViews: toNum(r.video_views),
|
||||
photoViews: toNum(r.photo_views),
|
||||
totalWatchTime: toNum(r.total_watch_time),
|
||||
lastActive: r.last_active ? r.last_active.toISOString() : null,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: toNum(totalResult[0]?.count),
|
||||
totalPages: Math.ceil(toNum(totalResult[0]?.count) / limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Individual user activity detail
|
||||
*/
|
||||
async getUserActivity(userId: string, days: number) {
|
||||
const since = sinceDate(days);
|
||||
|
||||
const [videoViews, photoViews, videoStats, photoStats] = await Promise.all([
|
||||
prisma.videoView.findMany({
|
||||
where: { userId, createdAt: { gte: since } },
|
||||
include: { video: { select: { id: true, title: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
|
||||
prisma.photoView.findMany({
|
||||
where: { userId, viewedAt: { gte: since } },
|
||||
include: { photo: { select: { id: true, title: true, filename: true } } },
|
||||
orderBy: { viewedAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
|
||||
prisma.videoView.aggregate({
|
||||
where: { userId, createdAt: { gte: since } },
|
||||
_count: true,
|
||||
_sum: { watchTimeSeconds: true },
|
||||
}),
|
||||
|
||||
prisma.photoView.aggregate({
|
||||
where: { userId, viewedAt: { gte: since } },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
videoViews: videoStats._count,
|
||||
totalWatchTime: videoStats._sum.watchTimeSeconds ?? 0,
|
||||
photoViews: photoStats._count,
|
||||
},
|
||||
recentVideoViews: videoViews.map((v) => ({
|
||||
videoId: v.videoId,
|
||||
videoTitle: v.video?.title ?? 'Untitled',
|
||||
watchTime: v.watchTimeSeconds,
|
||||
completed: v.completed,
|
||||
viewedAt: v.createdAt.toISOString(),
|
||||
})),
|
||||
recentPhotoViews: photoViews.map((v) => ({
|
||||
photoId: v.photoId,
|
||||
photoTitle: v.photo?.title ?? v.photo?.filename ?? 'Untitled',
|
||||
viewedAt: v.viewedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Self-service: authenticated user's own activity
|
||||
*/
|
||||
async getMyActivity(userId: string, days: number) {
|
||||
return this.getUserActivity(userId, days);
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { geoipService } from '../../services/geoip.service';
|
||||
|
||||
interface PageViewData {
|
||||
path: string;
|
||||
referrer?: string;
|
||||
sessionHash?: string;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
interface TopPage {
|
||||
@ -25,27 +28,53 @@ interface TopReferrer {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface CountryViews {
|
||||
country: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
interface GeoPoint {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface AnalyticsSummary {
|
||||
totalViews: number;
|
||||
uniqueSessions: number;
|
||||
topPages: TopPage[];
|
||||
viewsByDay: DayViews[];
|
||||
topReferrers: TopReferrer[];
|
||||
viewsByCountry: CountryViews[];
|
||||
geoPoints: GeoPoint[];
|
||||
}
|
||||
|
||||
type UniqueCountRow = { count: bigint };
|
||||
type TopPageRow = { path: string; views: bigint; unique_sessions: bigint };
|
||||
type DayViewRow = { day: Date; views: bigint; unique_sessions: bigint };
|
||||
type ReferrerRow = { referrer: string; count: bigint };
|
||||
type CountryRow = { country: string; views: bigint };
|
||||
type GeoPointRow = { latitude: number; longitude: number; count: bigint };
|
||||
|
||||
export const docsAnalyticsService = {
|
||||
async recordPageView(data: PageViewData): Promise<void> {
|
||||
const geo = data.ipAddress ? await geoipService.lookup(data.ipAddress) : null;
|
||||
const ipAddressHash = data.ipAddress
|
||||
? createHash('sha256').update(data.ipAddress).digest('hex')
|
||||
: null;
|
||||
|
||||
await prisma.docsPageView.create({
|
||||
data: {
|
||||
path: data.path,
|
||||
referrer: data.referrer || null,
|
||||
sessionHash: data.sessionHash || null,
|
||||
userAgent: data.userAgent || null,
|
||||
ipAddressHash,
|
||||
country: geo?.country ?? null,
|
||||
region: geo?.region ?? null,
|
||||
city: geo?.city ?? null,
|
||||
latitude: geo?.latitude ?? null,
|
||||
longitude: geo?.longitude ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
@ -98,8 +127,29 @@ export const docsAnalyticsService = {
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const [totalViews, uniqueSessionsResult, topPagesRaw, viewsByDayRaw, topReferrersRaw] =
|
||||
await Promise.all([totalViewsP, uniqueSessionsP, topPagesP, viewsByDayP, topReferrersP]);
|
||||
const viewsByCountryP = prisma.$queryRaw<CountryRow[]>`
|
||||
SELECT country,
|
||||
COUNT(*) as views
|
||||
FROM docs_page_views
|
||||
WHERE "createdAt" >= ${since}
|
||||
AND country IS NOT NULL
|
||||
GROUP BY country
|
||||
ORDER BY views DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
|
||||
const geoPointsP = prisma.$queryRaw<GeoPointRow[]>`
|
||||
SELECT latitude, longitude, COUNT(*) as count
|
||||
FROM docs_page_views
|
||||
WHERE "createdAt" >= ${since}
|
||||
AND latitude IS NOT NULL
|
||||
GROUP BY latitude, longitude
|
||||
ORDER BY count DESC
|
||||
LIMIT 500
|
||||
`;
|
||||
|
||||
const [totalViews, uniqueSessionsResult, topPagesRaw, viewsByDayRaw, topReferrersRaw, viewsByCountryRaw, geoPointsRaw] =
|
||||
await Promise.all([totalViewsP, uniqueSessionsP, topPagesP, viewsByDayP, topReferrersP, viewsByCountryP, geoPointsP]);
|
||||
|
||||
return {
|
||||
totalViews,
|
||||
@ -120,6 +170,15 @@ export const docsAnalyticsService = {
|
||||
referrer: r.referrer,
|
||||
count: Number(r.count),
|
||||
})),
|
||||
viewsByCountry: viewsByCountryRaw.map((r) => ({
|
||||
country: r.country,
|
||||
views: Number(r.views),
|
||||
})),
|
||||
geoPoints: geoPointsRaw.map((r) => ({
|
||||
latitude: r.latitude,
|
||||
longitude: r.longitude,
|
||||
count: Number(r.count),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)
|
||||
|
||||
69
api/src/services/geoip.service.ts
Normal file
69
api/src/services/geoip.service.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { Reader } from '@maxmind/geoip2-node';
|
||||
import fs from 'fs';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface GeoResult {
|
||||
country: string | null; // ISO 3166-1 alpha-2
|
||||
region: string | null;
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
}
|
||||
|
||||
class GeoIPService {
|
||||
private reader: Reader | null = null;
|
||||
private loadAttempted = false;
|
||||
|
||||
private async ensureReader(): Promise<Reader | null> {
|
||||
if (this.reader) return this.reader;
|
||||
if (this.loadAttempted) return null;
|
||||
|
||||
this.loadAttempted = true;
|
||||
const dbPath = env.GEOIP_DB_PATH;
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
logger.info('GeoIP database not found — geo lookups disabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
this.reader = await Reader.open(dbPath);
|
||||
logger.info('GeoIP database loaded successfully');
|
||||
return this.reader;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load GeoIP database', { error: (err as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async lookup(ip: string): Promise<GeoResult | null> {
|
||||
if (!ip) return null;
|
||||
|
||||
const reader = await this.ensureReader();
|
||||
if (!reader) return null;
|
||||
|
||||
try {
|
||||
const response = (reader as any).city(ip);
|
||||
return {
|
||||
country: response?.country?.isoCode ?? null,
|
||||
region: response?.subdivisions?.[0]?.names?.en ?? null,
|
||||
city: response?.city?.names?.en ?? null,
|
||||
latitude: response?.location?.latitude ?? null,
|
||||
longitude: response?.location?.longitude ?? null,
|
||||
};
|
||||
} catch {
|
||||
// Private IPs, invalid addresses, or not-found entries
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-reload the database (e.g., after a fresh download) */
|
||||
async reload(): Promise<void> {
|
||||
this.reader = null;
|
||||
this.loadAttempted = false;
|
||||
await this.ensureReader();
|
||||
}
|
||||
}
|
||||
|
||||
export const geoipService = new GeoIPService();
|
||||
@ -11,6 +11,7 @@ type ScheduledJobType =
|
||||
| 'close-stale-tracking-sessions'
|
||||
| 'cleanup-tracking-data'
|
||||
| 'cleanup-docs-analytics'
|
||||
| 'cleanup-analytics-data'
|
||||
| 'cleanup-verification-tokens'
|
||||
| 'listmonk-full-sync'
|
||||
| 'validate-mkdocs-exports'
|
||||
@ -30,6 +31,7 @@ const JOB_DEFINITIONS: Array<{ type: ScheduledJobType; every: number; conditiona
|
||||
{ type: 'close-stale-tracking-sessions', every: HOUR },
|
||||
{ type: 'cleanup-tracking-data', every: 24 * HOUR },
|
||||
{ type: 'cleanup-docs-analytics', every: 24 * HOUR },
|
||||
{ type: 'cleanup-analytics-data', every: 24 * HOUR },
|
||||
{ type: 'cleanup-verification-tokens', every: HOUR },
|
||||
{ type: 'listmonk-full-sync', every: 6 * HOUR, conditional: true },
|
||||
{ type: 'validate-mkdocs-exports', every: 24 * HOUR },
|
||||
@ -65,8 +67,14 @@ async function executeJob(type: ScheduledJobType): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case 'cleanup-docs-analytics': {
|
||||
const { docsAnalyticsService } = await import('../modules/docs-analytics/docs-analytics.service');
|
||||
await docsAnalyticsService.cleanupOldData(90);
|
||||
// Delegated to unified analytics cleanup
|
||||
const { analyticsCleanupService } = await import('../modules/analytics/analytics-cleanup.service');
|
||||
await analyticsCleanupService.cleanupAll();
|
||||
break;
|
||||
}
|
||||
case 'cleanup-analytics-data': {
|
||||
const { analyticsCleanupService } = await import('../modules/analytics/analytics-cleanup.service');
|
||||
await analyticsCleanupService.cleanupAll();
|
||||
break;
|
||||
}
|
||||
case 'cleanup-verification-tokens': {
|
||||
|
||||
@ -11,6 +11,7 @@ const ROLE_PRIORITY: Record<string, number> = {
|
||||
EVENTS_ADMIN: 4,
|
||||
SOCIAL_ADMIN: 4,
|
||||
POLLS_ADMIN: 4,
|
||||
ANALYTICS_ADMIN: 4,
|
||||
USER: 2,
|
||||
TEMP: 1,
|
||||
};
|
||||
@ -27,6 +28,7 @@ export const ADMIN_ROLES: UserRole[] = [
|
||||
UserRole.EVENTS_ADMIN,
|
||||
UserRole.SOCIAL_ADMIN,
|
||||
UserRole.POLLS_ADMIN,
|
||||
UserRole.ANALYTICS_ADMIN,
|
||||
];
|
||||
|
||||
// Module-specific role groups
|
||||
@ -41,6 +43,7 @@ export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_A
|
||||
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
|
||||
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
|
||||
export const POLLS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.POLLS_ADMIN, UserRole.INFLUENCE_ADMIN];
|
||||
export const ANALYTICS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.ANALYTICS_ADMIN];
|
||||
|
||||
/** Check if the user has any of the specified roles */
|
||||
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {
|
||||
|
||||
@ -287,6 +287,12 @@ export default function CreateWizardPage() {
|
||||
<span>Unified people management, contact linking (no additional containers)</span>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" title="Analytics & GeoIP">
|
||||
<Space>
|
||||
<Switch checked={data.enableAnalytics} onChange={(v) => update({ enableAnalytics: v })} />
|
||||
<span>Unified analytics dashboard, visitor geo-tracking</span>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@ -587,6 +587,9 @@ export default function InstanceDetailPage() {
|
||||
<Tag color={instance.enablePeople ? 'red' : 'default'}>
|
||||
People CRM {instance.enablePeople ? 'ON' : 'OFF'}
|
||||
</Tag>
|
||||
<Tag color={instance.enableAnalytics ? 'geekblue' : 'default'}>
|
||||
Analytics {instance.enableAnalytics ? 'ON' : 'OFF'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@ -980,6 +983,19 @@ export default function InstanceDetailPage() {
|
||||
disabled={isRegistered}
|
||||
/>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Typography.Text strong>Analytics & GeoIP</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary">Unified analytics, visitor geography, user drill-down</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={featureFlags.enableAnalytics}
|
||||
onChange={(v) => setFeatureFlags((f) => ({ ...f, enableAnalytics: v }))}
|
||||
disabled={isRegistered}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -100,6 +100,7 @@ function extractFeatureFlags(envVars: Record<string, string>) {
|
||||
enableSms: isTrue(envVars.ENABLE_SMS),
|
||||
enableSocial: isTrue(envVars.ENABLE_SOCIAL),
|
||||
enablePeople: isTrue(envVars.ENABLE_PEOPLE),
|
||||
enableAnalytics: isTrue(envVars.ENABLE_ANALYTICS),
|
||||
emailTestMode: isTrue(envVars.EMAIL_TEST_MODE),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}}
|
||||
|
||||
23
config.sh
23
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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4705,5 +4705,27 @@
|
||||
|
||||
})();
|
||||
</script>
|
||||
<!-- Docs Analytics Tracking -->
|
||||
<script>
|
||||
(function() {
|
||||
var apiUrl = "{{ config.extra.api_url }}";
|
||||
if (!apiUrl) return;
|
||||
var trackUrl = apiUrl + "/api/docs-analytics/track";
|
||||
function getSessionHash() {
|
||||
var key = "__docs_sh";
|
||||
var hash = sessionStorage.getItem(key);
|
||||
if (!hash) {
|
||||
hash = crypto.randomUUID ? crypto.randomUUID() : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
|
||||
var r = (Math.random() * 16) | 0; return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
sessionStorage.setItem(key, hash);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
var payload = JSON.stringify({ path: location.pathname, referrer: document.referrer || undefined, sessionHash: getSessionHash() });
|
||||
if (navigator.sendBeacon) { navigator.sendBeacon(trackUrl, new Blob([payload], { type: "application/json" })); }
|
||||
else { fetch(trackUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload, keepalive: true }).catch(function() {}); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user