diff --git a/e2e/reviewDrafts.spec.ts b/e2e/reviewDrafts.spec.ts new file mode 100644 index 00000000..b765ad9c --- /dev/null +++ b/e2e/reviewDrafts.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "./fixtures"; +import { populateDummyData } from "./helpers/db"; +import { loginAsUserA } from "./helpers/login"; + +test.beforeEach(async ({ page }) => { + await populateDummyData(page); + await loginAsUserA(page); +}); + +test("auto-saves a draft and lets the user resume", async ({ page }) => { + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + const draftResponse = page.waitForResponse( + (response) => + response.url().includes("/reviews/drafts") && + ["POST", "PUT"].includes(response.request().method()) && + response.ok() + ); + await page.getByLabel("When did you watch?").fill("2024-10-10"); + await page.getByLabel("Other thoughts?").fill("Draft thoughts"); + await draftResponse; + + await page.getByRole("menuitem", { name: "Account" }).click(); + await page.getByRole("menuitem", { name: "Drafts" }).click(); + + await expect(page).toHaveURL("/reviews/drafts"); + + const draftCard = page.getByTestId("draft-card").first(); + await expect(draftCard.getByText("Slow Learners")).toBeVisible(); + await draftCard.getByRole("link", { name: "Continue" }).click(); + + await expect(page.getByLabel("Other thoughts?")).toHaveValue( + "Draft thoughts" + ); + + await page.getByRole("button", { name: "Publish" }).click(); + await expect(page).toHaveURL(/\/movies\/3(#review\d+)?/); +}); + +test("redirects to an existing draft when starting the same review", async ({ + page, +}) => { + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + const draftResponse = page.waitForResponse( + (response) => + response.url().includes("/reviews/drafts") && + ["POST", "PUT"].includes(response.request().method()) && + response.ok() + ); + await page.getByLabel("When did you watch?").fill("2024-10-10"); + await page.getByLabel("Other thoughts?").fill("Second draft"); + await draftResponse; + + await page.getByRole("menuitem", { name: "Home" }).click(); + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + await expect(page).toHaveURL(/\/reviews\/\d+\/edit/); + await expect(page.locator("h2 .badge", { hasText: "Draft" })).toBeVisible(); +}); diff --git a/e2e/reviews.spec.ts b/e2e/reviews.spec.ts index 7248f384..c66e5a3a 100644 --- a/e2e/reviews.spec.ts +++ b/e2e/reviews.spec.ts @@ -153,7 +153,7 @@ test("adds a new movie rating and fills in only required fields", async ({ await page.getByLabel("Rating").selectOption({ label: "1.0" }); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -204,7 +204,7 @@ Haley Joel Osment is going places! I can't believe Bruce Willis was a ghost the whole time!`); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -248,7 +248,7 @@ test("adds a new TV show rating and fills in only required fields", async ({ await page.getByLabel("Rating").selectOption({ label: "4.5" }); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/tv-shows/2?season=5"); @@ -308,7 +308,7 @@ test("views a TV show with an existing review and adds a new review", async ({ await page.getByLabel("Rating").selectOption({ label: "5.0" }); await page.getByLabel("Other thoughts?").fill("I liked it, too!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/tv-shows/1?season=2"); }); @@ -345,7 +345,7 @@ Daniel Radcliffe is **fantastic**, and it's a great film role for Rainn Wilson. You'll like it if you enjoy things like _Children's Hospital_, _Comedy Bang Bang_, or _Popstar_.`); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -430,7 +430,7 @@ test("adds a new rating and fills all fields", async ({ page }) => { await page.getByLabel("Other thoughts?").fill("My favorite movie!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -486,7 +486,7 @@ test("HTML tags in reviews are stripped from review excerpt", async ({ .getByLabel("Other thoughts?") .fill("This is the best movie ever!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -523,7 +523,7 @@ test("adds a new movie rating and edits the details", async ({ page }) => { await page.getByLabel("Other thoughts?").fill("My favorite movie!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -601,7 +601,7 @@ test("adds a new rating and cancels the edit", async ({ page }) => { .getByLabel("Other thoughts?") .fill("What an English patient he was!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -663,7 +663,7 @@ test("editing another user's review fails", async ({ .getByLabel("Other thoughts?") .fill("What an English patient he was!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -706,7 +706,7 @@ test("views a movie with an existing review and adds a new review", async ({ await page.getByLabel("Rating").selectOption({ label: "5.0" }); await page.getByLabel("Other thoughts?").fill("Relevant as ever"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/1"); }); @@ -929,7 +929,7 @@ test("adds a new movie rating without a numeric rating", async ({ page }) => { .getByLabel("Other thoughts?") .fill("A classic, but I don't want to give it a numeric rating."); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); diff --git a/handlers/reviews.go b/handlers/reviews.go index 8432b476..2e262286 100644 --- a/handlers/reviews.go +++ b/handlers/reviews.go @@ -28,42 +28,16 @@ type reviewPutRequest struct { func (s Server) reviewsPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - req, err := parseReviewPostRequest(r) + req, err := parseReviewPostRequest(r, true) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) log.Printf("couldn't parse review POST request: %v", err) return } - review := screenjournal.Review{ - Owner: mustGetUsernameFromContext(r.Context()), - TvShowSeason: req.TvShowSeason, - Rating: req.Rating, - Watched: req.WatchDate, - Blurb: req.Blurb, - Comments: []screenjournal.ReviewComment{}, - } - - if req.MediaType == screenjournal.MediaTypeMovie { - review.Movie, err = s.moviefromTmdbID(s.store, req.TmdbID) - if err == store.ErrMovieNotFound { - http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } - } else if req.MediaType == screenjournal.MediaTypeTvShow { - review.TvShow, err = s.tvShowfromTmdbID(s.store, req.TmdbID) - if err == store.ErrTvShowNotFound { - http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } + review, ok := s.reviewFromPostRequest(w, req, mustGetUsernameFromContext(r.Context()), false) + if !ok { + return } review.ID, err = s.store.InsertReview(review) @@ -75,12 +49,7 @@ func (s Server) reviewsPost() http.HandlerFunc { s.announcer.AnnounceNewReview(review) - if review.MediaType() == screenjournal.MediaTypeMovie { - http.Redirect(w, r, fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()), http.StatusSeeOther) - } else { - http.Redirect(w, r, fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()), http.StatusSeeOther) - } - + http.Redirect(w, r, publishedReviewRoute(review), http.StatusSeeOther) } } @@ -92,22 +61,12 @@ func (s Server) reviewsPut() http.HandlerFunc { return } - review, err := s.store.ReadReview(id) - if err == store.ErrReviewNotFound { - http.Error(w, "Review not found", http.StatusNotFound) - return - } else if err != nil { - http.Error(w, fmt.Sprintf("Failed to read review: %v", err), http.StatusInternalServerError) + review, ok := s.loadOwnedReview(w, r, id) + if !ok { return } - loggedInUsername := mustGetUsernameFromContext(r.Context()) - if !review.Owner.Equal(loggedInUsername) { - http.Error(w, "You can't edit another user's review", http.StatusForbidden) - return - } - - parsedRequest, err := parseReviewPutRequest(r) + parsedRequest, err := parseReviewPutRequest(r, true) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) return @@ -116,6 +75,8 @@ func (s Server) reviewsPut() http.HandlerFunc { review.Rating = parsedRequest.Rating review.Blurb = parsedRequest.Blurb review.Watched = parsedRequest.Watched + wasDraft := review.IsDraft + review.IsDraft = false if err := s.store.UpdateReview(review); err != nil { log.Printf("failed to update review: %v", err) @@ -123,13 +84,11 @@ func (s Server) reviewsPut() http.HandlerFunc { return } - var newRoute string - if review.MediaType() == screenjournal.MediaTypeMovie { - newRoute = fmt.Sprintf("/movies/%d", review.Movie.ID.Int64()) - } else { - newRoute = fmt.Sprintf("/tv-shows/%d?season=%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8()) + if wasDraft && !review.IsDraft { + s.announcer.AnnounceNewReview(review) } - http.Redirect(w, r, newRoute, http.StatusSeeOther) + + http.Redirect(w, r, publishedReviewRoute(review), http.StatusSeeOther) } } @@ -166,7 +125,10 @@ func (s Server) reviewsDelete() http.HandlerFunc { } } -func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { +// parseReviewPostRequest parses the fields shared by published reviews and +// drafts. When requireWatchDate is false (drafts), an empty watch date is +// allowed so that incomplete drafts can still be saved. +func parseReviewPostRequest(r *http.Request, requireWatchDate bool) (reviewPostRequest, error) { if err := r.ParseForm(); err != nil { log.Printf("failed to decode review POST request: %v", err) return reviewPostRequest{}, err @@ -193,7 +155,7 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { return reviewPostRequest{}, err } - if parsed.WatchDate, err = parse.WatchDate(r.PostFormValue("watch-date")); err != nil { + if parsed.WatchDate, err = parseWatchDate(r, requireWatchDate); err != nil { return reviewPostRequest{}, err } @@ -204,7 +166,9 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { return parsed, nil } -func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { +// parseReviewPutRequest parses an update to an existing review or draft. When +// requireWatchDate is false (drafts), an empty watch date is allowed. +func parseReviewPutRequest(r *http.Request, requireWatchDate bool) (reviewPutRequest, error) { if err := r.ParseForm(); err != nil { log.Printf("failed to decode review PUT request: %v", err) return reviewPutRequest{}, err @@ -216,7 +180,7 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { return reviewPutRequest{}, err } - if parsed.Watched, err = parse.WatchDate(r.PostFormValue("watch-date")); err != nil { + if parsed.Watched, err = parseWatchDate(r, requireWatchDate); err != nil { return reviewPutRequest{}, err } @@ -227,6 +191,92 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { return parsed, nil } +// parseWatchDate parses the watch-date form field. When the field is optional +// (drafts) and empty, it returns the zero WatchDate without error. +func parseWatchDate(r *http.Request, required bool) (screenjournal.WatchDate, error) { + raw := r.PostFormValue("watch-date") + if !required && raw == "" { + return screenjournal.WatchDate{}, nil + } + return parse.WatchDate(raw) +} + +// formBool reports whether the named form field holds a truthy value. +func formBool(r *http.Request, field string) bool { + v := r.PostFormValue(field) + return v == "true" || v == "1" +} + +// loadOwnedReview reads the review at id and verifies the logged-in user owns +// it. On any failure it writes the appropriate error response and returns +// ok=false. +func (s Server) loadOwnedReview(w http.ResponseWriter, r *http.Request, id screenjournal.ReviewID) (screenjournal.Review, bool) { + review, err := s.store.ReadReview(id) + if err == store.ErrReviewNotFound { + http.Error(w, "Review not found", http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + http.Error(w, fmt.Sprintf("Failed to read review: %v", err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + + if !review.Owner.Equal(mustGetUsernameFromContext(r.Context())) { + http.Error(w, "You can't edit another user's review", http.StatusForbidden) + return screenjournal.Review{}, false + } + + return review, true +} + +// reviewFromPostRequest builds a Review from a parsed POST request, resolving +// its movie or TV show from the TMDB ID. On any failure it writes the +// appropriate error response and returns ok=false. +func (s Server) reviewFromPostRequest(w http.ResponseWriter, req reviewPostRequest, owner screenjournal.Username, isDraft bool) (screenjournal.Review, bool) { + review := screenjournal.Review{ + Owner: owner, + TvShowSeason: req.TvShowSeason, + Rating: req.Rating, + Watched: req.WatchDate, + Blurb: req.Blurb, + IsDraft: isDraft, + Comments: []screenjournal.ReviewComment{}, + } + + var err error + if req.MediaType == screenjournal.MediaTypeMovie { + review.Movie, err = s.moviefromTmdbID(s.store, req.TmdbID) + if err == store.ErrMovieNotFound { + http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + } else if req.MediaType == screenjournal.MediaTypeTvShow { + review.TvShow, err = s.tvShowfromTmdbID(s.store, req.TmdbID) + if err == store.ErrTvShowNotFound { + http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + } + + return review, true +} + +// publishedReviewRoute returns the URL of a published review, anchored to the +// review on its movie or TV show page. +func publishedReviewRoute(review screenjournal.Review) string { + if review.MediaType() == screenjournal.MediaTypeMovie { + return fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()) + } + return fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()) +} + func (s Server) moviefromTmdbID(db sqlite.Store, tmdbID screenjournal.TmdbID) (screenjournal.Movie, error) { movie, err := db.ReadMovieByTmdbID(tmdbID) if err != nil && err != store.ErrMovieNotFound { diff --git a/handlers/reviews_drafts.go b/handlers/reviews_drafts.go new file mode 100644 index 00000000..6729c207 --- /dev/null +++ b/handlers/reviews_drafts.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/mtlynch/screenjournal/v2/screenjournal" + "github.com/mtlynch/screenjournal/v2/store" +) + +type reviewDraftResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` +} + +func (s Server) reviewsDraftsPost() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := parseReviewPostRequest(r, false) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) + return + } + saveDraftIntent := formBool(r, "save-draft") + + loggedInUsername := mustGetUsernameFromContext(r.Context()) + review, ok := s.reviewFromPostRequest(w, req, loggedInUsername, true) + if !ok { + return + } + + existingDraft, err := s.findExistingDraft(r, loggedInUsername, review) + if err != nil { + log.Printf("failed to find existing draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + statusCode := http.StatusCreated + if existingDraft != nil { + review.ID = existingDraft.ID + if err := s.store.UpdateReview(review); err != nil { + log.Printf("failed to update draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + statusCode = http.StatusOK + } else { + review.ID, err = s.store.InsertReview(review) + if err != nil { + log.Printf("failed to save draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + } + + if saveDraftIntent { + http.Redirect(w, r, "/reviews/drafts", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(reviewDraftResponse{ReviewID: review.ID}); err != nil { + log.Printf("failed to encode draft response: %v", err) + } + } +} + +func (s Server) findExistingDraft(r *http.Request, owner screenjournal.Username, draft screenjournal.Review) (*screenjournal.Review, error) { + queryOptions := []store.ReadReviewsOption{ + store.FilterReviewsByUsername(owner), + store.FilterReviewsByDraftStatus(true), + } + if draft.MediaType() == screenjournal.MediaTypeMovie { + queryOptions = append(queryOptions, store.FilterReviewsByMovieID(draft.Movie.ID)) + } else { + queryOptions = append(queryOptions, store.FilterReviewsByTvShowID(draft.TvShow.ID)) + queryOptions = append(queryOptions, store.FilterReviewsByTvShowSeason(draft.TvShowSeason)) + } + + drafts, err := s.store.ReadReviews(queryOptions...) + if err != nil { + return nil, err + } + if len(drafts) == 0 { + return nil, nil + } + // A partial unique index (see migration 015) guarantees at most one draft + // per (owner, media, season), so the first match is the only match. This + // dedup is load-bearing: it's what keeps the editor's autosave from creating + // duplicate drafts when its initial "create" requests race. + return &drafts[0], nil +} + +func (s Server) reviewsDraftsPut() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := reviewIDFromRequestPath(r) + if err != nil { + http.Error(w, "Invalid review ID", http.StatusBadRequest) + return + } + + review, ok := s.loadOwnedReview(w, r, id) + if !ok { + return + } + + if !review.IsDraft { + http.Error(w, "Review already published", http.StatusBadRequest) + return + } + + parsedRequest, err := parseReviewPutRequest(r, false) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) + return + } + + review.Rating = parsedRequest.Rating + review.Blurb = parsedRequest.Blurb + review.Watched = parsedRequest.Watched + + if err := s.store.UpdateReview(review); err != nil { + log.Printf("failed to update draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to update draft: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/handlers/reviews_test.go b/handlers/reviews_test.go index 5857ba21..3da914fa 100644 --- a/handlers/reviews_test.go +++ b/handlers/reviews_test.go @@ -2,10 +2,12 @@ package handlers_test import ( "context" + "encoding/json" "fmt" "log" "net/http" "net/http/httptest" + "net/url" "reflect" "strings" "testing" @@ -21,6 +23,7 @@ import ( "github.com/mtlynch/screenjournal/v2/metadata" "github.com/mtlynch/screenjournal/v2/random" "github.com/mtlynch/screenjournal/v2/screenjournal" + "github.com/mtlynch/screenjournal/v2/store" "github.com/mtlynch/screenjournal/v2/store/test_sqlite" ) @@ -892,6 +895,388 @@ func TestReviewsPut(t *testing.T) { } } +func TestReviewsDraftsPost(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + if _, err := dataStore.InsertMovie(movie); err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + payload := "media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-28&blurb=Draft%20thoughts" + req, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusCreated; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var response struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + review, err := dataStore.ReadReview(response.ReviewID) + if err != nil { + t.Fatalf("failed to read draft: %v", err) + } + + if got, want := review.Owner, screenjournal.Username("userA"); got != want { + t.Fatalf("owner=%v, want=%v", got, want) + } + if got, want := review.IsDraft, true; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := review.Blurb, screenjournal.Blurb("Draft thoughts"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := review.Rating, screenjournal.NewRating(5); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } + + if got, want := len(announcer.announcedReviews), 0; got != want { + t.Fatalf("announcedReviews=%d, want=%d", got, want) + } +} + +func TestReviewsDraftsPostReusesExistingDraft(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + if _, err := dataStore.InsertMovie(movie); err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + firstPayload := "media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-28&blurb=Draft%20thoughts" + firstReq, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(firstPayload)) + if err != nil { + t.Fatal(err) + } + firstReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + firstReq.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + firstRec := httptest.NewRecorder() + s.Router().ServeHTTP(firstRec, firstReq) + firstRes := firstRec.Result() + + if got, want := firstRes.StatusCode, http.StatusCreated; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var firstResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(firstRes.Body).Decode(&firstResponse); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + secondPayload := "media-type=movie&tmdb-id=38&rating=4&watch-date=2022-10-29&blurb=Updated%20draft" + secondReq, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(secondPayload)) + if err != nil { + t.Fatal(err) + } + secondReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + secondReq.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + secondRec := httptest.NewRecorder() + s.Router().ServeHTTP(secondRec, secondReq) + secondRes := secondRec.Result() + + if got, want := secondRes.StatusCode, http.StatusOK; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var secondResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(secondRes.Body).Decode(&secondResponse); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if got, want := secondResponse.ReviewID, firstResponse.ReviewID; got != want { + t.Fatalf("reviewID=%v, want=%v", got, want) + } + + drafts, err := dataStore.ReadReviews( + store.FilterReviewsByUsername(screenjournal.Username("userA")), + store.FilterReviewsByMovieID(screenjournal.MovieID(1)), + store.FilterReviewsByDraftStatus(true), + ) + if err != nil { + t.Fatalf("failed to read drafts: %v", err) + } + if got, want := len(drafts), 1; got != want { + t.Fatalf("draftCount=%d, want=%d", got, want) + } + + if got, want := drafts[0].Blurb, screenjournal.Blurb("Updated draft"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := drafts[0].Rating, screenjournal.NewRating(4); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } +} + +func TestReviewsDraftsPut(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + movieID, err := dataStore.InsertMovie(movie) + if err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + draft := screenjournal.Review{ + Owner: screenjournal.Username("userA"), + IsDraft: true, + Rating: screenjournal.NewRating(3), + Watched: mustParseWatchDate("2022-10-28"), + Blurb: screenjournal.Blurb("Initial thoughts"), + Movie: screenjournal.Movie{ + ID: movieID, + }, + } + draftID, err := dataStore.InsertReview(draft) + if err != nil { + t.Fatalf("failed to insert draft: %v", err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + payload := "rating=4&watch-date=2022-10-30&blurb=Updated%20draft" + req, err := http.NewRequest("PUT", fmt.Sprintf("/reviews/drafts/%d", draftID), strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusNoContent; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + updated, err := dataStore.ReadReview(draftID) + if err != nil { + t.Fatalf("failed to read updated draft: %v", err) + } + + if got, want := updated.IsDraft, true; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := updated.Blurb, screenjournal.Blurb("Updated draft"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := updated.Rating, screenjournal.NewRating(4); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } +} + +func TestReviewsPutPublishesDraft(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + movieID, err := dataStore.InsertMovie(movie) + if err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + draft := screenjournal.Review{ + Owner: screenjournal.Username("userA"), + IsDraft: true, + Rating: screenjournal.NewRating(3), + Watched: mustParseWatchDate("2022-10-28"), + Blurb: screenjournal.Blurb("Initial draft"), + Movie: screenjournal.Movie{ + ID: movieID, + }, + } + draftID, err := dataStore.InsertReview(draft) + if err != nil { + t.Fatalf("failed to insert draft: %v", err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + payload := fmt.Sprintf( + "rating=5&watch-date=2022-10-30&blurb=%s", + url.QueryEscape("Final thoughts"), + ) + req, err := http.NewRequest("PUT", fmt.Sprintf("/reviews/%d", draftID), strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusSeeOther; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + published, err := dataStore.ReadReview(draftID) + if err != nil { + t.Fatalf("failed to read published review: %v", err) + } + + if got, want := published.IsDraft, false; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := published.Blurb, screenjournal.Blurb("Final thoughts"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := published.Rating, screenjournal.NewRating(5); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } + + rr, err := dataStore.ReadReviews() + if err != nil { + t.Fatalf("failed to read reviews: %v", err) + } + if got, want := len(rr), 1; got != want { + t.Fatalf("reviewCount=%d, want=%d", got, want) + } + + if got, want := len(announcer.announcedReviews), 1; got != want { + t.Fatalf("announcedReviews=%d, want=%d", got, want) + } +} + func clearUnpredictableReviewProperties(r *screenjournal.Review) { r.ID = screenjournal.ReviewID(0) r.Created = time.Time{} diff --git a/handlers/routes.go b/handlers/routes.go index a616b4b0..7cbba97f 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -58,6 +58,8 @@ func (s *Server) routes() { authenticatedRoutes.HandleFunc("/reviews", s.reviewsPost()).Methods(http.MethodPost) authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsPut()).Methods(http.MethodPut) authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsDelete()).Methods(http.MethodDelete) + authenticatedRoutes.HandleFunc("/reviews/drafts", s.reviewsDraftsPost()).Methods(http.MethodPost) + authenticatedRoutes.HandleFunc("/reviews/drafts/{reviewID}", s.reviewsDraftsPut()).Methods(http.MethodPut) authenticatedRoutes.HandleFunc("/reactions", s.reactionsPost()).Methods(http.MethodPost) authenticatedRoutes.HandleFunc("/reactions/{reactionID}", s.reactionsDelete()).Methods(http.MethodDelete) @@ -79,6 +81,7 @@ func (s *Server) routes() { authenticatedViews.HandleFunc("/movies/{movieID}", s.moviesReadGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/tv-shows/{tvShowID}", s.tvShowsReadGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews", s.reviewsGet()).Methods(http.MethodGet) + authenticatedViews.HandleFunc("/reviews/drafts", s.reviewsDraftsGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/by/{username}", s.reviewsGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/new", s.reviewsNewTitleSearchGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/new/tv/pick-season", s.reviewsNewPickSeasonGet()).Methods(http.MethodGet) diff --git a/handlers/templates/pages/reviews-drafts.html b/handlers/templates/pages/reviews-drafts.html new file mode 100644 index 00000000..867d17b6 --- /dev/null +++ b/handlers/templates/pages/reviews-drafts.html @@ -0,0 +1,52 @@ +{{ define "title" }} + Drafts +{{ end }} + +{{ define "content" }} + {{ if not .Drafts }} +

You don't have any drafts yet.

+ {{ else }} +
+ {{ range .Drafts }} + {{ $media := .Movie }} + {{ $reviewRoute := printf "/reviews/%s/edit" .ID.String }} + {{ if eq .Movie.ID.Int64 0 }} + {{ $media = .TvShow }} + {{ end }} + + +
+
+ + Poster for {{ $media.Title }} + +
+
+ + {{ $media.Title }} + {{- if ne .TvShowSeason 0 }} + (Season {{ .TvShowSeason }}) + {{ end -}} + +
+

+ Last saved + {{ relativeCommentDate .Modified }} +

+ {{ with .Blurb | elideBlurb }} +

+ {{ . }} +

+ {{ end }} + Continue +
+
+
+ {{ end }} +
+ {{ end }} +{{ end }} diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 5c0f8e41..c35bbee4 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -76,11 +76,177 @@ document.body.addEventListener("htmx:afterSwap", function () { addSpoilersRevealButtons(); }); + + const form = document.querySelector("form"); + const draftIdInput = document.getElementById("draft-id"); + const draftStatusEl = document.getElementById("draft-status"); + const draftSpinnerEl = document.getElementById("draft-spinner"); + const autosaveEnabled = form?.dataset.autosave === "true"; + let hasSavedDraft = false; + let fadeTimeout; + let autosaveTimeout; + let isSavingDraft = false; + let pendingSave = false; + + function renderDraftStatus() { + if (!draftStatusEl) { + return; + } + if (hasSavedDraft) { + draftStatusEl.textContent = "Saved draft"; + draftStatusEl.classList.remove("text-danger"); + draftStatusEl.classList.add("text-muted"); + scheduleFade(); + return; + } + draftStatusEl.textContent = "Draft will auto-save."; + draftStatusEl.classList.remove("text-danger"); + draftStatusEl.classList.add("text-muted"); + scheduleFade(); + } + + function showDraftError(message) { + if (!draftStatusEl) { + return; + } + draftStatusEl.textContent = message; + draftStatusEl.classList.add("text-danger"); + draftStatusEl.classList.remove("text-muted"); + if (fadeTimeout) { + window.clearTimeout(fadeTimeout); + } + draftStatusEl.classList.remove("opacity-0"); + } + + function scheduleFade() { + if (!draftStatusEl) { + return; + } + if (fadeTimeout) { + window.clearTimeout(fadeTimeout); + } + draftStatusEl.classList.remove("opacity-0"); + fadeTimeout = window.setTimeout(() => { + draftStatusEl.classList.add("opacity-0"); + }, 3000); + } + + function setSpinnerVisible(isVisible) { + if (!draftSpinnerEl) { + return; + } + if (isVisible) { + draftSpinnerEl.classList.remove("invisible"); + } else { + draftSpinnerEl.classList.add("invisible"); + } + } + + async function saveDraft() { + if (!autosaveEnabled || !form) { + return; + } + + if (isSavingDraft) { + pendingSave = true; + return; + } + + const draftId = draftIdInput?.value?.trim(); + const url = draftId ? `/reviews/drafts/${draftId}` : "/reviews/drafts"; + + isSavingDraft = true; + if (!hasSavedDraft && draftStatusEl) { + draftStatusEl.textContent = "Saving draft..."; + draftStatusEl.classList.remove("text-danger"); + draftStatusEl.classList.add("text-muted"); + scheduleFade(); + } + setSpinnerVisible(true); + try { + const payload = new URLSearchParams(new FormData(form)); + const response = await fetch(url, { + method: draftId ? "PUT" : "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: payload.toString(), + }); + + if (!response.ok) { + showDraftError("Failed to save draft."); + return; + } + + if (!draftId) { + const data = await response.json(); + if (data.reviewId && draftIdInput) { + draftIdInput.value = data.reviewId; + } + } + + if (draftStatusEl) { + hasSavedDraft = true; + renderDraftStatus(); + } + } catch (err) { + showDraftError("Failed to save draft."); + } finally { + setSpinnerVisible(false); + isSavingDraft = false; + if (pendingSave) { + pendingSave = false; + scheduleAutosave(); + } + } + } + + function scheduleAutosave() { + window.clearTimeout(autosaveTimeout); + autosaveTimeout = window.setTimeout(saveDraft, 750); + } + + if (autosaveEnabled && form) { + const handleAutoSaveEvent = (evt) => { + const target = evt.target; + if (!target || target.closest("button")) { + return; + } + if (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) { + scheduleAutosave(); + scheduleFade(); + } + }; + form.addEventListener("input", handleAutoSaveEvent); + form.addEventListener("change", handleAutoSaveEvent); + + // When autosave has already created a draft, publish by updating that + // draft instead of POSTing a brand-new review, which would orphan the + // draft. + form.addEventListener("htmx:configRequest", (evt) => { + const draftId = draftIdInput?.value?.trim(); + if ( + draftId && + evt.detail.verb.toLowerCase() === "post" && + evt.detail.path === "/reviews" + ) { + evt.detail.verb = "put"; + evt.detail.path = `/reviews/${draftId}`; + } + }); + } + if (autosaveEnabled) { + if (draftStatusEl?.dataset.hadSavedDraft === "true") { + hasSavedDraft = true; + } + renderDraftStatus(); + } {{ end }} {{ define "content" }} {{ $isEditing := ne .Review.ID 0 }} + {{ $isDraft := .Review.IsDraft }} {{ $title := "" }} {{ $releaseYear := 0 }} @@ -97,7 +263,12 @@ {{ end }} -

{{ $title }} ({{ $releaseYear }})

+

+ {{ $title }} ({{ $releaseYear }}) + {{ if $isDraft }} + Draft + {{ end }} +

{{ if ne .Review.TvShowSeason.UInt8 0 }}

Season {{ .Review.TvShowSeason.UInt8 }}

@@ -106,6 +277,7 @@

Season {{ .Review.TvShowSeason.UInt8 }}

{{ $isEditing := ne .Review.ID 0 }} + {{ $enableAutosave := or (not $isEditing) $isDraft }}
Season {{ .Review.TvShowSeason.UInt8 }} hx-target="body" hx-push-url="true" hx-disabled-elt="input, select, textarea, .btn" + data-autosave="{{ if $enableAutosave -}} + true + {{- else -}} + false + {{- end }}" > + {{ if not $isEditing }} @@ -171,8 +354,27 @@

Season {{ .Review.TvShowSeason.UInt8 }}

{{ .Review.Blurb }}
-
-

+

+ {{ if $enableAutosave }} +
+ +
+
+ {{ end }} +

formatting options Season {{ .Review.TvShowSeason.UInt8 }} {{ if $isEditing }}

@@ -225,7 +458,33 @@

Season {{ .Review.TvShowSeason.UInt8 }}

{{ else }}
- +
+
+ + + +
+
{{ end }} diff --git a/handlers/templates/partials/navbar.html b/handlers/templates/partials/navbar.html index ad2cd284..2b8a3736 100644 --- a/handlers/templates/partials/navbar.html +++ b/handlers/templates/partials/navbar.html @@ -55,6 +55,11 @@ >My ratings +
  • + Drafts +
  • 200 { + return plaintext[:200] + "..." + } + return plaintext + }, + "posterPathToURL": posterPathToURL, + "relativeCommentDate": relativeCommentDate, + } + + t := template.Must( + template.New("base.html"). + Funcs(fns). + ParseFS( + templatesFS, + append(baseTemplates, "templates/pages/reviews-drafts.html")...)) + + return func(w http.ResponseWriter, r *http.Request) { + loggedInUsername := mustGetUsernameFromContext(r.Context()) + drafts, err := s.store.ReadReviews( + store.FilterReviewsByUsername(loggedInUsername), + store.FilterReviewsByDraftStatus(true), + ) + if err != nil { + log.Printf("failed to read drafts: %v", err) + http.Error(w, "Failed to read drafts", http.StatusInternalServerError) + return + } + + if err := t.Execute(w, struct { + commonProps + Drafts []screenjournal.Review + }{ + commonProps: makeCommonProps(r.Context()), + Drafts: drafts, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + func (s Server) moviesReadGet() http.HandlerFunc { t := template.Must( template.New("base.html"). @@ -387,7 +433,10 @@ func (s Server) moviesReadGet() http.HandlerFunc { return } - reviews, err := s.store.ReadReviews(store.FilterReviewsByMovieID(mid)) + reviews, err := s.store.ReadReviews( + store.FilterReviewsByMovieID(mid), + store.FilterReviewsByDraftStatus(false), + ) if err != nil { log.Printf("failed to read movie reviews: %v", err) http.Error(w, "Failed to retrieve reviews", http.StatusInternalServerError) @@ -483,7 +532,11 @@ func (s Server) tvShowsReadGet() http.HandlerFunc { return } - reviews, err := s.store.ReadReviews(store.FilterReviewsByTvShowID(tvID), store.FilterReviewsByTvShowSeason(seasonNumber)) + reviews, err := s.store.ReadReviews( + store.FilterReviewsByTvShowID(tvID), + store.FilterReviewsByTvShowSeason(seasonNumber), + store.FilterReviewsByDraftStatus(false), + ) if err != nil { log.Printf("failed to read TV show reviews: %v", err) http.Error(w, "Failed to retrieve TV show reviews", http.StatusInternalServerError) @@ -754,6 +807,11 @@ func (s Server) reviewsNewWriteReviewGet() http.HandlerFunc { return } movie = m + if movie.ID.IsZero() && tmdbID != nil { + if existingMovie, err := s.store.ReadMovieByTmdbID(*tmdbID); err == nil { + movie = existingMovie + } + } } else if mediaType == screenjournal.MediaTypeTvShow { t, err := s.getTvShow(r, tvShowID, tmdbID) if err != nil { @@ -762,6 +820,11 @@ func (s Server) reviewsNewWriteReviewGet() http.HandlerFunc { return } tvShow = t + if tvShow.ID.IsZero() && tmdbID != nil { + if existingShow, err := s.store.ReadTvShowByTmdbID(*tmdbID); err == nil { + tvShow = existingShow + } + } season, err := tvShowSeasonFromQueryParams(r) if err != nil { @@ -772,6 +835,25 @@ func (s Server) reviewsNewWriteReviewGet() http.HandlerFunc { tvShowSeason = season } + loggedInUsername := mustGetUsernameFromContext(r.Context()) + draftCandidate := screenjournal.Review{ + Owner: loggedInUsername, + Movie: movie, + TvShow: tvShow, + TvShowSeason: tvShowSeason, + } + if (mediaType == screenjournal.MediaTypeMovie && !movie.ID.IsZero()) || + (mediaType == screenjournal.MediaTypeTvShow && !tvShow.ID.IsZero()) { + if existingDraft, err := s.findExistingDraft(r, loggedInUsername, draftCandidate); err != nil { + log.Printf("failed to check draft: %v", err) + http.Error(w, "Failed to load review", http.StatusInternalServerError) + return + } else if existingDraft != nil { + http.Redirect(w, r, fmt.Sprintf("/reviews/%d/edit", existingDraft.ID.UInt64()), http.StatusSeeOther) + return + } + } + renderTemplate(w, t, "base.html", struct { commonProps RatingOptions []ratingOption @@ -1044,15 +1126,20 @@ func formatWatchDate(t screenjournal.WatchDate) string { return t.Time().Format(time.DateOnly) } +// relativeCommentDate renders a coarse, human-friendly age for a timestamp +// (e.g. "just now", "an hour ago", "3 weeks ago"). It's used wherever we show +// when something was last touched, such as comments and draft reviews. func relativeCommentDate(t time.Time) string { - minutesAgo := int(time.Since(t).Minutes()) + elapsed := time.Since(t) + + minutesAgo := int(elapsed.Minutes()) if minutesAgo < 1 { return "just now" } if minutesAgo == 1 { return "a minute ago" } - hoursAgo := int(time.Since(t).Hours()) + hoursAgo := int(elapsed.Hours()) if hoursAgo < 1 { return fmt.Sprintf("%d minutes ago", minutesAgo) } @@ -1063,7 +1150,7 @@ func relativeCommentDate(t time.Time) string { return fmt.Sprintf("%d hours ago", hoursAgo) } - daysAgo := int(time.Since(t).Hours() / 24) + daysAgo := int(elapsed.Hours() / 24) weeksAgo := int(daysAgo / 7) switch { case daysAgo == 1: diff --git a/screenjournal/review.go b/screenjournal/review.go index ee1b672a..90829f9d 100644 --- a/screenjournal/review.go +++ b/screenjournal/review.go @@ -20,6 +20,7 @@ type ( Review struct { ID ReviewID Owner Username + IsDraft bool Rating Rating Blurb Blurb Watched WatchDate diff --git a/store/sqlite/migrations/015-review-drafts.sql b/store/sqlite/migrations/015-review-drafts.sql new file mode 100644 index 00000000..df626535 --- /dev/null +++ b/store/sqlite/migrations/015-review-drafts.sql @@ -0,0 +1,15 @@ +ALTER TABLE reviews +ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0; + +-- A user can have at most one draft per movie or per (TV show, season). We +-- COALESCE the nullable media columns because SQLite treats each NULL as +-- distinct in a UNIQUE index, which would otherwise let duplicate movie drafts +-- (tv_show_id/tv_show_season NULL) slip through. +CREATE UNIQUE INDEX reviews_unique_draft_per_media +ON reviews ( + review_owner, + COALESCE(movie_id, -1), + COALESCE(tv_show_id, -1), + COALESCE(tv_show_season, -1) +) +WHERE is_draft = 1; diff --git a/store/sqlite/reviews.go b/store/sqlite/reviews.go index 427e4916..6fd93dc8 100644 --- a/store/sqlite/reviews.go +++ b/store/sqlite/reviews.go @@ -21,6 +21,7 @@ func (s Store) ReadReview(id screenjournal.ReviewID) (screenjournal.Review, erro movie_id, tv_show_id, tv_show_season, + is_draft, rating, blurb, watched_date, @@ -75,6 +76,10 @@ func (s Store) ReadReviews(opts ...store.ReadReviewsOption) ([]screenjournal.Rev whereClauses = append(whereClauses, "tv_show_season = :tv_show_season") queryArgs = append(queryArgs, sql.Named("tv_show_season", params.Filters.TvShowSeason.UInt8())) } + if params.Filters.IsDraft != nil { + whereClauses = append(whereClauses, "is_draft = :is_draft") + queryArgs = append(queryArgs, sql.Named("is_draft", *params.Filters.IsDraft)) + } query := ` SELECT @@ -83,6 +88,7 @@ func (s Store) ReadReviews(opts ...store.ReadReviewsOption) ([]screenjournal.Rev movie_id, tv_show_id, tv_show_season, + is_draft, rating, blurb, watched_date, @@ -172,6 +178,7 @@ func (s Store) InsertReview(r screenjournal.Review) (screenjournal.ReviewID, err movie_id, tv_show_id, tv_show_season, + is_draft, rating, blurb, watched_date, @@ -179,13 +186,14 @@ func (s Store) InsertReview(r screenjournal.Review) (screenjournal.ReviewID, err last_modified_time ) VALUES ( - :owner, :movie_id, :tv_show_id, :tv_show_season, :rating, :blurb, :watched_date, :created_time, :last_modified_time + :owner, :movie_id, :tv_show_id, :tv_show_season, :is_draft, :rating, :blurb, :watched_date, :created_time, :last_modified_time ) `, sql.Named("owner", r.Owner), sql.Named("movie_id", movieID), sql.Named("tv_show_id", tvShowID), sql.Named("tv_show_season", tvShowSeason), + sql.Named("is_draft", r.IsDraft), sql.Named("rating", r.Rating.Value), sql.Named("blurb", r.Blurb), sql.Named("watched_date", formatWatchDate(r.Watched)), @@ -219,12 +227,14 @@ func (s Store) UpdateReview(r screenjournal.Review) error { if _, err := s.db.Exec(` UPDATE reviews SET + is_draft = :is_draft, rating = :rating, blurb = :blurb, watched_date = :watched_date, last_modified_time = :last_modified_time WHERE id = :id`, + sql.Named("is_draft", r.IsDraft), sql.Named("rating", r.Rating.Value), sql.Named("blurb", r.Blurb), sql.Named("watched_date", formatWatchDate(r.Watched)), @@ -267,13 +277,14 @@ func reviewFromRow(row rowScanner) (screenjournal.Review, error) { var movieIDRaw *int var tvShowIDRaw *int var tvShowSeason *int + var isDraftRaw int var ratingRaw *uint8 var blurb string var watchedDateRaw string var createdTimeRaw string var lastModifiedTimeRaw string - err := row.Scan(&id, &owner, &movieIDRaw, &tvShowIDRaw, &tvShowSeason, &ratingRaw, &blurb, &watchedDateRaw, &createdTimeRaw, &lastModifiedTimeRaw) + err := row.Scan(&id, &owner, &movieIDRaw, &tvShowIDRaw, &tvShowSeason, &isDraftRaw, &ratingRaw, &blurb, &watchedDateRaw, &createdTimeRaw, &lastModifiedTimeRaw) if err == sql.ErrNoRows { return screenjournal.Review{}, store.ErrReviewNotFound } else if err != nil { @@ -319,6 +330,7 @@ func reviewFromRow(row rowScanner) (screenjournal.Review, error) { return screenjournal.Review{ ID: screenjournal.ReviewID(id), Owner: screenjournal.Username(owner), + IsDraft: isDraftRaw != 0, Rating: rating, Blurb: screenjournal.Blurb(blurb), Watched: screenjournal.WatchDate(wd), diff --git a/store/store.go b/store/store.go index 47426f84..097bddb8 100644 --- a/store/store.go +++ b/store/store.go @@ -12,6 +12,7 @@ type ( MovieID *screenjournal.MovieID TvShowID *screenjournal.TvShowID TvShowSeason *screenjournal.TvShowSeason + IsDraft *bool } ReadReviewsParams struct { @@ -59,6 +60,12 @@ func FilterReviewsByTvShowSeason(season screenjournal.TvShowSeason) func(*ReadRe } } +func FilterReviewsByDraftStatus(isDraft bool) func(*ReadReviewsParams) { + return func(p *ReadReviewsParams) { + p.Filters.IsDraft = &isDraft + } +} + func SortReviews(order screenjournal.SortOrder) func(*ReadReviewsParams) { return func(p *ReadReviewsParams) { p.Order = new(order)