Yimaru-BackEnd/internal/repository/video_engagement.go
Yared Yemane 3f73afb4bf 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>
2026-05-24 02:59:46 -07:00

166 lines
4.5 KiB
Go

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