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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,23 @@ Known tenants (seeded in `V3__tenant.sql`): `test`, `jugda`, `cyberland`.
- **`EmailService`** — sends confirmation, waitlist-to-attendee, and bulk emails via Quarkus Mailer + Qute templates.
- **`CleanupJob`** — scheduled job that purges expired registrations (based on `ttl` epoch seconds, set to 1 week after the event).
- **`Content`** — tenant-scoped key/value texts stored in the `content` table; injected into templates via `Content.asMap()` as `helptext`.
- **`InviteJob`** (package `eu.ijug.events.invitation`) — daily scheduled job (10:00) that sends announcement/invitation emails and Mastodon toots for upcoming events. Only runs for tenants with a row in the `invite_config` table (`InviteConfig` entity). Uses `EventService` by setting `TenantContext` per tenant iteration. Does **not** use Hibernate `@TenantId` so it can iterate all tenants without a discriminator filter.

### Data Model
- `Registration` — one row per participant per event; `ttl` auto-expires ~1 week post-event.
- `Tenant` — configuration per JUG: name, website, privacy/imprint URLs, logo, reply-to address, events JSON URL.
- `EventData` — mutable per-event key/value metadata, editable by admins.
- `Content` — tenant-scoped UI help texts (name field hint, email hint, video recording notice, etc.).
- `InviteConfig` — opt-in config per tenant for the invite job: `mailTo` (newsletter recipient), `mastoUrl`, `mastoToken`. No `@TenantId` — not discriminator-filtered. Seeded manually; absence of a row disables the job for that tenant.

### Templates
Qute HTML templates in `src/main/resources/templates/`. Mail templates are in `templates/mail/`. All templates share `template.html` as the base layout via Qute includes.

### Database Migrations
Flyway migrations in `src/main/resources/db/migration/`. Run automatically at startup. Schema is managed solely via Flyway (`quarkus.hibernate-orm.schema-management.strategy=none`).

Note: there is no `V5` in the main migrations — `V5__test_tenant_events_url.sql` exists only in `src/test/resources` and is test-only. Production runs V1–V4, then V6. Flyway handles version gaps without error.

### Authentication (OIDC / Keycloak)
- Production Keycloak: `https://id.ijug.eu/realms/ijug`
- Client ID: `registration`
Expand Down
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-scheduler</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-client-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.mailpit</groupId>
<artifactId>quarkus-mailpit</artifactId>
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/de/jugda/registration/model/EventDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public class EventDto {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
public LocalDateTime end;
public String timezone;
public boolean externalEvent;
public boolean hideRegistration;

public String startDate() {
return start.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/eu/ijug/events/invitation/InviteConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package eu.ijug.events.invitation;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "invite_config")
@Getter
@Setter
public class InviteConfig extends PanacheEntityBase {

@Id
@Column(name = "tenant_id")
private String tenantId;

@Column(name = "mail_to")
private String mailTo;

@Column(name = "masto_url")
private String mastoUrl;

@Column(name = "masto_token")
private String mastoToken;
}
53 changes: 53 additions & 0 deletions src/main/java/eu/ijug/events/invitation/InviteEmailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package eu.ijug.events.invitation;

import de.jugda.registration.model.EventDto;
import io.quarkus.logging.Log;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import java.util.List;

@ApplicationScoped
class InviteEmailService {

private static final List<Integer> MAIL_DAYS = List.of(2, 7, 28);

@Inject
Mailer mailer;

@Location("invites/mail_announcement")
Template tplAnnouncement;

@Location("invites/mail_invitation")
Template tplInvitation;

void sendIfDue(InviteConfig config, EventDto event, int daysUntilEvent) {
if (event.isExternalEvent()) {
return;
}
if (!MAIL_DAYS.contains(daysUntilEvent)) {
return;
}
if (daysUntilEvent == 28 && event.isHideRegistration()) {
return;
}

Log.infof("Preparing invite mail for event %s in %d days (tenant: %s)", event.uid, daysUntilEvent, config.getTenantId());

boolean isAnnouncement = daysUntilEvent == 28;
String subject = (isAnnouncement ? "Ankündigung für " : "") + event.startDate() + ": " + event.summary;

Template tpl = isAnnouncement ? tplAnnouncement : tplInvitation;
String htmlBody = tpl
.data("event", event)
.render();

Mail mail = Mail.withHtml(config.getMailTo(), subject, htmlBody);
mailer.send(mail);
Log.infof("Invite mail sent for event %s to %s", event.uid, config.getMailTo());
}
}
65 changes: 65 additions & 0 deletions src/main/java/eu/ijug/events/invitation/InviteJob.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package eu.ijug.events.invitation;

import de.jugda.registration.TenantContext;
import de.jugda.registration.model.EventDto;
import de.jugda.registration.service.EventService;
import io.quarkus.logging.Log;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

@ApplicationScoped
public class InviteJob {

@Inject
TenantContext tenantCtx;

@Inject
EventService eventService;

@Inject
InviteEmailService inviteEmailService;

@Inject
InviteMastodonService inviteMastodonService;

@Scheduled(cron = "0 0 10 * * ?", identity = "invite-job")
@Transactional
@ActivateRequestContext
void run() {
List<InviteConfig> configs = InviteConfig.listAll();
if (configs.isEmpty()) {
Log.info("No tenant invite configs found, skipping invite job");
return;
}

LocalDate today = LocalDate.now();

for (InviteConfig config : configs) {
tenantCtx.setTenantId(config.getTenantId());
tenantCtx.setTenant(null);

Log.infof("Processing invite job for tenant: %s", config.getTenantId());
try {
List<EventDto> events = eventService.getAllEvents();
for (EventDto event : events) {
if (event.start == null) {
continue;
}
int daysUntilEvent = (int) ChronoUnit.DAYS.between(today, event.start.toLocalDate());
Log.debugf("Event %s: %d days until event", event.uid, daysUntilEvent);
inviteEmailService.sendIfDue(config, event, daysUntilEvent);
inviteMastodonService.tootIfDue(config, event, daysUntilEvent);
}
} catch (Exception e) {
Log.errorf(e, "Error processing invite job for tenant %s", config.getTenantId());
}
}
}
}
45 changes: 45 additions & 0 deletions src/main/java/eu/ijug/events/invitation/InviteMastodonService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package eu.ijug.events.invitation;

import de.jugda.registration.model.EventDto;
import io.quarkus.logging.Log;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.rest.client.RestClientBuilder;

import java.net.URI;
import java.util.List;

@ApplicationScoped
class InviteMastodonService {

private static final List<Integer> TOOT_DAYS = List.of(0, 2, 7, 28);

@Location("invites/toot_invitation.txt")
Template tplToot;

void tootIfDue(InviteConfig config, EventDto event, int daysUntilEvent) {
if (!TOOT_DAYS.contains(daysUntilEvent)) {
return;
}
if (config.getMastoUrl() == null || config.getMastoToken() == null) {
return;
}

Log.infof("Preparing toot for event %s in %d days (tenant: %s)", event.uid, daysUntilEvent, config.getTenantId());

String day = daysUntilEvent == 0 ? "!!!HEUTE!!! " : event.startDate();
String statusText = tplToot
.data("event", event)
.data("day", day)
.render();

MastodonClient client = RestClientBuilder.newBuilder()
.baseUri(URI.create(config.getMastoUrl()))
.build(MastodonClient.class);

try (var response = client.createStatus("Bearer " + config.getMastoToken(), statusText.trim(), "public")) {
Log.infof("Tooted for event %s (status: %d)", event.uid, response.getStatus());
}
}
}
23 changes: 23 additions & 0 deletions src/main/java/eu/ijug/events/invitation/MastodonClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package eu.ijug.events.invitation;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

@RegisterRestClient(configKey = "mastodon")
@Path("/api/v1/statuses")
public interface MastodonClient {

@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
Response createStatus(
@HeaderParam("Authorization") String authorization,
@FormParam("status") String status,
@FormParam("visibility") String visibility
);
}
8 changes: 8 additions & 0 deletions src/main/resources/db/migration/V6__tenant_invite_config.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE invite_config
(
tenant_id VARCHAR(255) NOT NULL,
mail_to VARCHAR(255),
masto_url VARCHAR(255),
masto_token VARCHAR(255),
CONSTRAINT pk_invite_config PRIMARY KEY (tenant_id)
);
24 changes: 24 additions & 0 deletions src/main/resources/templates/invites/mail_announcement.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<p>Wir möchten Euch heute folgende Veranstaltung ankündigen:</p>
<h3>&nbsp;&nbsp;&nbsp;&nbsp;{event.summary}</h3>
<p>
Wann: {event.startDate} ab {event.startTime} Uhr<br/>
Wo: {event.location}
</p>
<p>
<i style="color: red">Die Anmeldung ist jetzt ebenfalls geöffnet!</i>
</p>
<p>
Alle weiteren Infos zum Talk, wie z.B. Abstract, Speaker-Info und Anmeldung, gibt's wie immer auf unserer Website:<br/>
<a href="{event.url}">{event.url}</a>
</p>
<p>
Alle unsere Veranstaltungen bequem per iCal abonnieren: <a href="https://www.jug-da.de/events.ics">hier klicken</a>
</p>
<p>
Wir freuen uns auf Euer zahlreiches Kommen!<br/><br/>
Viele Grüße,<br/>
Euer JUG DA Orga-Team
</p>
<p>
<small><i>Eine Veranstaltung des iJUG e.V., organisiert durch die JUG Darmstadt.</i></small>
</p>
18 changes: 18 additions & 0 deletions src/main/resources/templates/invites/mail_invitation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<p>Am kommenden <b>{event.startDate}</b> findet ab <b>{event.startTime} Uhr</b> unser nächster Talk statt:</p>
<h3>&nbsp;&nbsp;&nbsp;&nbsp;{event.summary}</h3>
<p>Ort: {event.location}</p>
<p>
Alle weiteren Infos zum Talk, wie z.B. Abstract, Speaker-Info und Anmeldung, gibt's wie immer auf unserer Website:<br/>
<a href="{event.url}">{event.url}</a>
</p>
<p>
Alle unsere Veranstaltungen bequem per iCal abonnieren: <a href="https://www.jug-da.de/events.ics">hier klicken</a>
</p>
<p>
Wir freuen uns auf Euer zahlreiches Kommen!<br/><br/>
Viele Grüße,<br/>
Euer JUG DA Orga-Team
</p>
<p>
<small><i>Eine Veranstaltung des iJUG e.V., organisiert durch die JUG Darmstadt.</i></small>
</p>
1 change: 1 addition & 0 deletions src/main/resources/templates/invites/toot_invitation.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{day}: {event.title}{#if event.speaker} ({event.speaker}){/if} {event.url}