Skip to content

Commit

Permalink
Video progress (#33)
Browse files Browse the repository at this point in the history
* Draft

* Adjust video player

* Send progress when complete

* Update core/video/handler.go

* Remove useless show progress

* Remove useless fetch progress storage
  • Loading branch information
polldo authored Sep 13, 2023
1 parent 7428a36 commit 2e62a89
Show file tree
Hide file tree
Showing 7 changed files with 309 additions and 35 deletions.
2 changes: 2 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func APIMux(cfg APIConfig) http.Handler {

a.Handle(http.MethodGet, "/courses/owned", course.HandleListOwned(cfg.DB), authen)
a.Handle(http.MethodGet, "/courses/{course_id}/videos", video.HandleListByCourse(cfg.DB))
a.Handle(http.MethodGet, "/courses/{course_id}/progress", video.HandleListProgressByCourse(cfg.DB), authen)
a.Handle(http.MethodGet, "/courses/{id}", course.HandleShow(cfg.DB))
a.Handle(http.MethodGet, "/courses", course.HandleList(cfg.DB))
a.Handle(http.MethodPost, "/courses", course.HandleCreate(cfg.DB), admin)
Expand All @@ -102,6 +103,7 @@ func APIMux(cfg APIConfig) http.Handler {
a.Handle(http.MethodGet, "/videos/{id}", video.HandleShow(cfg.DB))
a.Handle(http.MethodGet, "/videos", video.HandleList(cfg.DB))
a.Handle(http.MethodPost, "/videos", video.HandleCreate(cfg.DB), admin)
a.Handle(http.MethodPut, "/videos/{id}/progress", video.HandleUpdateProgress(cfg.DB), authen)
a.Handle(http.MethodPut, "/videos/{id}", video.HandleUpdate(cfg.DB), admin)

a.Handle(http.MethodGet, "/cart", cart.HandleShow(cfg.DB), authen)
Expand Down
74 changes: 66 additions & 8 deletions core/video/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,18 +206,76 @@ func HandleShowFull(db *sqlx.DB) web.Handler {
return weberr.InternalError(err)
}

progress, err := FetchUserProgressByCourse(ctx, db, clm.UserID, video.CourseID)
if err != nil {
return err
}

fullVideo := struct {
Course course.Course `json:"course"`
Video Video `json:"video"`
AllVideos []Video `json:"all_videos"`
URL string `json:"url"`
Course course.Course `json:"course"`
Video Video `json:"video"`
AllVideos []Video `json:"all_videos"`
AllProgress []Progress `json:"all_progress"`
URL string `json:"url"`
}{
Course: crs,
Video: video,
URL: video.URL,
AllVideos: videos,
Course: crs,
Video: video,
AllVideos: videos,
AllProgress: progress,
URL: video.URL,
}

return web.Respond(ctx, w, fullVideo, http.StatusOK)
}
}

func HandleUpdateProgress(db *sqlx.DB) web.Handler {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
videoID := web.Param(r, "id")

clm, err := claims.Get(ctx)
if err != nil {
return weberr.NotAuthorized(errors.New("user not authenticated"))
}

var up ProgressUp
if err := web.Decode(r, &up); err != nil {
err = fmt.Errorf("unable to decode payload: %w", err)
return weberr.NewError(err, err.Error(), http.StatusBadRequest)
}

if err := validate.Check(up); err != nil {
err = fmt.Errorf("validating data: %w", err)
return weberr.NewError(err, err.Error(), http.StatusBadRequest)
}

if err := UpdateProgress(ctx, db, clm.UserID, videoID, up.Progress); err != nil {
return fmt.Errorf("updating video progress: %w", err)
}

return web.Respond(ctx, w, nil, http.StatusNoContent)
}
}

func HandleListProgressByCourse(db *sqlx.DB) web.Handler {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
courseID := web.Param(r, "course_id")

if err := validate.CheckID(courseID); err != nil {
err = fmt.Errorf("passed id is not valid: %w", err)
return weberr.NewError(err, err.Error(), http.StatusBadRequest)
}

clm, err := claims.Get(ctx)
if err != nil {
return weberr.NotAuthorized(errors.New("user not authenticated"))
}

progress, err := FetchUserProgressByCourse(ctx, db, clm.UserID, courseID)
if err != nil {
return weberr.InternalError(err)
}

return web.Respond(ctx, w, progress, http.StatusOK)
}
}
59 changes: 59 additions & 0 deletions core/video/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,62 @@ func FetchAllByCourse(ctx context.Context, db sqlx.ExtContext, courseID string)

return videos, nil
}

func UpdateProgress(ctx context.Context, db sqlx.ExtContext, userID string, videoID string, value int) error {
in := struct {
VideoID string `db:"video_id"`
UserID string `db:"user_id"`
Progress int `db:"progress"`
}{
VideoID: videoID,
UserID: userID,
Progress: value,
}

const q = `
INSERT INTO videos_progress
(video_id, user_id, progress, created_at, updated_at)
VALUES
(:video_id, :user_id, :progress, NOW(), NOW())
ON CONFLICT
(video_id, user_id)
DO UPDATE SET
progress = :progress,
updated_at = NOW()`

if err := database.NamedExecContext(ctx, db, q, in); err != nil {
return fmt.Errorf("upserting progress: %w", err)
}

return nil
}

func FetchUserProgressByCourse(ctx context.Context, db sqlx.ExtContext, userID string, courseID string) ([]Progress, error) {
in := struct {
CourseID string `db:"course_id"`
UserID string `db:"user_id"`
}{
CourseID: courseID,
UserID: userID,
}

const q = `
SELECT
p.*
FROM
videos_progress AS p
INNER JOIN
videos AS v ON p.video_id = v.video_id
INNER JOIN
courses AS c ON c.course_id = v.course_id
WHERE
c.course_id = :course_id AND
p.user_id = :user_id`

progress := []Progress{}
if err := database.NamedQuerySlice(ctx, db, q, in, &progress); err != nil {
return nil, fmt.Errorf("selecting progress: %w", err)
}

return progress, nil
}
12 changes: 12 additions & 0 deletions core/video/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,15 @@ type VideoUp struct {
Free *bool `json:"free"`
URL *string `json:"url" validate:"omitempty,url"`
}

type Progress struct {
VideoID string `json:"video_id" db:"video_id"`
UserID string `json:"user_id" db:"user_id"`
Progress int `json:"progress" db:"progress"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

type ProgressUp struct {
Progress int `json:"progress" validate:"gte=0,lte=100"`
}
14 changes: 14 additions & 0 deletions database/sql/migration/000004_create_videos_table.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,17 @@ CREATE TABLE IF NOT EXISTS videos
FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE,
UNIQUE(course_id, index)
);

CREATE TABLE IF NOT EXISTS videos_progress
(
video_id UUID NOT NULL,
user_id UUID NOT NULL,
progress INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),

CHECK (progress BETWEEN 0 AND 100),
PRIMARY KEY (video_id, user_id),
FOREIGN KEY (video_id) REFERENCES videos(video_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
46 changes: 43 additions & 3 deletions frontend/pages/dashboard/course/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,19 @@ type Video = {
description: string
}

type Progress = {
video_id: string
progress: number
}

type ProgressMap = {
[videoId: string]: number
}

export default function DashboardCourse() {
const [course, setCourse] = useState<Course>()
const [videos, setVideos] = useState<Video[]>()
const [progress, setProgress] = useState<ProgressMap>({})
const { isLoggedIn, isLoading } = useSession()
const fetch = useFetch()
const router = useRouter()
Expand All @@ -32,7 +42,6 @@ export default function DashboardCourse() {
if (!router.isReady) {
return
}

fetch('http://mylocal.com:8000/courses/' + id)
.then((res) => {
if (!res.ok) {
Expand All @@ -50,7 +59,29 @@ export default function DashboardCourse() {
if (!router.isReady) {
return
}
fetch('http://mylocal.com:8000/courses/' + id + '/progress')
.then((res) => {
if (!res.ok) {
throw new Error()
}
return res.json()
})
.then((data) => {
let map: ProgressMap = {}
data.forEach((progress: Progress) => {
map[progress.video_id] = progress.progress
})
setProgress(map)
})
.catch(() => {
toast.error('Something went wrong')
})
}, [id, fetch, router.isReady])

useEffect(() => {
if (!router.isReady) {
return
}
fetch('http://mylocal.com:8000/courses/' + id + '/videos')
.then((res) => {
if (!res.ok) {
Expand Down Expand Up @@ -86,15 +117,22 @@ export default function DashboardCourse() {
<p>{course?.description}</p>

<div className="flex flex-col items-center space-y-5 pt-6 pb-6">
{videos && videos.map((video) => <Card {...video} key={video.name} />)}
{videos &&
videos.map((video) => (
<Card {...video} progress={progress[video.id] || 0} key={video.name} />
))}
</div>
</div>
</Layout>
</>
)
}

function Card(props: Video) {
type CardProps = Video & {
progress: number
}

function Card(props: CardProps) {
return (
<a
href={`/dashboard/video/${props.id}`}
Expand All @@ -112,6 +150,8 @@ function Card(props: Video) {
<h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">{props.name}</h5>
<p className="mb-3 font-normal text-gray-700 dark:text-gray-400">{props.description}</p>
</div>

<p>progress: {props.progress}</p>
</a>
)
}
Loading

0 comments on commit 2e62a89

Please sign in to comment.