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"
|
||||
"Yimaru-Backend/internal/services/transaction"
|
||||
"Yimaru-Backend/internal/services/user"
|
||||
videoengagementservice "Yimaru-Backend/internal/services/videoengagement"
|
||||
httpserver "Yimaru-Backend/internal/web_server"
|
||||
jwtutil "Yimaru-Backend/internal/web_server/jwt"
|
||||
customvalidator "Yimaru-Backend/internal/web_server/validator"
|
||||
|
|
@ -416,6 +417,8 @@ func main() {
|
|||
|
||||
lmsProgressSvc := lmsprogress.NewService(store)
|
||||
|
||||
videoEngagementSvc := videoengagementservice.NewService(store)
|
||||
|
||||
// LMS practices (under course, module, or lesson)
|
||||
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
|
||||
|
||||
|
|
@ -514,6 +517,7 @@ func main() {
|
|||
domain.MongoDBLogger,
|
||||
analyticsDB,
|
||||
rbacSvc,
|
||||
videoEngagementSvc,
|
||||
)
|
||||
|
||||
logger.Info("Starting server", "port", cfg.Port)
|
||||
|
|
|
|||
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.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
|
||||
-- =====================
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type AnalyticsVideoDropOffPoint struct {
|
||||
CheckpointPercent int `json:"checkpoint_percent"`
|
||||
TotalSessions int64 `json:"total_sessions"`
|
||||
ViewersReached int64 `json:"viewers_reached"`
|
||||
DropOffRate float64 `json:"drop_off_rate"`
|
||||
}
|
||||
|
||||
type AnalyticsVideosSection struct {
|
||||
TotalWatchSessions int64 `json:"total_watch_sessions"`
|
||||
CompletedSessions int64 `json:"completed_sessions"`
|
||||
ReplaySessions int64 `json:"replay_sessions"`
|
||||
UniqueVideoStarts int64 `json:"unique_video_starts"`
|
||||
UsersWhoReplayed int64 `json:"users_who_replayed"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
ReplayRate float64 `json:"replay_rate"`
|
||||
DropOffByCheckpoint []AnalyticsVideoDropOffPoint `json:"drop_off_by_checkpoint"`
|
||||
}
|
||||
|
||||
type AnalyticsDashboard struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
DateFilter AnalyticsDateFilter `json:"date_filter"`
|
||||
|
|
@ -161,6 +179,7 @@ type AnalyticsDashboard struct {
|
|||
Subscriptions AnalyticsSubscriptionsSection `json:"subscriptions"`
|
||||
Payments AnalyticsPaymentsSection `json:"payments"`
|
||||
Courses AnalyticsCoursesSection `json:"courses"`
|
||||
Videos AnalyticsVideosSection `json:"videos"`
|
||||
Content AnalyticsContentSection `json:"content"`
|
||||
Notifications AnalyticsNotificationsSection `json:"notifications"`
|
||||
Issues AnalyticsIssuesSection `json:"issues"`
|
||||
|
|
|
|||
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.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"},
|
||||
{Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", GroupName: "Videos"},
|
||||
{Key: "videos.track_engagement", Name: "Track Video Engagement", Description: "Report video playback heartbeats for analytics", GroupName: "Videos"},
|
||||
|
||||
// Learning Tree
|
||||
{Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"},
|
||||
|
|
@ -338,7 +339,7 @@ var defaultStudentLearnerPermissions = []string{
|
|||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement",
|
||||
"learning_tree.get",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
|
|
@ -518,7 +519,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement",
|
||||
"learning_tree.get",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
|
|
@ -579,7 +580,7 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"lessons.get", "lessons.list_by_module",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.track_engagement",
|
||||
"learning_tree.get",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
|
|
|
|||
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/team"
|
||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||
"Yimaru-Backend/internal/services/videoengagement"
|
||||
|
||||
"Yimaru-Backend/internal/services/settings"
|
||||
"Yimaru-Backend/internal/services/transaction"
|
||||
|
|
@ -87,8 +88,9 @@ type App struct {
|
|||
Logger *slog.Logger
|
||||
mongoLoggerSvc *zap.Logger
|
||||
analyticsDB *dbgen.Queries
|
||||
rbacSvc *rbacservice.Service
|
||||
stopPurgeWorker context.CancelFunc
|
||||
rbacSvc *rbacservice.Service
|
||||
videoEngagementSvc *videoengagement.Service
|
||||
stopPurgeWorker context.CancelFunc
|
||||
}
|
||||
|
||||
func NewApp(
|
||||
|
|
@ -128,6 +130,7 @@ func NewApp(
|
|||
mongoLoggerSvc *zap.Logger,
|
||||
analyticsDB *dbgen.Queries,
|
||||
rbacSvc *rbacservice.Service,
|
||||
videoEngagementSvc *videoengagement.Service,
|
||||
) *App {
|
||||
app := fiber.New(fiber.Config{
|
||||
CaseSensitive: true,
|
||||
|
|
@ -184,8 +187,9 @@ func NewApp(
|
|||
recommendationSvc: recommendationSvc,
|
||||
cfg: cfg,
|
||||
mongoLoggerSvc: mongoLoggerSvc,
|
||||
analyticsDB: analyticsDB,
|
||||
rbacSvc: rbacSvc,
|
||||
analyticsDB: analyticsDB,
|
||||
rbacSvc: rbacSvc,
|
||||
videoEngagementSvc: videoEngagementSvc,
|
||||
}
|
||||
|
||||
s.initAppRoutes()
|
||||
|
|
|
|||
|
|
@ -196,6 +196,15 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch team by status")
|
||||
}
|
||||
|
||||
videoSummary, err := h.analyticsDB.AnalyticsVideoEngagementSummary(ctx, p.VideoEngagementSummary)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch video engagement analytics")
|
||||
}
|
||||
videoDropOff, err := h.analyticsDB.AnalyticsVideoDropOffByCheckpoint(ctx, p.VideoDropOffByCheckpoint)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch video drop-off analytics")
|
||||
}
|
||||
|
||||
dashboard := domain.AnalyticsDashboard{
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
DateFilter: filter,
|
||||
|
|
@ -207,6 +216,7 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error {
|
|||
Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30),
|
||||
Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear),
|
||||
Courses: mapCoursesSection(courseCounts),
|
||||
Videos: mapVideosSection(videoSummary, videoDropOff),
|
||||
Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType),
|
||||
Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType),
|
||||
Issues: mapIssuesSection(issuesSummary, issuesByStatus, issuesByType),
|
||||
|
|
@ -489,3 +499,37 @@ func mapTeamSection(
|
|||
ByStatus: statuses,
|
||||
}
|
||||
}
|
||||
|
||||
func mapVideosSection(
|
||||
summary dbgen.AnalyticsVideoEngagementSummaryRow,
|
||||
dropOff []dbgen.AnalyticsVideoDropOffByCheckpointRow,
|
||||
) domain.AnalyticsVideosSection {
|
||||
checkpoints := make([]domain.AnalyticsVideoDropOffPoint, len(dropOff))
|
||||
for i, r := range dropOff {
|
||||
checkpoints[i] = domain.AnalyticsVideoDropOffPoint{
|
||||
CheckpointPercent: int(r.CheckpointPercent),
|
||||
TotalSessions: r.TotalSessions,
|
||||
ViewersReached: r.ViewersReached,
|
||||
DropOffRate: r.DropOffRate,
|
||||
}
|
||||
}
|
||||
|
||||
var completionRate, replayRate float64
|
||||
if summary.TotalSessions > 0 {
|
||||
completionRate = float64(summary.CompletedSessions) / float64(summary.TotalSessions)
|
||||
}
|
||||
if summary.UniqueVideoStarts > 0 {
|
||||
replayRate = float64(summary.UsersWhoReplayed) / float64(summary.UniqueVideoStarts)
|
||||
}
|
||||
|
||||
return domain.AnalyticsVideosSection{
|
||||
TotalWatchSessions: summary.TotalSessions,
|
||||
CompletedSessions: summary.CompletedSessions,
|
||||
ReplaySessions: summary.ReplaySessions,
|
||||
UniqueVideoStarts: summary.UniqueVideoStarts,
|
||||
UsersWhoReplayed: summary.UsersWhoReplayed,
|
||||
CompletionRate: completionRate,
|
||||
ReplayRate: replayRate,
|
||||
DropOffByCheckpoint: checkpoints,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ type analyticsQueryParams struct {
|
|||
TeamSummary dbgen.AnalyticsTeamSummaryParams
|
||||
TeamByRole dbgen.AnalyticsTeamByRoleParams
|
||||
TeamByStatus dbgen.AnalyticsTeamByStatusParams
|
||||
|
||||
VideoEngagementSummary dbgen.AnalyticsVideoEngagementSummaryParams
|
||||
VideoDropOffByCheckpoint dbgen.AnalyticsVideoDropOffByCheckpointParams
|
||||
}
|
||||
|
||||
func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams {
|
||||
|
|
@ -108,6 +111,9 @@ func newAnalyticsQueryParams(f domain.AnalyticsDateFilter) analyticsQueryParams
|
|||
TeamSummary: dbgen.AnalyticsTeamSummaryParams{RangeStart: rs, RangeEnd: re},
|
||||
TeamByRole: dbgen.AnalyticsTeamByRoleParams{RangeStart: rs, RangeEnd: re},
|
||||
TeamByStatus: dbgen.AnalyticsTeamByStatusParams{RangeStart: rs, RangeEnd: re},
|
||||
|
||||
VideoEngagementSummary: dbgen.AnalyticsVideoEngagementSummaryParams{RangeStart: rs, RangeEnd: re},
|
||||
VideoDropOffByCheckpoint: dbgen.AnalyticsVideoDropOffByCheckpointParams{RangeStart: rs, RangeEnd: re},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import (
|
|||
"Yimaru-Backend/internal/services/subscriptions"
|
||||
"Yimaru-Backend/internal/services/team"
|
||||
vimeoservice "Yimaru-Backend/internal/services/vimeo"
|
||||
"Yimaru-Backend/internal/services/videoengagement"
|
||||
|
||||
// referralservice "Yimaru-Backend/internal/services/referal"
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ type Handler struct {
|
|||
minioSvc *minioservice.Service
|
||||
ratingSvc *ratingsservice.Service
|
||||
rbacSvc *rbacservice.Service
|
||||
videoEngagementSvc *videoengagement.Service
|
||||
jwtConfig jwtutil.JwtConfig
|
||||
validator *customvalidator.CustomValidator
|
||||
Cfg *config.Config
|
||||
|
|
@ -119,6 +121,7 @@ func New(
|
|||
minioSvc *minioservice.Service,
|
||||
ratingSvc *ratingsservice.Service,
|
||||
rbacSvc *rbacservice.Service,
|
||||
videoEngagementSvc *videoengagement.Service,
|
||||
jwtConfig jwtutil.JwtConfig,
|
||||
cfg *config.Config,
|
||||
mongoLoggerSvc *zap.Logger,
|
||||
|
|
@ -156,8 +159,9 @@ func New(
|
|||
cloudConvertSvc: cloudConvertSvc,
|
||||
minioSvc: minioSvc,
|
||||
ratingSvc: ratingSvc,
|
||||
rbacSvc: rbacSvc,
|
||||
jwtConfig: jwtConfig,
|
||||
rbacSvc: rbacSvc,
|
||||
videoEngagementSvc: videoEngagementSvc,
|
||||
jwtConfig: jwtConfig,
|
||||
Cfg: cfg,
|
||||
mongoLoggerSvc: mongoLoggerSvc,
|
||||
analyticsDB: analyticsDB,
|
||||
|
|
|
|||
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.ratingSvc,
|
||||
a.rbacSvc,
|
||||
a.videoEngagementSvc,
|
||||
a.JwtConfig,
|
||||
a.cfg,
|
||||
a.mongoLoggerSvc,
|
||||
|
|
@ -145,6 +146,7 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
|
||||
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
|
||||
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||
groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat)
|
||||
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("progress.complete"), h.CompletePractice)
|
||||
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
|
||||
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user