bunker-admin 08bd1f92b0 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
2026-04-03 08:47:44 -06:00

75 lines
2.9 KiB
TypeScript

import { UserRole } from '@prisma/client';
const ROLE_PRIORITY: Record<string, number> = {
SUPER_ADMIN: 5,
INFLUENCE_ADMIN: 4,
MAP_ADMIN: 4,
BROADCAST_ADMIN: 4,
CONTENT_ADMIN: 4,
MEDIA_ADMIN: 4,
PAYMENTS_ADMIN: 4,
EVENTS_ADMIN: 4,
SOCIAL_ADMIN: 4,
POLLS_ADMIN: 4,
ANALYTICS_ADMIN: 4,
USER: 2,
TEMP: 1,
};
/** All admin roles (any user with one of these can access /app) */
export const ADMIN_ROLES: UserRole[] = [
UserRole.SUPER_ADMIN,
UserRole.INFLUENCE_ADMIN,
UserRole.MAP_ADMIN,
UserRole.BROADCAST_ADMIN,
UserRole.CONTENT_ADMIN,
UserRole.MEDIA_ADMIN,
UserRole.PAYMENTS_ADMIN,
UserRole.EVENTS_ADMIN,
UserRole.SOCIAL_ADMIN,
UserRole.POLLS_ADMIN,
UserRole.ANALYTICS_ADMIN,
];
// Module-specific role groups
export const INFLUENCE_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
export const MAP_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
export const BROADCAST_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.BROADCAST_ADMIN];
export const CONTENT_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.CONTENT_ADMIN];
export const MEDIA_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MEDIA_ADMIN];
export const PAYMENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.PAYMENTS_ADMIN];
export const EVENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.EVENTS_ADMIN];
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
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 {
const userRoles = getUserRoles(user);
return userRoles.some(r => roles.includes(r));
}
/** Check if user has any admin role */
export function isAdmin(user: { roles?: unknown; role?: UserRole }): boolean {
return hasAnyRole(user, ADMIN_ROLES);
}
/** Get the primary (highest-priority) role from a roles array */
export function getPrimaryRole(roles: UserRole[]): UserRole {
if (roles.length === 0) return UserRole.USER;
return roles.reduce((highest, current) =>
(ROLE_PRIORITY[current] || 0) > (ROLE_PRIORITY[highest] || 0) ? current : highest
);
}
/** Safely extract UserRole[] from a user object (handles old single-role and new multi-role) */
export function getUserRoles(user: { roles?: unknown; role?: UserRole }): UserRole[] {
if (Array.isArray(user.roles) && user.roles.length > 0) {
return user.roles as UserRole[];
}
if (user.role) return [user.role];
return [UserRole.USER];
}