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