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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user