Add video card insert feature + MkDocs video hydration + fixes

- New video card block for GrapesJS landing pages, email templates,
  MkDocs export, and documentation editor Insert dropdown
- Shared HTML generators in admin/src/utils/videoCardHtml.ts
- MkDocs video-player.js hydrates .video-card-block elements:
  thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link
- Media API CORS: auto-add MkDocs + docs subdomain origins
- env_config_hook.py: smart Docker hostname detection, ADMIN_PORT
  resolution, pass env vars to MkDocs container
- Gallery URL uses /gallery?expanded=ID format
- VideoPickerModal: fix double /api prefix and Docker hostname thumbs
- Seed: default-video-card PageBlock
- Remove V1 legacy code (influence/, map/)

Bunker Admin
This commit is contained in:
2026-02-17 15:42:32 -07:00
parent 58dc1942ec
commit 99a6abab06
1511 changed files with 14551 additions and 1625410 deletions

View File

@@ -1,886 +0,0 @@
# Media Admin Features - Complete Guide
## Overview
The Video Admin Features system provides comprehensive content management capabilities for the Changemaker Lite video library. Implemented in February 2026, this system transforms the basic CRUD interface into a professional-grade video management platform with quick actions, scheduled publishing, and detailed analytics.
## Table of Contents
1. [Quick Action Buttons](#quick-action-buttons)
2. [Scheduled Publishing](#scheduled-publishing)
3. [Video Analytics](#video-analytics)
4. [Architecture](#architecture)
5. [API Reference](#api-reference)
6. [Security](#security)
---
## Quick Action Buttons
### Overview
Quick action buttons appear on video cards when hovering, providing instant access to common operations without navigating away from the library view.
### Features
**Primary Actions:**
- **Edit** (E) - Modify video metadata, title, producer, creator
- **Preview** (P) - Watch video with full analytics tracking
- **Analytics** (A) - View quick statistics modal
- **Schedule** (S) - Set publish/unpublish times
**Secondary Actions (Overflow Menu):**
- **Duplicate** - Clone video with new title
- **Generate Preview Link** - Create expiring share link (24h)
- **Download** - Download original video file (coming soon)
- **Generate Thumbnail** - Auto-generate thumbnail from video (coming soon)
- **Reset Analytics** - Clear all view data and statistics
- **Delete** - Permanently remove video
### Keyboard Shortcuts
Quick actions support keyboard shortcuts when the library page is focused:
| Key | Action |
|-----|--------|
| `E` | Edit video |
| `P` | Preview video |
| `A` | Show analytics |
| `S` | Schedule publishing |
**Note:** Shortcuts are disabled when typing in input fields or text areas.
### Preview Links
Preview links allow sharing videos with external parties without requiring authentication.
**Characteristics:**
- JWT-based token authentication
- 24-hour expiration (configurable via `VIDEO_PREVIEW_LINK_EXPIRY_HOURS`)
- Automatically copied to clipboard
- Single-use tracking (each open creates new view)
**Generating a Preview Link:**
1. Hover over video card
2. Click "More Actions" (three dots)
3. Select "Generate Preview Link"
4. Link is automatically copied to clipboard
5. Modal displays link and expiry time
**Preview Link Format:**
```
https://media.cmlite.org/api/videos/preview/{token}
```
---
## Scheduled Publishing
### Overview
The scheduled publishing system uses BullMQ job queue with Redis backend to automatically publish/unpublish videos at specific times with timezone support.
### Features
**Publishing Options:**
- **Publish Now** - Immediately set video to published state
- **Schedule Publish** - Set future publish date/time
- **Schedule Unpublish** - Auto-unpublish after period (optional)
- **Timezone Support** - 11 common timezones with auto-detection
- **Calendar View** - Visual overview of all upcoming schedules
**Supported Timezones:**
- UTC (Coordinated Universal Time)
- EST (Eastern Standard Time)
- CST (Central Standard Time)
- MST (Mountain Standard Time)
- PST (Pacific Standard Time)
- Toronto, Vancouver (Canada)
- GMT (London), CET (Paris)
- JST (Tokyo), AEDT (Sydney)
### Using Scheduled Publishing
**Schedule a Video:**
1. Click Schedule button on video card (clock icon)
2. Toggle "Publish immediately" off
3. Select timezone (defaults to your system timezone)
4. Choose publish date/time
5. Optionally enable "Auto-unpublish after period"
6. Click "Schedule" button
**View Scheduled Videos:**
1. Click "View Calendar" button in LibraryPage header
2. Calendar shows badge counts for days with scheduled events
3. Click a date to view scheduled publish/unpublish events
4. Cancel individual schedules from the list
**Schedule Badge:**
- Appears on video cards when publish/unpublish is scheduled
- Shows time until scheduled action
- Clock icon with color coding:
- Green: Scheduled to publish
- Orange: Scheduled to unpublish
- Red: Overdue (failed to execute)
### BullMQ Job Queue
**Queue Configuration:**
- Queue name: `video-schedules`
- Redis connection: Shared with email queue
- Job retry: 3 attempts with exponential backoff
- Job timeout: 30 seconds
**Job Processing:**
1. Schedule created → Job added to queue with delayed timestamp
2. Redis stores job until scheduled time
3. Worker picks up job at scheduled time
4. Updates video `isPublished` field
5. Records execution in `VideoScheduleHistory`
6. Removes job from queue
**Monitoring:**
See `MediaJobsPage` at `/app/media/jobs` for queue monitoring (coming soon).
---
## Video Analytics
### Overview
Comprehensive analytics tracking system that records views, watch time, user engagement, and traffic sources with privacy-focused design.
### Metrics Tracked
**Overview Statistics:**
- **Total Views** - Number of times video was played
- **Unique Viewers** - Deduplicated viewers (by IP hash or user ID)
- **Average Watch Time** - Mean watch duration across all views
- **Completion Rate** - Percentage of viewers who watched ≥95%
- **Total Watch Time** - Cumulative watch time across all views
**Per-View Data:**
- IP address (SHA-256 hashed for privacy)
- User agent (truncated, version numbers removed)
- Referrer URL (traffic source)
- User ID (for logged-in users)
- Watch time in seconds
- Completion status (boolean)
**Event Tracking:**
- **Play** - Video started/resumed
- **Pause** - Video paused
- **Seek** - User skipped forward/backward (with timestamp)
- **Complete** - Video watched to 95%+
### Privacy & GDPR Compliance
**Data Protection Measures:**
1. **IP Address Hashing** - All IP addresses hashed with SHA-256 before storage
2. **User Agent Truncation** - Version numbers and detailed info removed
3. **Anonymous Aggregation** - Anonymous views separated from registered users
4. **90-Day Retention** - Configurable data retention policy (default: 90 days)
5. **Do Not Track** - Respects DNT header (optional)
6. **User Opt-Out** - Users can disable analytics tracking in settings
**Compliance Features:**
- No personally identifiable information (PII) stored for anonymous users
- Registered user tracking requires explicit consent
- GDPR Article 17 "Right to be forgotten" via reset analytics
- Transparent data collection disclosure
### Analytics Dashboard
**Quick Analytics Modal** (accessible from video card):
- Overview stats (4 cards)
- Top referrers (up to 5 sources)
- Recent registered viewers (up to 10)
**Detailed Analytics Modal** (click Analytics button):
- **Overview Tab:**
- 4 overview stat cards
- Total watch time card
- Top referrers table (sortable)
- **Charts Tab:**
- Views over time (area chart, last 30 days)
- Traffic sources distribution (pie chart)
- **Viewers Tab:**
- Full registered viewers table
- Sortable by watch time, completion status
- Filter by completed/partial views
**Global Analytics Dashboard** (`/app/media/analytics`):
- Platform-wide statistics (total videos, views, watch time)
- Average completion rate across all videos
- Top 10 videos by views or watch time (switchable)
- Ranking system with medal icons (🥇🥈🥉)
### Tracking Implementation
**Client-Side Tracking:**
```typescript
// Record view when modal opens
await mediaApi.post('/track/view', {
videoId: video.id,
referer: document.referrer || undefined,
});
// Record events
await mediaApi.post('/track/event', {
videoId: video.id,
viewId: viewId,
eventType: 'play', // or 'pause', 'seek', 'complete'
timestamp: videoElement.currentTime,
});
// Heartbeat every 10 seconds
setInterval(() => {
navigator.sendBeacon(
'/api/track/heartbeat',
JSON.stringify({ viewId, watchTimeSeconds: currentTime })
);
}, 10000);
```
**Tracking Endpoints:**
- `POST /track/view` - Record video view start
- `POST /track/event` - Record video event (play, pause, etc.)
- `POST /track/heartbeat` - Update watch time (high frequency)
- `POST /track/batch` - Batch event submission (coming soon)
**Rate Limiting:**
- `/track/view`: 100 requests/minute per IP
- `/track/event`: 100 requests/minute per IP
- `/track/heartbeat`: 200 requests/minute per IP (higher for frequent updates)
### Analytics Aggregation
Analytics are aggregated in real-time via the `VideoAnalyticsService`:
```typescript
class VideoAnalyticsService {
async aggregateVideoAnalytics(videoId: number) {
// Aggregate from VideoView and VideoEvent tables
// Update Video model fields:
// - uniqueViewers
// - totalWatchTimeSeconds
// - averageWatchTimeSeconds
// - completionRate
}
}
```
**Aggregation Triggers:**
- After each video view completes
- On-demand via API endpoint
- Scheduled batch job (nightly, coming soon)
---
## Architecture
### System Design
**Technology Stack:**
- **Backend:** Fastify Media API (port 4100) with Prisma ORM
- **Frontend:** React + Ant Design + Zustand
- **Job Queue:** BullMQ with Redis
- **Database:** PostgreSQL 16 with Prisma migrations
- **Charts:** Recharts library
### Database Schema
**New Models:**
```prisma
model Video {
// ... existing fields ...
// Publishing
scheduledPublishAt DateTime?
scheduledUnpublishAt DateTime?
// Analytics
uniqueViewers Int @default(0)
totalWatchTimeSeconds Int @default(0)
averageWatchTimeSeconds Decimal @default(0) @db.Decimal(10, 2)
completionRate Decimal @default(0) @db.Decimal(5, 2)
// Relations
videoViews VideoView[]
videoEvents VideoEvent[]
}
model VideoView {
id Int @id @default(autoincrement())
videoId Int
userId Int?
ipAddress String? @db.VarChar(45) // SHA-256 hash
userAgent String? @db.Text
referer String? @db.Text
watchTimeSeconds Int @default(0)
completed Boolean @default(false)
createdAt DateTime @default(now())
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([videoId])
@@index([userId])
@@index([createdAt])
}
model VideoEvent {
id Int @id @default(autoincrement())
videoId Int
viewId Int?
eventType String @db.VarChar(50) // play, pause, seek, complete
timestamp Decimal @db.Decimal(10, 2) // Video timestamp in seconds
createdAt DateTime @default(now())
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
@@index([videoId])
@@index([viewId])
}
model VideoScheduleHistory {
id Int @id @default(autoincrement())
videoId Int
action String @db.VarChar(20) // 'publish' or 'unpublish'
scheduledFor DateTime
executedAt DateTime?
status String @db.VarChar(20) // 'pending', 'completed', 'failed', 'cancelled'
error String? @db.Text
scheduledByUserId Int
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
scheduledBy User @relation(fields: [scheduledByUserId], references: [id])
@@index([videoId])
@@index([scheduledFor])
@@index([status])
}
```
### File Structure
**Backend Services:**
```
api/src/modules/media/
├── services/
│ ├── video-analytics.service.ts # Analytics aggregation
│ └── ffprobe.service.ts # Video metadata (existing)
├── routes/
│ ├── videos.routes.ts # Video CRUD (existing)
│ ├── video-actions.routes.ts # Quick actions (duplicate, preview link, etc.)
│ ├── video-schedule.routes.ts # Schedule management
│ ├── video-analytics.routes.ts # Analytics queries (admin)
│ └── video-tracking.routes.ts # Public tracking endpoints
└── db/
└── schema.ts # Drizzle schema (existing)
api/src/services/
└── video-schedule-queue.service.ts # BullMQ queue + worker
```
**Frontend Components:**
```
admin/src/components/media/
├── VideoCard.tsx # Enhanced with actions overlay
├── VideoActions.tsx # Action buttons component
├── QuickAnalyticsModal.tsx # Quick stats modal
├── SchedulePublishModal.tsx # Schedule picker with timezone
├── ScheduleCalendarModal.tsx # Calendar view
├── ScheduleBadge.tsx # Schedule status badge
├── VideoAnalyticsModal.tsx # Detailed analytics (3 tabs)
├── AnalyticsChart.tsx # Recharts wrapper
├── ViewersTable.tsx # Registered viewers table
└── VideoViewerModal.tsx # Enhanced with tracking
admin/src/pages/media/
├── LibraryPage.tsx # Enhanced with calendar button
└── AnalyticsDashboardPage.tsx # Global analytics dashboard
```
---
## API Reference
### Quick Actions Endpoints
**Duplicate Video**
```http
POST /videos/:id/duplicate
Authorization: Bearer {admin_token}
Response:
{
"id": 123,
"title": "Original Title (Copy)",
"filename": "uuid-copy.mp4",
// ... other video fields
}
```
**Generate Preview Link**
```http
GET /videos/:id/preview-link
Authorization: Bearer {admin_token}
Response:
{
"previewUrl": "https://media.cmlite.org/api/videos/preview/{jwt_token}",
"expiryHours": 24
}
```
**Reset Analytics**
```http
POST /videos/:id/reset-analytics
Authorization: Bearer {admin_token}
Response:
{
"success": true,
"message": "Analytics reset successfully"
}
```
**Get Video Analytics**
```http
GET /videos/:id/analytics
Authorization: Bearer {admin_token}
Query Params:
- startDate (optional): ISO date string
- endDate (optional): ISO date string
Response:
{
"overview": {
"totalViews": 1234,
"uniqueViewers": 567,
"averageWatchTime": 180.5,
"completionRate": 67.3,
"totalWatchTime": 123456
},
"topReferrers": [
{ "referer": "google.com", "count": 45 },
{ "referer": "facebook.com", "count": 23 }
],
"registeredViewers": [
{
"userId": 1,
"userName": "John Doe",
"userEmail": "john@example.com",
"watchTime": 300,
"completed": true
}
],
"viewsOverTime": [
{ "date": "2026-02-01", "count": 12 },
{ "date": "2026-02-02", "count": 18 }
]
}
```
### Schedule Management Endpoints
**Schedule Publish**
```http
POST /videos/:id/schedule-publish
Authorization: Bearer {admin_token}
Body:
{
"publishAt": "2026-02-20T14:00:00Z",
"timezone": "America/New_York"
}
Response:
{
"success": true,
"jobId": "schedule:publish:123:abc-def",
"scheduledFor": "2026-02-20T14:00:00Z"
}
```
**Schedule Unpublish**
```http
POST /videos/:id/schedule-unpublish
Authorization: Bearer {admin_token}
Body:
{
"unpublishAt": "2026-03-01T00:00:00Z",
"timezone": "UTC"
}
Response:
{
"success": true,
"jobId": "schedule:unpublish:123:xyz-123",
"scheduledFor": "2026-03-01T00:00:00Z"
}
```
**Cancel Schedule**
```http
DELETE /videos/:id/schedule/:action
Authorization: Bearer {admin_token}
Params:
- action: 'publish' or 'unpublish'
Response:
{
"success": true,
"message": "publish schedule cancelled"
}
```
**Get Upcoming Schedules**
```http
GET /videos/schedules/upcoming
Authorization: Bearer {admin_token}
Query Params:
- limit (optional): default 100
Response:
{
"schedules": [
{
"jobId": "schedule:publish:123:abc",
"videoId": 123,
"videoTitle": "My Video",
"action": "publish",
"scheduledFor": "2026-02-20T14:00:00Z",
"status": "pending"
}
]
}
```
**Get Schedule History**
```http
GET /videos/:id/schedule-history
Authorization: Bearer {admin_token}
Response:
{
"history": [
{
"id": 1,
"action": "publish",
"scheduledFor": "2026-02-15T10:00:00Z",
"executedAt": "2026-02-15T10:00:03Z",
"status": "completed",
"scheduledBy": "admin@example.com"
}
]
}
```
### Analytics Tracking Endpoints (Public)
**Record View**
```http
POST /track/view
Content-Type: application/json
Body:
{
"videoId": 123,
"referer": "https://example.com" // optional
}
Response:
{
"viewId": 456
}
```
**Record Event**
```http
POST /track/event
Content-Type: application/json
Body:
{
"videoId": 123,
"viewId": 456, // optional
"eventType": "play", // 'play', 'pause', 'seek', 'complete'
"timestamp": 45.5
}
Response:
{
"success": true
}
```
**Update Watch Time (Heartbeat)**
```http
POST /track/heartbeat
Content-Type: application/json
Body:
{
"viewId": 456,
"watchTimeSeconds": 120
}
Response:
{
"success": true
}
```
### Analytics Query Endpoints (Admin)
**Get Top Videos**
```http
GET /videos/analytics/top
Authorization: Bearer {admin_token}
Query Params:
- metric: 'views' or 'watchTime'
- limit: default 10
Response:
{
"videos": [
{
"id": 123,
"title": "Popular Video",
"value": 1234 // views or watch time based on metric
}
]
}
```
**Get Analytics Overview**
```http
GET /videos/analytics/overview
Authorization: Bearer {admin_token}
Response:
{
"totalVideos": 50,
"totalViews": 12345,
"totalWatchTimeSeconds": 567890,
"averageCompletionRate": 65.4
}
```
---
## Security
### Authorization
**Admin-Only Endpoints:**
All quick action, schedule management, and analytics query endpoints require admin role:
- `requireAdminRole` middleware (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN)
**Public Tracking Endpoints:**
- No authentication required
- Rate limited (100-200 req/min per IP)
- Optional authentication via `optionalAuth` middleware (tracks user ID if logged in)
### Rate Limiting
**Tracking Endpoints:**
```typescript
{
'/track/view': { max: 100, windowMs: 60000 }, // 100/min
'/track/event': { max: 100, windowMs: 60000 }, // 100/min
'/track/heartbeat': { max: 200, windowMs: 60000 }, // 200/min (higher for frequent updates)
}
```
**Admin Endpoints:**
- Covered by global admin rate limits (500 req/min)
### Preview Link Security
**JWT Token Structure:**
```typescript
{
videoId: number,
exp: number, // 24 hours from generation
iat: number
}
```
**Validation:**
- JWT signature verification
- Expiration check (401 if expired)
- Video existence check (404 if deleted)
- Single-use recommended (no enforcement yet)
**Environment Configuration:**
```env
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
JWT_ACCESS_SECRET=your_secret_here # Used for signing preview tokens
```
### Privacy Protection
**IP Address Hashing:**
```typescript
import { createHash } from 'crypto';
function hashIpAddress(ipAddress: string): string {
return createHash('sha256')
.update(ipAddress)
.digest('hex');
}
```
**User Agent Truncation:**
```typescript
function truncateUserAgent(userAgent: string): string {
// Remove version numbers: "Chrome/91.0.4472.124" → "Chrome"
return userAgent
.replace(/\/[\d.]+/g, '')
.substring(0, 200);
}
```
**Data Retention:**
```typescript
// Scheduled cleanup (nightly)
async function cleanupOldAnalytics() {
const retentionDays = parseInt(process.env.VIDEO_ANALYTICS_RETENTION_DAYS || '90');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
await prisma.videoView.deleteMany({
where: { createdAt: { lt: cutoffDate } }
});
await prisma.videoEvent.deleteMany({
where: { createdAt: { lt: cutoffDate } }
});
}
```
---
## Environment Variables
Add to `.env`:
```env
# Video Analytics
VIDEO_ANALYTICS_RETENTION_DAYS=90
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
# Video Scheduling
VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC
VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
# Preview Links
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
```
---
## Troubleshooting
### Scheduled Publish Not Executing
**Check BullMQ Queue:**
```bash
# View queue status
docker compose exec media-api npm run queue:status
# Retry failed jobs
docker compose exec media-api npm run queue:retry
```
**Check Redis Connection:**
```bash
docker compose logs redis
docker compose exec redis redis-cli ping
```
**Check Schedule History:**
```sql
SELECT * FROM video_schedule_history
WHERE status = 'failed'
ORDER BY scheduled_for DESC
LIMIT 10;
```
### Analytics Not Tracking
**Check Rate Limits:**
```bash
# View Redis rate limit keys
docker compose exec redis redis-cli --scan --pattern "rl:*"
# Check remaining requests
docker compose exec redis redis-cli GET "rl:track-view:192.168.1.100"
```
**Check Network Tab:**
- Open browser DevTools → Network
- Filter by `/track/`
- Verify 200 OK responses
- Check for CORS errors
**Verify Tracking Endpoints:**
```bash
curl -X POST http://localhost:4100/api/track/view \
-H "Content-Type: application/json" \
-d '{"videoId": 1}'
```
### Preview Link Expired
**Regenerate Link:**
1. Navigate to LibraryPage
2. Hover over video card
3. Click "More Actions" → "Generate Preview Link"
4. New 24-hour link generated
**Adjust Expiry Time:**
```env
# In .env
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=48 # 2 days
```
---
## Future Enhancements
**Coming Soon:**
1. **Download Functionality** - Direct video file downloads
2. **Thumbnail Generation** - Auto-generate thumbnails from video frames
3. **Batch Event Submission** - `/track/batch` endpoint for bulk events
4. **Scheduled Reports** - Email weekly analytics summaries
5. **A/B Testing** - Compare multiple video versions
6. **Heatmaps** - Visual representation of drop-off points
7. **Export Analytics** - CSV/PDF export for reports
8. **Custom Dashboards** - User-configurable analytics views
---
## Support
**Documentation:**
- Main docs: `CLAUDE.md`
- Analytics guide: `VIDEO_ANALYTICS_GUIDE.md`
- API architecture: `api/src/modules/media/README.md`
**Issues:**
Report bugs or request features at: https://github.com/anthropics/changemaker-lite/issues
**Questions:**
Contact the development team or check the wiki for FAQs.

View File

@@ -1,176 +0,0 @@
# Nginx Domain Templating
## Overview
The nginx configuration now uses environment variable templating to support dynamic domain configuration. This allows you to change the domain for all services by simply updating the `DOMAIN` environment variable in `.env`.
## How It Works
1. **Template Files**: Nginx configuration files are stored as templates with `.template` extension
- `nginx/conf.d/api.conf.template`
- `nginx/conf.d/default.conf.template`
- `nginx/conf.d/services.conf.template`
2. **Environment Variable**: The `DOMAIN` variable in `.env` controls all subdomains
```bash
DOMAIN=betteredmonton.org
```
3. **Startup Process**: When the nginx container starts:
- The entrypoint script (`nginx/entrypoint.sh`) runs first
- It uses `envsubst` to replace `${DOMAIN}` in templates with the actual value
- Generated `.conf` files are created in `/etc/nginx/conf.d/`
- Nginx starts with the generated configuration
4. **Docker Configuration**: The `DOMAIN` env var is passed to nginx via `docker-compose.yml`:
```yaml
environment:
- DOMAIN=${DOMAIN:-cmlite.org}
```
## Configured Subdomains
All subdomains automatically use the `DOMAIN` value from `.env`:
| Subdomain | Service | Port |
|-----------|---------|------|
| `${DOMAIN}` | Admin GUI (root domain) | - |
| `app.${DOMAIN}` | Admin GUI | - |
| `api.${DOMAIN}` | API Server | - |
| `db.${DOMAIN}` | NocoDB | - |
| `docs.${DOMAIN}` | MkDocs | - |
| `code.${DOMAIN}` | Code Server | - |
| `listmonk.${DOMAIN}` | Listmonk | - |
| `grafana.${DOMAIN}` | Grafana | - |
| `git.${DOMAIN}` | Gitea | - |
| `n8n.${DOMAIN}` | n8n | - |
| `mail.${DOMAIN}` | MailHog | - |
| `qr.${DOMAIN}` | Mini QR | - |
| `draw.${DOMAIN}` | Excalidraw | - |
| `home.${DOMAIN}` | Homepage | - |
## Changing the Domain
To change to a new domain:
1. **Update `.env`**:
```bash
DOMAIN=newdomain.com
```
2. **Rebuild nginx and restart admin**:
```bash
docker compose build nginx
docker compose up -d nginx admin
```
Note: Admin needs to restart to pick up the new DOMAIN for Vite's allowed hosts configuration.
3. **Update Pangolin resources** (if using Pangolin tunnel):
- The Pangolin admin page will automatically show the new domain in the resource list
- Create Public resources in Pangolin dashboard for each subdomain you need
- Point each resource to `nginx:80` as the target
4. **Check nginx logs**:
```bash
docker compose logs nginx --tail 20
```
You should see: `Configuring nginx for domain: newdomain.com`
## Pangolin Resource Creation
For each subdomain you want accessible through Pangolin:
1. Go to Pangolin dashboard → Resources → Create Resource
2. **Resource Type**: Public Site
3. **URL**: `https://subdomain.yourdomain.org`
4. **Target**: `nginx` (or the Newt connection ID)
5. **Protocol**: http
6. **Backend**: `nginx:80`
Repeat for all required subdomains (app, api, db, etc.)
## Troubleshooting
**Duplicate server name warning**:
- Check nginx logs for conflicting `server_name` directives
- This usually means a subdomain is defined twice in the templates
**502 Bad Gateway**:
- Verify the backend service is running: `docker compose ps`
- Check nginx can reach the backend: `docker compose exec nginx wget -qO- http://backend:port`
- Review nginx error logs: `docker compose logs nginx`
**Domain not updating**:
- Ensure you rebuilt nginx: `docker compose build nginx`
- Verify the DOMAIN env var is set: `docker compose config | grep DOMAIN`
- Check generated configs: `docker compose exec nginx cat /etc/nginx/conf.d/services.conf | grep server_name`
## Technical Details
**Template Syntax**:
```nginx
server_name app.${DOMAIN};
```
**Generated Output** (with `DOMAIN=betteredmonton.org`):
```nginx
server_name app.betteredmonton.org;
```
**Entrypoint Script** (`nginx/entrypoint.sh`):
```bash
#!/bin/sh
export DOMAIN=${DOMAIN:-cmlite.org}
envsubst '${DOMAIN}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
envsubst '${DOMAIN}' < /etc/nginx/conf.d/api.conf.template > /etc/nginx/conf.d/api.conf
envsubst '${DOMAIN}' < /etc/nginx/conf.d/services.conf.template > /etc/nginx/conf.d/services.conf
nginx -t
exec /docker-entrypoint.sh "$@"
```
## Files Modified
- `nginx/Dockerfile` — Added gettext package, entrypoint script
- `nginx/entrypoint.sh` — New file: templates configs with envsubst
- `nginx/conf.d/*.template` — Template versions of all nginx configs
- `docker-compose.yml` — Added DOMAIN environment variable to nginx and admin services
- `docker-compose.yml` — Removed read-only conf.d volume mount (configs generated at runtime)
- `docker-compose.yml` — Updated healthcheck (removed crond check)
- `admin/vite.config.ts` — Dynamic allowed hosts based on DOMAIN env var
## Benefits
1. **Single Source of Truth**: Change domain in one place (`.env`)
2. **No Manual Edits**: No need to edit nginx configs or Vite config files
3. **Environment-Specific**: Use different domains for dev/staging/production
4. **Pangolin Integration**: Resource list automatically uses current domain
5. **Version Control Friendly**: Templates are committed, generated configs are not
6. **Vite Host Check**: Automatically allows the configured domain in Vite's dev server
## Vite Configuration
The admin Vite dev server now dynamically configures allowed hosts based on the DOMAIN environment variable:
```typescript
// admin/vite.config.ts
const domain = process.env.DOMAIN || 'cmlite.org';
export default defineConfig({
server: {
allowedHosts: [
`.${domain}`, // Allow all subdomains
'changemaker-v2-admin', // Container hostname
'localhost',
'127.0.0.1',
],
},
});
```
This prevents Vite's host check from blocking requests when accessing the app through:
- Pangolin tunnel with custom domain
- Cloudflare tunnel
- Any reverse proxy with custom domain
- Docker container networking

View File

@@ -1,689 +0,0 @@
# Video Analytics Guide
## Overview
This guide covers the setup, interpretation, and best practices for the Changemaker Lite video analytics system. The analytics platform tracks views, watch time, engagement metrics, and traffic sources while maintaining strong privacy protections.
## Table of Contents
1. [Getting Started](#getting-started)
2. [Understanding Metrics](#understanding-metrics)
3. [Interpreting Data](#interpreting-data)
4. [Privacy & Compliance](#privacy--compliance)
5. [Best Practices](#best-practices)
6. [Advanced Analytics](#advanced-analytics)
---
## Getting Started
### Initial Setup
**1. Enable Analytics in Environment:**
```env
# .env
VIDEO_ANALYTICS_RETENTION_DAYS=90
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
```
**2. Verify Tracking Endpoints:**
```bash
# Test view recording
curl -X POST http://localhost:4100/api/track/view \
-H "Content-Type: application/json" \
-d '{"videoId": 1, "referer": "https://example.com"}'
# Should return: {"viewId": 1}
```
**3. Check Database Tables:**
```sql
-- Verify tables exist
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE 'video_%';
-- Should show:
-- video_views
-- video_events
-- video_schedule_history
```
### Accessing Analytics
**Quick Analytics (Video Card):**
1. Navigate to `/app/media/library`
2. Hover over any video card
3. Click "Analytics" button (bar chart icon)
4. View quick stats modal
**Detailed Analytics (Full Modal):**
1. From quick analytics, click "View Detailed Analytics"
2. Or press `A` keyboard shortcut while hovering
3. Explore 3 tabs: Overview, Charts, Viewers
**Global Dashboard:**
1. Navigate to `/app/media/analytics`
2. View platform-wide statistics
3. See top performing videos
4. Switch between views/watch time metrics
---
## Understanding Metrics
### Core Metrics
#### 1. Total Views
**Definition:** Number of times the video play button was clicked.
**What it measures:**
- Initial engagement
- Link/sharing effectiveness
- Thumbnail appeal
**Good benchmark:**
- New video: 10+ views in first 24h
- Established content: 50+ views per week
- Viral content: 500+ views in first week
**Not included:**
- Page loads without play
- Autoplay (if implemented)
- Repeated views from same IP within 1 hour
#### 2. Unique Viewers
**Definition:** Deduplicated viewers based on IP hash (anonymous) or user ID (registered).
**What it measures:**
- Actual reach (not inflated by repeat views)
- Audience size
- Content discovery
**Calculation:**
```sql
-- Anonymous unique viewers (by IP hash)
SELECT COUNT(DISTINCT ip_address) FROM video_views
WHERE video_id = ? AND user_id IS NULL;
-- Registered unique viewers
SELECT COUNT(DISTINCT user_id) FROM video_views
WHERE video_id = ? AND user_id IS NOT NULL;
```
**Typical ratios:**
- Unique viewers / Total views: 60-80% (healthy)
- Below 50%: High repeat viewing (engagement or confusion?)
- Above 90%: Limited repeat engagement
#### 3. Average Watch Time
**Definition:** Mean duration viewers spent watching the video.
**What it measures:**
- Content quality
- Viewer interest retention
- Optimal video length validation
**Formula:**
```
Average Watch Time = Total Watch Time Seconds / Total Views
```
**Benchmarks by video length:**
- Short (< 2 min): 70%+ of duration
- Medium (2-10 min): 50%+ of duration
- Long (> 10 min): 30%+ of duration
**Red flags:**
- Average < 30s on 5-min video: Poor hook
- Average < 10% of duration: Wrong audience targeting
- Average > 95%: Possible bot traffic
#### 4. Completion Rate
**Definition:** Percentage of viewers who watched ≥95% of the video.
**What it measures:**
- Content value through to end
- Call-to-action effectiveness
- Audience match
**Formula:**
```
Completion Rate = (Completed Views / Total Views) × 100
```
**Industry benchmarks:**
- Educational content: 40-60%
- Entertainment: 30-50%
- Promotional: 20-40%
- Tutorial/How-to: 50-70%
**Completion threshold:** 95% (configurable in code)
#### 5. Total Watch Time
**Definition:** Cumulative seconds all viewers spent watching.
**What it measures:**
- Overall engagement
- Platform value (YouTube-style metric)
- Content ROI
**Use cases:**
- Compare videos of different lengths fairly
- Calculate "watch hours" for reporting
- Prioritize content for promotion
**Example:**
- Video A: 100 views, 2 min avg = 200 min total
- Video B: 50 views, 5 min avg = 250 min total
- **Video B has higher total watch time (better engagement)**
---
## Interpreting Data
### Analytics Dashboard Tabs
#### Overview Tab
**Stat Cards:**
- **Total Views:** Overall reach indicator
- **Unique Viewers:** True audience size
- **Avg Watch Time:** Engagement quality
- **Completion Rate:** Content effectiveness
**Total Watch Time Card:**
- Displayed in hours/minutes format
- Secondary display shows raw seconds
- Use for comparing videos of different lengths
**Top Referrers Table:**
- Shows traffic sources (domains)
- Sortable by view count
- Identifies effective promotion channels
- "Direct" = no referrer (bookmarks, direct links)
**Interpreting referrers:**
```
google.com (45 views) → SEO working well
facebook.com (23 views) → Social sharing successful
example.com (12 views) → Partner site traffic
(direct) (67 views) → Email links or bookmarks
```
#### Charts Tab
**Views Over Time (Area Chart):**
- Last 30 days by default
- Identifies trends and spikes
- Helps correlate with marketing campaigns
**Patterns to look for:**
- **Steady climb:** Organic growth, good SEO
- **Spike then drop:** Campaign effect, needs sustained promotion
- **Plateau:** Market saturation, needs refresh
- **Decline:** Algorithm change, content aging
**Traffic Sources Distribution (Pie Chart):**
- Visual breakdown of referrer sources
- Quickly identify dominant channels
- Guide marketing budget allocation
**Example interpretation:**
```
70% Direct → Email campaign working well
15% Social → Increase social promotion
10% Search → Improve SEO
5% Other → Diversify sources
```
#### Viewers Tab
**Registered Viewers Table:**
- Shows logged-in users who watched
- Columns: User, Email, Watch Time, Status
- Filter by "Completed" vs "Partial"
- Sort by watch time
**Use cases:**
1. **Follow-up emails** to partial viewers
2. **Identify super fans** (high completion)
3. **A/B test messaging** based on completion
4. **Segment audiences** for future content
**Privacy note:** Only shows registered users who consented to tracking.
### Global Analytics Dashboard
**Platform Statistics:**
- **Total Videos:** Content library size
- **Total Views:** Platform reach
- **Total Watch Time:** Cumulative engagement
- **Avg Completion Rate:** Platform-wide quality metric
**Calculated Averages:**
- **Avg Views per Video:** total views ÷ total videos
- **Avg Watch Time per Video:** total watch time ÷ total videos
**Top Videos Table:**
- Switchable metric (Views or Watch Time)
- Rank column (🥇🥈🥉 for top 3)
- Helps identify best performers
- Guide content strategy
---
## Privacy & Compliance
### GDPR Compliance
**Article 6 (Lawful Basis):**
- **Anonymous users:** Legitimate interest (analytics)
- **Registered users:** Explicit consent required
**Article 17 (Right to be Forgotten):**
- Users can request analytics deletion
- Admin can reset analytics via "Reset Analytics" button
- Deletes all views, events, and computed stats
**Article 13 (Transparency):**
- Disclose tracking in privacy policy
- Explain what data is collected
- How data is used (analytics only)
### Data Minimization
**What we collect:**
✅ IP address (SHA-256 hashed)
✅ User agent (truncated)
✅ Referrer URL
✅ Watch time (seconds)
✅ Video events (play, pause, seek, complete)
**What we don't collect:**
❌ Exact geolocation
❌ Device fingerprints
❌ Cross-site tracking cookies
❌ Personally identifiable information (PII) for anonymous users
### Retention Policy
**Default Settings:**
```env
VIDEO_ANALYTICS_RETENTION_DAYS=90
```
**Retention schedule:**
- **0-90 days:** Full data retained
- **90+ days:** Automatically deleted (nightly job)
**Configuring retention:**
```env
# Short-term (30 days)
VIDEO_ANALYTICS_RETENTION_DAYS=30
# Long-term (1 year)
VIDEO_ANALYTICS_RETENTION_DAYS=365
# Indefinite (not recommended)
VIDEO_ANALYTICS_RETENTION_DAYS=0
```
### IP Address Hashing
**How it works:**
```typescript
// SHA-256 hash before storage
const ipHash = crypto
.createHash('sha256')
.update(ipAddress)
.digest('hex');
// Original: 192.168.1.100
// Stored: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e
```
**Benefits:**
- **One-way:** Cannot reverse to original IP
- **Consistent:** Same IP = same hash (deduplication works)
- **Secure:** Prevents IP exposure in data breaches
**Limitations:**
- Cannot geolocate (by design)
- Cannot block specific IPs retroactively
- Rainbow table attacks possible (but impractical)
### User Consent
**Best practices:**
1. **Cookie banner:** Disclose analytics tracking
2. **Privacy policy:** Link to detailed data usage
3. **Opt-out option:** Allow users to disable tracking
4. **Clear language:** No legal jargon
**Example consent text:**
```
We use analytics to understand how our videos perform.
We collect anonymized view data (IP hash, watch time, referrer).
For registered users, we track which videos you watch to improve recommendations.
You can opt out in your account settings.
```
---
## Best Practices
### Analyzing Performance
**1. Compare Similar Videos**
Don't compare short vs long videos using view count alone.
**Good comparison:**
```
Video A (2 min): 100 views, 80% completion
Video B (2 min): 120 views, 65% completion
→ Video A is better (higher completion despite fewer views)
```
**Bad comparison:**
```
Video A (2 min): 100 views
Video B (10 min): 80 views
→ Can't conclude which is better without watch time/completion
```
**2. Track Trends, Not Absolutes**
Focus on improvement over time, not hitting arbitrary numbers.
**Example trend analysis:**
```
Month 1: 50 avg views/video, 40% completion
Month 2: 65 avg views/video, 45% completion
Month 3: 75 avg views/video, 50% completion
→ Positive trend! Content quality improving.
```
**3. Identify Drop-off Points**
Use the VideoEvent table to find where viewers quit.
**SQL Query:**
```sql
-- Find common seek/pause points
SELECT
FLOOR(timestamp / 30) * 30 AS time_bucket,
COUNT(*) AS event_count,
event_type
FROM video_events
WHERE video_id = ? AND event_type IN ('pause', 'seek')
GROUP BY time_bucket, event_type
ORDER BY time_bucket;
```
**Interpretation:**
- High pause rate at 2:30 → Boring section?
- Many seeks past 1:00 → Intro too long?
- Seeks to end → Looking for conclusion?
### Improving Metrics
**Increase Views:**
1. **Better thumbnails** - A/B test visual appeal
2. **Compelling titles** - Use action verbs, questions
3. **SEO optimization** - Keywords in title/description
4. **Social promotion** - Share on relevant platforms
5. **Email campaigns** - Send to engaged subscribers
**Increase Watch Time:**
1. **Strong hook** - First 10 seconds are critical
2. **Clear structure** - Tell viewers what to expect
3. **Pacing** - Remove slow sections
4. **Visual variety** - Change scenes, graphics
5. **Audio quality** - Poor audio = instant exit
**Increase Completion Rate:**
1. **Deliver value early** - Don't save best for last
2. **Match length to content** - Shorter often better
3. **Call to action** - Give reason to watch to end
4. **End screens** - Tease next video
5. **Remove fluff** - Every second must add value
### A/B Testing
**Test variables one at a time:**
1. **Thumbnails** - Same video, different thumbnail
2. **Titles** - "How to X" vs "X Explained"
3. **Length** - 5-min vs 10-min version
4. **Format** - Tutorial vs case study
**Minimum test duration:** 1 week or 100 views
**Statistical significance:**
Use chi-square test for completion rates:
```
Video A: 50/100 completed (50%)
Video B: 60/100 completed (60%)
→ 10% improvement, test for significance
```
### Reporting
**Weekly Report Template:**
```markdown
# Video Performance Report - Week of [Date]
## Top Performers
1. [Video Title] - 500 views, 65% completion
2. [Video Title] - 400 views, 70% completion
3. [Video Title] - 350 views, 55% completion
## Platform Metrics
- Total Views: 2,500 (+15% vs last week)
- Avg Watch Time: 3:45 (+10s vs last week)
- Avg Completion: 58% (+3% vs last week)
## Traffic Sources
- Direct: 45%
- Social: 30%
- Search: 15%
- Other: 10%
## Action Items
- [ ] Improve thumbnail for [Low Performer]
- [ ] Create follow-up email for partial viewers
- [ ] Double down on Facebook promotion (highest completion)
```
---
## Advanced Analytics
### Custom Queries
**Find your best time to publish:**
```sql
SELECT
EXTRACT(DOW FROM created_at) AS day_of_week,
EXTRACT(HOUR FROM created_at) AS hour_of_day,
COUNT(*) AS view_count,
AVG(watch_time_seconds) AS avg_watch_time
FROM video_views
GROUP BY day_of_week, hour_of_day
ORDER BY view_count DESC
LIMIT 10;
```
**Identify super fans (registered users):**
```sql
SELECT
u.name,
u.email,
COUNT(DISTINCT vv.video_id) AS videos_watched,
SUM(vv.watch_time_seconds) AS total_watch_time,
AVG(vv.watch_time_seconds) AS avg_watch_time
FROM video_views vv
JOIN users u ON vv.user_id = u.id
GROUP BY u.id, u.name, u.email
HAVING COUNT(DISTINCT vv.video_id) >= 5
ORDER BY total_watch_time DESC
LIMIT 20;
```
**Calculate retention curve:**
```sql
WITH time_buckets AS (
SELECT
video_id,
view_id,
FLOOR(timestamp / 10) * 10 AS time_bucket
FROM video_events
WHERE video_id = ? AND event_type = 'complete'
)
SELECT
time_bucket,
COUNT(DISTINCT view_id) AS viewers_at_time,
(COUNT(DISTINCT view_id) * 100.0 / (
SELECT COUNT(DISTINCT id) FROM video_views WHERE video_id = ?
)) AS retention_percentage
FROM time_buckets
GROUP BY time_bucket
ORDER BY time_bucket;
```
### Integrations
**Google Analytics (Future):**
- Send video events to GA4
- Track conversions from video views
- Cross-reference with site analytics
**Email Marketing (Future):**
- Segment users by completion rate
- Send follow-up emails to partial viewers
- Recommend similar videos based on watch history
**CRM Integration (Future):**
- Sync super fans to CRM
- Track video engagement per lead
- Score leads based on video views
### Machine Learning Opportunities
**Recommendation Engine:**
- Collaborative filtering (users who watched X also watched Y)
- Content-based (similar titles, producers, duration)
- Hybrid approach
**Predictive Analytics:**
- Predict video performance before publishing
- Forecast future views based on trends
- Identify optimal video length per category
**Anomaly Detection:**
- Flag unusual spike in views (bot traffic?)
- Detect sudden drop in completion (video broken?)
- Alert on referrer spam
---
## Troubleshooting
### Low View Count
**Diagnosis:**
1. Check if video is published (`isPublished = true`)
2. Verify video is not scheduled for future (`scheduledPublishAt`)
3. Review sharing/promotion efforts
4. Check if thumbnail is loading
**Solutions:**
- Publish immediately if scheduled too far out
- Generate and share preview link
- Post to social media
- Add to email newsletter
### Low Completion Rate
**Diagnosis:**
1. Watch video yourself critically
2. Check drop-off points (VideoEvent table)
3. Review average watch time vs duration
4. Compare to similar videos
**Solutions:**
- Trim intro (if drop-off < 30s)
- Add chapters/timestamps (if drop-off mid-video)
- Improve pacing (if gradual decline)
- Ensure audio quality (if sharp drop-off)
### Tracking Not Working
**Check list:**
1. Network tab shows 200 OK responses
2. Rate limits not exceeded (Redis keys)
3. No CORS errors in console
4. videoRef.current exists before tracking
5. Heartbeat interval running (check console logs)
**Debug mode:**
```typescript
// Add to VideoViewerModal.tsx
console.log('Recording view:', { videoId: video.id });
console.log('Heartbeat sent:', { viewId, watchTimeSeconds });
```
### Inaccurate Metrics
**Common causes:**
1. **Bot traffic** - High views, low watch time
2. **Autoplayers** - Views without intent
3. **Video loops** - Same user, repeated views
4. **Test data** - QA testing inflating numbers
**Solutions:**
- Filter views with < 5s watch time
- Exclude internal IPs (if known)
- Reset analytics for test videos
- Use unique viewers metric instead of total views
---
## Future Roadmap
**Q2 2026:**
- Heatmap visualization (drop-off points)
- Exported reports (CSV, PDF)
- Email digests (weekly summaries)
**Q3 2026:**
- Recommendation engine
- A/B testing framework
- Advanced segmentation
**Q4 2026:**
- Predictive analytics
- ML-based insights
- Real-time dashboards
---
## Resources
**Documentation:**
- [Media Admin Features Guide](./MEDIA_ADMIN_FEATURES.md)
- [API Documentation](../api/src/modules/media/README.md)
- [CLAUDE.md](../CLAUDE.md) - Project overview
**External Resources:**
- [YouTube Analytics Best Practices](https://support.google.com/youtube/answer/1714323)
- [Wistia Video Marketing Guide](https://wistia.com/learn/marketing)
- [Vimeo Analytics Documentation](https://vimeo.com/blog/post/video-analytics-guide)
**Support:**
- GitHub Issues: https://github.com/anthropics/changemaker-lite/issues
- Wiki: https://wiki.changemaker-lite.org
---
**Last Updated:** February 2026
**Version:** 1.0
**Author:** Changemaker Lite Development Team