From 3f73afb4bfd7f3c3c7b904626e67bb2201ddf77d Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Sun, 24 May 2026 02:59:46 -0700 Subject: [PATCH] Add video engagement tracking and analytics metrics. Record playback heartbeats via POST /api/v1/videos/engagement/heartbeat and expose completion, replay, and drop-off rates on the analytics dashboard. Co-authored-by: Cursor --- cmd/main.go | 4 + .../000073_user_video_watch_sessions.down.sql | 1 + .../000073_user_video_watch_sessions.up.sql | 18 ++ db/query/analytics.sql | 71 ++++++ db/query/video_engagement.sql | 74 +++++++ gen/db/analytics.sql.go | 136 ++++++++++++ gen/db/models.go | 14 ++ gen/db/video_engagement.sql.go | 205 ++++++++++++++++++ internal/domain/analytics.go | 19 ++ internal/domain/video_engagement.go | 23 ++ internal/repository/video_engagement.go | 165 ++++++++++++++ internal/services/rbac/seeds.go | 7 +- internal/services/videoengagement/service.go | 20 ++ internal/web_server/app.go | 12 +- .../web_server/handlers/analytics_handler.go | 44 ++++ .../web_server/handlers/analytics_params.go | 6 + internal/web_server/handlers/handlers.go | 8 +- .../handlers/video_engagement_handler.go | 106 +++++++++ internal/web_server/routes.go | 2 + 19 files changed, 926 insertions(+), 9 deletions(-) create mode 100644 db/migrations/000073_user_video_watch_sessions.down.sql create mode 100644 db/migrations/000073_user_video_watch_sessions.up.sql create mode 100644 db/query/video_engagement.sql create mode 100644 gen/db/video_engagement.sql.go create mode 100644 internal/domain/video_engagement.go create mode 100644 internal/repository/video_engagement.go create mode 100644 internal/services/videoengagement/service.go create mode 100644 internal/web_server/handlers/video_engagement_handler.go diff --git a/cmd/main.go b/cmd/main.go index eec006b..6153469 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -46,6 +46,7 @@ import ( // referralservice "Yimaru-Backend/internal/services/referal" "Yimaru-Backend/internal/services/transaction" "Yimaru-Backend/internal/services/user" + videoengagementservice "Yimaru-Backend/internal/services/videoengagement" httpserver "Yimaru-Backend/internal/web_server" jwtutil "Yimaru-Backend/internal/web_server/jwt" customvalidator "Yimaru-Backend/internal/web_server/validator" @@ -416,6 +417,8 @@ func main() { lmsProgressSvc := lmsprogress.NewService(store) + videoEngagementSvc := videoengagementservice.NewService(store) + // LMS practices (under course, module, or lesson) practiceSvc := practicesservice.NewService(store, store, store, store, store, store) @@ -514,6 +517,7 @@ func main() { domain.MongoDBLogger, analyticsDB, rbacSvc, + videoEngagementSvc, ) logger.Info("Starting server", "port", cfg.Port) diff --git a/db/migrations/000073_user_video_watch_sessions.down.sql b/db/migrations/000073_user_video_watch_sessions.down.sql new file mode 100644 index 0000000..101715d --- /dev/null +++ b/db/migrations/000073_user_video_watch_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_video_watch_sessions; diff --git a/db/migrations/000073_user_video_watch_sessions.up.sql b/db/migrations/000073_user_video_watch_sessions.up.sql new file mode 100644 index 0000000..b5356ce --- /dev/null +++ b/db/migrations/000073_user_video_watch_sessions.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE user_video_watch_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + content_kind VARCHAR(32) NOT NULL CHECK (content_kind IN ('lms_lesson', 'exam_prep_lesson')), + content_id BIGINT NOT NULL, + session_number INT NOT NULL CHECK (session_number > 0), + video_duration_sec INT, + max_position_sec INT NOT NULL DEFAULT 0 CHECK (max_position_sec >= 0), + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + UNIQUE (user_id, content_kind, content_id, session_number) +); + +CREATE INDEX idx_user_video_watch_sessions_user ON user_video_watch_sessions (user_id); +CREATE INDEX idx_user_video_watch_sessions_content ON user_video_watch_sessions (content_kind, content_id); +CREATE INDEX idx_user_video_watch_sessions_started_at ON user_video_watch_sessions (started_at); diff --git a/db/query/analytics.sql b/db/query/analytics.sql index 6ccfa92..3d5ed95 100644 --- a/db/query/analytics.sql +++ b/db/query/analytics.sql @@ -317,6 +317,77 @@ SELECT (SELECT COUNT(*)::bigint FROM exam_prep.unit_module_lessons WHERE NULLIF(BTRIM(video_url), '') IS NOT NULL) AS exam_prep_lessons_with_video, (SELECT COUNT(*)::bigint FROM exam_prep.lesson_practices) AS exam_prep_lesson_practices; +-- ===================== +-- Video Engagement Analytics +-- ===================== + +-- name: AnalyticsVideoEngagementSummary :one +SELECT + COUNT(*)::bigint AS total_sessions, + COUNT(*) FILTER (WHERE s.completed_at IS NOT NULL)::bigint AS completed_sessions, + COUNT(*) FILTER (WHERE s.session_number > 1)::bigint AS replay_sessions, + COUNT(DISTINCT (s.user_id, s.content_kind, s.content_id))::bigint AS unique_video_starts, + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT + s2.user_id, + s2.content_kind, + s2.content_id + FROM user_video_watch_sessions s2 + WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR s2.started_at >= sqlc.narg('range_start')::timestamptz) + AND (sqlc.narg('range_end')::timestamptz IS NULL OR s2.started_at < sqlc.narg('range_end')::timestamptz) + GROUP BY s2.user_id, s2.content_kind, s2.content_id + HAVING MAX(s2.session_number) > 1 + ) replayed + ) AS users_who_replayed +FROM user_video_watch_sessions s +WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR s.started_at >= sqlc.narg('range_start')::timestamptz) + AND (sqlc.narg('range_end')::timestamptz IS NULL OR s.started_at < sqlc.narg('range_end')::timestamptz); + +-- name: AnalyticsVideoDropOffByCheckpoint :many +WITH filtered AS ( + SELECT + s.max_position_sec, + s.video_duration_sec AS duration_sec + FROM user_video_watch_sessions s + WHERE (sqlc.narg('range_start')::timestamptz IS NULL OR s.started_at >= sqlc.narg('range_start')::timestamptz) + AND (sqlc.narg('range_end')::timestamptz IS NULL OR s.started_at < sqlc.narg('range_end')::timestamptz) + AND s.video_duration_sec IS NOT NULL + AND s.video_duration_sec > 0 +), +totals AS ( + SELECT COUNT(*)::bigint AS total + FROM filtered +), +checkpoints AS ( + SELECT unnest(ARRAY[10, 25, 50, 75, 90, 100])::int AS checkpoint_percent +) +SELECT + c.checkpoint_percent, + t.total AS total_sessions, + ( + SELECT COUNT(*)::bigint + FROM filtered f + WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent + ) AS viewers_reached, + CASE + WHEN t.total = 0 THEN 0::float8 + ELSE ROUND( + ( + 1.0 - ( + SELECT COUNT(*)::float8 + FROM filtered f + WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent + ) / t.total::float8 + )::numeric, + 4 + )::float8 + END AS drop_off_rate +FROM checkpoints c +CROSS JOIN totals t +ORDER BY c.checkpoint_percent; + -- ===================== -- Content Analytics -- ===================== diff --git a/db/query/video_engagement.sql b/db/query/video_engagement.sql new file mode 100644 index 0000000..1ab7771 --- /dev/null +++ b/db/query/video_engagement.sql @@ -0,0 +1,74 @@ +-- name: GetActiveVideoWatchSession :one +SELECT + id, + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec, + started_at, + last_heartbeat_at, + ended_at, + completed_at +FROM user_video_watch_sessions +WHERE user_id = $1 + AND content_kind = $2 + AND content_id = $3 + AND ended_at IS NULL + AND last_heartbeat_at >= $4 +ORDER BY session_number DESC +LIMIT 1; + +-- name: GetMaxVideoWatchSessionNumber :one +SELECT + coalesce(max(session_number), 0)::int AS max_session_number +FROM user_video_watch_sessions +WHERE user_id = $1 + AND content_kind = $2 + AND content_id = $3; + +-- name: InsertVideoWatchSession :one +INSERT INTO user_video_watch_sessions ( + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec +) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING + id, + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec, + started_at, + last_heartbeat_at, + ended_at, + completed_at; + +-- name: UpdateVideoWatchSession :one +UPDATE user_video_watch_sessions +SET + max_position_sec = $2, + video_duration_sec = $3, + last_heartbeat_at = $4, + completed_at = $5, + ended_at = $6 +WHERE id = $1 +RETURNING + id, + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec, + started_at, + last_heartbeat_at, + ended_at, + completed_at; diff --git a/gen/db/analytics.sql.go b/gen/db/analytics.sql.go index cbd1dd5..dbee750 100644 --- a/gen/db/analytics.sql.go +++ b/gen/db/analytics.sql.go @@ -1510,3 +1510,139 @@ func (q *Queries) AnalyticsUsersSummary(ctx context.Context, arg AnalyticsUsersS ) return i, err } + +const AnalyticsVideoDropOffByCheckpoint = `-- name: AnalyticsVideoDropOffByCheckpoint :many +WITH filtered AS ( + SELECT + s.max_position_sec, + s.video_duration_sec AS duration_sec + FROM user_video_watch_sessions s + WHERE ($1::timestamptz IS NULL OR s.started_at >= $1::timestamptz) + AND ($2::timestamptz IS NULL OR s.started_at < $2::timestamptz) + AND s.video_duration_sec IS NOT NULL + AND s.video_duration_sec > 0 +), +totals AS ( + SELECT COUNT(*)::bigint AS total + FROM filtered +), +checkpoints AS ( + SELECT unnest(ARRAY[10, 25, 50, 75, 90, 100])::int AS checkpoint_percent +) +SELECT + c.checkpoint_percent, + t.total AS total_sessions, + ( + SELECT COUNT(*)::bigint + FROM filtered f + WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent + ) AS viewers_reached, + CASE + WHEN t.total = 0 THEN 0::float8 + ELSE ROUND( + ( + 1.0 - ( + SELECT COUNT(*)::float8 + FROM filtered f + WHERE (f.max_position_sec * 100 / f.duration_sec) >= c.checkpoint_percent + ) / t.total::float8 + )::numeric, + 4 + )::float8 + END AS drop_off_rate +FROM checkpoints c +CROSS JOIN totals t +ORDER BY c.checkpoint_percent +` + +type AnalyticsVideoDropOffByCheckpointParams struct { + RangeStart pgtype.Timestamptz `json:"range_start"` + RangeEnd pgtype.Timestamptz `json:"range_end"` +} + +type AnalyticsVideoDropOffByCheckpointRow struct { + CheckpointPercent int32 `json:"checkpoint_percent"` + TotalSessions int64 `json:"total_sessions"` + ViewersReached int64 `json:"viewers_reached"` + DropOffRate float64 `json:"drop_off_rate"` +} + +func (q *Queries) AnalyticsVideoDropOffByCheckpoint(ctx context.Context, arg AnalyticsVideoDropOffByCheckpointParams) ([]AnalyticsVideoDropOffByCheckpointRow, error) { + rows, err := q.db.Query(ctx, AnalyticsVideoDropOffByCheckpoint, arg.RangeStart, arg.RangeEnd) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsVideoDropOffByCheckpointRow + for rows.Next() { + var i AnalyticsVideoDropOffByCheckpointRow + if err := rows.Scan( + &i.CheckpointPercent, + &i.TotalSessions, + &i.ViewersReached, + &i.DropOffRate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const AnalyticsVideoEngagementSummary = `-- name: AnalyticsVideoEngagementSummary :one + +SELECT + COUNT(*)::bigint AS total_sessions, + COUNT(*) FILTER (WHERE s.completed_at IS NOT NULL)::bigint AS completed_sessions, + COUNT(*) FILTER (WHERE s.session_number > 1)::bigint AS replay_sessions, + COUNT(DISTINCT (s.user_id, s.content_kind, s.content_id))::bigint AS unique_video_starts, + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT + s2.user_id, + s2.content_kind, + s2.content_id + FROM user_video_watch_sessions s2 + WHERE ($1::timestamptz IS NULL OR s2.started_at >= $1::timestamptz) + AND ($2::timestamptz IS NULL OR s2.started_at < $2::timestamptz) + GROUP BY s2.user_id, s2.content_kind, s2.content_id + HAVING MAX(s2.session_number) > 1 + ) replayed + ) AS users_who_replayed +FROM user_video_watch_sessions s +WHERE ($1::timestamptz IS NULL OR s.started_at >= $1::timestamptz) + AND ($2::timestamptz IS NULL OR s.started_at < $2::timestamptz) +` + +type AnalyticsVideoEngagementSummaryParams struct { + RangeStart pgtype.Timestamptz `json:"range_start"` + RangeEnd pgtype.Timestamptz `json:"range_end"` +} + +type AnalyticsVideoEngagementSummaryRow struct { + TotalSessions int64 `json:"total_sessions"` + CompletedSessions int64 `json:"completed_sessions"` + ReplaySessions int64 `json:"replay_sessions"` + UniqueVideoStarts int64 `json:"unique_video_starts"` + UsersWhoReplayed int64 `json:"users_who_replayed"` +} + +// ===================== +// Video Engagement Analytics +// ===================== +func (q *Queries) AnalyticsVideoEngagementSummary(ctx context.Context, arg AnalyticsVideoEngagementSummaryParams) (AnalyticsVideoEngagementSummaryRow, error) { + row := q.db.QueryRow(ctx, AnalyticsVideoEngagementSummary, arg.RangeStart, arg.RangeEnd) + var i AnalyticsVideoEngagementSummaryRow + err := row.Scan( + &i.TotalSessions, + &i.CompletedSessions, + &i.ReplaySessions, + &i.UniqueVideoStarts, + &i.UsersWhoReplayed, + ) + return i, err +} diff --git a/gen/db/models.go b/gen/db/models.go index 011a8b1..e8f9cc1 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -588,3 +588,17 @@ type UserSubscription struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } + +type UserVideoWatchSession struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + ContentKind string `json:"content_kind"` + ContentID int64 `json:"content_id"` + SessionNumber int32 `json:"session_number"` + VideoDurationSec pgtype.Int4 `json:"video_duration_sec"` + MaxPositionSec int32 `json:"max_position_sec"` + StartedAt pgtype.Timestamptz `json:"started_at"` + LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} diff --git a/gen/db/video_engagement.sql.go b/gen/db/video_engagement.sql.go new file mode 100644 index 0000000..50f5d2a --- /dev/null +++ b/gen/db/video_engagement.sql.go @@ -0,0 +1,205 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: video_engagement.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const GetActiveVideoWatchSession = `-- name: GetActiveVideoWatchSession :one +SELECT + id, + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec, + started_at, + last_heartbeat_at, + ended_at, + completed_at +FROM user_video_watch_sessions +WHERE user_id = $1 + AND content_kind = $2 + AND content_id = $3 + AND ended_at IS NULL + AND last_heartbeat_at >= $4 +ORDER BY session_number DESC +LIMIT 1 +` + +type GetActiveVideoWatchSessionParams struct { + UserID int64 `json:"user_id"` + ContentKind string `json:"content_kind"` + ContentID int64 `json:"content_id"` + LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"` +} + +func (q *Queries) GetActiveVideoWatchSession(ctx context.Context, arg GetActiveVideoWatchSessionParams) (UserVideoWatchSession, error) { + row := q.db.QueryRow(ctx, GetActiveVideoWatchSession, + arg.UserID, + arg.ContentKind, + arg.ContentID, + arg.LastHeartbeatAt, + ) + var i UserVideoWatchSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.ContentKind, + &i.ContentID, + &i.SessionNumber, + &i.VideoDurationSec, + &i.MaxPositionSec, + &i.StartedAt, + &i.LastHeartbeatAt, + &i.EndedAt, + &i.CompletedAt, + ) + return i, err +} + +const GetMaxVideoWatchSessionNumber = `-- name: GetMaxVideoWatchSessionNumber :one +SELECT + coalesce(max(session_number), 0)::int AS max_session_number +FROM user_video_watch_sessions +WHERE user_id = $1 + AND content_kind = $2 + AND content_id = $3 +` + +type GetMaxVideoWatchSessionNumberParams struct { + UserID int64 `json:"user_id"` + ContentKind string `json:"content_kind"` + ContentID int64 `json:"content_id"` +} + +func (q *Queries) GetMaxVideoWatchSessionNumber(ctx context.Context, arg GetMaxVideoWatchSessionNumberParams) (int32, error) { + row := q.db.QueryRow(ctx, GetMaxVideoWatchSessionNumber, arg.UserID, arg.ContentKind, arg.ContentID) + var max_session_number int32 + err := row.Scan(&max_session_number) + return max_session_number, err +} + +const InsertVideoWatchSession = `-- name: InsertVideoWatchSession :one +INSERT INTO user_video_watch_sessions ( + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec +) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING + id, + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec, + started_at, + last_heartbeat_at, + ended_at, + completed_at +` + +type InsertVideoWatchSessionParams struct { + UserID int64 `json:"user_id"` + ContentKind string `json:"content_kind"` + ContentID int64 `json:"content_id"` + SessionNumber int32 `json:"session_number"` + VideoDurationSec pgtype.Int4 `json:"video_duration_sec"` + MaxPositionSec int32 `json:"max_position_sec"` +} + +func (q *Queries) InsertVideoWatchSession(ctx context.Context, arg InsertVideoWatchSessionParams) (UserVideoWatchSession, error) { + row := q.db.QueryRow(ctx, InsertVideoWatchSession, + arg.UserID, + arg.ContentKind, + arg.ContentID, + arg.SessionNumber, + arg.VideoDurationSec, + arg.MaxPositionSec, + ) + var i UserVideoWatchSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.ContentKind, + &i.ContentID, + &i.SessionNumber, + &i.VideoDurationSec, + &i.MaxPositionSec, + &i.StartedAt, + &i.LastHeartbeatAt, + &i.EndedAt, + &i.CompletedAt, + ) + return i, err +} + +const UpdateVideoWatchSession = `-- name: UpdateVideoWatchSession :one +UPDATE user_video_watch_sessions +SET + max_position_sec = $2, + video_duration_sec = $3, + last_heartbeat_at = $4, + completed_at = $5, + ended_at = $6 +WHERE id = $1 +RETURNING + id, + user_id, + content_kind, + content_id, + session_number, + video_duration_sec, + max_position_sec, + started_at, + last_heartbeat_at, + ended_at, + completed_at +` + +type UpdateVideoWatchSessionParams struct { + ID int64 `json:"id"` + MaxPositionSec int32 `json:"max_position_sec"` + VideoDurationSec pgtype.Int4 `json:"video_duration_sec"` + LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +func (q *Queries) UpdateVideoWatchSession(ctx context.Context, arg UpdateVideoWatchSessionParams) (UserVideoWatchSession, error) { + row := q.db.QueryRow(ctx, UpdateVideoWatchSession, + arg.ID, + arg.MaxPositionSec, + arg.VideoDurationSec, + arg.LastHeartbeatAt, + arg.CompletedAt, + arg.EndedAt, + ) + var i UserVideoWatchSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.ContentKind, + &i.ContentID, + &i.SessionNumber, + &i.VideoDurationSec, + &i.MaxPositionSec, + &i.StartedAt, + &i.LastHeartbeatAt, + &i.EndedAt, + &i.CompletedAt, + ) + return i, err +} diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go index 0441cb8..41a1ad5 100644 --- a/internal/domain/analytics.go +++ b/internal/domain/analytics.go @@ -154,6 +154,24 @@ type AnalyticsTeamSection struct { ByStatus []AnalyticsLabelCount `json:"by_status"` } +type AnalyticsVideoDropOffPoint struct { + CheckpointPercent int `json:"checkpoint_percent"` + TotalSessions int64 `json:"total_sessions"` + ViewersReached int64 `json:"viewers_reached"` + DropOffRate float64 `json:"drop_off_rate"` +} + +type AnalyticsVideosSection struct { + TotalWatchSessions int64 `json:"total_watch_sessions"` + CompletedSessions int64 `json:"completed_sessions"` + ReplaySessions int64 `json:"replay_sessions"` + UniqueVideoStarts int64 `json:"unique_video_starts"` + UsersWhoReplayed int64 `json:"users_who_replayed"` + CompletionRate float64 `json:"completion_rate"` + ReplayRate float64 `json:"replay_rate"` + DropOffByCheckpoint []AnalyticsVideoDropOffPoint `json:"drop_off_by_checkpoint"` +} + type AnalyticsDashboard struct { GeneratedAt time.Time `json:"generated_at"` DateFilter AnalyticsDateFilter `json:"date_filter"` @@ -161,6 +179,7 @@ type AnalyticsDashboard struct { Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"` Payments AnalyticsPaymentsSection `json:"payments"` Courses AnalyticsCoursesSection `json:"courses"` + Videos AnalyticsVideosSection `json:"videos"` Content AnalyticsContentSection `json:"content"` Notifications AnalyticsNotificationsSection `json:"notifications"` Issues AnalyticsIssuesSection `json:"issues"` diff --git a/internal/domain/video_engagement.go b/internal/domain/video_engagement.go new file mode 100644 index 0000000..fdbb253 --- /dev/null +++ b/internal/domain/video_engagement.go @@ -0,0 +1,23 @@ +package domain + +const ( + VideoContentKindLMSLesson = "lms_lesson" + VideoContentKindExamPrepLesson = "exam_prep_lesson" +) + +const VideoCompletionThresholdPercent = 90 + +type VideoEngagementHeartbeatInput struct { + ContentKind string `json:"content_kind" validate:"required,oneof=lms_lesson exam_prep_lesson"` + ContentID int64 `json:"content_id" validate:"required,gt=0"` + PositionSec int `json:"position_sec" validate:"gte=0"` + DurationSec int `json:"duration_sec" validate:"gte=0"` + Ended bool `json:"ended"` +} + +type VideoWatchSessionResponse struct { + SessionID int64 `json:"session_id"` + SessionNumber int `json:"session_number"` + MaxPositionSec int `json:"max_position_sec"` + Completed bool `json:"completed"` +} diff --git a/internal/repository/video_engagement.go b/internal/repository/video_engagement.go new file mode 100644 index 0000000..1f96e9d --- /dev/null +++ b/internal/repository/video_engagement.go @@ -0,0 +1,165 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +const videoWatchSessionGap = 30 * time.Minute + +var ( + ErrVideoContentNotFound = errors.New("video content not found") + ErrVideoContentHasNoURL = errors.New("content has no video") +) + +func (s *Store) RecordVideoEngagementHeartbeat( + ctx context.Context, + userID int64, + input domain.VideoEngagementHeartbeatInput, +) (domain.VideoWatchSessionResponse, error) { + if err := s.validateVideoContent(ctx, input.ContentKind, input.ContentID); err != nil { + return domain.VideoWatchSessionResponse{}, err + } + + q, tx, err := s.BeginTx(ctx) + if err != nil { + return domain.VideoWatchSessionResponse{}, fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + now := time.Now().UTC() + activeCutoff := now.Add(-videoWatchSessionGap) + position := int32(input.PositionSec) + duration := nullableInt32(input.DurationSec) + + session, err := q.GetActiveVideoWatchSession(ctx, dbgen.GetActiveVideoWatchSessionParams{ + UserID: userID, + ContentKind: input.ContentKind, + ContentID: input.ContentID, + LastHeartbeatAt: pgtype.Timestamptz{Time: activeCutoff, Valid: true}, + }) + + var sessionID int64 + if errors.Is(err, pgx.ErrNoRows) { + maxNum, err := q.GetMaxVideoWatchSessionNumber(ctx, dbgen.GetMaxVideoWatchSessionNumberParams{ + UserID: userID, + ContentKind: input.ContentKind, + ContentID: input.ContentID, + }) + if err != nil { + return domain.VideoWatchSessionResponse{}, err + } + + inserted, err := q.InsertVideoWatchSession(ctx, dbgen.InsertVideoWatchSessionParams{ + UserID: userID, + ContentKind: input.ContentKind, + ContentID: input.ContentID, + SessionNumber: maxNum + 1, + VideoDurationSec: duration, + MaxPositionSec: position, + }) + if err != nil { + return domain.VideoWatchSessionResponse{}, err + } + session = inserted + sessionID = inserted.ID + } else if err != nil { + return domain.VideoWatchSessionResponse{}, err + } else { + sessionID = session.ID + if position > session.MaxPositionSec { + session.MaxPositionSec = position + } + if duration.Valid && duration.Int32 > 0 { + session.VideoDurationSec = duration + } + } + + completedAt := session.CompletedAt + if !completedAt.Valid && session.VideoDurationSec.Valid && session.VideoDurationSec.Int32 > 0 { + threshold := int32(float64(session.VideoDurationSec.Int32) * float64(domain.VideoCompletionThresholdPercent) / 100.0) + if session.MaxPositionSec >= threshold { + completedAt = pgtype.Timestamptz{Time: now, Valid: true} + } + } + + var endedAt pgtype.Timestamptz + if input.Ended { + endedAt = pgtype.Timestamptz{Time: now, Valid: true} + } else { + endedAt = session.EndedAt + } + + updated, err := q.UpdateVideoWatchSession(ctx, dbgen.UpdateVideoWatchSessionParams{ + ID: sessionID, + MaxPositionSec: session.MaxPositionSec, + VideoDurationSec: session.VideoDurationSec, + LastHeartbeatAt: pgtype.Timestamptz{Time: now, Valid: true}, + CompletedAt: completedAt, + EndedAt: endedAt, + }) + if err != nil { + return domain.VideoWatchSessionResponse{}, err + } + + if err := tx.Commit(ctx); err != nil { + return domain.VideoWatchSessionResponse{}, fmt.Errorf("commit: %w", err) + } + + return domain.VideoWatchSessionResponse{ + SessionID: updated.ID, + SessionNumber: int(updated.SessionNumber), + MaxPositionSec: int(updated.MaxPositionSec), + Completed: updated.CompletedAt.Valid, + }, nil +} + +func (s *Store) validateVideoContent(ctx context.Context, contentKind string, contentID int64) error { + switch contentKind { + case domain.VideoContentKindLMSLesson: + lesson, err := s.queries.GetLessonByID(ctx, contentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrVideoContentNotFound + } + return err + } + if !hasVideoURL(lesson.VideoUrl) { + return ErrVideoContentHasNoURL + } + case domain.VideoContentKindExamPrepLesson: + lesson, err := s.queries.ExamPrepGetUnitModuleLessonByID(ctx, contentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrVideoContentNotFound + } + return err + } + if !hasVideoURL(lesson.VideoUrl) { + return ErrVideoContentHasNoURL + } + default: + return fmt.Errorf("unsupported content kind: %s", contentKind) + } + return nil +} + +func hasVideoURL(url pgtype.Text) bool { + return url.Valid && strings.TrimSpace(url.String) != "" +} + +func nullableInt32(v int) pgtype.Int4 { + if v <= 0 { + return pgtype.Int4{Valid: false} + } + return pgtype.Int4{Int32: int32(v), Valid: true} +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 9e7a82b..707a49b 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -117,6 +117,7 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"}, {Key: "videos.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"}, {Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", GroupName: "Videos"}, + {Key: "videos.track_engagement", Name: "Track Video Engagement", Description: "Report video playback heartbeats for analytics", GroupName: "Videos"}, // Learning Tree {Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"}, @@ -338,7 +339,7 @@ var defaultStudentLearnerPermissions = []string{ "lessons.get", "lessons.list_by_module", "lessons.complete", "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", - "videos.get", "videos.list_by_subcourse", "videos.list_published", + "videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement", "learning_tree.get", "programs.list", "programs.get", @@ -518,7 +519,7 @@ var DefaultRolePermissions = map[string][]string{ "lessons.get", "lessons.list_by_module", "lessons.complete", "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", - "videos.get", "videos.list_by_subcourse", "videos.list_published", + "videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement", "learning_tree.get", "programs.list", "programs.get", @@ -579,7 +580,7 @@ var DefaultRolePermissions = map[string][]string{ "lessons.get", "lessons.list_by_module", "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", - "videos.get", "videos.list_by_subcourse", "videos.list_published", + "videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement", "learning_tree.get", "programs.list", "programs.get", diff --git a/internal/services/videoengagement/service.go b/internal/services/videoengagement/service.go new file mode 100644 index 0000000..74a482c --- /dev/null +++ b/internal/services/videoengagement/service.go @@ -0,0 +1,20 @@ +package videoengagement + +import ( + "context" + + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/repository" +) + +type Service struct { + store *repository.Store +} + +func NewService(store *repository.Store) *Service { + return &Service{store: store} +} + +func (s *Service) RecordHeartbeat(ctx context.Context, userID int64, input domain.VideoEngagementHeartbeatInput) (domain.VideoWatchSessionResponse, error) { + return s.store.RecordVideoEngagementHeartbeat(ctx, userID, input) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 29f935f..69ee93c 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -30,6 +30,7 @@ import ( "Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/team" vimeoservice "Yimaru-Backend/internal/services/vimeo" + "Yimaru-Backend/internal/services/videoengagement" "Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/transaction" @@ -87,8 +88,9 @@ type App struct { Logger *slog.Logger mongoLoggerSvc *zap.Logger analyticsDB *dbgen.Queries - rbacSvc *rbacservice.Service - stopPurgeWorker context.CancelFunc + rbacSvc *rbacservice.Service + videoEngagementSvc *videoengagement.Service + stopPurgeWorker context.CancelFunc } func NewApp( @@ -128,6 +130,7 @@ func NewApp( mongoLoggerSvc *zap.Logger, analyticsDB *dbgen.Queries, rbacSvc *rbacservice.Service, + videoEngagementSvc *videoengagement.Service, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -184,8 +187,9 @@ func NewApp( recommendationSvc: recommendationSvc, cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, - analyticsDB: analyticsDB, - rbacSvc: rbacSvc, + analyticsDB: analyticsDB, + rbacSvc: rbacSvc, + videoEngagementSvc: videoEngagementSvc, } s.initAppRoutes() diff --git a/internal/web_server/handlers/analytics_handler.go b/internal/web_server/handlers/analytics_handler.go index 79c9d07..47b23fa 100644 --- a/internal/web_server/handlers/analytics_handler.go +++ b/internal/web_server/handlers/analytics_handler.go @@ -196,6 +196,15 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status") } + videoSummary, err := h.analyticsDB.AnalyticsVideoEngagementSummary(ctx, p.VideoEngagementSummary) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch video engagement analytics") + } + videoDropOff, err := h.analyticsDB.AnalyticsVideoDropOffByCheckpoint(ctx, p.VideoDropOffByCheckpoint) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch video drop-off analytics") + } + dashboard := domain.AnalyticsDashboard{ GeneratedAt: time.Now().UTC(), DateFilter: filter, @@ -207,6 +216,7 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear), Courses: mapCoursesSection(courseCounts), + Videos: mapVideosSection(videoSummary, videoDropOff), Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType), @@ -489,3 +499,37 @@ func mapTeamSection( ByStatus: statuses, } } + +func mapVideosSection( + summary dbgen.AnalyticsVideoEngagementSummaryRow, + dropOff []dbgen.AnalyticsVideoDropOffByCheckpointRow, +) domain.AnalyticsVideosSection { + checkpoints := make([]domain.AnalyticsVideoDropOffPoint, len(dropOff)) + for i, r := range dropOff { + checkpoints[i] = domain.AnalyticsVideoDropOffPoint{ + CheckpointPercent: int(r.CheckpointPercent), + TotalSessions: r.TotalSessions, + ViewersReached: r.ViewersReached, + DropOffRate: r.DropOffRate, + } + } + + var completionRate, replayRate float64 + if summary.TotalSessions > 0 { + completionRate = float64(summary.CompletedSessions) / float64(summary.TotalSessions) + } + if summary.UniqueVideoStarts > 0 { + replayRate = float64(summary.UsersWhoReplayed) / float64(summary.UniqueVideoStarts) + } + + return domain.AnalyticsVideosSection{ + TotalWatchSessions: summary.TotalSessions, + CompletedSessions: summary.CompletedSessions, + ReplaySessions: summary.ReplaySessions, + UniqueVideoStarts: summary.UniqueVideoStarts, + UsersWhoReplayed: summary.UsersWhoReplayed, + CompletionRate: completionRate, + ReplayRate: replayRate, + DropOffByCheckpoint: checkpoints, + } +} diff --git a/internal/web_server/handlers/analytics_params.go b/internal/web_server/handlers/analytics_params.go index a21c5c4..785e5b7 100644 --- a/internal/web_server/handlers/analytics_params.go +++ b/internal/web_server/handlers/analytics_params.go @@ -47,6 +47,9 @@ type analyticsQueryParams struct { TeamSummary dbgen.AnalyticsTeamSummaryParams TeamByRole dbgen.AnalyticsTeamByRoleParams TeamByStatus dbgen.AnalyticsTeamByStatusParams + + VideoEngagementSummary dbgen.AnalyticsVideoEngagementSummaryParams + VideoDropOffByCheckpoint dbgen.AnalyticsVideoDropOffByCheckpointParams } func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams { @@ -108,6 +111,9 @@ func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re}, TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re}, TeamByStatus: dbgen.AnalyticsTeamByStatusParams{RangeStart: rs, RangeEnd: re}, + + VideoEngagementSummary: dbgen.AnalyticsVideoEngagementSummaryParams{RangeStart: rs, RangeEnd: re}, + VideoDropOffByCheckpoint: dbgen.AnalyticsVideoDropOffByCheckpointParams{RangeStart: rs, RangeEnd: re}, } } diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index f6f9164..b81c7da 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -33,6 +33,7 @@ import ( "Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/team" vimeoservice "Yimaru-Backend/internal/services/vimeo" + "Yimaru-Backend/internal/services/videoengagement" // referralservice "Yimaru-Backend/internal/services/referal" @@ -79,6 +80,7 @@ type Handler struct { minioSvc *minioservice.Service ratingSvc *ratingsservice.Service rbacSvc *rbacservice.Service + videoEngagementSvc *videoengagement.Service jwtConfig jwtutil.JwtConfig validator *customvalidator.CustomValidator Cfg *config.Config @@ -119,6 +121,7 @@ func New( minioSvc *minioservice.Service, ratingSvc *ratingsservice.Service, rbacSvc *rbacservice.Service, + videoEngagementSvc *videoengagement.Service, jwtConfig jwtutil.JwtConfig, cfg *config.Config, mongoLoggerSvc *zap.Logger, @@ -156,8 +159,9 @@ func New( cloudConvertSvc: cloudConvertSvc, minioSvc: minioSvc, ratingSvc: ratingSvc, - rbacSvc: rbacSvc, - jwtConfig: jwtConfig, + rbacSvc: rbacSvc, + videoEngagementSvc: videoEngagementSvc, + jwtConfig: jwtConfig, Cfg: cfg, mongoLoggerSvc: mongoLoggerSvc, analyticsDB: analyticsDB, diff --git a/internal/web_server/handlers/video_engagement_handler.go b/internal/web_server/handlers/video_engagement_handler.go new file mode 100644 index 0000000..42a3516 --- /dev/null +++ b/internal/web_server/handlers/video_engagement_handler.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "errors" + + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/repository" + "Yimaru-Backend/internal/services/lessons" + + "github.com/gofiber/fiber/v2" +) + +// RecordVideoEngagementHeartbeat godoc +// @Summary Report video playback progress +// @Description Records playback position for analytics (completion, replay, and drop-off). Send periodic heartbeats while watching; set ended=true when the viewer leaves. A new session starts after 30 minutes of inactivity or when ended=true on the prior session. +// @Tags videos +// @Accept json +// @Produce json +// @Param body body domain.VideoEngagementHeartbeatInput true "Playback heartbeat" +// @Success 200 {object} domain.Response{data=domain.VideoWatchSessionResponse} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/videos/engagement/heartbeat [post] +func (h *Handler) RecordVideoEngagementHeartbeat(c *fiber.Ctx) error { + var req domain.VideoEngagementHeartbeatInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: firstValidationError(valErrs), + }) + } + + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + + if req.ContentKind == domain.VideoContentKindLMSLesson { + les, err := h.lessonSvc.GetByID(c.Context(), req.ContentID) + if err != nil { + if errors.Is(err, lessons.ErrLessonNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Lesson not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load lesson", + Error: err.Error(), + }) + } + if role.IsCustomerLearnerRole() && !les.VisibleToLearners() { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Only published lessons can be tracked", + Error: "LESSON_NOT_PUBLISHED", + }) + } + if role.UsesLMSSequentialGating() { + ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, req.ContentID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to verify lesson access", + Error: err.Error(), + }) + } + if !ok { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: reason, + Error: "LMS_PREREQUISITE_NOT_MET", + }) + } + } + } + + res, err := h.videoEngagementSvc.RecordHeartbeat(c.Context(), uid, req) + if err != nil { + if errors.Is(err, repository.ErrVideoContentNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Video content not found", + Error: err.Error(), + }) + } + if errors.Is(err, repository.ErrVideoContentHasNoURL) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Content has no video", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to record video engagement", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Video engagement recorded", + Data: res, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 4831882..37dea7c 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -45,6 +45,7 @@ func (a *App) initAppRoutes() { a.minioSvc, a.ratingSvc, a.rbacSvc, + a.videoEngagementSvc, a.JwtConfig, a.cfg, a.mongoLoggerSvc, @@ -145,6 +146,7 @@ func (a *App) initAppRoutes() { groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson) groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson) + groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat) groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice) groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson) groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)