diff --git a/README.md b/README.md index ada0e89..25c1acd 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ curl "localhost:8080/api/extensions/1/stats?from=2026-10-01&granularity=week" -H | POST, GET | `/api/publishers` | Track a publisher on every store; list yours | | POST | `/api/extensions` | Add an extension by hand (for Chrome extensions) | | POST | `/api/extensions/{id}/listings` | Add a listing by its store ID (for example a Chrome item ID) | +| DELETE | `/api/extensions/{id}` | Stop tracking an extension and delete its numbers and reviews | | GET | `/api/extensions` | Installs today and gain over 7 days, per extension and store | | GET | `/api/extensions/{id}/stats` | Installs over time: `from`, `to` (ISO dates), `granularity=day\|week` | | GET | `/api/reviews` | Reviews from every store, newest first: `unread`, `unanswered`, `maxRating`, `store`, `extensionId`, `limit`, `offset` | diff --git a/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogController.java b/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogController.java index 94febda..be46fc0 100644 --- a/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogController.java +++ b/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogController.java @@ -14,6 +14,7 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -65,6 +66,13 @@ ListingResponse addListing(@AuthenticationPrincipal Jwt token, @PathVariable lon .of(this.catalog.addListing(AuthenticatedAccount.id(token), id, request.store(), request.externalId())); } + /** Stops tracking the extension and deletes its numbers and reviews. */ + @DeleteMapping("/api/extensions/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + void removeExtension(@AuthenticationPrincipal Jwt token, @PathVariable long id) { + this.catalog.removeExtension(AuthenticatedAccount.id(token), id); + } + record AddPublisherRequest(@NotBlank @Size(max = 100) @Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9._-]*", message = "letters, digits, dots, dashes and underscores") String name) { } diff --git a/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogPages.java b/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogPages.java index 1050007..bc2810a 100644 --- a/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogPages.java +++ b/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogPages.java @@ -15,6 +15,7 @@ import org.springframework.web.ErrorResponseException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.servlet.mvc.support.RedirectAttributes; @@ -66,6 +67,19 @@ String addListing(@AuthenticationPrincipal SignedInUser user, @Valid @ModelAttri }); } + @PostMapping("/app/catalog/extensions/{id}/remove") + String removeExtension(@AuthenticationPrincipal SignedInUser user, @PathVariable long id, + RedirectAttributes redirect) { + try { + redirect.addFlashAttribute("notice", + "Removed " + this.catalog.removeExtension(user.id(), id) + " and its history."); + } + catch (ErrorResponseException ex) { + redirect.addFlashAttribute("error", ex.getBody().getDetail()); + } + return "redirect:/app/catalog"; + } + /** * Runs the change and goes back to the page with what happened: the result, or why it * was refused. diff --git a/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogService.java b/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogService.java index f9a4b9f..eec4d2a 100644 --- a/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogService.java +++ b/src/main/java/com/mccprogrammer/extpulse/catalog/CatalogService.java @@ -158,6 +158,18 @@ Listing addListing(long userId, long extensionId, Store store, String externalId } } + /** + * Stops tracking the extension, for example one taken off its store. Its listings, + * snapshots and reviews go with it (the foreign keys cascade); past collection runs + * keep their entries without the listing. + * @return the name it had, to say what was removed + */ + String removeExtension(long userId, long extensionId) { + Extension extension = requireOwnedExtension(userId, extensionId); + this.extensions.delete(extension); + return extension.getDisplayName(); + } + List publishersOf(long userId) { return this.publishers.findByUserIdOrderByName(userId); } diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 4f194dc..fb180a4 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -351,6 +351,11 @@ button.secondary, border-color: var(--axis); } +button.danger { + background: var(--bad); + color: #fff; +} + button.link { background: none; border: 0; @@ -555,6 +560,24 @@ button.link:hover { opacity: 0.8; } +.removable { + list-style: none; + padding: 0; + margin: 0; +} + +.removable li + li { + border-top: 1px solid var(--axis); +} + +.removable li { + padding: 6px 0; +} + +.removable details summary { + margin: 4px 0; +} + details summary { cursor: pointer; color: var(--text-2); diff --git a/src/main/resources/templates/catalog/catalog.html b/src/main/resources/templates/catalog/catalog.html index 1f2a8e0..e801545 100644 --- a/src/main/resources/templates/catalog/catalog.html +++ b/src/main/resources/templates/catalog/catalog.html @@ -71,6 +71,25 @@

2. Where it is published

Add a publisher first.

+ + +

Your extensions

+

Remove one you no longer track, for example after taking it off its store.

+
+
    +
  • + extension +
    + Remove +

    Deletes its listings, numbers and reviews. This can't be undone.

    +
    + +
    +
    +
  • +
+
+
diff --git a/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogApiTests.java b/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogApiTests.java index 0d95b69..b8cee37 100644 --- a/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogApiTests.java +++ b/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogApiTests.java @@ -27,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -165,6 +166,36 @@ void anotherAccountsExtensionLooksMissing() throws Exception { addListing(other, extension, "CHROME_WEB_STORE", DIRECTORS_CUT).andExpect(status().isNotFound()); } + @Test + void removingAnExtensionDeletesItsListingsAndNumbers() throws Exception { + long extension = addExtensionNamed("Director's Cut for Letterboxd"); + stubChromePage(DIRECTORS_CUT, "chrome-web-store/" + DIRECTORS_CUT + ".html"); + addListing(this.token, extension, "CHROME_WEB_STORE", DIRECTORS_CUT).andExpect(status().isCreated()); + this.jdbc + .sql("INSERT INTO snapshots (listing_id, taken_on, installs) " + + "SELECT id, current_date, 37 FROM listings WHERE extension_id = :extension") + .param("extension", extension) + .update(); + long others = count("extensions") - 1; + + removeExtension(this.token, extension).andExpect(status().isNoContent()); + + assertThat(count("extensions")).isEqualTo(others); + assertThat(countWhere("listings", "extension_id = " + extension)).isZero(); + assertThat(countWhere("snapshots", "installs = 37")).isZero(); + removeExtension(this.token, extension).andExpect(status().isNotFound()); + } + + @Test + void anotherAccountCannotRemoveAnExtension() throws Exception { + long extension = addExtensionNamed("Director's Cut for Letterboxd"); + String other = TestAccounts.tokenFor(this.mvc, "someone@example.com"); + + removeExtension(other, extension).andExpect(status().isNotFound()); + + assertThat(countWhere("extensions", "id = " + extension)).isEqualTo(1); + } + @Test void needsALogin() throws Exception { this.mvc.perform(post("/api/publishers").contentType(MediaType.APPLICATION_JSON).content("{\"name\": \"x\"}")) @@ -192,6 +223,10 @@ private ResultActions addPublisher(String token, String name) throws Exception { .content("{\"name\": \"%s\"}".formatted(name))); } + private ResultActions removeExtension(String token, long extension) throws Exception { + return this.mvc.perform(delete("/api/extensions/" + extension).header("Authorization", "Bearer " + token)); + } + private ResultActions addListing(String token, long extension, String store, String externalId) throws Exception { return this.mvc .perform(post("/api/extensions/" + extension + "/listings").header("Authorization", "Bearer " + token) @@ -218,6 +253,10 @@ private void stubChromePage(String itemId, String bodyFile) { .willReturn(aResponse().withHeader("Content-Type", "text/html; charset=utf-8").withBodyFile(bodyFile))); } + private long countWhere(String table, String condition) { + return this.jdbc.sql("SELECT count(*) FROM " + table + " WHERE " + condition).query(Long.class).single(); + } + private long count(String table) { return this.jdbc.sql("SELECT count(*) FROM " + table).query(Long.class).single(); } diff --git a/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogPagesTests.java b/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogPagesTests.java index 319ef96..7000a36 100644 --- a/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogPagesTests.java +++ b/src/test/java/com/mccprogrammer/extpulse/catalog/CatalogPagesTests.java @@ -24,6 +24,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.startsWith; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -123,6 +124,30 @@ void aChromeExtensionIsAddedInTwoSteps() throws Exception { .andExpect(flash().attribute("notice", "Added " + DIRECTORS_CUT + " on Chrome Web Store.")); } + @Test + void anExtensionIsRemovedFromThePage() throws Exception { + long publisher = this.jdbc + .sql("INSERT INTO publishers (user_id, name) VALUES (:user, 'MCCProgrammer') RETURNING id") + .param("user", TestAccounts.idOf(this.jdbc, "miguel@example.com")) + .query(Long.class) + .single(); + long extension = this.jdbc.sql( + "INSERT INTO extensions (publisher_id, display_name) VALUES (:publisher, 'Unified Inbox') RETURNING id") + .param("publisher", publisher) + .query(Long.class) + .single(); + this.mvc.perform(get("/app/catalog").session(this.session)) + .andExpect(content().string(containsString("Remove Unified Inbox"))); + + this.mvc.perform(post("/app/catalog/extensions/{id}/remove", extension).session(this.session).with(csrf())) + .andExpect(redirectedUrl("/app/catalog")) + .andExpect(flash().attribute("notice", "Removed Unified Inbox and its history.")); + this.mvc.perform(post("/app/catalog/extensions/{id}/remove", extension).session(this.session).with(csrf())) + .andExpect(flash().attribute("error", "You have no extension " + extension)); + this.mvc.perform(get("/app/catalog").session(this.session)) + .andExpect(content().string(not(containsString("Remove Unified Inbox")))); + } + @Test void aRefusedChangeShowsWhy() throws Exception { this.mvc