Tonne of debugging - getting ready for the production builds
This commit is contained in:
886
docs/MEDIA_ADMIN_FEATURES.md
Normal file
886
docs/MEDIA_ADMIN_FEATURES.md
Normal file
@@ -0,0 +1,886 @@
|
||||
# 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.
|
||||
176
docs/NGINX_DOMAIN_TEMPLATING.md
Normal file
176
docs/NGINX_DOMAIN_TEMPLATING.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# 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
|
||||
689
docs/VIDEO_ANALYTICS_GUIDE.md
Normal file
689
docs/VIDEO_ANALYTICS_GUIDE.md
Normal file
@@ -0,0 +1,689 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user