diff --git a/CLAUDE.md b/CLAUDE.md index 374373f..e591dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,12 +66,14 @@ 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. @@ -79,6 +81,8 @@ Qute HTML templates in `src/main/resources/templates/`. Mail templates are in `t ### 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` diff --git a/pom.xml b/pom.xml index d5d39c0..aca9494 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,10 @@ io.quarkus quarkus-scheduler + + io.quarkus + quarkus-resteasy-client-jackson + io.quarkiverse.mailpit quarkus-mailpit diff --git a/src/main/java/de/jugda/registration/model/EventDto.java b/src/main/java/de/jugda/registration/model/EventDto.java index de78f27..b0ff245 100644 --- a/src/main/java/de/jugda/registration/model/EventDto.java +++ b/src/main/java/de/jugda/registration/model/EventDto.java @@ -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")); diff --git a/src/main/java/eu/ijug/events/invitation/InviteConfig.java b/src/main/java/eu/ijug/events/invitation/InviteConfig.java new file mode 100644 index 0000000..26ed0e2 --- /dev/null +++ b/src/main/java/eu/ijug/events/invitation/InviteConfig.java @@ -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; +} diff --git a/src/main/java/eu/ijug/events/invitation/InviteEmailService.java b/src/main/java/eu/ijug/events/invitation/InviteEmailService.java new file mode 100644 index 0000000..bd01f60 --- /dev/null +++ b/src/main/java/eu/ijug/events/invitation/InviteEmailService.java @@ -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 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()); + } +} diff --git a/src/main/java/eu/ijug/events/invitation/InviteJob.java b/src/main/java/eu/ijug/events/invitation/InviteJob.java new file mode 100644 index 0000000..e928e64 --- /dev/null +++ b/src/main/java/eu/ijug/events/invitation/InviteJob.java @@ -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 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 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()); + } + } + } +} diff --git a/src/main/java/eu/ijug/events/invitation/InviteMastodonService.java b/src/main/java/eu/ijug/events/invitation/InviteMastodonService.java new file mode 100644 index 0000000..bdc84a3 --- /dev/null +++ b/src/main/java/eu/ijug/events/invitation/InviteMastodonService.java @@ -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 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()); + } + } +} diff --git a/src/main/java/eu/ijug/events/invitation/MastodonClient.java b/src/main/java/eu/ijug/events/invitation/MastodonClient.java new file mode 100644 index 0000000..564d523 --- /dev/null +++ b/src/main/java/eu/ijug/events/invitation/MastodonClient.java @@ -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 + ); +} diff --git a/src/main/resources/db/migration/V6__tenant_invite_config.sql b/src/main/resources/db/migration/V6__tenant_invite_config.sql new file mode 100644 index 0000000..599f0ac --- /dev/null +++ b/src/main/resources/db/migration/V6__tenant_invite_config.sql @@ -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) +); diff --git a/src/main/resources/templates/invites/mail_announcement.html b/src/main/resources/templates/invites/mail_announcement.html new file mode 100644 index 0000000..6a9d271 --- /dev/null +++ b/src/main/resources/templates/invites/mail_announcement.html @@ -0,0 +1,24 @@ +

Wir möchten Euch heute folgende Veranstaltung ankündigen:

+

    {event.summary}

+

+ Wann: {event.startDate} ab {event.startTime} Uhr
+ Wo: {event.location} +

+

+ Die Anmeldung ist jetzt ebenfalls geöffnet! +

+

+ Alle weiteren Infos zum Talk, wie z.B. Abstract, Speaker-Info und Anmeldung, gibt's wie immer auf unserer Website:
+ {event.url} +

+

+ Alle unsere Veranstaltungen bequem per iCal abonnieren: hier klicken +

+

+ Wir freuen uns auf Euer zahlreiches Kommen!

+ Viele Grüße,
+ Euer JUG DA Orga-Team +

+

+ Eine Veranstaltung des iJUG e.V., organisiert durch die JUG Darmstadt. +

diff --git a/src/main/resources/templates/invites/mail_invitation.html b/src/main/resources/templates/invites/mail_invitation.html new file mode 100644 index 0000000..ac6067b --- /dev/null +++ b/src/main/resources/templates/invites/mail_invitation.html @@ -0,0 +1,18 @@ +

Am kommenden {event.startDate} findet ab {event.startTime} Uhr unser nächster Talk statt:

+

    {event.summary}

+

Ort: {event.location}

+

+ Alle weiteren Infos zum Talk, wie z.B. Abstract, Speaker-Info und Anmeldung, gibt's wie immer auf unserer Website:
+ {event.url} +

+

+ Alle unsere Veranstaltungen bequem per iCal abonnieren: hier klicken +

+

+ Wir freuen uns auf Euer zahlreiches Kommen!

+ Viele Grüße,
+ Euer JUG DA Orga-Team +

+

+ Eine Veranstaltung des iJUG e.V., organisiert durch die JUG Darmstadt. +

diff --git a/src/main/resources/templates/invites/toot_invitation.txt b/src/main/resources/templates/invites/toot_invitation.txt new file mode 100644 index 0000000..724f60f --- /dev/null +++ b/src/main/resources/templates/invites/toot_invitation.txt @@ -0,0 +1 @@ +{day}: {event.title}{#if event.speaker} ({event.speaker}){/if} {event.url}