From df9c38de7abc14e9762430be7c2445c30b81fbfe Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 5 Nov 2025 00:09:14 +0900 Subject: [PATCH] Added archiving endpoint --- backend/internal/http/handlers.go | 38 ++++++++++ backend/internal/repo/episode_repo.go | 83 +++++++++++++++++++++ backend/internal/service/episode_service.go | 36 +++++---- 3 files changed, 144 insertions(+), 13 deletions(-) diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index cd3c21e..665c652 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -19,6 +19,7 @@ func NewRouter(svc service.EpisodeService) *gin.Engine { v1 := r.Group("/v1") + // GET /v1/current v1.GET("/current", func(c *gin.Context) { cur, err := svc.GetCurrent(c.Request.Context()) if err != nil { @@ -32,6 +33,7 @@ func NewRouter(svc service.EpisodeService) *gin.Engine { c.JSON(http.StatusOK, cur) }) + // POST /v1/current (strict HH:MM:SS already enforced at service layer) type setCurrentReq struct { ID int64 `json:"id" binding:"required"` StartTime string `json:"start_time" binding:"required"` @@ -60,5 +62,41 @@ func NewRouter(svc service.EpisodeService) *gin.Engine { c.JSON(http.StatusOK, cur) }) + // POST /v1/archive (move one or many IDs from current → current_archive) + type moveReq struct { + ID *int64 `json:"id,omitempty"` + IDs []int64 `json:"ids,omitempty"` + } + v1.POST("/archive", func(c *gin.Context) { + var req moveReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + ids := make([]int64, 0, len(req.IDs)+1) + if req.ID != nil { + ids = append(ids, *req.ID) + } + ids = append(ids, req.IDs...) + + res, err := svc.MoveToArchive(c.Request.Context(), ids) + if err != nil { + if err == service.ErrEmptyIDs { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "move failed"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "moved_ids": res.MovedIDs, + "deleted_ids": res.DeletedIDs, + "skipped_ids": res.SkippedIDs, + "inserted": len(res.MovedIDs), + "deleted": len(res.DeletedIDs), + "skipped": len(res.SkippedIDs), + }) + }) + return r } diff --git a/backend/internal/repo/episode_repo.go b/backend/internal/repo/episode_repo.go index 5973b1f..519df9f 100644 --- a/backend/internal/repo/episode_repo.go +++ b/backend/internal/repo/episode_repo.go @@ -20,9 +20,16 @@ type Episode struct { DateCreated time.Time `json:"date_created"` } +type MoveResult struct { + MovedIDs []int64 `json:"moved_ids"` + DeletedIDs []int64 `json:"deleted_ids"` + SkippedIDs []int64 `json:"skipped_ids"` +} + type EpisodeRepository interface { GetCurrent(ctx context.Context) (Episode, error) SetCurrent(ctx context.Context, id int64, startHHMMSS string) error + MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) } type pgxEpisodeRepo struct { @@ -83,3 +90,79 @@ func (r *pgxEpisodeRepo) SetCurrent(ctx context.Context, id int64, startHHMMSS s return tx.Commit(ctx) } + +func (r *pgxEpisodeRepo) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) { + res := MoveResult{} + if len(ids) == 0 { + return res, nil + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return res, err + } + defer func() { _ = tx.Rollback(ctx) }() + + // 1) Insert into archive from current (skip duplicates by id) + insRows, err := tx.Query(ctx, ` + INSERT INTO current_archive ( + id, ep_num, ep_title, season_name, start_time, playback_length, current_ep, date_created + ) + SELECT id, ep_num, ep_title, season_name, start_time, playback_length, current_ep, date_created + FROM current + WHERE id = ANY($1::bigint[]) + ON CONFLICT (id) DO NOTHING + RETURNING id + `, ids) + if err != nil { + return res, err + } + for insRows.Next() { + var id int64 + if err := insRows.Scan(&id); err != nil { + return res, err + } + res.MovedIDs = append(res.MovedIDs, id) + } + if err := insRows.Err(); err != nil { + return res, err + } + + // 2) Delete from current only those actually inserted to archive + if len(res.MovedIDs) > 0 { + delRows, err := tx.Query(ctx, ` + DELETE FROM current + WHERE id = ANY($1::bigint[]) + RETURNING id + `, res.MovedIDs) + if err != nil { + return res, err + } + for delRows.Next() { + var id int64 + if err := delRows.Scan(&id); err != nil { + return res, err + } + res.DeletedIDs = append(res.DeletedIDs, id) + } + if err := delRows.Err(); err != nil { + return res, err + } + } + + // 3) Compute skipped = requested - moved + sel := make(map[int64]struct{}, len(res.MovedIDs)) + for _, id := range res.MovedIDs { + sel[id] = struct{}{} + } + for _, id := range ids { + if _, ok := sel[id]; !ok { + res.SkippedIDs = append(res.SkippedIDs, id) + } + } + + if err := tx.Commit(ctx); err != nil { + return res, err + } + return res, nil +} diff --git a/backend/internal/service/episode_service.go b/backend/internal/service/episode_service.go index e046d19..dac38b3 100644 --- a/backend/internal/service/episode_service.go +++ b/backend/internal/service/episode_service.go @@ -9,11 +9,15 @@ import ( "watch-party-backend/internal/repo" ) -var ErrInvalidTime = errors.New("invalid start_time (expected HH:MM:SS)") +var ( + ErrInvalidTime = errors.New("invalid start_time (expected HH:MM:SS)") + ErrEmptyIDs = errors.New("ids must not be empty") +) type EpisodeService interface { GetCurrent(ctx context.Context) (repo.Episode, error) SetCurrent(ctx context.Context, id int64, start string) (repo.Episode, error) + MoveToArchive(ctx context.Context, ids []int64) (repo.MoveResult, error) } type episodeService struct { @@ -30,28 +34,34 @@ func (s *episodeService) GetCurrent(ctx context.Context) (repo.Episode, error) { return s.repo.GetCurrent(c) } +var hhmmss = regexp.MustCompile(`^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$`) + func (s *episodeService) SetCurrent(ctx context.Context, id int64, start string) (repo.Episode, error) { - // Strict HH:MM:SS (24h, zero-padded) — e.g., 20:07:05 - if !isHHMMSS(start) { + if !hhmmss.MatchString(start) { return repo.Episode{}, ErrInvalidTime } - c, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - if err := s.repo.SetCurrent(c, id, start); err != nil { return repo.Episode{}, err } - // return the new current row return s.repo.GetCurrent(c) } -var hhmmss = regexp.MustCompile(`^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$`) - -func isHHMMSS(s string) bool { - if !hhmmss.MatchString(s) { - return false +func (s *episodeService) MoveToArchive(ctx context.Context, ids []int64) (repo.MoveResult, error) { + uniq := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + uniq = append(uniq, id) + } } - _, err := time.Parse("15:04:05", s) - return err == nil + if len(uniq) == 0 { + return repo.MoveResult{}, ErrEmptyIDs + } + + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return s.repo.MoveToArchive(c, uniq) }