package httpapi import ( "errors" "log" "net/http" "strconv" "strings" "time" "watch-party-backend/internal/auth" "watch-party-backend/internal/core/episode" "watch-party-backend/internal/danime" "github.com/gin-gonic/gin" ) func NewRouter(svc episode.UseCases, authClient auth.AuthClient, authEnabled bool) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) r.GET("healthz", healthzHandler) api := r.Group("/api") v1 := api.Group("/v1") v1.GET("/time", timeHandler) v1.GET("/current", getCurrentHandler(svc)) v1.POST("/current", setCurrentHandler(svc)) v1.POST("/shows", createShowHandler(svc)) v1.GET("/shows", listShowsHandler(svc)) v1.POST("/oauth/firebase", oauthFirebaseHandler(authClient, authEnabled)) v1.POST("/oauth/firebase/claim-admin", claimFirebaseAdminHandler(authClient, authEnabled)) v1.GET("/danime", getDanimeHandler()) if authEnabled && authClient != nil { protected := v1.Group("/") protected.Use(AuthMiddleware(authClient)) protected.DELETE("/shows", deleteShowsHandler(svc)) protected.POST("/archive", moveToArchiveHandler(svc)) protected.GET("/archive", AdminOnly(), listArchiveHandler(svc)) } else { v1.DELETE("/shows", deleteShowsHandler(svc)) v1.POST("/archive", moveToArchiveHandler(svc)) } return r } // GET /healthz func healthzHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // GET /v1/time func timeHandler(c *gin.Context) { now := time.Now().UTC().UnixMilli() c.Header("Cache-Control", "no-store, max-age=0, must-revalidate") c.JSON(http.StatusOK, gin.H{ "now": now, }) } // getCurrentHandler godoc // @Summary Get current show // @Description Returns the current row from `current` table. // @Tags current // @Produce json // @Success 200 {object} CurrentResponse // @Failure 404 {object} HTTPError "no current row found" // @Failure 500 {object} HTTPError "query failed" // @Router /api/v1/current [get] func getCurrentHandler(svc episode.UseCases) gin.HandlerFunc { return func(c *gin.Context) { cur, err := svc.GetCurrent(c.Request.Context()) if err != nil { if errors.Is(err, episode.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "no current row found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } c.JSON(http.StatusOK, cur) } } // setCurrentHandler godoc // @Summary Set current show // @Description Set the current show ID and start time. // @Tags current // @Accept json // @Produce json // @Param body body SetCurrentReq true "Current show payload" // @Success 200 {object} CurrentResponse // @Failure 400 {object} HTTPError "invalid payload or invalid time" // @Failure 404 {object} HTTPError "id not found" // @Failure 500 {object} HTTPError "update failed" // @Router /api/v1/current [post] func setCurrentHandler(svc episode.UseCases) gin.HandlerFunc { return func(c *gin.Context) { var req SetCurrentReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } cur, err := svc.SetCurrent(c.Request.Context(), req.ID, req.StartTime) if err != nil { switch { case errors.Is(err, episode.ErrInvalidStartTime): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return case errors.Is(err, episode.ErrNotFound): c.JSON(http.StatusNotFound, gin.H{"error": "id not found"}) return default: c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return } } c.JSON(http.StatusOK, cur) } } // createShowHandler godoc // @Summary Create show // @Description Insert a new show into `current`. // @Tags shows // @Accept json // @Produce json // @Param body body CreateShowReq true "New show payload" // @Success 201 {object} CurrentResponse // @Failure 400 {object} HTTPError "invalid payload or invalid time/duration" // @Failure 500 {object} HTTPError "create failed" // @Router /api/v1/shows [post] func createShowHandler(svc episode.UseCases) gin.HandlerFunc { return func(c *gin.Context) { var req CreateShowReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } start := strings.TrimSpace(req.StartTime) if start == "" { start = "22:00:00" } in := episode.NewShowInput{ EpNum: req.EpNum, EpTitle: req.EpTitle, SeasonName: req.SeasonName, StartTime: start, PlaybackLength: req.PlaybackLength, } item, err := svc.Create(c.Request.Context(), in) if err != nil { switch { case errors.Is(err, episode.ErrInvalidStartTime), errors.Is(err, episode.ErrInvalidPlayback), errors.Is(err, episode.ErrInvalidShowInput): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return default: c.JSON(http.StatusInternalServerError, gin.H{"error": "create failed"}) return } } c.JSON(http.StatusCreated, item) } } var FetchDanimeEpisode = danime.FetchEpisode // getDanimeHandler godoc // @Summary Fetch dアニメ episode metadata // @Description Fetch metadata from dアニメストア WS030101 by partId. // @Tags scraper // @Produce json // @Param part_id query string true "dアニメ partId" // @Success 200 {object} DanimeEpisodeResponse // @Failure 400 {object} HTTPError "missing part_id" // @Failure 502 {object} HTTPError "scrape failed" // @Router /api/v1/danime [get] func getDanimeHandler() gin.HandlerFunc { return func(c *gin.Context) { partID := strings.TrimSpace(c.Query("part_id")) if partID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "part_id is required"}) return } ep, err := FetchDanimeEpisode(c.Request.Context(), partID) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "scrape failed"}) return } c.JSON(http.StatusOK, DanimeEpisodeResponse{ EpNum: ep.EpNum, EpTitle: ep.EpTitle, SeasonName: ep.SeasonName, PlaybackLength: ep.PlaybackLength, }) } } // moveToArchiveHandler godoc // @Summary Move shows to archive // @Description Move one or many IDs from `current` to `current_archive`. // @Tags archive // @Accept json // @Produce json // @Param body body MoveReq true "IDs to move" // @Success 200 {object} MoveRes // @Failure 400 {object} HTTPError "empty ids" // @Failure 500 {object} HTTPError "move failed" // @Router /api/v1/archive [post] func moveToArchiveHandler(svc episode.UseCases) gin.HandlerFunc { return 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 errors.Is(err, episode.ErrEmptyIDs) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } log.Printf("archive move failed for ids=%v: %v", ids, err) 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), }) } } // listShowsHandler godoc // @Summary List current shows // @Description List all rows from `current` table. // @Tags shows // @Produce json // @Success 200 {array} CurrentResponse // @Failure 500 {object} HTTPError "list failed" // @Router /api/v1/shows [get] func listShowsHandler(svc episode.UseCases) gin.HandlerFunc { return func(c *gin.Context) { items, err := svc.ListAll(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) return } c.JSON(http.StatusOK, items) } } // deleteShowHandler godoc // @Summary Delete archived show // @Description Delete a row from `current_archive` by ID. // @Tags shows // @Produce json // @Param id query int64 true "Show ID" // @Success 204 "No Content" // @Failure 400 {object} HTTPError "invalid id" // @Failure 404 {object} HTTPError "id not found" // @Failure 500 {object} HTTPError "delete failed" // @Router /api/v1/shows [delete] func deleteShowsHandler(svc episode.UseCases) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Query("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } err = svc.Delete(c.Request.Context(), id) if err != nil { if errors.Is(err, episode.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "id not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } c.Status(http.StatusNoContent) } } // oauthFirebaseHandler godoc // @Summary Verify Firebase ID token // @Description Validate Firebase ID token and return basic claims. // @Tags auth // @Accept json // @Produce json // @Param body body FirebaseOAuthReq true "Firebase ID token" // @Success 200 {object} FirebaseOAuthRes // @Failure 400 {object} HTTPError "invalid payload" // @Failure 401 {object} HTTPError "invalid token" // @Failure 503 {object} HTTPError "auth disabled" // @Router /api/v1/oauth/firebase [post] func oauthFirebaseHandler(verifier auth.TokenVerifier, authEnabled bool) gin.HandlerFunc { return func(c *gin.Context) { if !authEnabled || verifier == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth disabled"}) return } var req FirebaseOAuthReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } idToken := strings.TrimSpace(req.IDToken) if idToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id_token is required"}) return } token, err := verifier.Verify(c.Request.Context(), idToken) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } email, _ := token.Claims["email"].(string) admin, _ := token.Claims["admin"].(bool) c.JSON(http.StatusOK, FirebaseOAuthRes{ UID: token.UID, Email: email, Issuer: token.Issuer, Expires: token.Expires, Admin: admin, CustomClaims: map[string]interface{}{ "admin": admin, }, }) } } // claimFirebaseAdminHandler godoc // @Summary Claim Firebase admin // @Description Set the \"admin\" custom claim for the given Firebase UID. // @Tags auth // @Accept json // @Produce json // @Param body body FirebaseAdminClaimReq true "Firebase UID" // @Success 200 {object} FirebaseAdminClaimRes // @Failure 400 {object} HTTPError "invalid payload" // @Failure 503 {object} HTTPError "auth disabled" // @Failure 500 {object} HTTPError "claim failed" // @Router /api/v1/oauth/firebase/claim-admin [post] func claimFirebaseAdminHandler(client auth.AuthClient, authEnabled bool) gin.HandlerFunc { return func(c *gin.Context) { if !authEnabled || client == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth disabled"}) return } var req FirebaseAdminClaimReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } uid := strings.TrimSpace(req.UID) if uid == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "uid is required"}) return } email, claims, err := client.SetAdminClaim(c.Request.Context(), uid) if err != nil { log.Printf("set admin claim failed for uid=%s: %v", uid, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"}) return } c.JSON(http.StatusOK, FirebaseAdminClaimRes{ UID: uid, Email: email, Admin: true, CustomClaims: claims, }) } } // listArchiveHandler godoc // @Summary List archive // @Description List all rows from `current_archive` (admin only). // @Tags archive // @Produce json // @Success 200 {array} ArchiveResponse // @Failure 403 {object} HTTPError "admin only" // @Failure 500 {object} HTTPError "list failed" // @Router /api/v1/archive [get] func listArchiveHandler(svc episode.UseCases) gin.HandlerFunc { return func(c *gin.Context) { items, err := svc.ListArchive(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) return } res := make([]ArchiveResponse, 0, len(items)) for _, it := range items { res = append(res, ArchiveResponse{ ID: it.Id, EpNum: it.EpNum, EpTitle: it.EpTitle, SeasonName: it.SeasonName, StartTime: it.StartTime, PlaybackLength: it.PlaybackLength, CurrentEp: it.CurrentEp, DateCreated: it.DateCreated, DateArchived: it.DateArchived, }) } c.JSON(http.StatusOK, res) } }