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>
107 lines
3.4 KiB
Go
107 lines
3.4 KiB
Go
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,
|
|
})
|
|
}
|