blob: 346b71f4e37ea108e5a91bb281ccf8068e1af92b [file] [log] [blame]
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +02001package eu.mulk.mulkcms2.benki.posts;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +01002
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +02003import static java.util.stream.Collectors.toList;
4
Matthias Andreas Benkard2d4f92e2020-02-09 16:15:07 +01005import eu.mulk.mulkcms2.benki.accesscontrol.Role;
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +01006import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
7import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
Matthias Andreas Benkardba3e58c2020-11-01 12:58:35 +01008import eu.mulk.mulkcms2.benki.newsletter.Newsletter;
Matthias Andreas Benkardd9b95882020-01-24 11:42:49 +01009import eu.mulk.mulkcms2.benki.users.User;
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +010010import eu.mulk.mulkcms2.benki.users.User_;
Matthias Andreas Benkard35cb1592020-01-24 11:05:20 +010011import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
Matthias Andreas Benkard1c2a8a72020-04-26 06:09:57 +020012import java.time.LocalDate;
Matthias Andreas Benkardd9b95882020-01-24 11:42:49 +010013import java.time.OffsetDateTime;
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +010014import java.util.ArrayList;
Matthias Andreas Benkardba3e58c2020-11-01 12:58:35 +010015import java.util.Collection;
Matthias Andreas Benkard1c2a8a72020-04-26 06:09:57 +020016import java.util.Comparator;
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +020017import java.util.HashMap;
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +010018import java.util.List;
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +020019import java.util.Map;
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +010020import java.util.Objects;
Matthias Andreas Benkardf9c74272020-01-24 11:51:35 +010021import java.util.Set;
Matthias Andreas Benkard1c2a8a72020-04-26 06:09:57 +020022import java.util.TimeZone;
23import java.util.stream.Collectors;
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +020024import java.util.stream.Stream;
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +010025import javax.annotation.CheckForNull;
Matthias Andreas Benkard6cfe16b2020-04-18 15:36:04 +020026import javax.annotation.Nullable;
Matthias Andreas Benkard06e6c812020-04-13 17:01:35 +020027import javax.json.bind.annotation.JsonbTransient;
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +020028import javax.persistence.CascadeType;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010029import javax.persistence.Column;
30import javax.persistence.Entity;
Matthias Andreas Benkardf9c74272020-01-24 11:51:35 +010031import javax.persistence.FetchType;
Matthias Andreas Benkard0246c3e2020-01-27 05:39:08 +010032import javax.persistence.GeneratedValue;
33import javax.persistence.GenerationType;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010034import javax.persistence.Id;
Matthias Andreas Benkardd9b95882020-01-24 11:42:49 +010035import javax.persistence.Inheritance;
36import javax.persistence.InheritanceType;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010037import javax.persistence.JoinColumn;
Matthias Andreas Benkardf9c74272020-01-24 11:51:35 +010038import javax.persistence.JoinTable;
39import javax.persistence.ManyToMany;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010040import javax.persistence.ManyToOne;
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +020041import javax.persistence.MapKey;
42import javax.persistence.OneToMany;
Matthias Andreas Benkard0246c3e2020-01-27 05:39:08 +010043import javax.persistence.SequenceGenerator;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010044import javax.persistence.Table;
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +010045import javax.persistence.criteria.CriteriaBuilder;
46import javax.persistence.criteria.CriteriaQuery;
47import javax.persistence.criteria.From;
48import javax.persistence.criteria.JoinType;
49import javax.persistence.criteria.Predicate;
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +010050import org.hibernate.Session;
51import org.jboss.logging.Logger;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010052
53@Entity
Matthias Andreas Benkard57c9a8a2020-01-24 19:09:38 +010054@Table(name = "posts", schema = "benki")
Matthias Andreas Benkardd9b95882020-01-24 11:42:49 +010055@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +020056public abstract class Post<Text extends PostText<?>> extends PanacheEntityBase {
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010057
Matthias Andreas Benkard593765d2020-04-18 20:44:07 +020058 private static final Logger log = Logger.getLogger(Post.class);
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +010059
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010060 @Id
Matthias Andreas Benkard0246c3e2020-01-27 05:39:08 +010061 @SequenceGenerator(
62 allocationSize = 1,
63 sequenceName = "posts_id_seq",
64 name = "posts_id_seq",
65 schema = "benki")
66 @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "posts_id_seq")
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010067 @Column(name = "id", nullable = false)
Matthias Andreas Benkard0246c3e2020-01-27 05:39:08 +010068 public Integer id;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010069
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010070 @Column(name = "date", nullable = true)
Matthias Andreas Benkard1e7674c2020-04-18 20:28:51 +020071 @CheckForNull
Matthias Andreas Benkardd9b95882020-01-24 11:42:49 +010072 public OffsetDateTime date;
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010073
Matthias Andreas Benkardaa754802020-01-24 11:55:26 +010074 @ManyToOne(fetch = FetchType.LAZY)
Matthias Andreas Benkardba3e58c2020-11-01 12:58:35 +010075 @JoinColumn(name = "newsletter", referencedColumnName = "id", nullable = true)
76 @CheckForNull
77 @JsonbTransient
78 public Newsletter newsletter;
79
80 @ManyToOne(fetch = FetchType.LAZY)
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +010081 @JoinColumn(name = "owner", referencedColumnName = "id")
Matthias Andreas Benkardcf0fe882020-04-19 18:33:37 +020082 @CheckForNull
Matthias Andreas Benkard06e6c812020-04-13 17:01:35 +020083 @JsonbTransient
Matthias Andreas Benkard35cb1592020-01-24 11:05:20 +010084 public User owner;
Matthias Andreas Benkardf9c74272020-01-24 11:51:35 +010085
86 @ManyToMany(fetch = FetchType.LAZY)
87 @JoinTable(
88 name = "user_visible_posts",
Matthias Andreas Benkard553de3e2020-01-27 05:33:15 +010089 schema = "benki",
Matthias Andreas Benkardf9c74272020-01-24 11:51:35 +010090 joinColumns = @JoinColumn(name = "message"),
91 inverseJoinColumns = @JoinColumn(name = "user"))
Matthias Andreas Benkard06e6c812020-04-13 17:01:35 +020092 @JsonbTransient
Matthias Andreas Benkardf9c74272020-01-24 11:51:35 +010093 public Set<User> visibleTo;
Matthias Andreas Benkard2d4f92e2020-02-09 16:15:07 +010094
95 @ManyToMany(fetch = FetchType.LAZY)
96 @JoinTable(
97 name = "post_targets",
98 schema = "benki",
99 joinColumns = @JoinColumn(name = "message"),
100 inverseJoinColumns = @JoinColumn(name = "target"))
Matthias Andreas Benkard06e6c812020-04-13 17:01:35 +0200101 @JsonbTransient
Matthias Andreas Benkard2d4f92e2020-02-09 16:15:07 +0100102 public Set<Role> targets;
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100103
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200104 @OneToMany(
105 mappedBy = "post",
106 fetch = FetchType.LAZY,
107 cascade = CascadeType.ALL,
108 targetEntity = PostText.class)
109 @MapKey(name = "language")
110 public Map<String, Text> texts = new HashMap<>();
111
112 public Map<String, Text> getTexts() {
113 return texts;
114 }
115
Matthias Andreas Benkard371164a2020-03-23 06:21:25 +0100116 public abstract boolean isBookmark();
117
118 public abstract boolean isLazychatMessage();
119
Matthias Andreas Benkardd5ae0d52020-03-29 18:57:22 +0200120 @CheckForNull
121 public abstract String getTitle();
122
123 @CheckForNull
Matthias Andreas Benkardd5ae0d52020-03-29 18:57:22 +0200124 public abstract String getUri();
125
Matthias Andreas Benkard06e6c812020-04-13 17:01:35 +0200126 public Visibility getVisibility() {
127 if (targets.isEmpty()) {
128 return Visibility.PRIVATE;
129 } else if (targets.contains(Role.getWorld())) {
130 return Visibility.PUBLIC;
131 } else {
132 // FIXME: There should really be a check whether targets.equals(owner.defaultTargets) here.
133 // Otherwise the actual visibility is DISCRETIONARY.
134 return Visibility.SEMIPRIVATE;
135 }
136 }
137
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100138 protected static <T extends Post> CriteriaQuery<T> queryViewable(
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100139 Class<T> entityClass,
Matthias Andreas Benkardcf0fe882020-04-19 18:33:37 +0200140 @CheckForNull User reader,
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100141 @CheckForNull User owner,
142 @CheckForNull Integer cursor,
143 CriteriaBuilder cb,
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200144 boolean forward,
145 @CheckForNull String searchQuery) {
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100146 CriteriaQuery<T> query = cb.createQuery(entityClass);
147
148 var conditions = new ArrayList<Predicate>();
149
150 From<?, T> post;
Matthias Andreas Benkardcf0fe882020-04-19 18:33:37 +0200151 if (reader == null) {
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100152 post = query.from(entityClass);
153 var target = post.join(Post_.targets);
154 conditions.add(cb.equal(target, Role.getWorld()));
155 } else {
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100156 var root = query.from(User.class);
Matthias Andreas Benkardcf0fe882020-04-19 18:33:37 +0200157 conditions.add(cb.equal(root, reader));
Matthias Andreas Benkardca4d7942020-04-18 14:13:41 +0200158 if (entityClass.isAssignableFrom(Post.class)) {
159 post = (From<?, T>) root.join(User_.visiblePosts);
160 } else if (entityClass.isAssignableFrom(Bookmark.class)) {
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100161 post = (From<?, T>) root.join(User_.visibleBookmarks);
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +0200162 } else if (entityClass.isAssignableFrom(LazychatMessage.class)) {
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100163 post = (From<?, T>) root.join(User_.visibleLazychatMessages);
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +0200164 } else {
Matthias Andreas Benkardca4d7942020-04-18 14:13:41 +0200165 throw new IllegalArgumentException();
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100166 }
167 }
168
169 query.select(post);
170 post.fetch(Post_.owner, JoinType.LEFT);
171
172 if (owner != null) {
173 conditions.add(cb.equal(post.get(Post_.owner), owner));
174 }
175
176 if (forward) {
177 query.orderBy(cb.desc(post.get(Post_.id)));
178 } else {
179 query.orderBy(cb.asc(post.get(Post_.id)));
180 }
181
182 if (cursor != null) {
183 if (forward) {
184 conditions.add(cb.le(post.get(Post_.id), cursor));
185 } else {
186 conditions.add(cb.gt(post.get(Post_.id), cursor));
187 }
188 }
189
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200190 if (searchQuery != null && !searchQuery.isBlank()) {
191 var postTexts = post.join(Post_.texts);
192 var localizedSearches =
193 Stream.of("de", "en")
194 .map(
195 language ->
196 cb.isTrue(
197 cb.function(
198 "post_matches_websearch",
199 Boolean.class,
200 postTexts.get(PostText_.searchTerms),
201 cb.literal(language),
202 cb.literal(searchQuery))))
203 .toArray(n -> new Predicate[n]);
204 conditions.add(cb.or(localizedSearches));
205 }
206
Matthias Andreas Benkardf5999552020-03-22 06:52:06 +0100207 query.where(conditions.toArray(new Predicate[0]));
208
209 return query;
210 }
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100211
Matthias Andreas Benkard6cfe16b2020-04-18 15:36:04 +0200212 public final boolean isVisibleTo(@Nullable User user) {
213 // FIXME: Make this more efficient.
214 return getVisibility() == Visibility.PUBLIC || (user != null && visibleTo.contains(user));
215 }
216
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200217 @CheckForNull
218 public final String getDescriptionHtml() {
219 var text = getText();
220 if (text == null) {
221 return null;
222 }
223 return text.getDescriptionHtml();
224 }
225
226 public static class PostPage<T extends Post<? extends PostText>> {
Matthias Andreas Benkard593765d2020-04-18 20:44:07 +0200227 public @CheckForNull final Integer prevCursor;
228 public @CheckForNull final Integer cursor;
229 public @CheckForNull final Integer nextCursor;
230 public final List<T> posts;
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100231
Matthias Andreas Benkard1c2a8a72020-04-26 06:09:57 +0200232 private static final TimeZone timeZone = TimeZone.getDefault();
233
234 public PostPage(
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100235 @CheckForNull Integer c0,
236 @CheckForNull Integer c1,
237 @CheckForNull Integer c2,
238 List<T> resultList) {
239 this.prevCursor = c0;
240 this.cursor = c1;
241 this.nextCursor = c2;
242 this.posts = resultList;
243 }
Matthias Andreas Benkard1c2a8a72020-04-26 06:09:57 +0200244
Matthias Andreas Benkard60c08922020-06-13 19:22:25 +0200245 public void cacheDescriptions() {
246 days().forEach(Day::cacheDescriptions);
247 }
248
Matthias Andreas Benkard1c2a8a72020-04-26 06:09:57 +0200249 public class Day {
250 public final @CheckForNull LocalDate date;
251 public final List<T> posts;
252
253 private Day(LocalDate date, List<T> posts) {
254 this.date = date;
255 this.posts = posts;
256 }
Matthias Andreas Benkard60c08922020-06-13 19:22:25 +0200257
258 public void cacheDescriptions() {
259 for (var post : posts) {
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200260 post.getTexts().values().forEach(PostText::getDescriptionHtml);
Matthias Andreas Benkard60c08922020-06-13 19:22:25 +0200261 }
262 }
Matthias Andreas Benkard1c2a8a72020-04-26 06:09:57 +0200263 }
264
265 public List<Day> days() {
266 return posts.stream()
267 .collect(Collectors.groupingBy(post -> post.date.toLocalDate()))
268 .entrySet()
269 .stream()
270 .map(x -> new Day(x.getKey(), x.getValue()))
271 .sorted(Comparator.comparing((Day day) -> day.date).reversed())
272 .collect(Collectors.toUnmodifiableList());
273 }
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100274 }
275
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200276 public static PostPage<Post<? extends PostText>> findViewable(
Matthias Andreas Benkardcf0fe882020-04-19 18:33:37 +0200277 PostFilter postFilter, Session session, @CheckForNull User viewer, @CheckForNull User owner) {
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200278 return findViewable(postFilter, session, viewer, owner, null, null, null);
Matthias Andreas Benkardd5ae0d52020-03-29 18:57:22 +0200279 }
280
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200281 public static PostPage<Post<? extends PostText>> findViewable(
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +0200282 PostFilter postFilter,
283 Session session,
Matthias Andreas Benkardcf0fe882020-04-19 18:33:37 +0200284 @CheckForNull User viewer,
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +0200285 @CheckForNull User owner,
286 @CheckForNull Integer cursor,
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200287 @CheckForNull Integer count,
288 @CheckForNull String searchQuery) {
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +0200289 Class<? extends Post> entityClass;
290 switch (postFilter) {
291 case BOOKMARKS_ONLY:
292 entityClass = Bookmark.class;
293 break;
294 case LAZYCHAT_MESSAGES_ONLY:
295 entityClass = LazychatMessage.class;
296 break;
297 default:
298 entityClass = Post.class;
299 }
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200300 return findViewable(entityClass, session, viewer, owner, cursor, count, searchQuery);
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100301 }
302
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200303 protected static <T extends Post<? extends PostText>> PostPage<T> findViewable(
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +0200304 Class<? extends T> entityClass,
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100305 Session session,
Matthias Andreas Benkardcf0fe882020-04-19 18:33:37 +0200306 @CheckForNull User viewer,
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100307 @CheckForNull User owner,
308 @CheckForNull Integer cursor,
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200309 @CheckForNull Integer count,
310 @CheckForNull String searchQuery) {
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100311
312 if (cursor != null) {
313 Objects.requireNonNull(count);
314 }
315
316 var cb = session.getCriteriaBuilder();
317
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200318 var forwardCriteria = queryViewable(entityClass, viewer, owner, cursor, cb, true, searchQuery);
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100319 var forwardQuery = session.createQuery(forwardCriteria);
320
321 if (count != null) {
322 forwardQuery.setMaxResults(count + 1);
323 }
324
325 log.debug(forwardQuery.unwrap(org.hibernate.query.Query.class).getQueryString());
326
327 @CheckForNull Integer prevCursor = null;
328 @CheckForNull Integer nextCursor = null;
329
330 if (cursor != null) {
331 // Look backwards as well so we can find the prevCursor.
Matthias Andreas Benkard8563a3c2020-09-16 17:57:24 +0200332 var backwardCriteria =
333 queryViewable(entityClass, viewer, owner, cursor, cb, false, searchQuery);
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100334 var backwardQuery = session.createQuery(backwardCriteria);
335 backwardQuery.setMaxResults(count);
336 var backwardResults = backwardQuery.getResultList();
337 if (!backwardResults.isEmpty()) {
338 prevCursor = backwardResults.get(backwardResults.size() - 1).id;
339 }
340 }
341
Matthias Andreas Benkard4940b292020-03-29 18:41:07 +0200342 var forwardResults = (List<T>) forwardQuery.getResultList();
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100343 if (count != null) {
344 if (forwardResults.size() == count + 1) {
345 nextCursor = forwardResults.get(count).id;
346 forwardResults.remove((int) count);
347 }
348 }
349
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200350 // Fetch texts (to avoid n+1 selects).
Matthias Andreas Benkardba3e58c2020-11-01 12:58:35 +0100351 fetchTexts(forwardResults);
352
353 return new PostPage<>(prevCursor, cursor, nextCursor, forwardResults);
354 }
355
356 public static <T extends Post<?>> void fetchTexts(Collection<T> posts) {
357 var postIds = posts.stream().map(x -> x.id).collect(toList());
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200358
359 if (!postIds.isEmpty()) {
360 find("SELECT p FROM Post p LEFT JOIN FETCH p.texts WHERE p.id IN (?1)", postIds).stream()
361 .count();
362 }
Matthias Andreas Benkard3d399f32020-03-22 07:23:07 +0100363 }
Matthias Andreas Benkard06e6c812020-04-13 17:01:35 +0200364
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200365 @CheckForNull
Matthias Andreas Benkardba3e58c2020-11-01 12:58:35 +0100366 public Text getText() {
Matthias Andreas Benkardd5498fc2020-08-23 21:51:00 +0200367 var texts = getTexts();
368 if (texts.isEmpty()) {
369 return null;
370 } else if (texts.containsKey("")) {
371 return texts.get("");
372 } else if (texts.containsKey("en")) {
373 return texts.get("en");
374 } else {
375 return texts.values().stream().findAny().get();
376 }
377 }
378
Matthias Andreas Benkard06e6c812020-04-13 17:01:35 +0200379 public enum Visibility {
380 PUBLIC,
381 SEMIPRIVATE,
382 DISCRETIONARY,
383 PRIVATE,
384 }
385
386 @Override
387 public boolean equals(Object o) {
388 if (this == o) {
389 return true;
390 }
391 if (!(o instanceof Post)) {
392 return false;
393 }
394 Post post = (Post) o;
395 return Objects.equals(id, post.id);
396 }
397
398 @Override
399 public int hashCode() {
400 return Objects.hash(id);
401 }
Matthias Andreas Benkard734879e2020-01-24 10:47:37 +0100402}