KB68 Implement newsletter sending.

Change-Id: I1d56e40d7f35d6be77fde1a1e8519a91bd2dc3b8
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/Newsletter.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/Newsletter.java
new file mode 100644
index 0000000..3d9a3fe
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/Newsletter.java
@@ -0,0 +1,29 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import eu.mulk.mulkcms2.benki.posts.Post;
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import java.time.OffsetDateTime;
+import java.util.Collection;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.FetchType;
+import javax.persistence.Id;
+import javax.persistence.OneToMany;
+import javax.persistence.OrderBy;
+import javax.persistence.Table;
+
+@Entity
+@Table(name = "newsletters", schema = "benki")
+public class Newsletter extends PanacheEntityBase {
+
+  @Id
+  @Column(name = "id", nullable = false)
+  public Integer id;
+
+  @Column(name = "date", nullable = false)
+  public OffsetDateTime date = OffsetDateTime.now();
+
+  @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
+  @OrderBy("date")
+  public Collection<Post<?>> posts;
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java
new file mode 100644
index 0000000..1f13c08
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java
@@ -0,0 +1,117 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import static java.util.stream.Collectors.partitioningBy;
+import static java.util.stream.Collectors.toList;
+
+import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
+import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
+import eu.mulk.mulkcms2.benki.posts.Post;
+import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
+import io.quarkus.panache.common.Sort;
+import io.quarkus.qute.TemplateExtension;
+import io.quarkus.qute.api.CheckedTemplate;
+import io.quarkus.scheduler.Scheduled;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.CheckForNull;
+import javax.enterprise.context.Dependent;
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+import javax.transaction.Transactional;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.hibernate.Session;
+
+@Dependent
+public class NewsletterSender {
+
+  private static final DateTimeFormatter humanDateFormatter =
+      DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG);
+
+  @ConfigProperty(name = "mulkcms.newsletter.time-zone")
+  ZoneId newsletterTimeZone;
+
+  @PersistenceContext EntityManager em;
+
+  @CheckedTemplate
+  static class Templates {
+    public static native MailTemplateInstance newsletter(
+        Newsletter newsletter, List<Bookmark> bookmarks, List<LazychatMessage> lazychatMessages);
+  }
+
+  @Scheduled(cron = "0 0 0 ? * Mon")
+  @Transactional
+  void run() throws InterruptedException, TimeoutException, ExecutionException {
+    var session = em.unwrap(Session.class);
+
+    List<Post<?>> posts = Post.list("newsletter IS NULL", Sort.ascending("date"));
+    Post.fetchTexts(posts);
+
+    if (posts.isEmpty()) {
+      return;
+    }
+
+    var postsByClass = posts.stream().collect(partitioningBy(Post::isBookmark));
+    var bookmarks =
+        postsByClass.getOrDefault(Boolean.TRUE, List.of()).stream()
+            .map(x -> (Bookmark) x)
+            .collect(toList());
+    var lazychatMessages =
+        postsByClass.getOrDefault(Boolean.FALSE, List.of()).stream()
+            .map(x -> (LazychatMessage) x)
+            .collect(toList());
+
+    var date = OffsetDateTime.now(newsletterTimeZone);
+    var newsletterNumber =
+        (int)
+            session
+                .createQuery("SELECT max(id) FROM Newsletter", Integer.class)
+                .uniqueResultOptional()
+                .map(x -> x + 1)
+                .orElse(1);
+
+    var newsletter = new Newsletter();
+    newsletter.id = newsletterNumber;
+    newsletter.date = date;
+    newsletter.persist();
+
+    posts.forEach(post -> post.newsletter = newsletter);
+
+    var subscriberEmails =
+        NewsletterSubscription.<NewsletterSubscription>streamAll()
+            .map(x -> x.email)
+            .toArray(String[]::new);
+
+    var mailText = Templates.newsletter(newsletter, bookmarks, lazychatMessages);
+    var sendJob =
+        mailText
+            .subject(String.format("MulkCMS newsletter #%d", newsletterNumber))
+            .bcc(subscriberEmails)
+            .send();
+    sendJob.toCompletableFuture().get(10000, TimeUnit.SECONDS);
+  }
+
+  @TemplateExtension
+  @CheckForNull
+  static String humanDate(@CheckForNull LocalDate x) {
+    if (x == null) {
+      return null;
+    }
+    return humanDateFormatter.format(x);
+  }
+
+  @TemplateExtension
+  @CheckForNull
+  static String humanDate(@CheckForNull OffsetDateTime x) {
+    if (x == null) {
+      return null;
+    }
+    return humanDateFormatter.format(x);
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java
new file mode 100644
index 0000000..7aeda60
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java
@@ -0,0 +1,29 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import java.time.OffsetDateTime;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.validation.constraints.Email;
+import org.hibernate.annotations.NaturalId;
+
+@Entity
+@Table(name = "newsletter_subscriptions", schema = "benki")
+public class NewsletterSubscription extends PanacheEntityBase {
+
+  @Id
+  @Column(name = "id", nullable = false)
+  @GeneratedValue
+  public Integer id;
+
+  @Column(name = "start_date", nullable = false)
+  public OffsetDateTime startDate = OffsetDateTime.now();
+
+  @NaturalId
+  @Column(name = "email", nullable = false)
+  @Email
+  public String email;
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index fd023d7..346b71f 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -5,12 +5,14 @@
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
 import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
 import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
+import eu.mulk.mulkcms2.benki.newsletter.Newsletter;
 import eu.mulk.mulkcms2.benki.users.User;
 import eu.mulk.mulkcms2.benki.users.User_;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
 import java.time.LocalDate;
 import java.time.OffsetDateTime;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
@@ -70,6 +72,12 @@
   public OffsetDateTime date;
 
   @ManyToOne(fetch = FetchType.LAZY)
+  @JoinColumn(name = "newsletter", referencedColumnName = "id", nullable = true)
+  @CheckForNull
+  @JsonbTransient
+  public Newsletter newsletter;
+
+  @ManyToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "owner", referencedColumnName = "id")
   @CheckForNull
   @JsonbTransient
@@ -340,18 +348,22 @@
     }
 
     // Fetch texts (to avoid n+1 selects).
-    var postIds = forwardResults.stream().map(x -> x.id).collect(toList());
+    fetchTexts(forwardResults);
+
+    return new PostPage<>(prevCursor, cursor, nextCursor, forwardResults);
+  }
+
+  public static <T extends Post<?>> void fetchTexts(Collection<T> posts) {
+    var postIds = posts.stream().map(x -> x.id).collect(toList());
 
     if (!postIds.isEmpty()) {
       find("SELECT p FROM Post p LEFT JOIN FETCH p.texts WHERE p.id IN (?1)", postIds).stream()
           .count();
     }
-
-    return new PostPage<>(prevCursor, cursor, nextCursor, forwardResults);
   }
 
   @CheckForNull
-  protected Text getText() {
+  public Text getText() {
     var texts = getTexts();
     if (texts.isEmpty()) {
       return null;