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:
parent
56089fa8fd
commit
3f73afb4bf
|
|
@ -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)
|
||||||
|
|
|
||||||
1
db/migrations/000073_user_video_watch_sessions.down.sql
Normal file
1
db/migrations/000073_user_video_watch_sessions.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS user_video_watch_sessions;
|
||||||
18
db/migrations/000073_user_video_watch_sessions.up.sql
Normal file
18
db/migrations/000073_user_video_watch_sessions.up.sql
Normal 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);
|
||||||
|
|
@ -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
|
||||||
-- =====================
|
-- =====================
|
||||||
|
|
|
||||||
74
db/query/video_engagement.sql
Normal file
74
db/query/video_engagement.sql
Normal 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;
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
205
gen/db/video_engagement.sql.go
Normal file
205
gen/db/video_engagement.sql.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
23
internal/domain/video_engagement.go
Normal file
23
internal/domain/video_engagement.go
Normal 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"`
|
||||||
|
}
|
||||||
165
internal/repository/video_engagement.go
Normal file
165
internal/repository/video_engagement.go
Normal 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}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
20
internal/services/videoengagement/service.go
Normal file
20
internal/services/videoengagement/service.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -87,8 +88,9 @@ type App struct {
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
mongoLoggerSvc *zap.Logger
|
mongoLoggerSvc *zap.Logger
|
||||||
analyticsDB *dbgen.Queries
|
analyticsDB *dbgen.Queries
|
||||||
rbacSvc *rbacservice.Service
|
rbacSvc *rbacservice.Service
|
||||||
stopPurgeWorker context.CancelFunc
|
videoEngagementSvc *videoengagement.Service
|
||||||
|
stopPurgeWorker context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(
|
func NewApp(
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -184,8 +187,9 @@ func NewApp(
|
||||||
recommendationSvc: recommendationSvc,
|
recommendationSvc: recommendationSvc,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
mongoLoggerSvc: mongoLoggerSvc,
|
mongoLoggerSvc: mongoLoggerSvc,
|
||||||
analyticsDB: analyticsDB,
|
analyticsDB: analyticsDB,
|
||||||
rbacSvc: rbacSvc,
|
rbacSvc: rbacSvc,
|
||||||
|
videoEngagementSvc: videoEngagementSvc,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.initAppRoutes()
|
s.initAppRoutes()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -156,8 +159,9 @@ func New(
|
||||||
cloudConvertSvc: cloudConvertSvc,
|
cloudConvertSvc: cloudConvertSvc,
|
||||||
minioSvc: minioSvc,
|
minioSvc: minioSvc,
|
||||||
ratingSvc: ratingSvc,
|
ratingSvc: ratingSvc,
|
||||||
rbacSvc: rbacSvc,
|
rbacSvc: rbacSvc,
|
||||||
jwtConfig: jwtConfig,
|
videoEngagementSvc: videoEngagementSvc,
|
||||||
|
jwtConfig: jwtConfig,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
mongoLoggerSvc: mongoLoggerSvc,
|
mongoLoggerSvc: mongoLoggerSvc,
|
||||||
analyticsDB: analyticsDB,
|
analyticsDB: analyticsDB,
|
||||||
|
|
|
||||||
106
internal/web_server/handlers/video_engagement_handler.go
Normal file
106
internal/web_server/handlers/video_engagement_handler.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user