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