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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-24 02:59:46 -07:00
parent 56089fa8fd
commit 3f73afb4bf
19 changed files with 926 additions and 9 deletions

View File

@ -46,6 +46,7 @@ import (
// referralservice "Yimaru-Backend/internal/services/referal" // referralservice "Yimaru-Backend/internal/services/referal"
"Yimaru-Backend/internal/services/transaction" "Yimaru-Backend/internal/services/transaction"
"Yimaru-Backend/internal/services/user" "Yimaru-Backend/internal/services/user"
videoengagementservice "Yimaru-Backend/internal/services/videoengagement"
httpserver "Yimaru-Backend/internal/web_server" httpserver "Yimaru-Backend/internal/web_server"
jwtutil "Yimaru-Backend/internal/web_server/jwt" jwtutil "Yimaru-Backend/internal/web_server/jwt"
customvalidator "Yimaru-Backend/internal/web_server/validator" customvalidator "Yimaru-Backend/internal/web_server/validator"
@ -416,6 +417,8 @@ func main() {
lmsProgressSvc := lmsprogress.NewService(store) lmsProgressSvc := lmsprogress.NewService(store)
videoEngagementSvc := videoengagementservice.NewService(store)
// LMS practices (under course, module, or lesson) // LMS practices (under course, module, or lesson)
practiceSvc := practicesservice.NewService(store, store, store, store, store, store) practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
@ -514,6 +517,7 @@ func main() {
domain.MongoDBLogger, domain.MongoDBLogger,
analyticsDB, analyticsDB,
rbacSvc, rbacSvc,
videoEngagementSvc,
) )
logger.Info("Starting server", "port", cfg.Port) logger.Info("Starting server", "port", cfg.Port)

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS user_video_watch_sessions;

View File

@ -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);

View File

@ -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.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; (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 -- Content Analytics
-- ===================== -- =====================

View File

@ -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;

View File

@ -1510,3 +1510,139 @@ func (q *Queries) AnalyticsUsersSummary(ctx context.Context, arg AnalyticsUsersS
) )
return i, err 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
}

View File

@ -588,3 +588,17 @@ type UserSubscription struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_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"`
}

View File

@ -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
}

View File

@ -154,6 +154,24 @@ type AnalyticsTeamSection struct {
ByStatus []AnalyticsLabelCount `json:"by_status"` 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 { type AnalyticsDashboard struct {
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
DateFilter AnalyticsDateFilter `json:"date_filter"` DateFilter AnalyticsDateFilter `json:"date_filter"`
@ -161,6 +179,7 @@ type AnalyticsDashboard struct {
Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"` Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"`
Payments AnalyticsPaymentsSection `json:"payments"` Payments AnalyticsPaymentsSection `json:"payments"`
Courses AnalyticsCoursesSection `json:"courses"` Courses AnalyticsCoursesSection `json:"courses"`
Videos AnalyticsVideosSection `json:"videos"`
Content AnalyticsContentSection `json:"content"` Content AnalyticsContentSection `json:"content"`
Notifications AnalyticsNotificationsSection `json:"notifications"` Notifications AnalyticsNotificationsSection `json:"notifications"`
Issues AnalyticsIssuesSection `json:"issues"` Issues AnalyticsIssuesSection `json:"issues"`

View File

@ -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"`
}

View File

@ -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}
}

View File

@ -117,6 +117,7 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"}, {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.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"},
{Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", 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 // Learning Tree
{Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "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", "lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "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", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
@ -518,7 +519,7 @@ var DefaultRolePermissions = map[string][]string{
"lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "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", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
@ -579,7 +580,7 @@ var DefaultRolePermissions = map[string][]string{
"lessons.get", "lessons.list_by_module", "lessons.get", "lessons.list_by_module",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "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", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",

View File

@ -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)
}

View File

@ -30,6 +30,7 @@ import (
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
"Yimaru-Backend/internal/services/team" "Yimaru-Backend/internal/services/team"
vimeoservice "Yimaru-Backend/internal/services/vimeo" vimeoservice "Yimaru-Backend/internal/services/vimeo"
"Yimaru-Backend/internal/services/videoengagement"
"Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/settings"
"Yimaru-Backend/internal/services/transaction" "Yimaru-Backend/internal/services/transaction"
@ -88,6 +89,7 @@ type App struct {
mongoLoggerSvc *zap.Logger mongoLoggerSvc *zap.Logger
analyticsDB *dbgen.Queries analyticsDB *dbgen.Queries
rbacSvc *rbacservice.Service rbacSvc *rbacservice.Service
videoEngagementSvc *videoengagement.Service
stopPurgeWorker context.CancelFunc stopPurgeWorker context.CancelFunc
} }
@ -128,6 +130,7 @@ func NewApp(
mongoLoggerSvc *zap.Logger, mongoLoggerSvc *zap.Logger,
analyticsDB *dbgen.Queries, analyticsDB *dbgen.Queries,
rbacSvc *rbacservice.Service, rbacSvc *rbacservice.Service,
videoEngagementSvc *videoengagement.Service,
) *App { ) *App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
CaseSensitive: true, CaseSensitive: true,
@ -186,6 +189,7 @@ func NewApp(
mongoLoggerSvc: mongoLoggerSvc, mongoLoggerSvc: mongoLoggerSvc,
analyticsDB: analyticsDB, analyticsDB: analyticsDB,
rbacSvc: rbacSvc, rbacSvc: rbacSvc,
videoEngagementSvc: videoEngagementSvc,
} }
s.initAppRoutes() s.initAppRoutes()

View File

@ -196,6 +196,15 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status") 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{ dashboard := domain.AnalyticsDashboard{
GeneratedAt: time.Now().UTC(), GeneratedAt: time.Now().UTC(),
DateFilter: filter, DateFilter: filter,
@ -207,6 +216,7 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear), Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear),
Courses: mapCoursesSection(courseCounts), Courses: mapCoursesSection(courseCounts),
Videos: mapVideosSection(videoSummary, videoDropOff),
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType), Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
@ -489,3 +499,37 @@ func mapTeamSection(
ByStatus: statuses, 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,
}
}

View File

@ -47,6 +47,9 @@ type analyticsQueryParams struct {
TeamSummary dbgen.AnalyticsTeamSummaryParams TeamSummary dbgen.AnalyticsTeamSummaryParams
TeamByRole dbgen.AnalyticsTeamByRoleParams TeamByRole dbgen.AnalyticsTeamByRoleParams
TeamByStatus dbgen.AnalyticsTeamByStatusParams TeamByStatus dbgen.AnalyticsTeamByStatusParams
VideoEngagementSummary dbgen.AnalyticsVideoEngagementSummaryParams
VideoDropOffByCheckpoint dbgen.AnalyticsVideoDropOffByCheckpointParams
} }
func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams { func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams {
@ -108,6 +111,9 @@ func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams
TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re}, TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re},
TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re}, TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re},
TeamByStatus: dbgen.AnalyticsTeamByStatusParams{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},
} }
} }

View File

@ -33,6 +33,7 @@ import (
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
"Yimaru-Backend/internal/services/team" "Yimaru-Backend/internal/services/team"
vimeoservice "Yimaru-Backend/internal/services/vimeo" vimeoservice "Yimaru-Backend/internal/services/vimeo"
"Yimaru-Backend/internal/services/videoengagement"
// referralservice "Yimaru-Backend/internal/services/referal" // referralservice "Yimaru-Backend/internal/services/referal"
@ -79,6 +80,7 @@ type Handler struct {
minioSvc *minioservice.Service minioSvc *minioservice.Service
ratingSvc *ratingsservice.Service ratingSvc *ratingsservice.Service
rbacSvc *rbacservice.Service rbacSvc *rbacservice.Service
videoEngagementSvc *videoengagement.Service
jwtConfig jwtutil.JwtConfig jwtConfig jwtutil.JwtConfig
validator *customvalidator.CustomValidator validator *customvalidator.CustomValidator
Cfg *config.Config Cfg *config.Config
@ -119,6 +121,7 @@ func New(
minioSvc *minioservice.Service, minioSvc *minioservice.Service,
ratingSvc *ratingsservice.Service, ratingSvc *ratingsservice.Service,
rbacSvc *rbacservice.Service, rbacSvc *rbacservice.Service,
videoEngagementSvc *videoengagement.Service,
jwtConfig jwtutil.JwtConfig, jwtConfig jwtutil.JwtConfig,
cfg *config.Config, cfg *config.Config,
mongoLoggerSvc *zap.Logger, mongoLoggerSvc *zap.Logger,
@ -157,6 +160,7 @@ func New(
minioSvc: minioSvc, minioSvc: minioSvc,
ratingSvc: ratingSvc, ratingSvc: ratingSvc,
rbacSvc: rbacSvc, rbacSvc: rbacSvc,
videoEngagementSvc: videoEngagementSvc,
jwtConfig: jwtConfig, jwtConfig: jwtConfig,
Cfg: cfg, Cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc, mongoLoggerSvc: mongoLoggerSvc,

View File

@ -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,
})
}

View File

@ -45,6 +45,7 @@ func (a *App) initAppRoutes() {
a.minioSvc, a.minioSvc,
a.ratingSvc, a.ratingSvc,
a.rbacSvc, a.rbacSvc,
a.videoEngagementSvc,
a.JwtConfig, a.JwtConfig,
a.cfg, a.cfg,
a.mongoLoggerSvc, a.mongoLoggerSvc,
@ -145,6 +146,7 @@ func (a *App) initAppRoutes() {
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) 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.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("/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.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.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) groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)