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:
2026-04-03 08:47:44 -06:00
parent 0a20444a74
commit 08bd1f92b0
46 changed files with 2718 additions and 24 deletions

View File

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

40
api/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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