Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-11
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
<!-- None. The change reuses existing user-BC persistence and mail infrastructure without altering their spec-level behavior: UserRepository.findByRole already exists, the notification BC is not modified, and the digest recipient is a mail-recipient value (email + first name) rather than a user-identity projection, so shared-user-project-refs is unaffected. -->

## 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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading