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>
166 lines
4.5 KiB
Go
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}
|
|
}
|