From f1c24d8e038e4dc40f1e2366016c439260bcbbc4 Mon Sep 17 00:00:00 2001 From: Oliver Tod Date: Sat, 11 Jul 2026 21:26:25 +0200 Subject: [PATCH 1/2] [Gepardec/mega#820] feat: add employee recognition mailbox to make good deeds visible --- .../.openspec.yaml | 2 + .../design.md | 59 ++++++ .../proposal.md | 30 +++ .../specs/recognition-entry/spec.md | 49 +++++ .../specs/recognition-rest-api/spec.md | 37 ++++ .../specs/recognition-weekly-digest/spec.md | 66 +++++++ .../tasks.md | 49 +++++ openspec/specs/recognition-entry/spec.md | 66 +++++++ openspec/specs/recognition-rest-api/spec.md | 52 +++++ .../specs/recognition-weekly-digest/spec.md | 72 +++++++ .../inbound/RecognitionDigestScheduler.java | 25 +++ .../RecognitionDomainExceptionMapper.java | 20 ++ .../inbound/rest/RecognitionResource.java | 43 +++++ .../inbound/rest/RecognitionRestMapper.java | 14 ++ .../outbound/ProjectLeadDirectoryAdapter.java | 37 ++++ .../outbound/ProjectLeadDirectoryMapper.java | 14 ++ .../QuarkusRecognitionMailAdapter.java | 110 +++++++++++ .../outbound/RecognitionEntryEntity.java | 89 +++++++++ .../outbound/RecognitionEntryMapper.java | 32 +++ .../RecognitionEntryPanacheRepository.java | 8 + .../RecognitionEntryRepositoryAdapter.java | 40 ++++ .../application/RecognitionDigestService.java | 61 ++++++ .../SubmitRecognitionEntryService.java | 44 +++++ .../inbound/SendRecognitionDigestUseCase.java | 6 + .../SubmitRecognitionEntryCommand.java | 10 + .../SubmitRecognitionEntryUseCase.java | 8 + .../outbound/ProjectLeadDirectoryPort.java | 11 ++ .../port/outbound/RecognitionMailPort.java | 11 ++ .../domain/error/RecognitionException.java | 8 + .../error/RecognitionValidationException.java | 8 + .../domain/model/RecognitionCategory.java | 6 + .../domain/model/RecognitionEntry.java | 58 ++++++ .../domain/model/RecognitionEntryId.java | 14 ++ .../domain/model/RecognitionEntryStatus.java | 6 + .../model/RecognitionMailRecipient.java | 15 ++ .../outbound/RecognitionEntryRepository.java | 13 ++ src/main/resources/db/changelog-master.xml | 1 + .../hexagon/015-recognition-entry.yaml | 45 +++++ .../resources/emails/recognition-digest.html | 4 + src/main/resources/messages.properties | 1 + src/main/resources/messages_en.properties | 1 + src/main/resources/openapi/openapi.yaml | 6 + .../resources/openapi/paths/recognition.yaml | 23 +++ .../openapi/schemas/recognition.yaml | 19 ++ .../inbound/rest/RecognitionResourceTest.java | 182 ++++++++++++++++++ .../ProjectLeadDirectoryAdapterTest.java | 73 +++++++ .../QuarkusRecognitionMailAdapterTest.java | 74 +++++++ .../outbound/RecognitionEntryMapperTest.java | 51 +++++ .../RecognitionDigestServiceTest.java | 124 ++++++++++++ .../SubmitRecognitionEntryServiceTest.java | 87 +++++++++ src/test/resources/messages.properties | 1 + src/test/resources/messages_en.properties | 1 + 52 files changed, 1886 insertions(+) create mode 100644 openspec/changes/archive/2026-07-13-add-recognition-briefkasten/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-13-add-recognition-briefkasten/design.md create mode 100644 openspec/changes/archive/2026-07-13-add-recognition-briefkasten/proposal.md create mode 100644 openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-entry/spec.md create mode 100644 openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-rest-api/spec.md create mode 100644 openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-weekly-digest/spec.md create mode 100644 openspec/changes/archive/2026-07-13-add-recognition-briefkasten/tasks.md create mode 100644 openspec/specs/recognition-entry/spec.md create mode 100644 openspec/specs/recognition-rest-api/spec.md create mode 100644 openspec/specs/recognition-weekly-digest/spec.md create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/RecognitionDigestScheduler.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionDomainExceptionMapper.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResource.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionRestMapper.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapter.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryMapper.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryEntity.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapper.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryPanacheRepository.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryRepositoryAdapter.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryService.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SendRecognitionDigestUseCase.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryCommand.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryUseCase.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/ProjectLeadDirectoryPort.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionException.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionValidationException.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionCategory.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntry.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryId.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryStatus.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionMailRecipient.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/domain/port/outbound/RecognitionEntryRepository.java create mode 100644 src/main/resources/db/changelog/hexagon/015-recognition-entry.yaml create mode 100644 src/main/resources/emails/recognition-digest.html create mode 100644 src/main/resources/openapi/paths/recognition.yaml create mode 100644 src/main/resources/openapi/schemas/recognition.yaml create mode 100644 src/test/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResourceTest.java create mode 100644 src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapterTest.java create mode 100644 src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java create mode 100644 src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapperTest.java create mode 100644 src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java create mode 100644 src/test/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryServiceTest.java diff --git a/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/.openspec.yaml b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/.openspec.yaml new file mode 100644 index 000000000..68b717479 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-11 diff --git a/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/design.md b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/design.md new file mode 100644 index 000000000..c9f451b65 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/design.md @@ -0,0 +1,59 @@ +## Context + +The hexagon codebase already hosts several bounded contexts (`user`, `project`, `worktime`, `monthend`, `notification`, `shared`). This change adds a new `recognition` context for a company-wide "Briefkasten": employees submit short notes about praiseworthy or brave deeds of colleagues, and the leadership circle (Tipi-JFX Teilnehmerkreis) receives a weekly digest to take entries over into the JFX. + +Two existing patterns are directly relevant and are followed here: + +- **Cross-BC user access** — `project`'s `UserIdentityLookupPort` / `UserIdentityLookupAdapter`: the consuming BC declares its own outbound port, and an adapter in that BC reaches into the `user` BC's persistence. The recognition domain never depends on the `user` aggregate. +- **Scheduled mail** — `notification`'s `ReminderEmailScheduler` (`@Scheduled` cron) → `SendScheduledRemindersService` (use case) → `QuarkusMailNotificationAdapter` (Quarkus `Mailer`, HTML templates under `emails/`, inline logo, `mega.mail.subject-prefix`, ResourceBundle subjects). + +## Goals / Non-Goals + +**Goals:** +- Let any authenticated `EMPLOYEE` submit a free-text recognition entry tagged as praise (Lob/Wertschätzung) or courage (Mut). +- Send a weekly digest of not-yet-included entries to every internal project lead active on the run date. +- Keep the recognition domain and application layers isolated from the `user` aggregate. +- Reuse existing mail infrastructure without modifying the `notification` BC. + +**Non-Goals:** +- No editing, deletion, archiving, or moderation of entries. +- No UI work (backend only; a REST submit endpoint is provided). +- No changes to the `user`, `notification`, or `shared` spec-level behavior. +- No structured reference to the praised colleague or the submitter — the entry is free text. + +## Decisions + +### 1. Separate `recognition` bounded context (not part of `user`) +Recognition has its own ubiquitous language (entry, Briefkasten, digest, JFX, praise/courage) and its own reason to change (the JFX culture process), distinct from the `user` BC's identity/employment/role/sync concerns. "Every user is an employee" is data gravity, not domain cohesion — by that logic `worktime`, `monthend`, and `notification` would all collapse into `user`, which the codebase deliberately avoids. Recognition is a downstream **customer** of `user` (supplier). *Alternative considered:* placing the feature in `user` — rejected because it would entangle an unrelated aggregate, table, endpoint, and cron into the identity context. + +### 2. `RecognitionEntry` aggregate with a lifecycle flag +Fields: identity, free-text `message`, `RecognitionCategory` (`APPRECIATION` | `COURAGE`), submission timestamp, and `status` (`NEW` → `INCLUDED_IN_DIGEST`). The status flag (rather than a "last digest sent" watermark) makes the weekly run idempotent and missed-run-safe: a skipped week simply leaves more `NEW` entries for the next run. Entries are immutable after creation and are never deleted. *Alternative considered:* time-window watermark — rejected for weaker guarantees on missed/partial runs. + +### 3. Recipient resolution via a recognition-owned outbound port (clean isolation) +Recognition declares `ProjectLeadDirectoryPort` (outbound) returning the mail recipients that are internal project leads active on a given `LocalDate`. The adapter lives in `recognition/adapter/outbound`, reuses the `user` BC's `UserRepository.findByRole(PROJECT_LEAD)`, and filters `isActiveOn(date) && !isExternal()`, mapping each match to a recognition mail-recipient value. This mirrors `project`'s `UserIdentityLookupPort` and keeps recognition's domain/application layers free of `User`. *Alternative considered:* injecting `UserRepository` directly into the digest application service (as `notification` does) — rejected in favor of the stricter, more isolated port pattern. + +### 4. Recipient type is a mail-recipient value, not a user projection +The digest needs an email address and a salutation name. The shared-kernel `UserRef` (`{id, fullName, zepUsername}`) carries no email, and the shared-kernel rule forbids non-`user` modules from declaring their own **identity** projection. The recognition recipient carries only mail-oriented data (email + first name) — a contact concern, not user identity — so it does not conflict with `shared-user-project-refs`, and no `UserRef` is threaded through the recognition domain. + +### 5. Reuse mail infrastructure behind a recognition-owned mail port +`notification`'s `NotificationMailPort` is typed to a **sealed** `MailNotificationId permits ReminderType, ClarificationNotificationType`, with `instanceof` dispatch that throws for unknown types. Routing recognition mail through it would force recognition's mail concept into the `notification` domain (wrong-way coupling) and doesn't fit the digest's list-of-entries shape. Instead, recognition declares its own `RecognitionMailPort` (its own language: send a digest to a recipient) and its adapter reuses the underlying Quarkus `Mailer`, subject-prefix config, inline logo, and an HTML template under `emails/`. If real duplication emerges, a small shared mail helper can be extracted later — deferred, not committed. *Alternative considered:* extend `MailNotificationId` and reuse `NotificationMailPort` — rejected as wrong-way coupling and scope creep. + +### 6. Weekly cron inside the recognition BC +An inbound `@Scheduled` adapter in `recognition/adapter/inbound` fires every Monday at 17:00 — the "Redaktionsschluss" (editorial deadline) — and triggers the digest use case, mirroring `ReminderEmailScheduler`. The cron expression is `0 0 17 ? * MON`. The scheduler stays thin: it does not compute the current date itself; the application service derives the reference date from the injected `Clock` (see decision 9). The recipient-resolution outbound port still takes an explicit `LocalDate`, so adapter filtering is testable with arbitrary dates. The schedule lives in the BC that owns the behavior rather than adding a new `MailScheduleType` to `notification`. + +### 7. Always send the digest, including an empty state +The weekly mail is sent to all resolved recipients even when there are no new entries, with an explicit empty-state body. A silent week could be read as a broken system; an "no new entries this week" mail is a reliable liveness signal. When entries exist, they are dispatched and then transitioned to `INCLUDED_IN_DIGEST`; when none exist, no state changes. + +### 8. Submit endpoint requires an internal `EMPLOYEE` +The submission REST endpoint is protected: the caller must be authenticated and hold the `EMPLOYEE` role, consistent with existing employee-facing endpoints in the codebase. Beyond the role check, external employees are forbidden — only internal employees may submit — so the resource additionally rejects callers where `isExternal()` is true. + +### 9. Time is sourced from an injected `Clock` +Both time-dependent operations SHALL obtain the current time from an injected `java.time.Clock` rather than calling the no-argument `now()` methods, matching the established application-service pattern in the codebase (`monthend`, `user` services use `YearMonth.now(clock)`). Specifically: the digest application service derives its reference date via `LocalDate.now(clock)`, and the submit application service stamps `submittedAt` via the clock (e.g. `Instant.now(clock)` / `LocalDateTime.now(clock)`). This keeps clock handling consistent across the codebase and makes both flows deterministic under test by injecting a fixed `Clock` — superior to the bare `LocalDate.now()` still used in the existing scheduler adapters. *Alternative considered:* computing `LocalDate.now()` in the scheduler adapter (as `ReminderEmailScheduler` does) — rejected as non-deterministic and inconsistent with the service-layer clock convention. + +## Risks / Trade-offs + +- **Adapter reads `User` domain logic** → recognition's outbound adapter depends on the `user` BC's repository and aggregate. This is intentional and confined to the adapter layer; the recognition domain/application stays isolated (same trade-off `project` already accepts). +- **Filter logic (`role + active + internal`) is BC-local** → mild duplication with `notification`'s `role + active` filtering. Accepted: each BC owns its own recipient rule; extracting a shared query would over-couple the contexts for little gain. +- **Digest sent per recipient with the same content** → if recipient count is large, many near-identical mails are sent. Acceptable at the JFX circle's scale; mirrors the existing reminder dispatch. +- **Partial send failure mid-digest** → if a mail send throws after some recipients are served, entries may or may not be marked included depending on transaction boundary. Mitigation: mark entries `INCLUDED_IN_DIGEST` only after the send loop completes within the use case's transaction, so a failed run leaves entries `NEW` for retry next week. +- **Free-text content is unmoderated** → no filtering of inappropriate content. Accepted per scope; leadership reviews entries in the JFX. diff --git a/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/proposal.md b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/proposal.md new file mode 100644 index 000000000..9d5176899 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/proposal.md @@ -0,0 +1,30 @@ +## Why + +Praiseworthy and brave everyday deeds of colleagues often stay invisible because there is no low-friction way for employees to report them to leadership. We want a company-wide "Briefkasten" (mailbox) that any employee can drop an entry into, and a weekly digest that surfaces the newest entries to the leadership circle (Tipi-JFX Teilnehmerkreis) so they can be taken over into the JFX. + +## What Changes + +- Introduce a new **`recognition`** bounded context (`com.gepardec.mega.hexagon.recognition`), a downstream consumer of the `user` bounded context. +- Any authenticated employee can **submit a recognition entry**: free-text message plus a category identifying it as praise/appreciation (Lob/Wertschätzung) or courage (Mut). Entries are persisted and never deleted or archived. +- A **weekly digest email** is sent to every internal project lead active on the run date. The digest lists all entries not yet included in a previous digest and is **always sent**, even when there are no new entries, so recipients get a reliable liveness signal. +- Recipient resolution — "all internal project leads active on a given `LocalDate`" — is exposed through a recognition-owned outbound port, keeping the recognition domain isolated from the `user` aggregate. +- The digest reuses the existing mail-sending infrastructure (Quarkus `Mailer`, subject-prefix config, inline logo, HTML templates under `emails/`) behind the recognition BC's own outbound mail port; the `notification` bounded context is left untouched. + +## Capabilities + +### New Capabilities +- `recognition-entry`: The `RecognitionEntry` aggregate — its fields (free-text message, category, submission timestamp), the `APPRECIATION`/`COURAGE` category, the `NEW → INCLUDED_IN_DIGEST` lifecycle, and the submission behavior. Entries are immutable and never removed. +- `recognition-rest-api`: The employee-facing REST endpoint for submitting a recognition entry, protected so that only an authenticated internal employee holding the `EMPLOYEE` role may submit (external employees are forbidden). +- `recognition-weekly-digest`: The weekly scheduled digest — the cron-triggered use case, resolution of active internal project leads for a given date via the outbound directory port, always-send behavior (including the empty-state mail), transitioning included entries to `INCLUDED_IN_DIGEST`, and dispatch via the recognition mail port. + +### Modified Capabilities + + +## Impact + +- **New package**: `com.gepardec.mega.hexagon.recognition` (domain, application, adapter layers). +- **Persistence**: new `recognition_entry` table introduced via a Liquibase changelog; new Panache repository + entity. +- **REST**: new employee-facing submit endpoint; secured with the `EMPLOYEE` role and restricted to internal employees (external employees forbidden). +- **Scheduling**: new weekly `@Scheduled` cron adapter in the recognition BC. +- **Cross-BC**: recognition's outbound directory adapter reads user data from the `user` BC's persistence (mirroring `project`'s `UserIdentityLookupAdapter`); no `user` spec behavior changes. +- **Mail**: reuses the Quarkus `Mailer` bean and existing template/logo/subject-prefix conventions; adds a new digest HTML template resource and a subject message key. The `notification` BC is not modified. diff --git a/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-entry/spec.md b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-entry/spec.md new file mode 100644 index 000000000..17d5b1e73 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-entry/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: A recognition entry captures a free-text message and a category +A recognition entry SHALL hold a non-empty free-text message describing a colleague's deed, a category, a submission timestamp, and a lifecycle status. The message SHALL be stored verbatim as submitted; the system SHALL NOT parse, split, or require any structured reference to the praised colleague or the submitter. + +#### Scenario: Entry is created with a message and a category +- **WHEN** a recognition entry is submitted with a non-empty message and a valid category +- **THEN** the entry is stored with that message, that category, a submission timestamp, and the status "new" + +#### Scenario: Empty message is rejected +- **WHEN** a recognition entry is submitted with a blank or missing message +- **THEN** the entry is not stored and the submission is rejected + +### Requirement: A recognition entry is classified as praise or courage +Every recognition entry SHALL carry exactly one category with one of two values: praise/appreciation (Lob/Wertschätzung) or courage (Mut). The category SHALL be provided at submission time and SHALL NOT change afterwards. + +#### Scenario: Entry classified as praise +- **WHEN** an entry is submitted with the praise/appreciation category +- **THEN** the stored entry's category is praise/appreciation + +#### Scenario: Entry classified as courage +- **WHEN** an entry is submitted with the courage category +- **THEN** the stored entry's category is courage + +#### Scenario: Unknown category is rejected +- **WHEN** an entry is submitted with a category that is neither praise/appreciation nor courage +- **THEN** the entry is not stored and the submission is rejected + +### Requirement: A recognition entry moves through a two-state lifecycle +A recognition entry SHALL start in the status "new" when created and SHALL transition to "included in digest" once it has been sent in a weekly digest. The transition SHALL be one-directional; an entry that is "included in digest" SHALL NOT return to "new". + +#### Scenario: New entry becomes included after being sent in a digest +- **WHEN** a "new" entry is sent as part of a weekly digest +- **THEN** the entry's status becomes "included in digest" + +#### Scenario: Already included entry is not sent again +- **WHEN** a weekly digest is assembled +- **THEN** entries whose status is "included in digest" are not part of the digest + +### Requirement: Recognition entries are immutable and permanent +Once created, a recognition entry's message and category SHALL NOT be editable, and the entry SHALL NOT be deletable or archivable. The only permitted change to a stored entry is the one-directional lifecycle transition from "new" to "included in digest". + +#### Scenario: Entry content cannot be changed +- **WHEN** an attempt is made to modify a stored entry's message or category +- **THEN** the system provides no operation to do so and the stored content is unchanged + +#### Scenario: Entry cannot be removed +- **WHEN** an entry has been stored +- **THEN** the system provides no operation to delete or archive it diff --git a/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-rest-api/spec.md b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-rest-api/spec.md new file mode 100644 index 000000000..a86705fd3 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-rest-api/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Authenticated employees can submit a recognition entry +The system SHALL expose an endpoint that lets an authenticated user submit a recognition entry. The request body SHALL contain a `message` field (the free-text description) and a `category` field (praise/appreciation or courage). On success the system SHALL persist a new entry and respond with a success status. + +#### Scenario: Employee submits a valid recognition entry +- **WHEN** an authenticated employee sends a submit request with a non-empty `message` and a valid `category` +- **THEN** a new recognition entry is stored with status "new" +- **THEN** the response indicates the submission succeeded + +#### Scenario: Submission with a blank message is rejected +- **WHEN** an authenticated employee sends a submit request with a blank or missing `message` +- **THEN** no entry is stored +- **THEN** the response indicates a client error + +#### Scenario: Submission with an invalid category is rejected +- **WHEN** an authenticated employee sends a submit request with a `category` that is neither praise/appreciation nor courage +- **THEN** no entry is stored +- **THEN** the response indicates a client error + +### Requirement: Submitting a recognition entry requires an internal employee +The submit endpoint SHALL require the caller to be authenticated, to hold the `EMPLOYEE` role, and to be an internal employee. Unauthenticated callers, authenticated callers lacking the `EMPLOYEE` role, and external employees SHALL be rejected and SHALL NOT create an entry. + +#### Scenario: Unauthenticated request is rejected +- **WHEN** an unauthenticated caller sends a submit request +- **THEN** the request is rejected as unauthorized +- **THEN** no entry is stored + +#### Scenario: Caller without the employee role is rejected +- **WHEN** an authenticated caller that does not hold the `EMPLOYEE` role sends a submit request +- **THEN** the request is rejected as forbidden +- **THEN** no entry is stored + +#### Scenario: External employee is rejected +- **WHEN** an authenticated employee that is external sends a submit request +- **THEN** the request is rejected as forbidden +- **THEN** no entry is stored diff --git a/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-weekly-digest/spec.md b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-weekly-digest/spec.md new file mode 100644 index 000000000..6130c427c --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/specs/recognition-weekly-digest/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Recipients are the internal project leads active on a given date +The digest SHALL determine its recipients as all internal project leads active on a supplied reference date. A recipient qualifies when they hold the project-lead role, are employed (active) on that date, and are internal (not external). Recipient resolution SHALL be parameterized by the reference date so it can be evaluated for any date. External users and users not active on the reference date SHALL be excluded. + +#### Scenario: Active internal project lead is included +- **WHEN** recipients are resolved for a reference date +- **AND** a user holds the project-lead role, is active on that date, and is internal +- **THEN** that user is included in the recipient list + +#### Scenario: External project lead is excluded +- **WHEN** recipients are resolved for a reference date +- **AND** a user holds the project-lead role and is active on that date but is external +- **THEN** that user is excluded from the recipient list + +#### Scenario: Inactive project lead is excluded +- **WHEN** recipients are resolved for a reference date +- **AND** a user holds the project-lead role and is internal but is not active on that date +- **THEN** that user is excluded from the recipient list + +#### Scenario: Non-lead user is excluded +- **WHEN** recipients are resolved for a reference date +- **AND** a user is active and internal but does not hold the project-lead role +- **THEN** that user is excluded from the recipient list + +### Requirement: The digest is sent weekly at the editorial deadline +The system SHALL trigger the weekly digest automatically every Monday at 17:00 (the "Redaktionsschluss" / editorial deadline). When triggered, it SHALL resolve recipients for the current date and send the digest to each of them. + +#### Scenario: Weekly trigger dispatches the digest +- **WHEN** the Monday 17:00 editorial deadline is reached +- **THEN** recipients are resolved for the current date +- **THEN** the digest is sent to each resolved recipient + +### Requirement: The digest contains all entries not yet included in a previous digest +Each weekly digest SHALL contain every recognition entry whose status is "new" at the time the digest is assembled, and SHALL NOT contain entries already included in a previous digest. The digest content SHALL present each entry's message and its category (praise/appreciation or courage). + +#### Scenario: New entries appear in the digest +- **WHEN** the digest is assembled and there are entries with status "new" +- **THEN** every such entry is included in the digest, showing its message and category + +#### Scenario: Previously included entries do not reappear +- **WHEN** the digest is assembled +- **THEN** entries already marked "included in digest" are not part of the digest + +### Requirement: Included entries transition to included-in-digest after sending +After a digest has been sent, every entry contained in that digest SHALL transition from "new" to "included in digest" so it is not sent again. The transition SHALL occur only after the send completes, so that a failed run leaves the entries as "new" for the next weekly run. + +#### Scenario: Sent entries are marked included +- **WHEN** a digest containing one or more "new" entries has been sent successfully +- **THEN** each of those entries has status "included in digest" + +#### Scenario: Failed send leaves entries new +- **WHEN** assembling or sending the digest fails before completion +- **THEN** the affected entries retain the status "new" + +### Requirement: The digest is sent even when there are no new entries +When a weekly digest run finds no entries with status "new", the system SHALL still send a digest to all resolved recipients, using an explicit empty-state message indicating there are no new entries this week. This provides recipients a reliable signal that the process is operating. + +#### Scenario: Empty-state digest is sent when there are no new entries +- **WHEN** the weekly digest runs and no entries have status "new" +- **THEN** a digest with an empty-state message is still sent to every resolved recipient +- **THEN** no entry status changes occur + +#### Scenario: No recipients means nothing is sent +- **WHEN** the weekly digest runs and no internal project leads are active on the current date +- **THEN** no digest mail is sent diff --git a/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/tasks.md b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/tasks.md new file mode 100644 index 000000000..0aafe876c --- /dev/null +++ b/openspec/changes/archive/2026-07-13-add-recognition-briefkasten/tasks.md @@ -0,0 +1,49 @@ +## 1. Domain + +- [x] 1.1 Create the `recognition` bounded-context package skeleton (`domain`, `application`, `adapter/inbound`, `adapter/outbound`) under `com.gepardec.mega.hexagon.recognition` +- [x] 1.2 Add the `RecognitionCategory` domain type with values for praise/appreciation and courage +- [x] 1.3 Add the recognition entry status type with values `NEW` and `INCLUDED_IN_DIGEST` +- [x] 1.4 Implement the `RecognitionEntry` aggregate: identity, non-empty message, category, submission timestamp, status; reject blank message; provide the one-directional transition from `NEW` to `INCLUDED_IN_DIGEST`; no edit/delete behavior +- [x] 1.5 Add the mail-recipient value type (email + first name) used by the digest, owned by the recognition BC + +## 2. Persistence + +- [x] 2.1 Add a Liquibase changelog creating the `recognition_entry` table (message, category, submitted_at, status) and wire it into the changelog master +- [x] 2.2 Add the recognition entry JPA entity and Panache repository +- [x] 2.3 Add the outbound repository port and its adapter, with a mapper between entity and aggregate; support persisting a new entry, finding entries by status `NEW`, and saving status transitions + +## 3. Recipient resolution (cross-BC) + +- [x] 3.1 Define the outbound `ProjectLeadDirectoryPort` returning the active internal project-lead recipients for a supplied `LocalDate` +- [x] 3.2 Implement its adapter in `recognition/adapter/outbound`: query the `user` BC for project-lead-role users, filter to active-on-date and internal (non-external), and map each to the mail-recipient value +- [x] 3.3 Unit-test the adapter's filtering (active/inactive, internal/external, non-lead) for arbitrary dates + +## 4. Submission use case + REST + +- [x] 4.1 Add the inbound submit use case and application service that validates and persists a new entry with status `NEW`, stamping `submittedAt` from an injected `Clock` (not the no-arg `now()`) +- [x] 4.2 Add the REST resource with a submit endpoint, request DTO (`message`, `category`), and mapping to the use case +- [x] 4.3 Secure the endpoint to require authentication and the `EMPLOYEE` role, and reject external employees (only internal employees may submit) +- [x] 4.4 Add REST tests: successful submit, blank message rejected, invalid category rejected, unauthenticated rejected, missing-role rejected, external employee rejected + +## 5. Weekly digest use case + +- [x] 5.1 Add the inbound digest use case that derives its reference date via `LocalDate.now(clock)` from an injected `Clock`, resolves recipients via `ProjectLeadDirectoryPort` for that date, and gathers entries with status `NEW` +- [x] 5.2 Send the digest to every recipient (grouping/labeling entries by category), including an empty-state message when there are no new entries; send nothing when there are no recipients +- [x] 5.3 Transition sent entries to `INCLUDED_IN_DIGEST` only after the send loop completes, so a failed run leaves them `NEW` +- [x] 5.4 Unit-test the use case with a fixed injected `Clock`: new entries included and marked, empty-state sent when no entries, no-op when no recipients, failure leaves entries `NEW` + +## 6. Mail adapter + +- [x] 6.1 Define the outbound `RecognitionMailPort` speaking the digest's language (send a digest of entries to a recipient) +- [x] 6.2 Implement its adapter reusing the Quarkus mailer, subject-prefix config, and inline-logo conventions +- [x] 6.3 Add the digest HTML email template under `emails/` and the subject message key +- [x] 6.4 Test the adapter renders the entry list and the empty-state variant and sends to the recipient address + +## 7. Scheduling + +- [x] 7.1 Add the inbound weekly `@Scheduled` adapter (cron `0 0 17 ? * MON` — Monday 17:00 Redaktionsschluss) that invokes the digest use case; keep it thin (no date computation — the service derives the date from the injected `Clock`); log start and outcome +- [x] 7.2 Ensure the scheduler is disabled under the test profile, consistent with existing scheduled jobs + +## 8. Architecture checks + +- [x] 8.1 Confirm the recognition domain and application layers do not depend on the `user` aggregate (only the outbound adapter reaches into `user`), and that architecture tests pass diff --git a/openspec/specs/recognition-entry/spec.md b/openspec/specs/recognition-entry/spec.md new file mode 100644 index 000000000..a3f97d07c --- /dev/null +++ b/openspec/specs/recognition-entry/spec.md @@ -0,0 +1,66 @@ +# Recognition Entry + +## Purpose + +Defines the recognition entry (Briefkasten) domain concept: a submitted, immutable record capturing a free-text message about a colleague's deed and its category (praise/appreciation or courage), an optional submitter identity, and its two-state lifecycle from "new" to "included in digest". + +## Requirements + +### Requirement: A recognition entry captures a free-text message and a category +A recognition entry SHALL hold a non-empty free-text message describing a colleague's deed, a category, a submission timestamp, and a lifecycle status. The message SHALL be stored verbatim as submitted; the system SHALL NOT parse, split, or require any structured reference to the praised colleague. + +#### Scenario: Entry is created with a message and a category +- **WHEN** a recognition entry is submitted with a non-empty message and a valid category +- **THEN** the entry is stored with that message, that category, a submission timestamp, and the status "new" + +#### Scenario: Empty message is rejected +- **WHEN** a recognition entry is submitted with a blank or missing message +- **THEN** the entry is not stored and the submission is rejected + +### Requirement: A recognition entry retains the submitter only when not anonymous +For a non-anonymous submission, the recognition entry SHALL store the authenticated submitter's user ID. For an anonymous submission, the recognition entry SHALL NOT persist any information about the submitter. + +#### Scenario: Non-anonymous entry stores the submitter user ID +- **WHEN** an authenticated employee submits a recognition entry without choosing anonymity +- **THEN** the stored entry contains that employee's user ID as its submitter + +#### Scenario: Anonymous entry does not retain submitter information +- **WHEN** an authenticated employee submits a recognition entry anonymously +- **THEN** the stored entry contains no information about that employee as its submitter + +### Requirement: A recognition entry is classified as praise or courage +Every recognition entry SHALL carry exactly one category with one of two values: praise/appreciation (Lob/Wertschätzung) or courage (Mut). The category SHALL be provided at submission time and SHALL NOT change afterwards. + +#### Scenario: Entry classified as praise +- **WHEN** an entry is submitted with the praise/appreciation category +- **THEN** the stored entry's category is praise/appreciation + +#### Scenario: Entry classified as courage +- **WHEN** an entry is submitted with the courage category +- **THEN** the stored entry's category is courage + +#### Scenario: Unknown category is rejected +- **WHEN** an entry is submitted with a category that is neither praise/appreciation nor courage +- **THEN** the entry is not stored and the submission is rejected + +### Requirement: A recognition entry moves through a two-state lifecycle +A recognition entry SHALL start in the status "new" when created and SHALL transition to "included in digest" once it has been sent in a weekly digest. The transition SHALL be one-directional; an entry that is "included in digest" SHALL NOT return to "new". + +#### Scenario: New entry becomes included after being sent in a digest +- **WHEN** a "new" entry is sent as part of a weekly digest +- **THEN** the entry's status becomes "included in digest" + +#### Scenario: Already included entry is not sent again +- **WHEN** a weekly digest is assembled +- **THEN** entries whose status is "included in digest" are not part of the digest + +### Requirement: Recognition entries are immutable and permanent +Once created, a recognition entry's message and category SHALL NOT be editable, and the entry SHALL NOT be deletable or archivable. The only permitted change to a stored entry is the one-directional lifecycle transition from "new" to "included in digest". + +#### Scenario: Entry content cannot be changed +- **WHEN** an attempt is made to modify a stored entry's message or category +- **THEN** the system provides no operation to do so and the stored content is unchanged + +#### Scenario: Entry cannot be removed +- **WHEN** an entry has been stored +- **THEN** the system provides no operation to delete or archive it diff --git a/openspec/specs/recognition-rest-api/spec.md b/openspec/specs/recognition-rest-api/spec.md new file mode 100644 index 000000000..a4c113940 --- /dev/null +++ b/openspec/specs/recognition-rest-api/spec.md @@ -0,0 +1,52 @@ +# Recognition REST API + +## Purpose + +Defines the HTTP endpoint exposed by the Recognition bounded context for submitting recognition entries, together with the authentication and authorization rules that restrict submission to authenticated internal employees holding the `EMPLOYEE` role. + +## Requirements + +### Requirement: Authenticated employees can submit a recognition entry +The system SHALL expose an endpoint that lets an authenticated user submit a recognition entry. The request body SHALL contain a `message` field (the free-text description) and a `category` field (praise/appreciation or courage), and MAY contain an `anonymous` boolean flag. The `anonymous` flag SHALL default to `false`. On success the system SHALL persist a new entry and respond with a success status. + +#### Scenario: Employee submits a valid recognition entry +- **WHEN** an authenticated employee sends a submit request with a non-empty `message` and a valid `category` +- **THEN** a new recognition entry is stored with status "new" +- **THEN** the response indicates the submission succeeded + +#### Scenario: Omitted anonymous flag defaults to non-anonymous submission +- **WHEN** an authenticated employee sends a valid submit request without an `anonymous` flag +- **THEN** the submission is treated as non-anonymous +- **THEN** the stored entry contains the authenticated employee's user ID as its submitter + +#### Scenario: Anonymous submission does not persist submitter information +- **WHEN** an authenticated employee sends a valid submit request with `anonymous` set to `true` +- **THEN** the stored entry contains no information about the authenticated employee as its submitter + +#### Scenario: Submission with a blank message is rejected +- **WHEN** an authenticated employee sends a submit request with a blank or missing `message` +- **THEN** no entry is stored +- **THEN** the response indicates a client error + +#### Scenario: Submission with an invalid category is rejected +- **WHEN** an authenticated employee sends a submit request with a `category` that is neither praise/appreciation nor courage +- **THEN** no entry is stored +- **THEN** the response indicates a client error + +### Requirement: Submitting a recognition entry requires an internal employee +The submit endpoint SHALL require the caller to be authenticated, to hold the `EMPLOYEE` role, and to be an internal employee. Unauthenticated callers, authenticated callers lacking the `EMPLOYEE` role, and external employees SHALL be rejected and SHALL NOT create an entry. + +#### Scenario: Unauthenticated request is rejected +- **WHEN** an unauthenticated caller sends a submit request +- **THEN** the request is rejected as unauthorized +- **THEN** no entry is stored + +#### Scenario: Caller without the employee role is rejected +- **WHEN** an authenticated caller that does not hold the `EMPLOYEE` role sends a submit request +- **THEN** the request is rejected as forbidden +- **THEN** no entry is stored + +#### Scenario: External employee is rejected +- **WHEN** an authenticated employee that is external sends a submit request +- **THEN** the request is rejected as forbidden +- **THEN** no entry is stored diff --git a/openspec/specs/recognition-weekly-digest/spec.md b/openspec/specs/recognition-weekly-digest/spec.md new file mode 100644 index 000000000..ac5c12b77 --- /dev/null +++ b/openspec/specs/recognition-weekly-digest/spec.md @@ -0,0 +1,72 @@ +# Recognition Weekly Digest + +## Purpose + +Defines the weekly recognition digest: how recipients (active internal project leads) are resolved for a reference date, when the digest is triggered (Monday 17:00 editorial deadline / Redaktionsschluss), which entries it contains, and how those entries transition to "included in digest" after a successful send. + +## Requirements + +### Requirement: Recipients are the internal project leads active on a given date +The digest SHALL determine its recipients as all internal project leads active on a supplied reference date. A recipient qualifies when they hold the project-lead role, are employed (active) on that date, and are internal (not external). Recipient resolution SHALL be parameterized by the reference date so it can be evaluated for any date. External users and users not active on the reference date SHALL be excluded. + +#### Scenario: Active internal project lead is included +- **WHEN** recipients are resolved for a reference date +- **AND** a user holds the project-lead role, is active on that date, and is internal +- **THEN** that user is included in the recipient list + +#### Scenario: External project lead is excluded +- **WHEN** recipients are resolved for a reference date +- **AND** a user holds the project-lead role and is active on that date but is external +- **THEN** that user is excluded from the recipient list + +#### Scenario: Inactive project lead is excluded +- **WHEN** recipients are resolved for a reference date +- **AND** a user holds the project-lead role and is internal but is not active on that date +- **THEN** that user is excluded from the recipient list + +#### Scenario: Non-lead user is excluded +- **WHEN** recipients are resolved for a reference date +- **AND** a user is active and internal but does not hold the project-lead role +- **THEN** that user is excluded from the recipient list + +### Requirement: The digest is sent weekly at the editorial deadline +The system SHALL trigger the weekly digest automatically every Monday at 17:00 (the "Redaktionsschluss" / editorial deadline). When triggered, it SHALL resolve recipients for the current date and send the digest to each of them. + +#### Scenario: Weekly trigger dispatches the digest +- **WHEN** the Monday 17:00 editorial deadline is reached +- **THEN** recipients are resolved for the current date +- **THEN** the digest is sent to each resolved recipient + +### Requirement: The digest contains all entries not yet included in a previous digest +Each weekly digest SHALL contain every recognition entry whose status is "new" at the time the digest is assembled, and SHALL NOT contain entries already included in a previous digest. The digest content SHALL present each entry's message and its category (praise/appreciation or courage). + +#### Scenario: New entries appear in the digest +- **WHEN** the digest is assembled and there are entries with status "new" +- **THEN** every such entry is included in the digest, showing its message and category + +#### Scenario: Previously included entries do not reappear +- **WHEN** the digest is assembled +- **THEN** entries already marked "included in digest" are not part of the digest + +### Requirement: Included entries transition to included-in-digest after sending +After a digest has been sent, every entry contained in that digest SHALL transition from "new" to "included in digest" so it is not sent again. The transition SHALL occur only after the send completes, so that a failed run leaves the entries as "new" for the next weekly run. + +#### Scenario: Sent entries are marked included +- **WHEN** a digest containing one or more "new" entries has been sent successfully +- **THEN** each of those entries has status "included in digest" + +#### Scenario: Failed send leaves entries new +- **WHEN** assembling or sending the digest fails before completion +- **THEN** the affected entries retain the status "new" + +### Requirement: The digest is sent even when there are no new entries +When a weekly digest run finds no entries with status "new", the system SHALL still send a digest to all resolved recipients, using an explicit empty-state message indicating there are no new entries this week. This provides recipients a reliable signal that the process is operating. + +#### Scenario: Empty-state digest is sent when there are no new entries +- **WHEN** the weekly digest runs and no entries have status "new" +- **THEN** a digest with an empty-state message is still sent to every resolved recipient +- **THEN** no entry status changes occur + +#### Scenario: No recipients means nothing is sent +- **WHEN** the weekly digest runs and no internal project leads are active on the current date +- **THEN** no digest mail is sent diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/RecognitionDigestScheduler.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/RecognitionDigestScheduler.java new file mode 100644 index 000000000..6740e0dc5 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/RecognitionDigestScheduler.java @@ -0,0 +1,25 @@ +package com.gepardec.mega.hexagon.recognition.adapter.inbound; + +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SendRecognitionDigestUseCase; +import io.quarkus.logging.Log; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class RecognitionDigestScheduler { + + private final SendRecognitionDigestUseCase sendRecognitionDigestUseCase; + + @Inject + public RecognitionDigestScheduler(SendRecognitionDigestUseCase sendRecognitionDigestUseCase) { + this.sendRecognitionDigestUseCase = sendRecognitionDigestUseCase; + } + + @Scheduled(identity = "Send weekly recognition digest", cron = "0 0 17 ? * MON") + void sendWeeklyDigest() { + Log.info("Starting scheduled recognition digest dispatch"); + sendRecognitionDigestUseCase.sendDigest(); + Log.info("Finished scheduled recognition digest dispatch"); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionDomainExceptionMapper.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionDomainExceptionMapper.java new file mode 100644 index 000000000..16656e696 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionDomainExceptionMapper.java @@ -0,0 +1,20 @@ +package com.gepardec.mega.hexagon.recognition.adapter.inbound.rest; + +import com.gepardec.mega.hexagon.generated.model.ApiErrorDto; +import com.gepardec.mega.hexagon.recognition.domain.error.RecognitionException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@ApplicationScoped +@Provider +public class RecognitionDomainExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(RecognitionException exception) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ApiErrorDto().message(exception.getMessage())) + .build(); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResource.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResource.java new file mode 100644 index 000000000..94d67d99b --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResource.java @@ -0,0 +1,43 @@ +package com.gepardec.mega.hexagon.recognition.adapter.inbound.rest; + +import com.gepardec.mega.hexagon.generated.api.RecognitionApi; +import com.gepardec.mega.hexagon.generated.model.RecognitionEntrySubmissionDto; +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SubmitRecognitionEntryUseCase; +import com.gepardec.mega.hexagon.shared.application.security.AuthenticatedActorContext; +import com.gepardec.mega.hexagon.shared.application.security.ForbiddenException; +import com.gepardec.mega.hexagon.shared.application.security.MegaRolesAllowed; +import com.gepardec.mega.hexagon.shared.domain.model.Role; +import io.quarkus.security.Authenticated; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; + +@RequestScoped +@Authenticated +@MegaRolesAllowed(Role.EMPLOYEE) +public class RecognitionResource implements RecognitionApi { + + private final SubmitRecognitionEntryUseCase submitRecognitionEntryUseCase; + private final RecognitionRestMapper recognitionRestMapper; + private final AuthenticatedActorContext authenticatedActorContext; + + @Inject + public RecognitionResource( + SubmitRecognitionEntryUseCase submitRecognitionEntryUseCase, + RecognitionRestMapper recognitionRestMapper, + AuthenticatedActorContext authenticatedActorContext + ) { + this.submitRecognitionEntryUseCase = submitRecognitionEntryUseCase; + this.recognitionRestMapper = recognitionRestMapper; + this.authenticatedActorContext = authenticatedActorContext; + } + + @Override + public Response submitRecognitionEntry(RecognitionEntrySubmissionDto request) { + if (authenticatedActorContext.user().isExternal()) { + throw new ForbiddenException("external users must not submit recognition entries"); + } + submitRecognitionEntryUseCase.submit(recognitionRestMapper.toCommand(request), authenticatedActorContext.userId()); + return Response.status(Response.Status.CREATED).build(); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionRestMapper.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionRestMapper.java new file mode 100644 index 000000000..bebd74036 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionRestMapper.java @@ -0,0 +1,14 @@ +package com.gepardec.mega.hexagon.recognition.adapter.inbound.rest; + +import com.gepardec.mega.hexagon.generated.model.RecognitionEntrySubmissionDto; +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SubmitRecognitionEntryCommand; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA) +public interface RecognitionRestMapper { + + @Mapping(target = "anonymous", source = "anonymous", defaultValue = "false") + SubmitRecognitionEntryCommand toCommand(RecognitionEntrySubmissionDto request); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapter.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapter.java new file mode 100644 index 000000000..6632d7e3b --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapter.java @@ -0,0 +1,37 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.application.port.outbound.ProjectLeadDirectoryPort; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; +import com.gepardec.mega.hexagon.shared.domain.model.Role; +import com.gepardec.mega.hexagon.user.domain.port.outbound.UserRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +@ApplicationScoped +public class ProjectLeadDirectoryAdapter implements ProjectLeadDirectoryPort { + + private final UserRepository userRepository; + private final ProjectLeadDirectoryMapper mapper; + + @Inject + public ProjectLeadDirectoryAdapter(UserRepository userRepository, ProjectLeadDirectoryMapper mapper) { + this.userRepository = userRepository; + this.mapper = mapper; + } + + @Override + public List findActiveInternalProjectLeads(LocalDate referenceDate) { + Objects.requireNonNull(referenceDate, "referenceDate must not be null"); + + return userRepository.findByRole(Role.PROJECT_LEAD).stream() + .filter(user -> user.roles().contains(Role.PROJECT_LEAD)) + .filter(user -> user.isActiveOn(referenceDate)) + .filter(user -> !user.isExternal()) + .map(mapper::toRecipient) + .toList(); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryMapper.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryMapper.java new file mode 100644 index 000000000..dd3f111c5 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryMapper.java @@ -0,0 +1,14 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; +import com.gepardec.mega.hexagon.user.domain.model.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA) +public interface ProjectLeadDirectoryMapper { + + @Mapping(target = "firstName", source = "name.firstname") + RecognitionMailRecipient toRecipient(User user); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java new file mode 100644 index 000000000..6df859cce --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java @@ -0,0 +1,110 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionMailPort; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; +import com.google.common.html.HtmlEscapers; +import com.google.common.net.MediaType; +import io.quarkus.logging.Log; +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.Mailer; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.commons.io.IOUtils; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.ResourceBundle; + +@ApplicationScoped +public class QuarkusRecognitionMailAdapter implements RecognitionMailPort { + + private static final String TEMPLATE_PATH = "emails/recognition-digest.html"; + private static final String LOGO_RESOURCE_PATH = "img/logo.png"; + private static final String SUBJECT_KEY = "mail.RECOGNITION_DIGEST.subject"; + private static final String FIRST_NAME_PARAMETER = "$firstName$"; + private static final String ENTRIES_PARAMETER = "$entries$"; + private static final String EMPTY_STATE = "

Diese Woche wurden keine neuen Anerkennungen eingereicht.

"; + + private final Mailer mailer; + private final Optional subjectPrefix; + + @Inject + public QuarkusRecognitionMailAdapter( + Mailer mailer, + @ConfigProperty(name = "mega.mail.subject-prefix") Optional subjectPrefix + ) { + this.mailer = mailer; + this.subjectPrefix = subjectPrefix; + } + + @Override + public void sendDigest(RecognitionMailRecipient recipient, List entries) { + String subject = subjectPrefix.orElse("") + ResourceBundle.getBundle( + "messages", + Locale.GERMAN, + ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_PROPERTIES) + ) + .getString(SUBJECT_KEY); + String content = readTemplate() + .replace(FIRST_NAME_PARAMETER, escapeHtml(recipient.firstName())) + .replace(ENTRIES_PARAMETER, renderEntries(entries)); + + mailer.send(Mail.withHtml(recipient.email().value(), subject, content) + .addInlineAttachment("logo.png", readLogo(), MediaType.PNG.type(), "")); + Log.info("Recognition digest email sent"); + } + + private String renderEntries(List entries) { + if (entries == null || entries.isEmpty()) { + return EMPTY_STATE; + } + + String appreciationEntries = renderCategoryEntries(entries, RecognitionCategory.APPRECIATION, "Lob & Wertschätzung"); + String courageEntries = renderCategoryEntries(entries, RecognitionCategory.COURAGE, "Mut"); + return appreciationEntries + courageEntries; + } + + private String renderCategoryEntries(List entries, RecognitionCategory category, String heading) { + String listItems = entries.stream() + .filter(entry -> entry.category() == category) + .map(entry -> "
  • " + escapeHtml(entry.message()) + "
  • ") + .reduce("", String::concat); + if (listItems.isEmpty()) { + return ""; + } + + return "

    " + heading + "

      " + listItems + "
    "; + } + + private String readTemplate() { + try (InputStream inputStream = QuarkusRecognitionMailAdapter.class.getClassLoader().getResourceAsStream(TEMPLATE_PATH)) { + if (inputStream == null) { + throw new IllegalStateException("Could not read email template resource '%s'".formatted(TEMPLATE_PATH)); + } + return IOUtils.toString(inputStream, StandardCharsets.UTF_8); + } catch (Exception exception) { + throw new IllegalStateException("Cannot read email template resource '%s'".formatted(TEMPLATE_PATH), exception); + } + } + + private byte[] readLogo() { + try (InputStream inputStream = QuarkusRecognitionMailAdapter.class.getClassLoader().getResourceAsStream(LOGO_RESOURCE_PATH)) { + if (inputStream == null) { + throw new IllegalStateException("Could not read logo resource '%s'".formatted(LOGO_RESOURCE_PATH)); + } + return IOUtils.toByteArray(inputStream); + } catch (Exception exception) { + throw new IllegalStateException("Cannot read logo resource '%s'".formatted(LOGO_RESOURCE_PATH), exception); + } + } + + private String escapeHtml(String value) { + return HtmlEscapers.htmlEscaper().escape(value); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryEntity.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryEntity.java new file mode 100644 index 000000000..16237fc12 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryEntity.java @@ -0,0 +1,89 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.validator.constraints.Length; + +import java.time.Instant; +import java.util.UUID; + +@Entity(name = "RecognitionEntryEntity") +@Table(name = "recognition_entry") +public class RecognitionEntryEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "message", nullable = false) + @Length(max = 500) + private String message; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false) + private RecognitionCategory category; + + @Column(name = "submitted_at", nullable = false) + private Instant submittedAt; + + @Column(name = "submitted_by") + private UUID submittedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private RecognitionEntryStatus status; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public RecognitionCategory getCategory() { + return category; + } + + public void setCategory(RecognitionCategory category) { + this.category = category; + } + + public Instant getSubmittedAt() { + return submittedAt; + } + + public void setSubmittedAt(Instant submittedAt) { + this.submittedAt = submittedAt; + } + + public UUID getSubmittedBy() { + return submittedBy; + } + + public void setSubmittedBy(UUID submittedBy) { + this.submittedBy = submittedBy; + } + + public RecognitionEntryStatus getStatus() { + return status; + } + + public void setStatus(RecognitionEntryStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapper.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapper.java new file mode 100644 index 000000000..25cc6563c --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapper.java @@ -0,0 +1,32 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryId; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.MappingTarget; + +import java.util.UUID; + +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA) +public interface RecognitionEntryMapper { + + @Mapping(target = "id", source = "id.value") + void updateEntity(RecognitionEntry entry, @MappingTarget RecognitionEntryEntity entity); + + RecognitionEntry toDomain(RecognitionEntryEntity entity); + + default RecognitionEntryId toRecognitionEntryId(UUID id) { + return id == null ? null : RecognitionEntryId.of(id); + } + + default UUID fromUserId(UserId userId) { + return userId == null ? null : userId.value(); + } + + default UserId toUserId(UUID userId) { + return userId == null ? null : UserId.of(userId); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryPanacheRepository.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryPanacheRepository.java new file mode 100644 index 000000000..967e79166 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryPanacheRepository.java @@ -0,0 +1,8 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class RecognitionEntryPanacheRepository implements PanacheRepository { +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryRepositoryAdapter.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryRepositoryAdapter.java new file mode 100644 index 000000000..b3fb8dbe0 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryRepositoryAdapter.java @@ -0,0 +1,40 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryStatus; +import com.gepardec.mega.hexagon.recognition.domain.port.outbound.RecognitionEntryRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.List; + +@ApplicationScoped +public class RecognitionEntryRepositoryAdapter implements RecognitionEntryRepository { + + @Inject + RecognitionEntryPanacheRepository panache; + + @Inject + RecognitionEntryMapper mapper; + + @Override + public void save(RecognitionEntry entry) { + RecognitionEntryEntity entity = panache.find("id", entry.id().value()) + .firstResultOptional() + .orElseGet(RecognitionEntryEntity::new); + boolean isNew = entity.getId() == null; + mapper.updateEntity(entry, entity); + if (isNew) { + panache.persist(entity); + } else { + panache.getEntityManager().merge(entity); + } + } + + @Override + public List findByStatus(RecognitionEntryStatus status) { + return panache.list("status", status).stream() + .map(mapper::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java new file mode 100644 index 000000000..c73cb02f2 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java @@ -0,0 +1,61 @@ +package com.gepardec.mega.hexagon.recognition.application; + +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SendRecognitionDigestUseCase; +import com.gepardec.mega.hexagon.recognition.application.port.outbound.ProjectLeadDirectoryPort; +import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionMailPort; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryStatus; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; +import com.gepardec.mega.hexagon.recognition.domain.port.outbound.RecognitionEntryRepository; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + +@ApplicationScoped +@Transactional +public class RecognitionDigestService implements SendRecognitionDigestUseCase { + + private final RecognitionEntryRepository recognitionEntryRepository; + private final ProjectLeadDirectoryPort projectLeadDirectoryPort; + private final RecognitionMailPort recognitionMailPort; + private final Clock clock; + + @Inject + public RecognitionDigestService( + RecognitionEntryRepository recognitionEntryRepository, + ProjectLeadDirectoryPort projectLeadDirectoryPort, + RecognitionMailPort recognitionMailPort, + Clock clock + ) { + this.recognitionEntryRepository = recognitionEntryRepository; + this.projectLeadDirectoryPort = projectLeadDirectoryPort; + this.recognitionMailPort = recognitionMailPort; + this.clock = clock; + } + + @Override + public void sendDigest() { + LocalDate referenceDate = LocalDate.now(clock); + List recipients = projectLeadDirectoryPort.findActiveInternalProjectLeads(referenceDate); + if (recipients.isEmpty()) { + Log.infof("Skipping recognition digest on %s because no active internal project leads were found", referenceDate); + return; + } + + List entries = recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW); + for (RecognitionMailRecipient recipient : recipients) { + recognitionMailPort.sendDigest(recipient, entries); + } + + for (RecognitionEntry entry : entries) { + recognitionEntryRepository.save(entry.includeInDigest()); + } + + Log.infof("Sent recognition digest to %d recipient(s) with %d new entry/entries", recipients.size(), entries.size()); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryService.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryService.java new file mode 100644 index 000000000..9f232ed08 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryService.java @@ -0,0 +1,44 @@ +package com.gepardec.mega.hexagon.recognition.application; + +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SubmitRecognitionEntryCommand; +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SubmitRecognitionEntryUseCase; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryId; +import com.gepardec.mega.hexagon.recognition.domain.port.outbound.RecognitionEntryRepository; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; + +@ApplicationScoped +@Transactional +public class SubmitRecognitionEntryService implements SubmitRecognitionEntryUseCase { + + private final RecognitionEntryRepository recognitionEntryRepository; + private final Clock clock; + + @Inject + public SubmitRecognitionEntryService(RecognitionEntryRepository recognitionEntryRepository, Clock clock) { + this.recognitionEntryRepository = recognitionEntryRepository; + this.clock = clock; + } + + @Override + public void submit(SubmitRecognitionEntryCommand command, UserId submitterId) { + Objects.requireNonNull(command, "command must not be null"); + Objects.requireNonNull(submitterId, "submitterId must not be null"); + + RecognitionEntry entry = RecognitionEntry.create( + RecognitionEntryId.generate(), + command.message(), + command.category(), + Instant.now(clock), + command.anonymous() ? null : submitterId + ); + recognitionEntryRepository.save(entry); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SendRecognitionDigestUseCase.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SendRecognitionDigestUseCase.java new file mode 100644 index 000000000..f19d8c9d5 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SendRecognitionDigestUseCase.java @@ -0,0 +1,6 @@ +package com.gepardec.mega.hexagon.recognition.application.port.inbound; + +public interface SendRecognitionDigestUseCase { + + void sendDigest(); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryCommand.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryCommand.java new file mode 100644 index 000000000..34d0a3a88 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryCommand.java @@ -0,0 +1,10 @@ +package com.gepardec.mega.hexagon.recognition.application.port.inbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; + +public record SubmitRecognitionEntryCommand( + String message, + RecognitionCategory category, + boolean anonymous +) { +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryUseCase.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryUseCase.java new file mode 100644 index 000000000..9825aad0f --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/inbound/SubmitRecognitionEntryUseCase.java @@ -0,0 +1,8 @@ +package com.gepardec.mega.hexagon.recognition.application.port.inbound; + +import com.gepardec.mega.hexagon.shared.domain.model.UserId; + +public interface SubmitRecognitionEntryUseCase { + + void submit(SubmitRecognitionEntryCommand command, UserId submitterId); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/ProjectLeadDirectoryPort.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/ProjectLeadDirectoryPort.java new file mode 100644 index 000000000..308164e3a --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/ProjectLeadDirectoryPort.java @@ -0,0 +1,11 @@ +package com.gepardec.mega.hexagon.recognition.application.port.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; + +import java.time.LocalDate; +import java.util.List; + +public interface ProjectLeadDirectoryPort { + + List findActiveInternalProjectLeads(LocalDate referenceDate); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java new file mode 100644 index 000000000..a158eca8c --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java @@ -0,0 +1,11 @@ +package com.gepardec.mega.hexagon.recognition.application.port.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; + +import java.util.List; + +public interface RecognitionMailPort { + + void sendDigest(RecognitionMailRecipient recipient, List entries); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionException.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionException.java new file mode 100644 index 000000000..6cbedb770 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionException.java @@ -0,0 +1,8 @@ +package com.gepardec.mega.hexagon.recognition.domain.error; + +public abstract class RecognitionException extends RuntimeException { + + protected RecognitionException(String message) { + super(message); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionValidationException.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionValidationException.java new file mode 100644 index 000000000..7988c5556 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/error/RecognitionValidationException.java @@ -0,0 +1,8 @@ +package com.gepardec.mega.hexagon.recognition.domain.error; + +public class RecognitionValidationException extends RecognitionException { + + public RecognitionValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionCategory.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionCategory.java new file mode 100644 index 000000000..41a95c6b0 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionCategory.java @@ -0,0 +1,6 @@ +package com.gepardec.mega.hexagon.recognition.domain.model; + +public enum RecognitionCategory { + APPRECIATION, + COURAGE +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntry.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntry.java new file mode 100644 index 000000000..b1ad32167 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntry.java @@ -0,0 +1,58 @@ +package com.gepardec.mega.hexagon.recognition.domain.model; + +import com.gepardec.mega.hexagon.recognition.domain.error.RecognitionValidationException; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; + +import java.time.Instant; +import java.util.Objects; + +public record RecognitionEntry( + RecognitionEntryId id, + String message, + RecognitionCategory category, + Instant submittedAt, + RecognitionEntryStatus status, + UserId submittedBy +) { + + public RecognitionEntry { + Objects.requireNonNull(id, "id must not be null"); + if (message == null || message.isBlank()) { + throw new RecognitionValidationException("message must not be blank"); + } + Objects.requireNonNull(category, "category must not be null"); + Objects.requireNonNull(submittedAt, "submittedAt must not be null"); + Objects.requireNonNull(status, "status must not be null"); + + if (message.length() > 500) { + throw new RecognitionValidationException("message must not exceed 500 characters"); + } + } + + public static RecognitionEntry create( + RecognitionEntryId id, + String message, + RecognitionCategory category, + Instant submittedAt + ) { + return create(id, message, category, submittedAt, null); + } + + public static RecognitionEntry create( + RecognitionEntryId id, + String message, + RecognitionCategory category, + Instant submittedAt, + UserId submittedBy + ) { + return new RecognitionEntry(id, message, category, submittedAt, RecognitionEntryStatus.NEW, submittedBy); + } + + public RecognitionEntry includeInDigest() { + if (status == RecognitionEntryStatus.INCLUDED_IN_DIGEST) { + return this; + } + + return new RecognitionEntry(id, message, category, submittedAt, RecognitionEntryStatus.INCLUDED_IN_DIGEST, submittedBy); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryId.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryId.java new file mode 100644 index 000000000..0c6e698ce --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryId.java @@ -0,0 +1,14 @@ +package com.gepardec.mega.hexagon.recognition.domain.model; + +import java.util.UUID; + +public record RecognitionEntryId(UUID value) { + + public static RecognitionEntryId generate() { + return new RecognitionEntryId(UUID.randomUUID()); + } + + public static RecognitionEntryId of(UUID value) { + return new RecognitionEntryId(value); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryStatus.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryStatus.java new file mode 100644 index 000000000..58a80f2cc --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionEntryStatus.java @@ -0,0 +1,6 @@ +package com.gepardec.mega.hexagon.recognition.domain.model; + +public enum RecognitionEntryStatus { + NEW, + INCLUDED_IN_DIGEST +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionMailRecipient.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionMailRecipient.java new file mode 100644 index 000000000..9a5946b4d --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/model/RecognitionMailRecipient.java @@ -0,0 +1,15 @@ +package com.gepardec.mega.hexagon.recognition.domain.model; + +import com.gepardec.mega.hexagon.shared.domain.model.Email; + +import java.util.Objects; + +public record RecognitionMailRecipient(Email email, String firstName) { + + public RecognitionMailRecipient { + Objects.requireNonNull(email, "email must not be null"); + if (firstName == null || firstName.isBlank()) { + throw new IllegalArgumentException("firstName must not be blank"); + } + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/domain/port/outbound/RecognitionEntryRepository.java b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/port/outbound/RecognitionEntryRepository.java new file mode 100644 index 000000000..52d6e88c8 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/domain/port/outbound/RecognitionEntryRepository.java @@ -0,0 +1,13 @@ +package com.gepardec.mega.hexagon.recognition.domain.port.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryStatus; + +import java.util.List; + +public interface RecognitionEntryRepository { + + void save(RecognitionEntry entry); + + List findByStatus(RecognitionEntryStatus status); +} diff --git a/src/main/resources/db/changelog-master.xml b/src/main/resources/db/changelog-master.xml index 88fd726bc..f0e56d513 100644 --- a/src/main/resources/db/changelog-master.xml +++ b/src/main/resources/db/changelog-master.xml @@ -31,5 +31,6 @@ + diff --git a/src/main/resources/db/changelog/hexagon/015-recognition-entry.yaml b/src/main/resources/db/changelog/hexagon/015-recognition-entry.yaml new file mode 100644 index 000000000..995b8534c --- /dev/null +++ b/src/main/resources/db/changelog/hexagon/015-recognition-entry.yaml @@ -0,0 +1,45 @@ +databaseChangeLog: + - changeSet: + id: hexagon-recognition-entry-001 + author: mega + changes: + - createTable: + tableName: recognition_entry + columns: + - column: + name: id + type: uuid + constraints: + primaryKey: true + nullable: false + - column: + name: message + type: varchar(500) + constraints: + nullable: false + - column: + name: category + type: varchar(32) + constraints: + nullable: false + - column: + name: submitted_at + type: timestamp + constraints: + nullable: false + - column: + name: submitted_by + type: uuid + constraints: + nullable: true + - column: + name: status + type: varchar(32) + constraints: + nullable: false + - createIndex: + indexName: idx_recognition_entry_status + tableName: recognition_entry + columns: + - column: + name: status diff --git a/src/main/resources/emails/recognition-digest.html b/src/main/resources/emails/recognition-digest.html new file mode 100644 index 000000000..2596e912c --- /dev/null +++ b/src/main/resources/emails/recognition-digest.html @@ -0,0 +1,4 @@ +

    Hallo $firstName$,

    +

    hier ist der aktuelle Briefkasten für den Tipi-JFX Teilnehmerkreis:

    +$entries$ +LogoMEGADash diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 0e66dcc8b..910d9e292 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -14,6 +14,7 @@ mail.CLARIFICATION_COMPLETED.subject=MEGA: Anmerkung von {0} erledigt mail.CLARIFICATION_UPDATED.subject=MEGA: Anmerkung von {0} aktualisiert mail.CLARIFICATION_DELETED.subject=MEGA: Anmerkung von {0} gelöscht mail.ZEP_CLARIFICATION_PROCESSING_ERROR.subject=MEGA: Kommentar an {0} konnte nicht verarbeitet werden +mail.RECOGNITION_DIGEST.subject=MEGA Briefkasten: Wöchentliche Anerkennungen warning.EXCESS_WORKTIME=Warnung: Sie haben mehr als 10 Stunden eingetragen warning.MISSING_BREAKTIME=Warnung: Sie haben zu wenig Pause eingetragen warning.MISSING_RESTTIME=Warnung: Sie haben zu wenig Ruhezeit eingetragen diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index 303251b04..29b05387d 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -14,6 +14,7 @@ mail.CLARIFICATION_COMPLETED.subject=MEGA: note from {0} completed mail.CLARIFICATION_UPDATED.subject=MEGA: note from {0} updated mail.CLARIFICATION_DELETED.subject=MEGA: Note from {0} deleted mail.ZEP_CLARIFICATION_PROCESSING_ERROR.subject=MEGA: Comment to {0} couldnt be edited +mail.RECOGNITION_DIGEST.subject=MEGA mailbox: weekly recognition entries warning.EXCESS_WORKTIME=Warning: You have entered more than 10 hours warning.MISSING_BREAKTIME=Warning: You have entered too little break time warning.MISSING_RESTTIME=Warning: You have entered too little rest time diff --git a/src/main/resources/openapi/openapi.yaml b/src/main/resources/openapi/openapi.yaml index ecef32cda..da742f86c 100644 --- a/src/main/resources/openapi/openapi.yaml +++ b/src/main/resources/openapi/openapi.yaml @@ -12,6 +12,8 @@ tags: description: Employee-scoped work time endpoints - name: WorkTimeProjectLead description: Project-lead-scoped work time endpoints + - name: Recognition + description: Employee recognition-entry submission endpoints paths: /monthend/payroll-month/employee: $ref: './paths/monthend.yaml#/~1monthend~1payroll-month~1employee' @@ -49,6 +51,8 @@ paths: $ref: './paths/worktime.yaml#/~1worktime~1employee~1{payrollMonth}' /worktime/projects/{payrollMonth}: $ref: './paths/worktime.yaml#/~1worktime~1projects~1{payrollMonth}' + /recognition/entries: + $ref: './paths/recognition.yaml#/~1recognition~1entries' components: schemas: ActiveUser: @@ -63,6 +67,8 @@ components: $ref: './schemas/user.yaml#/UpdateReleaseDatesResponse' InternalRateUploadError: $ref: './schemas/user.yaml#/InternalRateUploadError' + RecognitionEntrySubmission: + $ref: './schemas/recognition.yaml#/RecognitionEntrySubmission' securitySchemes: bearerAuth: type: oauth2 diff --git a/src/main/resources/openapi/paths/recognition.yaml b/src/main/resources/openapi/paths/recognition.yaml new file mode 100644 index 000000000..e91903d78 --- /dev/null +++ b/src/main/resources/openapi/paths/recognition.yaml @@ -0,0 +1,23 @@ +'/recognition/entries': + post: + tags: + - Recognition + operationId: submitRecognitionEntry + summary: Submit a recognition entry for the weekly leadership digest + security: + - bearerAuth: [ ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/recognition.yaml#/RecognitionEntrySubmission' + responses: + '201': + description: Recognition entry submitted successfully. + '400': + $ref: '../responses/common.yaml#/BadRequest' + '403': + $ref: '../responses/common.yaml#/Forbidden' + '500': + $ref: '../responses/common.yaml#/InternalServerError' diff --git a/src/main/resources/openapi/schemas/recognition.yaml b/src/main/resources/openapi/schemas/recognition.yaml new file mode 100644 index 000000000..1de5cdfc1 --- /dev/null +++ b/src/main/resources/openapi/schemas/recognition.yaml @@ -0,0 +1,19 @@ +RecognitionEntrySubmission: + type: object + additionalProperties: false + required: + - message + - category + properties: + message: + type: string + minLength: 1 + maxLength: 500 + category: + type: string + enum: + - APPRECIATION + - COURAGE + anonymous: + type: boolean + default: false diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResourceTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResourceTest.java new file mode 100644 index 000000000..5b8703301 --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/inbound/rest/RecognitionResourceTest.java @@ -0,0 +1,182 @@ +package com.gepardec.mega.hexagon.recognition.adapter.inbound.rest; + +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SubmitRecognitionEntryUseCase; +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SubmitRecognitionEntryCommand; +import com.gepardec.mega.hexagon.recognition.domain.error.RecognitionValidationException; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; +import com.gepardec.mega.hexagon.shared.application.security.AuthenticatedActorContext; +import com.gepardec.mega.hexagon.shared.domain.model.Role; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import com.gepardec.mega.hexagon.shared.domain.model.ZepUsername; +import com.gepardec.mega.hexagon.user.domain.model.User; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.http.ContentType; +import org.instancio.Instancio; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.instancio.Select.field; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@QuarkusTest +@TestSecurity(user = "test") +class RecognitionResourceTest { + + @InjectMock + AuthenticatedActorContext authenticatedActorContext; + + @InjectMock + SubmitRecognitionEntryUseCase submitRecognitionEntryUseCase; + + private UserId submitterId; + + @BeforeEach + void setUp() { + submitterId = UserId.of(Instancio.create(UUID.class)); + allowRoles(Role.EMPLOYEE); + stubInternalUser(); + when(authenticatedActorContext.userId()).thenReturn(submitterId); + } + + @Test + void submitRecognitionEntry_shouldSubmitValidEntryForEmployeeRole() { + given() + .contentType(ContentType.JSON) + .body(""" + {"message":"Danke für deine Hilfe.","category":"APPRECIATION"} + """) + .post("/recognition/entries") + .then() + .statusCode(201); + + verify(submitRecognitionEntryUseCase).submit(argThat(command -> + command.message().equals("Danke für deine Hilfe.") + && command.category() == RecognitionCategory.APPRECIATION + && !command.anonymous()), + eq(submitterId)); + } + + @Test + void submitRecognitionEntry_shouldForwardAnonymousSubmission() { + given() + .contentType(ContentType.JSON) + .body(""" + {"message":"Danke für deine Hilfe.","category":"APPRECIATION","anonymous":true} + """) + .post("/recognition/entries") + .then() + .statusCode(201); + + verify(submitRecognitionEntryUseCase).submit(argThat(SubmitRecognitionEntryCommand::anonymous), eq(submitterId)); + } + + @Test + void submitRecognitionEntry_shouldRejectBlankMessage() { + doThrow(new RecognitionValidationException("message must not be blank")) + .when(submitRecognitionEntryUseCase) + .submit(argThat(command -> command.message().isBlank()), eq(submitterId)); + + given() + .contentType(ContentType.JSON) + .body(""" + {"message":" ","category":"COURAGE"} + """) + .post("/recognition/entries") + .then() + .statusCode(400); + + verify(submitRecognitionEntryUseCase).submit(argThat(command -> command.message().isBlank()), eq(submitterId)); + } + + @Test + void submitRecognitionEntry_shouldRejectInvalidCategory() { + given() + .contentType(ContentType.JSON) + .body(""" + {"message":"Danke für deine Hilfe.","category":"UNKNOWN"} + """) + .post("/recognition/entries") + .then() + .statusCode(400); + + verifyNoInteractions(submitRecognitionEntryUseCase); + } + + @Test + void submitRecognitionEntry_shouldRejectCallerWithoutEmployeeRole() { + allowRoles(Role.PROJECT_LEAD); + + given() + .contentType(ContentType.JSON) + .body(""" + {"message":"Danke für deine Hilfe.","category":"APPRECIATION"} + """) + .post("/recognition/entries") + .then() + .statusCode(403); + + verifyNoInteractions(submitRecognitionEntryUseCase); + } + + @Test + void submitRecognitionEntry_shouldRejectExternalCaller() { + stubExternalUser(); + + given() + .contentType(ContentType.JSON) + .body(""" + {"message":"Danke für deine Hilfe.","category":"APPRECIATION"} + """) + .post("/recognition/entries") + .then() + .statusCode(403); + + verifyNoInteractions(submitRecognitionEntryUseCase); + } + + @Test + @TestSecurity + void submitRecognitionEntry_shouldRejectUnauthenticatedCaller() { + given() + .contentType(ContentType.JSON) + .body(""" + {"message":"Danke für deine Hilfe.","category":"APPRECIATION"} + """) + .post("/recognition/entries") + .then() + .statusCode(401); + + verifyNoInteractions(submitRecognitionEntryUseCase); + } + + private void allowRoles(Role... roles) { + when(authenticatedActorContext.roles()).thenReturn(Set.of(roles)); + } + + private void stubInternalUser() { + stubUser("test.internal"); + } + + private void stubExternalUser() { + stubUser("e.external"); + } + + private void stubUser(String zepUsername) { + User user = Instancio.of(User.class) + .set(field(User::id), submitterId) + .set(field(User::zepUsername), ZepUsername.of(zepUsername)) + .create(); + when(authenticatedActorContext.user()).thenReturn(user); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapterTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapterTest.java new file mode 100644 index 000000000..6e5e30025 --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/ProjectLeadDirectoryAdapterTest.java @@ -0,0 +1,73 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; +import com.gepardec.mega.hexagon.shared.domain.model.Email; +import com.gepardec.mega.hexagon.shared.domain.model.FullName; +import com.gepardec.mega.hexagon.shared.domain.model.Role; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import com.gepardec.mega.hexagon.shared.domain.model.ZepUsername; +import com.gepardec.mega.hexagon.user.domain.model.EmploymentPeriod; +import com.gepardec.mega.hexagon.user.domain.model.EmploymentPeriods; +import com.gepardec.mega.hexagon.user.domain.model.User; +import com.gepardec.mega.hexagon.user.domain.port.outbound.UserRepository; +import org.instancio.Instancio; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class ProjectLeadDirectoryAdapterTest { + + private UserRepository userRepository; + private ProjectLeadDirectoryMapper mapper; + private ProjectLeadDirectoryAdapter adapter; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + mapper = mock(ProjectLeadDirectoryMapper.class); + adapter = new ProjectLeadDirectoryAdapter(userRepository, mapper); + } + + @Test + void findActiveInternalProjectLeads_shouldReturnOnlyActiveInternalProjectLeadsForReferenceDate() { + LocalDate referenceDate = LocalDate.of(2026, 7, 6); + User activeInternalLead = user("lead", Set.of(Role.PROJECT_LEAD), LocalDate.of(2024, 1, 1), null); + User inactiveLead = user("inactive", Set.of(Role.PROJECT_LEAD), LocalDate.of(2024, 1, 1), LocalDate.of(2026, 7, 5)); + User externalLead = user("external", Set.of(Role.PROJECT_LEAD), LocalDate.of(2024, 1, 1), null); + User nonLead = user("employee", Set.of(Role.EMPLOYEE), LocalDate.of(2024, 1, 1), null); + RecognitionMailRecipient recipient = new RecognitionMailRecipient(Email.of("lead@example.com"), "Lead"); + + when(userRepository.findByRole(Role.PROJECT_LEAD)) + .thenReturn(List.of(activeInternalLead, inactiveLead, externalLead, nonLead)); + when(mapper.toRecipient(activeInternalLead)).thenReturn(recipient); + + List recipients = adapter.findActiveInternalProjectLeads(referenceDate); + + assertThat(recipients).containsExactly(recipient); + verify(userRepository).findByRole(Role.PROJECT_LEAD); + verify(mapper).toRecipient(activeInternalLead); + verifyNoMoreInteractions(mapper); + } + + private User user(String username, Set roles, LocalDate start, LocalDate end) { + return new User( + UserId.of(Instancio.create(UUID.class)), + Email.of(username + "@example.com"), + FullName.of(username, "User"), + ZepUsername.of(username), + null, + new EmploymentPeriods(new EmploymentPeriod(start, end)), + roles + ); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java new file mode 100644 index 000000000..7868d7640 --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java @@ -0,0 +1,74 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryId; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; +import com.gepardec.mega.hexagon.shared.domain.model.Email; +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.Mailer; +import org.instancio.Instancio; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class QuarkusRecognitionMailAdapterTest { + + private Mailer mailer; + private QuarkusRecognitionMailAdapter adapter; + + @BeforeEach + void setUp() { + mailer = mock(Mailer.class); + adapter = new QuarkusRecognitionMailAdapter(mailer, Optional.of("TEST: ")); + } + + @Test + void sendDigest_shouldRenderCategoryGroupedEntriesAndSendToRecipientAddress() { + RecognitionMailRecipient recipient = new RecognitionMailRecipient(Email.of("lead@example.com"), "Ada"); + + adapter.sendDigest(recipient, List.of( + entry("Danke für die Hilfe im Kundentermin.", RecognitionCategory.APPRECIATION), + entry("Mutige Entscheidung unter Druck.", RecognitionCategory.COURAGE) + )); + + Mail mail = capturedMail(); + assertThat(mail.getTo()).containsExactly("lead@example.com"); + assertThat(mail.getSubject()).isEqualTo("TEST: MEGA Briefkasten: Wöchentliche Anerkennungen"); + assertThat(mail.getHtml()).contains("Hallo Ada,"); + assertThat(mail.getHtml()).contains("Lob & Wertschätzung", "Danke für die Hilfe im Kundentermin."); + assertThat(mail.getHtml()).contains("

    Mut

    ", "Mutige Entscheidung unter Druck."); + } + + @Test + void sendDigest_shouldRenderEmptyStateWhenNoEntriesExist() { + adapter.sendDigest(new RecognitionMailRecipient(Email.of("lead@example.com"), "Ada"), List.of()); + + assertThat(capturedMail().getHtml()) + .contains("Diese Woche wurden keine neuen Anerkennungen eingereicht."); + } + + private Mail capturedMail() { + ArgumentCaptor mailCaptor = ArgumentCaptor.forClass(Mail.class); + verify(mailer).send(mailCaptor.capture()); + return mailCaptor.getValue(); + } + + private RecognitionEntry entry(String message, RecognitionCategory category) { + return RecognitionEntry.create( + RecognitionEntryId.of(Instancio.create(UUID.class)), + message, + category, + Instant.parse("2026-07-06T15:30:00Z") + ); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapperTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapperTest.java new file mode 100644 index 000000000..5d02ef42e --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionEntryMapperTest.java @@ -0,0 +1,51 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryId; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import org.instancio.Instancio; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class RecognitionEntryMapperTest { + + private final RecognitionEntryMapper mapper = Mappers.getMapper(RecognitionEntryMapper.class); + + @Test + void updateEntity_shouldStoreSubmitterIdForNonAnonymousEntry() { + UserId submitterId = UserId.of(Instancio.create(UUID.class)); + RecognitionEntry entry = RecognitionEntry.create( + RecognitionEntryId.of(Instancio.create(UUID.class)), + "Danke für den Einsatz.", + RecognitionCategory.APPRECIATION, + Instant.parse("2026-07-06T15:30:00Z"), + submitterId + ); + RecognitionEntryEntity entity = Instancio.create(RecognitionEntryEntity.class); + + mapper.updateEntity(entry, entity); + + assertThat(entity.getSubmittedBy()).isEqualTo(submitterId.value()); + } + + @Test + void updateEntity_shouldRemoveSubmitterIdForAnonymousEntry() { + RecognitionEntry entry = RecognitionEntry.create( + RecognitionEntryId.of(Instancio.create(UUID.class)), + "Danke für den Einsatz.", + RecognitionCategory.APPRECIATION, + Instant.parse("2026-07-06T15:30:00Z") + ); + RecognitionEntryEntity entity = Instancio.create(RecognitionEntryEntity.class); + + mapper.updateEntity(entry, entity); + + assertThat(entity.getSubmittedBy()).isNull(); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java new file mode 100644 index 000000000..93e625734 --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java @@ -0,0 +1,124 @@ +package com.gepardec.mega.hexagon.recognition.application; + +import com.gepardec.mega.hexagon.recognition.application.port.outbound.ProjectLeadDirectoryPort; +import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionMailPort; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryId; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryStatus; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; +import com.gepardec.mega.hexagon.recognition.domain.port.outbound.RecognitionEntryRepository; +import com.gepardec.mega.hexagon.shared.domain.model.Email; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import org.instancio.Instancio; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class RecognitionDigestServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-06T15:30:00Z"); + private static final LocalDate REFERENCE_DATE = LocalDate.of(2026, 7, 6); + + private RecognitionEntryRepository recognitionEntryRepository; + private ProjectLeadDirectoryPort projectLeadDirectoryPort; + private RecognitionMailPort recognitionMailPort; + private RecognitionDigestService service; + + @BeforeEach + void setUp() { + recognitionEntryRepository = mock(RecognitionEntryRepository.class); + projectLeadDirectoryPort = mock(ProjectLeadDirectoryPort.class); + recognitionMailPort = mock(RecognitionMailPort.class); + service = new RecognitionDigestService( + recognitionEntryRepository, + projectLeadDirectoryPort, + recognitionMailPort, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + } + + @Test + void sendDigest_shouldSendNewEntriesToEveryRecipientAndMarkThemIncluded() { + RecognitionEntry entry = entry("Großartige Unterstützung im Projekt.", RecognitionCategory.APPRECIATION); + RecognitionMailRecipient firstRecipient = recipient("lead-one@example.com", "Ada"); + RecognitionMailRecipient secondRecipient = recipient("lead-two@example.com", "Grace"); + when(projectLeadDirectoryPort.findActiveInternalProjectLeads(REFERENCE_DATE)) + .thenReturn(List.of(firstRecipient, secondRecipient)); + when(recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW)).thenReturn(List.of(entry)); + + service.sendDigest(); + + verify(projectLeadDirectoryPort).findActiveInternalProjectLeads(REFERENCE_DATE); + verify(recognitionMailPort).sendDigest(firstRecipient, List.of(entry)); + verify(recognitionMailPort).sendDigest(secondRecipient, List.of(entry)); + verify(recognitionEntryRepository).save(entry.includeInDigest()); + } + + @Test + void sendDigest_shouldSendEmptyStateWithoutChangingEntryStatusWhenNoNewEntriesExist() { + RecognitionMailRecipient recipient = recipient("lead@example.com", "Ada"); + when(projectLeadDirectoryPort.findActiveInternalProjectLeads(REFERENCE_DATE)).thenReturn(List.of(recipient)); + when(recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW)).thenReturn(List.of()); + + service.sendDigest(); + + verify(recognitionMailPort).sendDigest(recipient, List.of()); + verify(recognitionEntryRepository, never()).save(any()); + } + + @Test + void sendDigest_shouldDoNothingWhenNoRecipientsExist() { + when(projectLeadDirectoryPort.findActiveInternalProjectLeads(REFERENCE_DATE)).thenReturn(List.of()); + + service.sendDigest(); + + verify(projectLeadDirectoryPort).findActiveInternalProjectLeads(REFERENCE_DATE); + verifyNoInteractions(recognitionEntryRepository, recognitionMailPort); + } + + @Test + void sendDigest_shouldLeaveEntriesNewWhenMailDispatchFails() { + RecognitionEntry entry = entry("Mutiger Einsatz", RecognitionCategory.COURAGE); + RecognitionMailRecipient recipient = recipient("lead@example.com", "Ada"); + when(projectLeadDirectoryPort.findActiveInternalProjectLeads(REFERENCE_DATE)).thenReturn(List.of(recipient)); + when(recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW)).thenReturn(List.of(entry)); + doThrow(new IllegalStateException("mail dispatch failed")) + .when(recognitionMailPort).sendDigest(recipient, List.of(entry)); + + assertThatThrownBy(service::sendDigest) + .isInstanceOf(IllegalStateException.class) + .hasMessage("mail dispatch failed"); + + verify(recognitionEntryRepository, never()).save(any()); + } + + private RecognitionEntry entry(String message, RecognitionCategory category) { + return RecognitionEntry.create( + RecognitionEntryId.of(Instancio.create(UUID.class)), + message, + category, + NOW, + UserId.of(Instancio.create(UUID.class)) + ); + } + + private RecognitionMailRecipient recipient(String email, String firstName) { + return new RecognitionMailRecipient(Email.of(email), firstName); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryServiceTest.java new file mode 100644 index 000000000..ea5462c0c --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/application/SubmitRecognitionEntryServiceTest.java @@ -0,0 +1,87 @@ +package com.gepardec.mega.hexagon.recognition.application; + +import com.gepardec.mega.hexagon.recognition.application.port.inbound.SubmitRecognitionEntryCommand; +import com.gepardec.mega.hexagon.recognition.domain.error.RecognitionValidationException; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryStatus; +import com.gepardec.mega.hexagon.recognition.domain.port.outbound.RecognitionEntryRepository; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import org.instancio.Instancio; +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +class SubmitRecognitionEntryServiceTest { + + private static final Instant SUBMISSION_TIME = Instant.parse("2026-07-06T15:30:00Z"); + private static final UserId SUBMITTER_ID = UserId.of(Instancio.create(UUID.class)); + + private RecognitionEntryRepository recognitionEntryRepository; + private SubmitRecognitionEntryService service; + + @BeforeEach + void setUp() { + recognitionEntryRepository = mock(RecognitionEntryRepository.class); + service = new SubmitRecognitionEntryService( + recognitionEntryRepository, + Clock.fixed(SUBMISSION_TIME, ZoneOffset.UTC) + ); + } + + @Test + void submit_shouldPersistSubmitterForNonAnonymousEntry() { + service.submit( + new SubmitRecognitionEntryCommand("Danke für den Mut im Kundentermin.", RecognitionCategory.COURAGE, false), + SUBMITTER_ID + ); + + ArgumentCaptor entryCaptor = ArgumentCaptor.forClass(RecognitionEntry.class); + verify(recognitionEntryRepository).save(entryCaptor.capture()); + RecognitionEntry entry = entryCaptor.getValue(); + assertThat(entry.message()).isEqualTo("Danke für den Mut im Kundentermin."); + assertThat(entry.category()).isEqualTo(RecognitionCategory.COURAGE); + assertThat(entry.submittedAt()).isEqualTo(SUBMISSION_TIME); + assertThat(entry.status()).isEqualTo(RecognitionEntryStatus.NEW); + assertThat(entry.submittedBy()).isEqualTo(SUBMITTER_ID); + } + + @Test + void submit_shouldNotPersistSubmitterForAnonymousEntry() { + service.submit( + new SubmitRecognitionEntryCommand("Danke für den Mut im Kundentermin.", RecognitionCategory.COURAGE, true), + SUBMITTER_ID + ); + + ArgumentCaptor entryCaptor = ArgumentCaptor.forClass(RecognitionEntry.class); + verify(recognitionEntryRepository).save(entryCaptor.capture()); + + assertThat(entryCaptor.getValue().submittedBy()).isNull(); + } + + @Test + void submit_shouldRejectBlankMessageWithoutPersistingEntry() { + ThrowableAssert.ThrowingCallable throwingCallable = () -> service.submit( + new SubmitRecognitionEntryCommand(" ", RecognitionCategory.APPRECIATION, false), + SUBMITTER_ID + ); + + assertThatThrownBy(throwingCallable) + .isInstanceOf(RecognitionValidationException.class) + .hasMessage("message must not be blank"); + + verifyNoInteractions(recognitionEntryRepository); + } +} diff --git a/src/test/resources/messages.properties b/src/test/resources/messages.properties index dabe10d20..d830ec56b 100644 --- a/src/test/resources/messages.properties +++ b/src/test/resources/messages.properties @@ -15,6 +15,7 @@ mail.CLARIFICATION_COMPLETED.subject=MEGA: Anmerkung von {0} erledigt mail.CLARIFICATION_UPDATED.subject=MEGA: Anmerkung von {0} aktualisiert mail.CLARIFICATION_DELETED.subject=MEGA: Anmerkung von {0} gelöscht mail.ZEP_CLARIFICATION_PROCESSING_ERROR.subject=MEGA: Kommentar an {0} konnte nicht verarbeitet werden +mail.RECOGNITION_DIGEST.subject=MEGA Briefkasten: Wöchentliche Anerkennungen warning.EXCESS_WORKTIME=Warnung: Sie haben mehr als 10 Stunden eingetragen warning.MISSING_BREAKTIME=Warnung: Sie haben zu wenig Pause eingetragen warning.MISSING_RESTTIME=Warnung: Sie haben zu wenig Ruhezeit eingetragen diff --git a/src/test/resources/messages_en.properties b/src/test/resources/messages_en.properties index 87210d06b..93cad34ba 100644 --- a/src/test/resources/messages_en.properties +++ b/src/test/resources/messages_en.properties @@ -10,6 +10,7 @@ mail.CLARIFICATION_COMPLETED.subject=MEGA: note from {0} completed mail.CLARIFICATION_UPDATED.subject=MEGA: note from {0} updated mail.CLARIFICATION_DELETED.subject=MEGA: Note from {0} deleted mail.ZEP_CLARIFICATION_PROCESSING_ERROR.subject=MEGA: Comment to {0} couldnt be edited +mail.RECOGNITION_DIGEST.subject=MEGA mailbox: weekly recognition entries warning.journey.BACK_MISSING=Warning: Back direction of journey is missing or is before of time range warning.journey.TO_MISSING=Warning: To direction of journey is missing or is before the time range warning.journey.INVALID_WORKING_LOCATION=Invalid working location for an entry during a journey From a6bf8cd16f735675f8bcc4718b37708189ed3377 Mon Sep 17 00:00:00 2001 From: Oliver Tod Date: Mon, 13 Jul 2026 22:15:57 +0200 Subject: [PATCH 2/2] [Gepardec/mega#820] feat: build recognition digest email with qute template --- .../.openspec.yaml | 2 + .../design.md | 107 ++++++++++++++++++ .../proposal.md | 28 +++++ .../specs/recognition-weekly-digest/spec.md | 25 ++++ .../tasks.md | 45 ++++++++ .../specs/recognition-weekly-digest/spec.md | 17 ++- .../QuarkusRecognitionMailAdapter.java | 85 ++++++-------- .../RecognitionSubmitterDirectoryAdapter.java | 32 ++++++ .../application/RecognitionDigestService.java | 43 ++++++- .../model/RecognitionDigestEntry.java | 18 +++ .../port/outbound/RecognitionMailPort.java | 4 +- .../RecognitionSubmitterDirectoryPort.java | 11 ++ .../resources/emails/recognition-digest.html | 4 - .../templates/recognition-digest.html | 24 ++++ .../QuarkusRecognitionMailAdapterTest.java | 101 +++++++++++++---- .../RecognitionDigestServiceTest.java | 46 +++++++- 16 files changed, 501 insertions(+), 91 deletions(-) create mode 100644 openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/design.md create mode 100644 openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/proposal.md create mode 100644 openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/specs/recognition-weekly-digest/spec.md create mode 100644 openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/tasks.md create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionSubmitterDirectoryAdapter.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/model/RecognitionDigestEntry.java create mode 100644 src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionSubmitterDirectoryPort.java delete mode 100644 src/main/resources/emails/recognition-digest.html create mode 100644 src/main/resources/templates/recognition-digest.html diff --git a/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/.openspec.yaml b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/.openspec.yaml new file mode 100644 index 000000000..b119b6350 --- /dev/null +++ b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/design.md b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/design.md new file mode 100644 index 000000000..87f7dda1d --- /dev/null +++ b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/design.md @@ -0,0 +1,107 @@ +## Context + +The recognition bounded context sends a weekly digest ("Briefkasten") email to internal project leads. The outbound adapter `QuarkusRecognitionMailAdapter` (`src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/`) currently builds the mail body imperatively: + +- It reads a raw HTML resource (`emails/recognition-digest.html`) via `getResourceAsStream` and substitutes `$firstName$` and `$entries$` with `String.replace`. +- It hand-rolls the entries HTML through `renderEntries` / `renderCategoryEntries`, concatenating `
  • ` elements and grouping by `RecognitionCategory` (APPRECIATION → "Lob & Wertschätzung", COURAGE → "Mut"), or emitting a fixed empty-state paragraph. +- It manually HTML-escapes every user-supplied value via a helper (`escapeHtml`, delegating to Guava `HtmlEscapers`). +- It resolves the subject through `ResourceBundle` (`messages.properties`, key `mail.RECOGNITION_DIGEST.subject`), prepends the configured `mega.mail.subject-prefix`, and sends via the imperative `Mailer` / `Mail.withHtml(...)` API, adding the inline logo as a CID attachment (``). + +Recognition entries now retain an optional `submittedBy` user ID. A null value means the submitter chose to remain anonymous; the digest must display `Anonym` in that case and the submitter's display name otherwise. + +Constraints: + +- Quarkus 3 / JDK 21. `quarkus-mailer` is already a dependency; there is no explicit `quarkus-qute` dependency in `pom.xml` (Qute ships transitively with the mailer, but the mailer's typed template integration must be confirmed at build time). +- Submitter-name resolution belongs in the application layer. Digest assembly creates display-ready entries containing `message`, `category`, and `submitterName`; the mail adapter receives these values and does not query user persistence. +- Project convention: use the framework-native templating and default auto-escaping rather than bespoke helpers. + +Current behavior that MUST be preserved (already covered by `recognition-weekly-digest` spec, unchanged here): always-send (including empty-state), appreciation/courage grouping, recipient resolution, weekly trigger, and post-send status transitions. + +## Goals / Non-Goals + +**Goals:** + +- Render the digest body from a Qute mail template under `src/main/resources/templates/` instead of raw-resource reading plus manual placeholder substitution. +- Replace the manual entry loops and category grouping with template-level `{#if}` / `{#for}` constructs, including the empty-state message and the two category sections. +- Delegate HTML escaping to Qute's default auto-escaping for `{expression}`, deleting the `escapeHtml` helper; opt out with `.raw` only where markup is intentional (e.g. the logo `` fragment if it lives in the template). +- Preserve the inline CID logo attachment and the subject-prefix behavior. +- Keep the observable output equivalent (same sections, same headings, same empty-state text) so existing tests and recipients see no behavior change. +- Show every entry's submitter display name when `submittedBy` is present, or the literal `Anonym` when it is absent. + +**Non-Goals:** + +- No change to recognition submission, `submittedBy` persistence semantics, the scheduler, recipient resolution, or entry status transitions. +- No change to the visible content/wording of the email beyond the required submitter attribution. +- No spec-level (behavioral) changes — this is an internal rendering refactor. No delta spec is produced. +- No redesign of the email's HTML/CSS styling. + +## Decisions + +### Decision 1: Typed mail template via the mailer's Qute integration + +Use the `quarkus-mailer` + Qute integration to render the body. Two concrete shapes are available: + +- A typed `@CheckedTemplate` declaring a static method returning a `MailTemplate.MailTemplateInstance` (or `TemplateInstance`), giving compile-time checking of the template name and parameters. +- A field-injected `MailTemplate` (`@Inject @Location("recognition-digest") MailTemplate template`). + +**Choose the typed `@CheckedTemplate`** for compile-time safety of template name and parameter names, consistent with the DDD/hexagonal preference for explicit, checked boundaries. The template file lives at `src/main/resources/templates/.html` matching the checked-template method. + +*Alternative considered:* keep imperative `Mail.withHtml` and only move the HTML fragment into Qute rendered via `Template#data(...)`. Rejected in favor of the typed template for parameter safety, but see Decision 3 for why the send is still partly imperative. + +### Decision 2: Template-driven grouping and escaping + +Pass the recipient's first name and the entry list (or two pre-filtered lists) to the template. The template: + +- Uses `{#if entries.isEmpty()}` (or an equivalent empty flag) to emit the empty-state paragraph ("Diese Woche wurden keine neuen Anerkennungen eingereicht."). +- Uses `{#for}` over entries filtered by category to render each `
  • {entry.message}
  • `, wrapped in the `

    `/`
      ` section, and only renders a section when it has entries. +- Relies on Qute's default HTML escaping for `{recipient.firstName}` and `{entry.message}`, so the `escapeHtml` helper and the Guava `HtmlEscapers` usage in this adapter are removed. + +Category filtering may be done either in the template (via `{#if entry.category ...}`) or by passing already-partitioned lists from the adapter. **Prefer passing partitioned data** (two lists, plus a boolean/empty indicator) from the adapter so the template stays simple and the category-to-heading mapping ("Lob & Wertschätzung", "Mut") is explicit; the template only iterates and renders headings. This keeps enum-comparison logic out of the template. + +*Note:* the category headings themselves are static literal HTML text in the template and are safe; only user-supplied `message` values require escaping, which Qute does by default. + +### Decision 3: Inline logo stays on the imperative Mail API (mixed approach) + +Qute renders the HTML body; the inline logo is attached through the imperative `Mail` API as today (`addInlineAttachment(... "")`), and the template references it via `src="cid:LogoMEGAdash@gepardec.com"`. The typed template produces the body string / `MailTemplateInstance`, and the CID attachment is added on the resulting `Mail`. This deliberately mixes typed-template rendering with imperative attachment, which is the supported pattern for inline attachments and avoids re-implementing attachment handling. The `cid:` reference in the template is emitted with `.raw` if it would otherwise be escaped, or simply written as static markup. + +### Decision 4: Subject source — keep ResourceBundle + +The subject is currently `subject-prefix` + `ResourceBundle` lookup of `mail.RECOGNITION_DIGEST.subject` from `messages.properties`. + +**Decided: keep the existing `ResourceBundle` subject lookup.** It works, is shared with the legacy notification stack, and is orthogonal to body rendering — keeping it minimizes blast radius. Only the body moves to Qute in this change. + +*Alternative considered:* moving the subject to Qute's message bundle / i18n (`{msg:...}` / `@MessageBundle`). Rejected for this change — it would introduce a parallel message-bundle mechanism alongside the legacy `messages.properties` used elsewhere, risking duplication/drift for a single key. If pursued later, it only affects the subject line, not the body template; left as a possible follow-up. + +### Decision 5: Resolve submitter names before rendering + +`RecognitionEntry` retains a user ID rather than a display name, so the digest service derives a `RecognitionDigestEntry` rendering projection before mail dispatch. It collects all non-null `submittedBy` IDs, resolves them in one batch through a recognition-owned submitter-directory port, then supplies `message`, `category`, and `submitterName` to the mail port. + +Entries with a null `submittedBy` use the literal `Anonym`. A non-null ID that cannot be resolved is treated as an integrity failure rather than silently rendered as anonymous; this prevents accidental misattribution. The Qute template only interpolates the already-resolved `submitterName` alongside each message and continues to auto-escape it. + +*Alternative considered:* resolve user names inside `QuarkusRecognitionMailAdapter`. Rejected because the outbound mail adapter would then depend on user persistence and mix application-level identity lookup with rendering. + +## Risks / Trade-offs + +- **Qute mailer template integration not on the classpath** → Build/inject failure. Mitigation: verify at build time whether the mailer's typed-template support needs an explicit dependency (e.g. `quarkus-qute`) or is transitive via `quarkus-mailer`; add the dependency in the first task if missing (captured in tasks.md). +- **Auto-escaping changes rendered output** (e.g. an entry message that previously round-tripped through Guava escaping now escaped slightly differently, or the `cid:` reference/logo markup getting escaped) → Broken image or subtly different HTML. Mitigation: use `.raw` only for the intentional static logo markup; assert rendered output in tests, including an entry containing HTML special characters to confirm escaping. +- **Behavioral drift from "equivalent output"** (missing a section, wrong empty-state text, different heading) → Recipients see a changed email. Mitigation: keep/extend existing adapter tests asserting empty-state, single-category, and both-category rendering; compare against current output semantics. +- **Mixed typed-template + imperative attachment is easy to get subtly wrong** (body not attached to the same `Mail` that carries the inline attachment) → Missing logo or unstyled body. Mitigation: build the `Mail` from the rendered template instance and add the inline attachment to that same instance; cover with a test asserting the inline attachment CID is present. +- **Template file location/name mismatch with `@CheckedTemplate`** → Runtime template-not-found. Mitigation: place the template at the exact `templates/.html` path matching the checked-template method; a rendering test catches this early. + +## Migration Plan + +1. Confirm/add the Qute mailer template dependency in `pom.xml`. +2. Add the Qute template under `src/main/resources/templates/` reproducing the current body (greeting, intro line, category sections or empty-state, logo `img` referencing the CID). +3. Rewrite `QuarkusRecognitionMailAdapter` to render via the typed template and attach the inline logo imperatively; delete `escapeHtml`, `renderEntries`, `renderCategoryEntries`, raw-template reading, and the Guava `HtmlEscapers` import. +4. Retire the raw `emails/recognition-digest.html` resource. +5. Add the digest display projection and a recognition-owned submitter-directory port; batch-resolve recorded submitter IDs in `RecognitionDigestService`, using `Anonym` for null IDs. +6. Render `submitterName` in each Qute entry and cover named and anonymous attribution in service and mail-adapter tests. +7. Run the digest adapter/behavior tests (mailer mock) and adjust assertions to the equivalent rendered output; add an escaping test. + +Rollback: revert the adapter and template changes; the raw HTML resource and imperative rendering are restored from version control. No data or schema migration is involved, so rollback is code-only. + +## Resolved Questions + +- Subject i18n (Decision 4): **keep `ResourceBundle`** for the subject in this change; the Qute `{msg:...}` migration is a possible separate follow-up. +- Category partitioning: **partition adapter-side** (Decision 2) and pass pre-filtered appreciation/courage lists to the template; the template only iterates and renders headings. +- Submitter attribution (Decision 5): **resolve names application-side in one batch** and render `Anonym` only for entries with no recorded submitter. diff --git a/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/proposal.md b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/proposal.md new file mode 100644 index 000000000..0b1a47581 --- /dev/null +++ b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/proposal.md @@ -0,0 +1,28 @@ +## Why + +The recognition weekly-digest email is currently built by reading a raw HTML file and performing manual string-placeholder substitution, hand-rolling the entry list with string concatenation, and manually HTML-escaping every piece of user-supplied text. This is error-prone (a missed escape is an HTML-injection risk), hard to read, and diverges from the framework-native templating the platform already ships. Migrating the digest body to the mailer's Qute templating makes the rendering declarative, delegates HTML escaping to the template engine by default, and removes bespoke helper code. The digest must also disclose the recorded submitter for each recognition while preserving the submitter's anonymous choice. + +## What Changes + +- Render the digest body from a Qute mail template instead of reading a raw HTML resource and doing manual `$firstName$` / `$entries$` placeholder replacement. +- Replace the hand-rolled entry-rendering loops and category grouping with template-level conditionals and iteration (empty-state message, appreciation/praise vs. courage sections). +- Rely on the template engine's default HTML auto-escaping for interpolated values, removing the manual HTML-escaping helper entirely (opting out only where raw HTML is intentional, e.g. the pre-rendered logo markup). +- Preserve the inline logo attachment: the template renders the body while the imperative mail API still attaches the inline CID logo (``); the two remain mixed. +- Keep the subject as-is (subject-prefix + `ResourceBundle` lookup); only the body moves to Qute. Migrating the subject to the template engine's message/i18n mechanism is left as a possible follow-up (see design.md, Decision 4). +- Add the mailer-template / Qute integration dependency if it is not already transitively present; remove the now-unused manual-escaping and raw-template-reading dependencies from this adapter. +- Present attribution for every digest entry: resolve the recorded `submittedBy` user ID to a display name, or render `Anonym` when `submittedBy` is absent. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `recognition-weekly-digest`: The digest retains its always-send behavior, empty-state message, appreciation/courage grouping, recipient resolution, weekly trigger, and status transitions. It now guarantees that markup-significant characters in entry messages reach recipients as literal text, and that each entry shows its recorded submitter's name or `Anonym` when no submitter was recorded. Captured as a MODIFIED delta on the "digest contains all entries" requirement in `specs/recognition-weekly-digest/spec.md`. + +## Impact + +- **Code**: `QuarkusRecognitionMailAdapter` (outbound adapter of the recognition bounded context) renders a display-ready digest model via a Qute mail template. `RecognitionDigestService` resolves submitter display names through a recognition-owned directory port before dispatch. The Qute template lives under `src/main/resources/templates/`; the raw `emails/recognition-digest.html` resource is retired. +- **Dependencies**: Requires the `quarkus-mailer` Qute template integration (verify whether an explicit dependency such as `quarkus-qute` is needed or whether it is already transitive via `quarkus-mailer`). The manual-escaping dependency (Guava `HtmlEscapers`) and raw-resource reading in this adapter are no longer used here. +- **Tests**: Digest service tests cover named and anonymous attribution; mail-adapter tests cover the rendered name, `Anonym`, template rendering, and auto-escaping. +- **Behavior / specs**: The `recognition-weekly-digest` delta guarantees literal rendering of markup-significant message characters and submitter attribution. Send behavior is otherwise unchanged. diff --git a/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/specs/recognition-weekly-digest/spec.md b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/specs/recognition-weekly-digest/spec.md new file mode 100644 index 000000000..0f95fa521 --- /dev/null +++ b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/specs/recognition-weekly-digest/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: The digest contains all entries not yet included in a previous digest +Each weekly digest SHALL contain every recognition entry whose status is "new" at the time the digest is assembled, and SHALL NOT contain entries already included in a previous digest. The digest content SHALL present each entry's message, its category (praise/appreciation or courage), and its submitter attribution. When an entry has a recorded `submittedBy` value, the attribution SHALL show that user's display name; when `submittedBy` is absent, it SHALL show the literal `Anonym`. Entry messages and submitter names SHALL be presented as text: any characters that are significant to the digest's markup SHALL be shown to the recipient as literal text and SHALL NOT be interpreted as markup. + +#### Scenario: New entries appear in the digest +- **WHEN** the digest is assembled and there are entries with status "new" +- **THEN** every such entry is included in the digest, showing its message, category, and submitter attribution + +#### Scenario: Recorded submitter appears by name +- **WHEN** a new recognition entry has a `submittedBy` value +- **THEN** its digest entry shows the corresponding submitter display name + +#### Scenario: Anonymous submitter is labeled explicitly +- **WHEN** a new recognition entry has no `submittedBy` value +- **THEN** its digest entry shows `Anonym` as the submitter attribution + +#### Scenario: Previously included entries do not reappear +- **WHEN** the digest is assembled +- **THEN** entries already marked "included in digest" are not part of the digest + +#### Scenario: Entry content with markup-significant characters is shown as text +- **WHEN** a "new" entry's message or submitter name contains characters that are significant to the digest's markup +- **THEN** those characters appear in the digest as literal text to the recipient +- **AND** they are not interpreted as markup diff --git a/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/tasks.md b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/tasks.md new file mode 100644 index 000000000..94f3eb698 --- /dev/null +++ b/openspec/changes/archive/2026-07-15-refactor-recognition-digest-qute-templating/tasks.md @@ -0,0 +1,45 @@ +## 1. Dependency verification + +- [x] 1.1 Check `pom.xml` for whether the mailer's Qute template support (typed `MailTemplate` / `@CheckedTemplate` rendering) is already available transitively via `quarkus-mailer`; run a quick build/inject smoke check. +- [x] 1.2 If the typed mail-template support is not resolvable, add the required dependency (e.g. `quarkus-qute`) to `pom.xml` and confirm it resolves. + +## 2. Qute template + +- [x] 2.1 Create the digest Qute template under `src/main/resources/templates/` (name matching the checked-template method, e.g. `recognition-digest.html`). +- [x] 2.2 Reproduce the current body: greeting with `{recipient.firstName}` (or an equivalent parameter) and the fixed intro line ("hier ist der aktuelle Briefkasten für den Tipi-JFX Teilnehmerkreis:"). +- [x] 2.3 Add the empty-state branch with `{#if}` emitting "Diese Woche wurden keine neuen Anerkennungen eingereicht." when there are no entries. +- [x] 2.4 Add the appreciation/praise section: only render when non-empty, with heading "Lob & Wertschätzung" and a `{#for}` over the appreciation entries rendering the escaped message and submitter attribution inside `
        `. +- [x] 2.5 Add the courage section: only render when non-empty, with heading "Mut" and a `{#for}` over the courage entries rendering the escaped message and submitter attribution inside `
          `. +- [x] 2.6 Add the logo `` markup, using `.raw` only where needed so the CID reference is not escaped. +- [x] 2.7 Confirm interpolated values (`{recipient.firstName}`, `{entry.message}`) use Qute's default HTML auto-escaping (no `.raw` on user-supplied text). + +## 3. Adapter rewrite + +- [x] 3.1 Declare a typed `@CheckedTemplate` (static method returning the mail template instance) for the digest template, or inject a `MailTemplate` via `@Location`; per design, prefer `@CheckedTemplate`. +- [x] 3.2 In `QuarkusRecognitionMailAdapter`, partition the incoming entries into appreciation and courage lists (adapter-side, per design) plus an empty indicator, and pass the recipient first name and these lists to the template. +- [x] 3.3 Render the body via the typed template and build the `Mail`, adding the inline logo attachment on the same mail instance with CID `` (mixed typed-template + imperative attachment). +- [x] 3.4 Keep the subject as `subject-prefix` + `ResourceBundle` lookup of `mail.RECOGNITION_DIGEST.subject` (Decision 4, Option A); leave the `{msg:...}` i18n migration as a noted follow-up. +- [x] 3.5 Preserve the existing `Log.info` on successful send; update `RecognitionMailPort.sendDigest(...)` to accept the display-ready digest entry projection. + +## 4. Cleanup + +- [x] 4.1 Delete `escapeHtml`, `renderEntries`, `renderCategoryEntries`, the raw-template `readTemplate` method, and the associated constants (`TEMPLATE_PATH`, `FIRST_NAME_PARAMETER`, `ENTRIES_PARAMETER`, `EMPTY_STATE`) from the adapter. +- [x] 4.2 Remove the now-unused imports in this adapter (Guava `HtmlEscapers`, `IOUtils`/`InputStream` for template reading, `ResourceBundle`-related helpers only if fully unused). +- [x] 4.3 Retire the raw `src/main/resources/emails/recognition-digest.html` resource. +- [x] 4.4 Confirm the logo resource read path is retained (still needed for the inline attachment). + +## 5. Tests + +- [x] 5.1 Run existing recognition digest adapter/behavior tests (mailer mock in the test profile) and update assertions to the equivalent rendered output. +- [x] 5.2 Add/verify a test for the empty-state body (no entries → empty-state paragraph present). +- [x] 5.3 Add/verify tests for single-category and both-category rendering (correct headings and entry messages). +- [x] 5.4 Add a test asserting that an entry message containing markup-significant characters is auto-escaped in the rendered body (escaping behavior). +- [x] 5.5 Add/verify a test asserting the inline logo attachment (CID ``) is present on the sent mail and the body references `cid:LogoMEGAdash@gepardec.com`. +- [x] 5.6 Run the full build/tests to confirm no regression. + +## 6. Submitter attribution + +- [x] 6.1 Add a display-ready recognition digest entry projection and a recognition-owned submitter-directory port/adapter that maps user IDs to display names. +- [x] 6.2 Batch-resolve non-null `submittedBy` values in `RecognitionDigestService`; use the literal `Anonym` when a recognition has no recorded submitter. +- [x] 6.3 Pass the projection through the mail port and render `{entry.submitterName}` alongside every Qute entry message. +- [x] 6.4 Add/verify digest-service and mail-adapter tests for named submitters and anonymous entries. diff --git a/openspec/specs/recognition-weekly-digest/spec.md b/openspec/specs/recognition-weekly-digest/spec.md index ac5c12b77..0a126a657 100644 --- a/openspec/specs/recognition-weekly-digest/spec.md +++ b/openspec/specs/recognition-weekly-digest/spec.md @@ -38,16 +38,29 @@ The system SHALL trigger the weekly digest automatically every Monday at 17:00 ( - **THEN** the digest is sent to each resolved recipient ### Requirement: The digest contains all entries not yet included in a previous digest -Each weekly digest SHALL contain every recognition entry whose status is "new" at the time the digest is assembled, and SHALL NOT contain entries already included in a previous digest. The digest content SHALL present each entry's message and its category (praise/appreciation or courage). +Each weekly digest SHALL contain every recognition entry whose status is "new" at the time the digest is assembled, and SHALL NOT contain entries already included in a previous digest. The digest content SHALL present each entry's message, its category (praise/appreciation or courage), and its submitter attribution. When an entry has a recorded `submittedBy` value, the attribution SHALL show that user's display name; when `submittedBy` is absent, it SHALL show the literal `Anonym`. Entry messages and submitter names SHALL be presented as text: any characters that are significant to the digest's markup SHALL be shown to the recipient as literal text and SHALL NOT be interpreted as markup. #### Scenario: New entries appear in the digest - **WHEN** the digest is assembled and there are entries with status "new" -- **THEN** every such entry is included in the digest, showing its message and category +- **THEN** every such entry is included in the digest, showing its message, category, and submitter attribution + +#### Scenario: Recorded submitter appears by name +- **WHEN** a new recognition entry has a `submittedBy` value +- **THEN** its digest entry shows the corresponding submitter display name + +#### Scenario: Anonymous submitter is labeled explicitly +- **WHEN** a new recognition entry has no `submittedBy` value +- **THEN** its digest entry shows `Anonym` as the submitter attribution #### Scenario: Previously included entries do not reappear - **WHEN** the digest is assembled - **THEN** entries already marked "included in digest" are not part of the digest +#### Scenario: Entry content with markup-significant characters is shown as text +- **WHEN** a "new" entry's message or submitter name contains characters that are significant to the digest's markup +- **THEN** those characters appear in the digest as literal text to the recipient +- **AND** they are not interpreted as markup + ### Requirement: Included entries transition to included-in-digest after sending After a digest has been sent, every entry contained in that digest SHALL transition from "new" to "included in digest" so it is not sent again. The transition SHALL occur only after the send completes, so that a failed run leaves the entries as "new" for the next weekly run. diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java index 6df859cce..03579841d 100644 --- a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapter.java @@ -1,21 +1,19 @@ package com.gepardec.mega.hexagon.recognition.adapter.outbound; import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionMailPort; +import com.gepardec.mega.hexagon.recognition.application.model.RecognitionDigestEntry; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; -import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; -import com.google.common.html.HtmlEscapers; import com.google.common.net.MediaType; import io.quarkus.logging.Log; -import io.quarkus.mailer.Mail; -import io.quarkus.mailer.Mailer; +import io.quarkus.mailer.MailTemplate; +import io.quarkus.qute.CheckedTemplate; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.apache.commons.io.IOUtils; import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -24,75 +22,47 @@ @ApplicationScoped public class QuarkusRecognitionMailAdapter implements RecognitionMailPort { - private static final String TEMPLATE_PATH = "emails/recognition-digest.html"; private static final String LOGO_RESOURCE_PATH = "img/logo.png"; private static final String SUBJECT_KEY = "mail.RECOGNITION_DIGEST.subject"; - private static final String FIRST_NAME_PARAMETER = "$firstName$"; - private static final String ENTRIES_PARAMETER = "$entries$"; - private static final String EMPTY_STATE = "

          Diese Woche wurden keine neuen Anerkennungen eingereicht.

          "; - private final Mailer mailer; private final Optional subjectPrefix; @Inject public QuarkusRecognitionMailAdapter( - Mailer mailer, @ConfigProperty(name = "mega.mail.subject-prefix") Optional subjectPrefix ) { - this.mailer = mailer; this.subjectPrefix = subjectPrefix; } @Override - public void sendDigest(RecognitionMailRecipient recipient, List entries) { + public void sendDigest(RecognitionMailRecipient recipient, List entries) { String subject = subjectPrefix.orElse("") + ResourceBundle.getBundle( "messages", Locale.GERMAN, ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_PROPERTIES) ) .getString(SUBJECT_KEY); - String content = readTemplate() - .replace(FIRST_NAME_PARAMETER, escapeHtml(recipient.firstName())) - .replace(ENTRIES_PARAMETER, renderEntries(entries)); + List digestEntries = entries == null ? List.of() : entries; + List appreciationEntries = digestEntries.stream() + .filter(entry -> entry.category() == RecognitionCategory.APPRECIATION) + .toList(); + List courageEntries = digestEntries.stream() + .filter(entry -> entry.category() == RecognitionCategory.COURAGE) + .toList(); - mailer.send(Mail.withHtml(recipient.email().value(), subject, content) - .addInlineAttachment("logo.png", readLogo(), MediaType.PNG.type(), "")); + Templates.recognitionDigest( + recipient.firstName(), + digestEntries.isEmpty(), + appreciationEntries, + courageEntries + ) + .to(recipient.email().value()) + .subject(subject) + .addInlineAttachment("logo.png", readLogo(), MediaType.PNG.type(), "") + .sendAndAwait(); Log.info("Recognition digest email sent"); } - private String renderEntries(List entries) { - if (entries == null || entries.isEmpty()) { - return EMPTY_STATE; - } - - String appreciationEntries = renderCategoryEntries(entries, RecognitionCategory.APPRECIATION, "Lob & Wertschätzung"); - String courageEntries = renderCategoryEntries(entries, RecognitionCategory.COURAGE, "Mut"); - return appreciationEntries + courageEntries; - } - - private String renderCategoryEntries(List entries, RecognitionCategory category, String heading) { - String listItems = entries.stream() - .filter(entry -> entry.category() == category) - .map(entry -> "
        • " + escapeHtml(entry.message()) + "
        • ") - .reduce("", String::concat); - if (listItems.isEmpty()) { - return ""; - } - - return "

          " + heading + "

            " + listItems + "
          "; - } - - private String readTemplate() { - try (InputStream inputStream = QuarkusRecognitionMailAdapter.class.getClassLoader().getResourceAsStream(TEMPLATE_PATH)) { - if (inputStream == null) { - throw new IllegalStateException("Could not read email template resource '%s'".formatted(TEMPLATE_PATH)); - } - return IOUtils.toString(inputStream, StandardCharsets.UTF_8); - } catch (Exception exception) { - throw new IllegalStateException("Cannot read email template resource '%s'".formatted(TEMPLATE_PATH), exception); - } - } - private byte[] readLogo() { try (InputStream inputStream = QuarkusRecognitionMailAdapter.class.getClassLoader().getResourceAsStream(LOGO_RESOURCE_PATH)) { if (inputStream == null) { @@ -104,7 +74,16 @@ private byte[] readLogo() { } } - private String escapeHtml(String value) { - return HtmlEscapers.htmlEscaper().escape(value); + @CheckedTemplate(basePath = "", defaultName = CheckedTemplate.HYPHENATED_ELEMENT_NAME) + static class Templates { + private Templates() { + } + + static native MailTemplate.MailTemplateInstance recognitionDigest( + String recipientFirstName, + boolean hasNoEntries, + List appreciationEntries, + List courageEntries + ); } } diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionSubmitterDirectoryAdapter.java b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionSubmitterDirectoryAdapter.java new file mode 100644 index 000000000..27c539ab1 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/RecognitionSubmitterDirectoryAdapter.java @@ -0,0 +1,32 @@ +package com.gepardec.mega.hexagon.recognition.adapter.outbound; + +import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionSubmitterDirectoryPort; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import com.gepardec.mega.hexagon.user.domain.model.User; +import com.gepardec.mega.hexagon.user.domain.port.outbound.UserRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +public class RecognitionSubmitterDirectoryAdapter implements RecognitionSubmitterDirectoryPort { + + private final UserRepository userRepository; + + @Inject + public RecognitionSubmitterDirectoryAdapter(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public Map findDisplayNamesByIds(Set userIds) { + Objects.requireNonNull(userIds, "userIds must not be null"); + + return userRepository.findByIds(userIds).stream() + .collect(Collectors.toMap(User::id, user -> user.name().displayName())); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java index c73cb02f2..11a99955e 100644 --- a/src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestService.java @@ -1,12 +1,15 @@ package com.gepardec.mega.hexagon.recognition.application; +import com.gepardec.mega.hexagon.recognition.application.model.RecognitionDigestEntry; import com.gepardec.mega.hexagon.recognition.application.port.inbound.SendRecognitionDigestUseCase; import com.gepardec.mega.hexagon.recognition.application.port.outbound.ProjectLeadDirectoryPort; import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionMailPort; +import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionSubmitterDirectoryPort; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryStatus; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; import com.gepardec.mega.hexagon.recognition.domain.port.outbound.RecognitionEntryRepository; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; import io.quarkus.logging.Log; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -15,13 +18,20 @@ import java.time.Clock; import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; @ApplicationScoped @Transactional public class RecognitionDigestService implements SendRecognitionDigestUseCase { + private static final String ANONYMOUS_SUBMITTER_NAME = "Anonym"; + private final RecognitionEntryRepository recognitionEntryRepository; private final ProjectLeadDirectoryPort projectLeadDirectoryPort; + private final RecognitionSubmitterDirectoryPort recognitionSubmitterDirectoryPort; private final RecognitionMailPort recognitionMailPort; private final Clock clock; @@ -29,11 +39,13 @@ public class RecognitionDigestService implements SendRecognitionDigestUseCase { public RecognitionDigestService( RecognitionEntryRepository recognitionEntryRepository, ProjectLeadDirectoryPort projectLeadDirectoryPort, + RecognitionSubmitterDirectoryPort recognitionSubmitterDirectoryPort, RecognitionMailPort recognitionMailPort, Clock clock ) { this.recognitionEntryRepository = recognitionEntryRepository; this.projectLeadDirectoryPort = projectLeadDirectoryPort; + this.recognitionSubmitterDirectoryPort = recognitionSubmitterDirectoryPort; this.recognitionMailPort = recognitionMailPort; this.clock = clock; } @@ -48,8 +60,9 @@ public void sendDigest() { } List entries = recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW); + List digestEntries = toDigestEntries(entries); for (RecognitionMailRecipient recipient : recipients) { - recognitionMailPort.sendDigest(recipient, entries); + recognitionMailPort.sendDigest(recipient, digestEntries); } for (RecognitionEntry entry : entries) { @@ -58,4 +71,32 @@ public void sendDigest() { Log.infof("Sent recognition digest to %d recipient(s) with %d new entry/entries", recipients.size(), entries.size()); } + + private List toDigestEntries(List entries) { + Set submitterIds = entries.stream() + .map(RecognitionEntry::submittedBy) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map submitterNames = submitterIds.isEmpty() + ? Map.of() + : recognitionSubmitterDirectoryPort.findDisplayNamesByIds(submitterIds); + + return entries.stream() + .map(entry -> new RecognitionDigestEntry( + entry.message(), + entry.category(), + entry.submittedBy() == null + ? ANONYMOUS_SUBMITTER_NAME + : requireSubmitterName(entry, submitterNames) + )) + .toList(); + } + + private String requireSubmitterName(RecognitionEntry entry, Map submitterNames) { + String submitterName = submitterNames.get(entry.submittedBy()); + if (submitterName == null) { + throw new IllegalStateException("Could not resolve recognition submitter '%s'".formatted(entry.submittedBy().value())); + } + return submitterName; + } } diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/model/RecognitionDigestEntry.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/model/RecognitionDigestEntry.java new file mode 100644 index 000000000..7db712228 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/model/RecognitionDigestEntry.java @@ -0,0 +1,18 @@ +package com.gepardec.mega.hexagon.recognition.application.model; + +import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; + +import java.util.Objects; + +public record RecognitionDigestEntry( + String message, + RecognitionCategory category, + String submitterName +) { + + public RecognitionDigestEntry { + Objects.requireNonNull(message, "message must not be null"); + Objects.requireNonNull(category, "category must not be null"); + Objects.requireNonNull(submitterName, "submitterName must not be null"); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java index a158eca8c..860da0393 100644 --- a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionMailPort.java @@ -1,11 +1,11 @@ package com.gepardec.mega.hexagon.recognition.application.port.outbound; -import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; +import com.gepardec.mega.hexagon.recognition.application.model.RecognitionDigestEntry; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; import java.util.List; public interface RecognitionMailPort { - void sendDigest(RecognitionMailRecipient recipient, List entries); + void sendDigest(RecognitionMailRecipient recipient, List entries); } diff --git a/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionSubmitterDirectoryPort.java b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionSubmitterDirectoryPort.java new file mode 100644 index 000000000..9d60dad7b --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/recognition/application/port/outbound/RecognitionSubmitterDirectoryPort.java @@ -0,0 +1,11 @@ +package com.gepardec.mega.hexagon.recognition.application.port.outbound; + +import com.gepardec.mega.hexagon.shared.domain.model.UserId; + +import java.util.Map; +import java.util.Set; + +public interface RecognitionSubmitterDirectoryPort { + + Map findDisplayNamesByIds(Set userIds); +} diff --git a/src/main/resources/emails/recognition-digest.html b/src/main/resources/emails/recognition-digest.html deleted file mode 100644 index 2596e912c..000000000 --- a/src/main/resources/emails/recognition-digest.html +++ /dev/null @@ -1,4 +0,0 @@ -

          Hallo $firstName$,

          -

          hier ist der aktuelle Briefkasten für den Tipi-JFX Teilnehmerkreis:

          -$entries$ -LogoMEGADash diff --git a/src/main/resources/templates/recognition-digest.html b/src/main/resources/templates/recognition-digest.html new file mode 100644 index 000000000..4f22929d8 --- /dev/null +++ b/src/main/resources/templates/recognition-digest.html @@ -0,0 +1,24 @@ +

          Hallo {recipientFirstName},

          +{#if hasNoEntries} +

          Diese Woche wurden keine neuen Anerkennungen eingereicht. Bis nächste Woche!

          +{#else} +

          Hier ist der aktuelle Inhalt des Anerkennung-Briefkastens. Wenn du der Moderator des Tipi-JFX bist, übertrage + bitte den Inhalt des Briefkastens in das Tipi-Protokoll.

          +{/if} +{#if ! appreciationEntries.isEmpty} +

          Lob & Wertschätzung

          +
            + {#for entry in appreciationEntries} +
          • {entry.message} (Eingereicht von: {entry.submitterName})
          • + {/for} +
          +{/if} +{#if ! courageEntries.isEmpty} +

          Mut

          +
            + {#for entry in courageEntries} +
          • {entry.message} (Eingereicht von: {entry.submitterName})
          • + {/for} +
          +{/if} +LogoMEGADash diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java index 7868d7640..a7c20b682 100644 --- a/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/adapter/outbound/QuarkusRecognitionMailAdapterTest.java @@ -1,35 +1,33 @@ package com.gepardec.mega.hexagon.recognition.adapter.outbound; +import com.gepardec.mega.hexagon.recognition.application.model.RecognitionDigestEntry; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; -import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; -import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryId; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionMailRecipient; import com.gepardec.mega.hexagon.shared.domain.model.Email; +import io.quarkus.mailer.Attachment; import io.quarkus.mailer.Mail; -import io.quarkus.mailer.Mailer; -import org.instancio.Instancio; +import io.quarkus.mailer.MockMailbox; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import java.time.Instant; import java.util.List; -import java.util.Optional; -import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; +@QuarkusTest class QuarkusRecognitionMailAdapterTest { - private Mailer mailer; - private QuarkusRecognitionMailAdapter adapter; + @Inject + QuarkusRecognitionMailAdapter adapter; + + @Inject + MockMailbox mailbox; @BeforeEach void setUp() { - mailer = mock(Mailer.class); - adapter = new QuarkusRecognitionMailAdapter(mailer, Optional.of("TEST: ")); + mailbox.clear(); } @Test @@ -43,10 +41,11 @@ void sendDigest_shouldRenderCategoryGroupedEntriesAndSendToRecipientAddress() { Mail mail = capturedMail(); assertThat(mail.getTo()).containsExactly("lead@example.com"); - assertThat(mail.getSubject()).isEqualTo("TEST: MEGA Briefkasten: Wöchentliche Anerkennungen"); + assertThat(mail.getSubject()).isEqualTo("UNIT-TEST: MEGA Briefkasten: Wöchentliche Anerkennungen"); assertThat(mail.getHtml()).contains("Hallo Ada,"); assertThat(mail.getHtml()).contains("Lob & Wertschätzung", "Danke für die Hilfe im Kundentermin."); assertThat(mail.getHtml()).contains("

          Mut

          ", "Mutige Entscheidung unter Druck."); + assertThat(mail.getHtml()).contains("Eingereicht von: Grace Hopper"); } @Test @@ -57,18 +56,70 @@ void sendDigest_shouldRenderEmptyStateWhenNoEntriesExist() { .contains("Diese Woche wurden keine neuen Anerkennungen eingereicht."); } + @Test + void sendDigest_shouldRenderOnlyAppreciationSectionWhenNoCourageEntriesExist() { + adapter.sendDigest(new RecognitionMailRecipient(Email.of("lead@example.com"), "Ada"), List.of( + entry("Danke für die Hilfe im Kundentermin.", RecognitionCategory.APPRECIATION) + )); + + assertThat(capturedMail().getHtml()) + .contains("

          Lob & Wertschätzung

          ", "Danke für die Hilfe im Kundentermin.") + .doesNotContain("

          Mut

          "); + } + + @Test + void sendDigest_shouldRenderOnlyCourageSectionWhenNoAppreciationEntriesExist() { + adapter.sendDigest(new RecognitionMailRecipient(Email.of("lead@example.com"), "Ada"), List.of( + entry("Mutige Entscheidung unter Druck.", RecognitionCategory.COURAGE) + )); + + assertThat(capturedMail().getHtml()) + .contains("

          Mut

          ", "Mutige Entscheidung unter Druck.") + .doesNotContain("

          Lob & Wertschätzung

          "); + } + + @Test + void sendDigest_shouldHtmlEscapeEntryMessages() { + String message = "Danke & willkommen"; + adapter.sendDigest(new RecognitionMailRecipient(Email.of("lead@example.com"), "Ada"), List.of( + entry(message, RecognitionCategory.APPRECIATION) + )); + + assertThat(capturedMail().getHtml()) + .contains("<strong>Danke & willkommen</strong>") + .doesNotContain(message); + } + + @Test + void sendDigest_shouldRenderAnonymForAnonymousEntries() { + adapter.sendDigest(new RecognitionMailRecipient(Email.of("lead@example.com"), "Ada"), List.of( + entry("Danke für deine Hilfe.", RecognitionCategory.APPRECIATION, "Anonym") + )); + + assertThat(capturedMail().getHtml()).contains("Eingereicht von: Anonym"); + } + + @Test + void sendDigest_shouldIncludeInlineLogoAttachmentAndReferenceItFromTheBody() { + adapter.sendDigest(new RecognitionMailRecipient(Email.of("lead@example.com"), "Ada"), List.of()); + + Mail mail = capturedMail(); + assertThat(mail.getHtml()).contains("src=\"cid:LogoMEGAdash@gepardec.com\""); + assertThat(mail.getAttachments()).singleElement().satisfies(attachment -> { + assertThat(attachment.getContentId()).isEqualTo(""); + assertThat(attachment.getDisposition()).isEqualTo(Attachment.DISPOSITION_INLINE); + }); + } + private Mail capturedMail() { - ArgumentCaptor mailCaptor = ArgumentCaptor.forClass(Mail.class); - verify(mailer).send(mailCaptor.capture()); - return mailCaptor.getValue(); + return mailbox.getMailsSentTo("lead@example.com").getFirst(); + } + + private RecognitionDigestEntry entry(String message, RecognitionCategory category) { + return entry(message, category, "Grace Hopper"); } - private RecognitionEntry entry(String message, RecognitionCategory category) { - return RecognitionEntry.create( - RecognitionEntryId.of(Instancio.create(UUID.class)), - message, - category, - Instant.parse("2026-07-06T15:30:00Z") - ); + private RecognitionDigestEntry entry(String message, RecognitionCategory category, String submitterName) { + return new RecognitionDigestEntry(message, category, submitterName); } } diff --git a/src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java index 93e625734..986907de3 100644 --- a/src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/recognition/application/RecognitionDigestServiceTest.java @@ -2,6 +2,8 @@ import com.gepardec.mega.hexagon.recognition.application.port.outbound.ProjectLeadDirectoryPort; import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionMailPort; +import com.gepardec.mega.hexagon.recognition.application.port.outbound.RecognitionSubmitterDirectoryPort; +import com.gepardec.mega.hexagon.recognition.application.model.RecognitionDigestEntry; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionCategory; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntry; import com.gepardec.mega.hexagon.recognition.domain.model.RecognitionEntryId; @@ -19,6 +21,8 @@ import java.time.LocalDate; import java.time.ZoneOffset; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -37,6 +41,7 @@ class RecognitionDigestServiceTest { private RecognitionEntryRepository recognitionEntryRepository; private ProjectLeadDirectoryPort projectLeadDirectoryPort; + private RecognitionSubmitterDirectoryPort recognitionSubmitterDirectoryPort; private RecognitionMailPort recognitionMailPort; private RecognitionDigestService service; @@ -44,10 +49,12 @@ class RecognitionDigestServiceTest { void setUp() { recognitionEntryRepository = mock(RecognitionEntryRepository.class); projectLeadDirectoryPort = mock(ProjectLeadDirectoryPort.class); + recognitionSubmitterDirectoryPort = mock(RecognitionSubmitterDirectoryPort.class); recognitionMailPort = mock(RecognitionMailPort.class); service = new RecognitionDigestService( recognitionEntryRepository, projectLeadDirectoryPort, + recognitionSubmitterDirectoryPort, recognitionMailPort, Clock.fixed(NOW, ZoneOffset.UTC) ); @@ -61,12 +68,17 @@ void sendDigest_shouldSendNewEntriesToEveryRecipientAndMarkThemIncluded() { when(projectLeadDirectoryPort.findActiveInternalProjectLeads(REFERENCE_DATE)) .thenReturn(List.of(firstRecipient, secondRecipient)); when(recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW)).thenReturn(List.of(entry)); + when(recognitionSubmitterDirectoryPort.findDisplayNamesByIds(Set.of(entry.submittedBy()))) + .thenReturn(Map.of(entry.submittedBy(), "Grace Hopper")); service.sendDigest(); verify(projectLeadDirectoryPort).findActiveInternalProjectLeads(REFERENCE_DATE); - verify(recognitionMailPort).sendDigest(firstRecipient, List.of(entry)); - verify(recognitionMailPort).sendDigest(secondRecipient, List.of(entry)); + List digestEntries = List.of(new RecognitionDigestEntry( + entry.message(), entry.category(), "Grace Hopper" + )); + verify(recognitionMailPort).sendDigest(firstRecipient, digestEntries); + verify(recognitionMailPort).sendDigest(secondRecipient, digestEntries); verify(recognitionEntryRepository).save(entry.includeInDigest()); } @@ -80,6 +92,7 @@ void sendDigest_shouldSendEmptyStateWithoutChangingEntryStatusWhenNoNewEntriesEx verify(recognitionMailPort).sendDigest(recipient, List.of()); verify(recognitionEntryRepository, never()).save(any()); + verifyNoInteractions(recognitionSubmitterDirectoryPort); } @Test @@ -89,7 +102,7 @@ void sendDigest_shouldDoNothingWhenNoRecipientsExist() { service.sendDigest(); verify(projectLeadDirectoryPort).findActiveInternalProjectLeads(REFERENCE_DATE); - verifyNoInteractions(recognitionEntryRepository, recognitionMailPort); + verifyNoInteractions(recognitionEntryRepository, recognitionSubmitterDirectoryPort, recognitionMailPort); } @Test @@ -98,8 +111,12 @@ void sendDigest_shouldLeaveEntriesNewWhenMailDispatchFails() { RecognitionMailRecipient recipient = recipient("lead@example.com", "Ada"); when(projectLeadDirectoryPort.findActiveInternalProjectLeads(REFERENCE_DATE)).thenReturn(List.of(recipient)); when(recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW)).thenReturn(List.of(entry)); + when(recognitionSubmitterDirectoryPort.findDisplayNamesByIds(Set.of(entry.submittedBy()))) + .thenReturn(Map.of(entry.submittedBy(), "Grace Hopper")); doThrow(new IllegalStateException("mail dispatch failed")) - .when(recognitionMailPort).sendDigest(recipient, List.of(entry)); + .when(recognitionMailPort).sendDigest(recipient, List.of(new RecognitionDigestEntry( + entry.message(), entry.category(), "Grace Hopper" + ))); assertThatThrownBy(service::sendDigest) .isInstanceOf(IllegalStateException.class) @@ -108,6 +125,27 @@ void sendDigest_shouldLeaveEntriesNewWhenMailDispatchFails() { verify(recognitionEntryRepository, never()).save(any()); } + @Test + void sendDigest_shouldUseAnonymWhenEntryHasNoSubmitter() { + RecognitionEntry entry = RecognitionEntry.create( + RecognitionEntryId.of(Instancio.create(UUID.class)), + "Mutiger Einsatz", + RecognitionCategory.COURAGE, + NOW, + null + ); + RecognitionMailRecipient recipient = recipient("lead@example.com", "Ada"); + when(projectLeadDirectoryPort.findActiveInternalProjectLeads(REFERENCE_DATE)).thenReturn(List.of(recipient)); + when(recognitionEntryRepository.findByStatus(RecognitionEntryStatus.NEW)).thenReturn(List.of(entry)); + + service.sendDigest(); + + verify(recognitionMailPort).sendDigest(recipient, List.of(new RecognitionDigestEntry( + entry.message(), entry.category(), "Anonym" + ))); + verifyNoInteractions(recognitionSubmitterDirectoryPort); + } + private RecognitionEntry entry(String message, RecognitionCategory category) { return RecognitionEntry.create( RecognitionEntryId.of(Instancio.create(UUID.class)),