diff --git a/client/src/main/java/com/gdevxy/blog/client/google/GoogleClient.java b/client/src/main/java/com/gdevxy/blog/client/google/GoogleClient.java index c502816..ae69dbc 100644 --- a/client/src/main/java/com/gdevxy/blog/client/google/GoogleClient.java +++ b/client/src/main/java/com/gdevxy/blog/client/google/GoogleClient.java @@ -2,8 +2,8 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; -import com.gdevxy.blog.client.google.model.CaptchaVerifyRequest; import com.gdevxy.blog.client.google.model.CaptchaVerifyResponse; import io.smallrye.mutiny.Uni; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; @@ -13,6 +13,6 @@ public interface GoogleClient { @POST @Path("/recaptcha/api/siteverify") - Uni verifyCaptcha(CaptchaVerifyRequest req); + Uni verifyCaptcha(@QueryParam("secret") String secret, @QueryParam("response") String response); } diff --git a/client/src/main/java/com/gdevxy/blog/client/google/model/CaptchaVerifyRequest.java b/client/src/main/java/com/gdevxy/blog/client/google/model/CaptchaVerifyRequest.java deleted file mode 100644 index f793ac8..0000000 --- a/client/src/main/java/com/gdevxy/blog/client/google/model/CaptchaVerifyRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.gdevxy.blog.client.google.model; - -public record CaptchaVerifyRequest(String secret, String response) { - -} diff --git a/component/src/main/java/com/gdevxy/blog/component/BlogPostResource.java b/component/src/main/java/com/gdevxy/blog/component/BlogPostResource.java index f95230a..50fed71 100644 --- a/component/src/main/java/com/gdevxy/blog/component/BlogPostResource.java +++ b/component/src/main/java/com/gdevxy/blog/component/BlogPostResource.java @@ -1,6 +1,7 @@ package com.gdevxy.blog.component; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import jakarta.validation.Valid; @@ -12,7 +13,9 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.gdevxy.blog.component.cookie.Cookies; import com.gdevxy.blog.model.BlogPost; import com.gdevxy.blog.model.BlogPostRateReq; import com.gdevxy.blog.model.Image; @@ -23,6 +26,7 @@ import io.quarkus.qute.TemplateInstance; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; +import io.vertx.core.http.HttpServerRequest; import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; @@ -37,7 +41,8 @@ public class BlogPostResource { @GET @Path("/{slug}") @Produces(MediaType.TEXT_HTML) - public Uni blogPost(@Valid @Size(max = 255) @PathParam("slug") String slug, @QueryParam("previewToken") String previewToken) { + public Uni blogPost(HttpServerRequest req, @Valid @Size(max = 255) @PathParam("slug") String slug, + @QueryParam("previewToken") String previewToken) { var blogPost = blogPostService.findBlogPost(previewToken, slug); var images = blogPost.onItem().transformToMulti(p -> Multi.createFrom().iterable(p.getBlocks())) @@ -47,22 +52,36 @@ public Uni blogPost(@Valid @Size(max = 255) @PathParam("slug") .collect() .with(Collectors.toUnmodifiableMap(Image::getId, i -> i)); - return Uni.combine().all().unis(blogPost, images) + var thumbsUpEnabled = blogPost.map(p -> Cookies.findBlogPostRatingCookie(req, p.getId())).map(Optional::isPresent); + + return Uni.combine().all().unis(blogPost, images, thumbsUpEnabled) .with(Templates::blogPost); } @POST - @Path("/{id}/rate") - public Uni rate(@Valid @Size(max = 22) @PathParam("id") String id, @Valid BlogPostRateReq req) { + @Path("/{id}/thumbs-up") + public Uni thumbsUp(@Valid @Size(max = 22) @PathParam("id") String key, @Valid BlogPostRateReq req) { + + return blogPostService.thumbsUp(key, req.captcha()) + .map(uuid -> Response.noContent() + .cookie(Cookies.createBlogPostRatingCookie(key, uuid)) + .build()); + } + + @POST + @Path("/{id}/thumbs-down") + public Uni thumbsDown(HttpServerRequest req, @Valid @Size(max = 22) @PathParam("id") String key) { - return blogPostService.rate(id, req.captcha()); + return Cookies.findBlogPostRatingCookie(req, key) + .map(c -> blogPostService.thumbsDown(c.getValue()).map(v -> Response.ok().cookie(Cookies.deleteBlogPostRatingCookie(key)).build())) + .orElseGet(() -> Uni.createFrom().item(Response.noContent().build())); } @CheckedTemplate @NoArgsConstructor(access = AccessLevel.PRIVATE) public static class Templates { - public static native TemplateInstance blogPost(BlogPost blogPost, Map images); + public static native TemplateInstance blogPost(BlogPost blogPost, Map images, Boolean thumbsUpEnabled); } diff --git a/component/src/main/java/com/gdevxy/blog/component/cookie/Cookies.java b/component/src/main/java/com/gdevxy/blog/component/cookie/Cookies.java new file mode 100644 index 0000000..c0e3678 --- /dev/null +++ b/component/src/main/java/com/gdevxy/blog/component/cookie/Cookies.java @@ -0,0 +1,49 @@ +package com.gdevxy.blog.component.cookie; + +import java.time.Instant; +import java.util.Date; +import java.util.Optional; + +import jakarta.ws.rs.core.NewCookie; + +import io.vertx.core.http.Cookie; +import io.vertx.core.http.HttpServerRequest; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class Cookies { + + private final String BLOG_POST_RATING_COOKIE_NAME = "gdevxy-bpr-%s"; + + public Optional findBlogPostRatingCookie(HttpServerRequest req, String id) { + + return req.cookies(BLOG_POST_RATING_COOKIE_NAME.formatted(id)).stream().findAny(); + } + + public NewCookie createBlogPostRatingCookie(String id, String uuid) { + + return new NewCookie.Builder(BLOG_POST_RATING_COOKIE_NAME.formatted(id)) + .secure(true) + .httpOnly(true) + .sameSite(NewCookie.SameSite.STRICT) + .maxAge(Integer.MAX_VALUE) + .expiry(Date.from(Instant.now().plusSeconds(Integer.MAX_VALUE))) + .value(uuid) + .path("/") + .build(); + } + + public NewCookie deleteBlogPostRatingCookie(String id) { + + return new NewCookie.Builder(BLOG_POST_RATING_COOKIE_NAME.formatted(id)) + .secure(true) + .httpOnly(true) + .sameSite(NewCookie.SameSite.STRICT) + .maxAge(Integer.MAX_VALUE) + .expiry(Date.from(Instant.now().plusSeconds(Integer.MAX_VALUE))) + .value("goodbye") + .path("/") + .build(); + } + +} diff --git a/component/src/main/resources/templates/BlogPostResource/blogPost.html b/component/src/main/resources/templates/BlogPostResource/blogPost.html index b080f33..2184661 100644 --- a/component/src/main/resources/templates/BlogPostResource/blogPost.html +++ b/component/src/main/resources/templates/BlogPostResource/blogPost.html @@ -12,7 +12,7 @@ {/extraCss} {#extraJs} - + @@ -21,28 +21,45 @@ @@ -125,7 +142,7 @@

{blogPost.title}

{/if}
- Leave a thumbs up if you liked this post + Leave a thumbs up if you liked it
diff --git a/component/src/main/resources/web/app/app.css b/component/src/main/resources/web/app/app.css index 18f9553..6dc2e95 100644 --- a/component/src/main/resources/web/app/app.css +++ b/component/src/main/resources/web/app/app.css @@ -136,7 +136,7 @@ padding-left: 3em; } -.text-neon { +.text-neon, .thumbs-up-success { color: var(--bs-color-neon); } @@ -171,7 +171,7 @@ } .pulse { - animation: pulse-animation 2s infinite; + animation: pulse-animation 2s 1; } @keyframes pulse-animation { diff --git a/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostDao.java b/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostDao.java index dc9931d..9b016db 100644 --- a/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostDao.java +++ b/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostDao.java @@ -8,7 +8,9 @@ import io.vertx.mutiny.sqlclient.Row; import io.vertx.mutiny.sqlclient.Tuple; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @ApplicationScoped @RequiredArgsConstructor public class BlogPostDao extends DaoSupport { @@ -20,18 +22,25 @@ public Uni findByKey(String key) { return as(sql.preparedQuery(""" select id, - key, - rating - from blog_post where key = $1 + key + from blog_post + where key = $1 """) .execute(Tuple.of(key)), BlogPostDao::toBlogPostEntity); } - public Uni rate(String key) { + public Uni save(BlogPostEntity entity) { - return sql.preparedQuery(""" - update blog_post set rating = coalesce(rating, 0) + 1 where key = $1 - """).execute(Tuple.of(key)).replaceWithVoid(); + log.info("Persisting: {}", entity); + + return as(sql.preparedQuery(""" + insert into blog_post( + key + ) values ( + $1 + ) returning * + """) + .execute(Tuple.of(entity.getKey())), row -> entity.toBuilder().id(row.getInteger("id")).build()); } private static BlogPostEntity toBlogPostEntity(Row row) { @@ -39,7 +48,6 @@ private static BlogPostEntity toBlogPostEntity(Row row) { return BlogPostEntity.builder() .id(row.getInteger("id")) .key(row.getString("key")) - .rating(row.getInteger("rating")) .build(); } diff --git a/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostEntity.java b/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostEntity.java index c9cb895..f764418 100644 --- a/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostEntity.java +++ b/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostEntity.java @@ -5,12 +5,11 @@ import lombok.ToString; @Getter -@Builder +@Builder(toBuilder = true) @ToString public class BlogPostEntity { private final Integer id; private final String key; - private final Integer rating; } diff --git a/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostRatingDao.java b/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostRatingDao.java new file mode 100644 index 0000000..685a5a4 --- /dev/null +++ b/service/src/main/java/com/gdevxy/blog/dao/blogpost/BlogPostRatingDao.java @@ -0,0 +1,46 @@ +package com.gdevxy.blog.dao.blogpost; + +import java.util.UUID; + +import jakarta.enterprise.context.ApplicationScoped; + +import com.gdevxy.blog.dao.DaoSupport; +import io.smallrye.mutiny.Uni; +import io.vertx.mutiny.pgclient.PgPool; +import io.vertx.mutiny.sqlclient.Tuple; +import lombok.RequiredArgsConstructor; + +@ApplicationScoped +@RequiredArgsConstructor +public class BlogPostRatingDao extends DaoSupport { + + private final PgPool sql; + + public Uni findRating(Integer id) { + + return as(sql.preparedQuery(""" + select + count(*) as rating + from blog_post_rating + where blog_post_id = $1 + """) + .execute(Tuple.of(id)), r -> r.getLong("rating")); + } + + public Uni thumbsUp(Integer blogId) { + + var uuid = UUID.randomUUID().toString(); + + return sql.preparedQuery(""" + insert into blog_post_rating (blog_post_id, uuid) values ($1, $2) + """).execute(Tuple.of(blogId, uuid)).map(i -> uuid); + } + + public Uni thumbsDown(String uuid) { + + return sql.preparedQuery(""" + delete from blog_post_rating where uuid = $1 + """).execute(Tuple.of(uuid)).replaceWithVoid(); + } + +} diff --git a/service/src/main/java/com/gdevxy/blog/dao/blogpostcomment/BlogPostCommentDao.java b/service/src/main/java/com/gdevxy/blog/dao/blogpostcomment/BlogPostCommentDao.java deleted file mode 100644 index f30a20e..0000000 --- a/service/src/main/java/com/gdevxy/blog/dao/blogpostcomment/BlogPostCommentDao.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.gdevxy.blog.dao.blogpostcomment; - -import jakarta.enterprise.context.ApplicationScoped; - -import io.vertx.mutiny.pgclient.PgPool; -import lombok.RequiredArgsConstructor; - -@ApplicationScoped -@RequiredArgsConstructor -public class BlogPostCommentDao { - - private final PgPool sql; - -} diff --git a/service/src/main/java/com/gdevxy/blog/dao/blogpostcomment/BlogPostCommentEntity.java b/service/src/main/java/com/gdevxy/blog/dao/blogpostcomment/BlogPostCommentEntity.java deleted file mode 100644 index 2621baa..0000000 --- a/service/src/main/java/com/gdevxy/blog/dao/blogpostcomment/BlogPostCommentEntity.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.gdevxy.blog.dao.blogpostcomment; - -import lombok.Builder; -import lombok.Getter; -import lombok.ToString; - -@Getter -@Builder -@ToString -public class BlogPostCommentEntity { - - private final Integer id; - private final String comment; - -} diff --git a/service/src/main/java/com/gdevxy/blog/service/captcha/CaptchaService.java b/service/src/main/java/com/gdevxy/blog/service/captcha/CaptchaService.java index c7d81a2..a8e8218 100644 --- a/service/src/main/java/com/gdevxy/blog/service/captcha/CaptchaService.java +++ b/service/src/main/java/com/gdevxy/blog/service/captcha/CaptchaService.java @@ -4,7 +4,6 @@ import jakarta.ws.rs.ForbiddenException; import com.gdevxy.blog.client.google.GoogleClient; -import com.gdevxy.blog.client.google.model.CaptchaVerifyRequest; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import lombok.extern.slf4j.Slf4j; @@ -23,7 +22,7 @@ public class CaptchaService { public Uni verify(String captcha) { - return googleClient.verifyCaptcha(new CaptchaVerifyRequest(secret, captcha)) + return googleClient.verifyCaptcha(secret, captcha) .invoke(Unchecked.consumer(res -> { if (!res.getSuccess()){ log.info("Captcha validation failed {}", res); diff --git a/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/BlogPostService.java b/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/BlogPostService.java index 150b072..3299196 100644 --- a/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/BlogPostService.java +++ b/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/BlogPostService.java @@ -1,7 +1,6 @@ package com.gdevxy.blog.service.contentful.blogpost; import java.util.Set; -import java.util.function.Function; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; @@ -13,6 +12,7 @@ import com.gdevxy.blog.client.contentful.model.Pagination; import com.gdevxy.blog.dao.blogpost.BlogPostDao; import com.gdevxy.blog.dao.blogpost.BlogPostEntity; +import com.gdevxy.blog.dao.blogpost.BlogPostRatingDao; import com.gdevxy.blog.model.BlogPost; import com.gdevxy.blog.model.RecentBlogPost; import com.gdevxy.blog.service.captcha.CaptchaService; @@ -27,6 +27,8 @@ public class BlogPostService { @Inject BlogPostDao blogPostDao; @Inject + BlogPostRatingDao blogPostRatingDao; + @Inject ContentfulClient contentfulClient; @Inject CaptchaService captchaService; @@ -39,7 +41,7 @@ public Uni findBlogPost(@Nullable String previewToken, String slug) { return contentfulClient.findBlogPost(slug, previewToken) .onItem().ifNull().failWith(new NotFoundException("BlogPost [%s] not found".formatted(slug))) - .onItem().transformToUni(toBlogPost()); + .onItem().transformToUni(this::toBlogPost); } public Multi findBlogPosts(@Nullable String previewToken, Set tags) { @@ -48,7 +50,7 @@ public Multi findBlogPosts(@Nullable String previewToken, Set return contentfulClient.findBlogPosts(pagination, tags, previewToken) .onItem().transformToMulti(p -> Multi.createFrom().iterable(p.getItems())) - .onItem().transformToUniAndConcatenate(toBlogPost()); + .onItem().transformToUniAndConcatenate(this::toBlogPost); } public Multi findRecentBlogPosts(@Nullable String previewToken) { @@ -58,17 +60,24 @@ public Multi findRecentBlogPosts(@Nullable String previewToken) .map(recentBlogPostConverter); } - public Uni rate(String id, String captcha) { + public Uni thumbsUp(String key, String captcha) { return captchaService.verify(captcha) - .flatMap(i -> blogPostDao.rate(id)); + .flatMap(i -> blogPostDao.findByKey(key)) + .flatMap(e -> blogPostRatingDao.thumbsUp(e.getId())); + } + + public Uni thumbsDown(String uuid) { + + return blogPostRatingDao.thumbsDown(uuid); } - private Function> toBlogPost() { + private Uni toBlogPost(PageBlogPost p) { - return p -> blogPostDao.findByKey(p.getSys().getId()) - .map(BlogPostEntity::getRating) - .onFailure(NotFoundException.class).recoverWithItem(0) + return blogPostDao.findByKey(p.getSys().getId()) + .onFailure(NotFoundException.class).recoverWithUni(() -> blogPostDao.save(BlogPostEntity.builder().key(p.getSys().getId()).build())) + .flatMap(bp -> blogPostRatingDao.findRating(bp.getId())) + .onFailure(NotFoundException.class).recoverWithItem(0L) .map(rating -> blogPostConverter.convert(p, rating)); } diff --git a/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/converter/BlogPostConverter.java b/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/converter/BlogPostConverter.java index 546b11d..e26a2e5 100644 --- a/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/converter/BlogPostConverter.java +++ b/service/src/main/java/com/gdevxy/blog/service/contentful/blogpost/converter/BlogPostConverter.java @@ -24,7 +24,7 @@ public class BlogPostConverter { private final ImageConverter imageConverter; - public BlogPost convert(PageBlogPost p, Integer rating) { + public BlogPost convert(PageBlogPost p, Long rating) { return BlogPost.builder() .id(p.getSys().getId()) diff --git a/service/src/main/resources/application.properties b/service/src/main/resources/application.properties index 3bcc21d..a50d624 100644 --- a/service/src/main/resources/application.properties +++ b/service/src/main/resources/application.properties @@ -19,4 +19,4 @@ quarkus.datasource.reactive.url=postgresql://localhost:5432/defaultdb quarkus.flyway.migrate-at-start=true # Google Captcha -google.captcha.secret=${GOOGLE_CAPTCHA_SECRET:} +google.captcha.secret=${GOOGLE_CAPTCHA_SECRET:test} diff --git a/service/src/main/resources/db/migration/V1__Create_BlogPost.sql b/service/src/main/resources/db/migration/V1__Create_BlogPost.sql index 8a097ea..fe07866 100644 --- a/service/src/main/resources/db/migration/V1__Create_BlogPost.sql +++ b/service/src/main/resources/db/migration/V1__Create_BlogPost.sql @@ -1,8 +1,7 @@ create table blog_post ( id integer primary key generated always as identity, - key varchar(22) not null, - rating integer not null + key varchar(22) not null ); create unique index idx_blog_post_key on blog_post (key); diff --git a/service/src/main/resources/db/migration/V2__Create_BlogPostRating.sql b/service/src/main/resources/db/migration/V2__Create_BlogPostRating.sql new file mode 100644 index 0000000..46ced7e --- /dev/null +++ b/service/src/main/resources/db/migration/V2__Create_BlogPostRating.sql @@ -0,0 +1,8 @@ +create table blog_post_rating +( + blog_post_id integer not null, + uuid varchar(36) not null, + foreign key (blog_post_id) references blog_post (id) on delete cascade +); + +create unique index idx_blog_post_rating_uuid on blog_post_rating (uuid);