Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions e2e/reviewDrafts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { test, expect } from "@playwright/test";
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();

await page.getByLabel("When did you watch?").fill("2024-10-10");

const draftResponse = page.waitForResponse(
(response) =>
response.url().includes("/reviews/drafts") &&
response.request().method() === "POST" &&
response.status() === 201
);
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();

await page.getByLabel("When did you watch?").fill("2024-10-10");

const draftResponse = page.waitForResponse(
(response) =>
response.url().includes("/reviews/drafts") &&
response.request().method() === "POST" &&
response.status() === 201
);
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();
});
68 changes: 68 additions & 0 deletions handlers/reviews.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ type reviewPostRequest struct {
Rating screenjournal.Rating
WatchDate screenjournal.WatchDate
Blurb screenjournal.Blurb
DraftID *screenjournal.ReviewID
}

type reviewPutRequest struct {
Rating screenjournal.Rating
Blurb screenjournal.Blurb
Watched screenjournal.WatchDate
Publish bool
}

func (s Server) reviewsPost() http.HandlerFunc {
Expand All @@ -34,12 +36,55 @@ func (s Server) reviewsPost() http.HandlerFunc {
return
}

if req.DraftID != nil {
review, err := s.getDB(r).ReadReview(*req.DraftID)
if err == store.ErrReviewNotFound {
http.Error(w, "Draft not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, fmt.Sprintf("Failed to read draft: %v", err), http.StatusInternalServerError)
return
}

loggedInUsername := mustGetUsernameFromContext(r.Context())
if !review.Owner.Equal(loggedInUsername) {
http.Error(w, "You can't edit another user's draft", http.StatusForbidden)
return
}

if !review.IsDraft {
http.Error(w, "Draft already published", http.StatusBadRequest)
return
}

review.Rating = req.Rating
review.Blurb = req.Blurb
review.Watched = req.WatchDate
review.IsDraft = false

if err := s.getDB(r).UpdateReview(review); err != nil {
log.Printf("failed to publish draft: %v", err)
http.Error(w, fmt.Sprintf("Failed to save review: %v", err), http.StatusInternalServerError)
return
}

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)
}
return
}

review := screenjournal.Review{
Owner: mustGetUsernameFromContext(r.Context()),
TvShowSeason: req.TvShowSeason,
Rating: req.Rating,
Watched: req.WatchDate,
Blurb: req.Blurb,
IsDraft: false,
Comments: []screenjournal.ReviewComment{},
}

Expand Down Expand Up @@ -115,13 +160,25 @@ func (s Server) reviewsPut() http.HandlerFunc {
review.Rating = parsedRequest.Rating
review.Blurb = parsedRequest.Blurb
review.Watched = parsedRequest.Watched
wasDraft := review.IsDraft
if parsedRequest.Publish {
if !review.IsDraft {
http.Error(w, "Review already published", http.StatusBadRequest)
return
}
review.IsDraft = false
}

if err := s.getDB(r).UpdateReview(review); err != nil {
log.Printf("failed to update review: %v", err)
http.Error(w, fmt.Sprintf("Failed to update review: %v", err), http.StatusInternalServerError)
return
}

if wasDraft && !review.IsDraft {
s.announcer.AnnounceNewReview(review)
}

var newRoute string
if review.MediaType() == screenjournal.MediaTypeMovie {
newRoute = fmt.Sprintf("/movies/%d", review.Movie.ID.Int64())
Expand Down Expand Up @@ -174,6 +231,14 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) {
parsed := reviewPostRequest{}
var err error

if draftRaw := r.PostFormValue("draft-id"); draftRaw != "" {
draftID, err := parse.ReviewIDFromString(draftRaw)
if err != nil {
return reviewPostRequest{}, err
}
parsed.DraftID = &draftID
}

if parsed.MediaType, err = parse.MediaType(r.PostFormValue("media-type")); err != nil {
return reviewPostRequest{}, err
}
Expand Down Expand Up @@ -223,6 +288,9 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) {
return reviewPutRequest{}, err
}

publishRaw := r.PostFormValue("publish")
parsed.Publish = publishRaw == "true" || publishRaw == "1"

return parsed, nil
}

Expand Down
172 changes: 172 additions & 0 deletions handlers/reviews_drafts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest)
return
}
saveDraftIntent := r.PostFormValue("save-draft") == "true" ||
r.PostFormValue("save-draft") == "1"

loggedInUsername := mustGetUsernameFromContext(r.Context())
review := screenjournal.Review{
Owner: loggedInUsername,
TvShowSeason: req.TvShowSeason,
Rating: req.Rating,
Watched: req.WatchDate,
Blurb: req.Blurb,
IsDraft: true,
Comments: []screenjournal.ReviewComment{},
}

if req.MediaType == screenjournal.MediaTypeMovie {
review.Movie, err = s.moviefromTmdbID(s.getDB(r), 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.getDB(r), 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
}
}

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.getDB(r).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.getDB(r).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.getDB(r).ReadReviews(queryOptions...)
if err != nil {
return nil, err
}
if len(drafts) == 0 {
return nil, nil
}

latest := drafts[0]
for _, candidate := range drafts[1:] {
if candidate.Modified.After(latest.Modified) {
latest = candidate
}
}
return &latest, 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, err := s.getDB(r).ReadReview(id)
if err == store.ErrReviewNotFound {
http.Error(w, "Draft not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, fmt.Sprintf("Failed to read draft: %v", err), http.StatusInternalServerError)
return
}

loggedInUsername := mustGetUsernameFromContext(r.Context())
if !review.Owner.Equal(loggedInUsername) {
http.Error(w, "You can't edit another user's draft", http.StatusForbidden)
return
}

if !review.IsDraft {
http.Error(w, "Review already published", http.StatusBadRequest)
return
}

parsedRequest, err := parseReviewPutRequest(r)
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.getDB(r).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)
}
}
Loading