diff --git a/internal/app/app.go b/internal/app/app.go index 214abe4..d59ee9d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,6 +4,7 @@ import ( "mal/internal/database" "mal/internal/auth" "mal/internal/anime" + "mal/internal/watchlist" "mal/internal/server" "mal/internal/templates" @@ -18,6 +19,7 @@ func NewApp() *fx.App { jikan.Module, auth.Module, anime.Module, + watchlist.Module, templates.Module, server.Module, fx.Decorate(func(r *templates.Renderer) render.HTMLRender { diff --git a/internal/domain/watchlist.go b/internal/domain/watchlist.go new file mode 100644 index 0000000..b7eeed4 --- /dev/null +++ b/internal/domain/watchlist.go @@ -0,0 +1,26 @@ +package domain + +import ( + "context" + "mal/internal/db" +) + +type WatchlistEntry = db.WatchListEntry +type UserWatchListRow = db.GetUserWatchListRow + +type WatchlistService interface { + UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error + RemoveEntry(ctx context.Context, userID string, animeID int64) error + GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error) + DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error +} + +type WatchlistRepository interface { + UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) + GetAnime(ctx context.Context, id int64) (db.Anime, error) + UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) + DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error + GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) + DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error + SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error +} diff --git a/internal/watchlist/handler/handler.go b/internal/watchlist/handler/handler.go new file mode 100644 index 0000000..8293323 --- /dev/null +++ b/internal/watchlist/handler/handler.go @@ -0,0 +1,95 @@ +package handler + +import ( + "mal/internal/domain" + "mal/internal/server" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +type WatchlistHandler struct { + svc domain.WatchlistService +} + +func NewWatchlistHandler(svc domain.WatchlistService) *WatchlistHandler { + return &WatchlistHandler{svc: svc} +} + +func (h *WatchlistHandler) Register(r *gin.Engine) { + r.POST("/api/watchlist", h.HandleUpdateWatchlist) + r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist) + r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching) + r.GET("/watchlist", h.HandleGetWatchlist) +} + +func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) { + userID := "" // TODO: get from auth context + animeID, _ := strconv.ParseInt(c.PostForm("anime_id"), 10, 64) + status := c.PostForm("status") + + if animeID <= 0 || status == "" { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.UpdateEntry(c.Request.Context(), userID, animeID, status) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} + +func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) { + userID := "" // TODO: get from auth context + animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + + if animeID <= 0 { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.RemoveEntry(c.Request.Context(), userID, animeID) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} + +func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) { + userID := "" // TODO: get from auth context + animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + + if animeID <= 0 { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} + +func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) { + userID := "" // TODO: get from auth context + entries, err := h.svc.GetWatchlist(c.Request.Context(), userID) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.HTML(http.StatusOK, "watchlist.gohtml", gin.H{ + "Entries": entries, + "CurrentPath": "/watchlist", + }) +} diff --git a/internal/watchlist/module.go b/internal/watchlist/module.go new file mode 100644 index 0000000..b798dc4 --- /dev/null +++ b/internal/watchlist/module.go @@ -0,0 +1,23 @@ +package watchlist + +import ( + "mal/internal/server" + "mal/internal/watchlist/handler" + "mal/internal/watchlist/repository" + "mal/internal/watchlist/service" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + repository.NewWatchlistRepository, + service.NewWatchlistService, + handler.NewWatchlistHandler, + ), + fx.Provide( + server.AsRouteRegister(func(h *handler.WatchlistHandler) server.RouteRegister { + return h + }), + ), +) diff --git a/internal/watchlist/repository/repository.go b/internal/watchlist/repository/repository.go new file mode 100644 index 0000000..86cfb42 --- /dev/null +++ b/internal/watchlist/repository/repository.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "mal/internal/db" + "mal/internal/domain" +) + +type watchlistRepository struct { + queries *db.Queries +} + +func NewWatchlistRepository(queries *db.Queries) domain.WatchlistRepository { + return &watchlistRepository{queries: queries} +} + +func (r *watchlistRepository) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) { + return r.queries.UpsertAnime(ctx, arg) +} + +func (r *watchlistRepository) GetAnime(ctx context.Context, id int64) (db.Anime, error) { + return r.queries.GetAnime(ctx, id) +} + +func (r *watchlistRepository) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) { + return r.queries.UpsertWatchListEntry(ctx, arg) +} + +func (r *watchlistRepository) DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error { + return r.queries.DeleteWatchListEntry(ctx, arg) +} + +func (r *watchlistRepository) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) { + return r.queries.GetUserWatchList(ctx, userID) +} + +func (r *watchlistRepository) DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error { + return r.queries.DeleteContinueWatchingEntry(ctx, arg) +} + +func (r *watchlistRepository) SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error { + return r.queries.SaveWatchProgress(ctx, arg) +} diff --git a/internal/watchlist/service/service.go b/internal/watchlist/service/service.go new file mode 100644 index 0000000..09a0c03 --- /dev/null +++ b/internal/watchlist/service/service.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "mal/integrations/jikan" + "mal/internal/db" + "mal/internal/domain" +) + +type watchlistService struct { + repo domain.WatchlistRepository + jikan *jikan.Client +} + +func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client) domain.WatchlistService { + return &watchlistService{repo: repo, jikan: jikan} +} + +func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error { + _, err := s.repo.GetAnime(ctx, animeID) + if err != nil { + anime, err := s.jikan.GetAnimeByID(ctx, int(animeID)) + if err == nil { + _, _ = s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{ + ID: int64(anime.MalID), + TitleOriginal: anime.Title, + TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, + TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, + ImageUrl: anime.ImageURL(), + Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + }) + } + } + + _, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ + UserID: userID, + AnimeID: animeID, + Status: status, + }) + return err +} + +func (s *watchlistService) RemoveEntry(ctx context.Context, userID string, animeID int64) error { + return s.repo.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{ + UserID: userID, + AnimeID: animeID, + }) +} + +func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]domain.UserWatchListRow, error) { + return s.repo.GetUserWatchList(ctx, userID) +} + +func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error { + _ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{ + UserID: userID, + AnimeID: animeID, + }) + return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{ + UserID: userID, + AnimeID: animeID, + CurrentEpisode: sql.NullInt64{Valid: false}, + CurrentTimeSeconds: 0, + }) +}