Epic 10: Additional Stories
Status: π¨ IN PROGRESS β Stories 10.1β10.4 in sprint (2026-02-24). Stories 10.19β10.25 added (2026-03-02). Story 10.26 added (2026-03-11).
Status: π¨ IN PROGRESS β Stories 10.1β10.4 in sprint (2026-02-24). Stories 10.19β10.25 added (2026-03-02). Story 10.26 added (2026-03-11).
Epic Goal: Consolidate scattered admin configuration and tooling into coherent, discoverable interfaces. These are standalone improvements that donβt belong to any prior epicβs domain.
Deliverable: A single Event Management Administration page at /organizer/admin that unifies event type config, batch data imports, task template management, and email template management β replacing scattered entry points with one organized hub.
Story split (IR review 2026-02-24): Originally one story; split into 10.1 (frontend-only, 3 tabs) and 10.2 (email templates backend + 4th tab) to reduce delivery risk and unblock frontend work from backend ADR compliance changes.
L&F template pattern (added 2026-02-24): Story 10.2 introduces a layout template (batbern-default) that owns the BATbern HTML shell (CSS, logo header, footer). Individual content templates hold only email-specific body copy. Content templates are edited with TinyMCE (WYSIWYG). The layout template is edited with Monaco (source code). Story 10.3 (future) will migrate the 22 existing system templates from standalone full-HTML to content-only using the layout.
Epic Overview
Architecture Context
- Primary service:
event-management-serviceβ email template storage + CRUD API (Story 10.2) - Frontend: React 19 SPA β new
/organizer/adminpage with 3 tabs (Story 10.1) + 4th tab added (Story 10.2) - No new services required β all changes land in existing services
- DB change: New
email_templatestable in event-management-service (Flyway V62) β Story 10.2 only. Includesis_layout+layout_keycolumns for the L&F pattern. - Email sending: All existing email services updated to load templates from DB, merge with layout if
layout_keyset, then substitute variables. Classpath fallback preserved. β Story 10.2 only - L&F layout template:
batbern-default-{de,en}layout templates seeded from new classpath files; own the full HTML shell (CSS, BATbern header, footer). Content templates injected at{{content}}placeholder. - Editor split: Layout templates β Monaco Editor (technical, full HTML). Content templates β TinyMCE WYSIWYG (simple, paragraphs/CTAs only).
Prerequisites
- β Epic 5 β Task system + task templates (backend + frontend complete)
- β Epic 6 β Email template classpath files exist (22 HTML files to seed)
- β Epic 2 β Batch import modals implemented for all entity types
- β Epic 5 β Event type configuration admin page exists
Epic 10 Stories
Story 10.1: Event Management Administration Page (3 tabs β frontend only)
Story file: docs/stories/archived/epic-10/10-1-event-management-administration-page.md
Status: ready-for-dev
User Story: As an organizer, I want a single Event Management Administration page accessible from the user menu, so that I can manage event types, import data, and task templates in one organized place instead of hunting across different pages.
Scope:
Tab 0 β Event Types:
- Moves content from
/organizer/event-typesinto this page (tab 0) - Edit FULL_DAY / AFTERNOON / EVENING slot configurations
- Old route redirects to
/organizer/admin?tab=0 - No functionality change β pure relocation
Tab 1 β Import Event Data:
- Consolidates all 5 batch import modals (Events, Sessions, Companies, Speakers, Participants/Attendees)
- Each import type in its own Card with description + trigger button
- Import buttons removed from their current homes:
EventManagementDashboard,CompanyManagementScreen,UserList
Tab 2 β Task Templates:
- Standalone management (previously only accessible contextually from TaskBoardPage or event form)
- Default templates: read-only list (name, trigger state, due date)
- Custom templates: create / edit / delete
- Reuses
CustomTaskModal(create,eventId=null) and a newTaskTemplateEditModal - Backed by existing
taskService.updateTemplate()/taskService.deleteTemplate()
Key new files:
web-frontend/src/pages/organizer/EventManagementAdminPage.tsx
web-frontend/src/components/organizer/Admin/EventTypesTab.tsx
web-frontend/src/components/organizer/Admin/ImportDataTab.tsx
web-frontend/src/components/organizer/Admin/TaskTemplatesTab.tsx
web-frontend/src/components/organizer/Admin/TaskTemplateEditModal.tsx
Key modified files:
web-frontend/src/App.tsx β new route + redirect /organizer/event-types
web-frontend/src/components/.../UserMenuDropdown.tsx β Administration menu item
web-frontend/src/components/.../EventManagementDashboard.tsx β remove imports
web-frontend/src/components/.../CompanyManagementScreen.tsx β remove imports
web-frontend/src/components/.../UserList.tsx β remove imports
public/locales/de/common.json + en/common.json β menu.administration key
Story 10.2: Email Template Management
Story file: docs/stories/archived/epic-10/10-2-email-template-management.md
Status: ready-for-dev
Prerequisite: Story 10.1 must be complete
User Story: As an organizer, I want to view and edit email templates directly from the Administration page, so that I can update subject lines and email content without requiring a code deploy.
Scope:
Backend β Email Templates API:
- New DB-backed storage (
email_templatestable, Flyway V62) - 22 existing classpath HTML templates seeded on startup via
EmailTemplateSeedService(@PostConstruct, idempotent) - REST API at
/api/v1/email-templatesβ ADR-003 compliant paths:/{templateKey}/{locale} - System templates (seeded): editable but not deletable
- Custom templates: full CRUD
- Email services updated to load templates from DB with classpath fallback
- OpenAPI spec written FIRST (ADR-006 contract-first)
Tab 3 β Email Templates Frontend:
- 4th tab added to the
/organizer/adminpage - Layout Templates section:
batbern-defaulttemplates listed with Monaco editor (source HTML) - Content Templates section: category filter (Speaker | Registration | Task Reminders), DE/EN toggle; TinyMCE WYSIWYG editor; preview shows fully merged branded email (layout + content)
- New custom templates default to
layoutKey='batbern-default' - Newsletter category scaffolded in DB but omitted from initial UI (no seeded templates)
Key new files:
web-frontend/src/services/emailTemplateService.ts
web-frontend/src/hooks/useEmailTemplates.ts
web-frontend/src/components/organizer/Admin/EmailTemplatesTab.tsx
web-frontend/src/components/organizer/Admin/EmailTemplatePreviewModal.tsx
web-frontend/src/components/organizer/Admin/EmailTemplateEditModal.tsx
services/event-management-service/.../domain/EmailTemplate.java
services/event-management-service/.../service/EmailTemplateService.java
services/event-management-service/.../service/EmailTemplateSeedService.java
services/event-management-service/.../service/mapper/EmailTemplateMapper.java
services/event-management-service/.../controller/EmailTemplateController.java β implements EmailTemplatesApi
services/event-management-service/src/main/resources/db/migration/V62__create_email_templates_table.sql
Key modified files:
docs/api/events.openapi.yml β email-templates endpoints (FIRST β ADR-006)
web-frontend/src/pages/organizer/EventManagementAdminPage.tsx β add 4th tab
public/locales/de/organizer.json + en/organizer.json β emailTemplates.* keys
services/.../SpeakerInvitationEmailService.java β DB lookup with classpath fallback
services/.../SpeakerReminderEmailService.java β DB lookup with classpath fallback
services/.../SpeakerAcceptanceEmailService.java β DB lookup with classpath fallback
services/.../RegistrationEmailService.java β DB lookup with classpath fallback
Success Criteria
Epic 10 Success Criteria:
- β
Single entry point (
/organizer/admin) for all event management configuration - β No scattered admin buttons on entity management pages (imports moved)
- β Email templates editable by organizers without code deploys
- β Task templates manageable standalone (not just inside event form context)
- β All email sending continues to work (DB lookup with classpath fallback)
Metrics:
- 22 email templates seeded and queryable via REST API
- Import flow accessible from admin page, removed from 3 original pages
/organizer/event-typesroute redirects cleanly
Definition of Done (Epic 10)
Story 10.1:
- i18n:
menu.administration
Story 10.2:
- Layout classpath files (
layout-batbern-default-de/en.html - i18n:
emailTemplates.*
Story 10.3: Task Deadline Reminder Email
Story file: docs/stories/archived/epic-10/10-3-task-deadline-reminder-email.md
Status: β
done (2026-02-24)
Prerequisite: Story 10.2 (DB-backed template loading + task-reminder-* seeded by EmailTemplateSeedService)
User Story: As an organizer, I want to receive an email reminder the day before a task Iβm assigned to is due, so that I donβt miss important deadlines for event-related tasks.
Scope:
Scheduler (TaskDeadlineReminderScheduler):
- Runs daily at 8 AM Swiss time (
cron: 0 0 8 * * *, configurable viabatbern.task-reminders.cron) - ShedLock prevents duplicate execution across ECS instances (
lockAtMostFor: PT30M) - Time window:
startOfTomorrowβendOfTomorrowinEurope/Zurich - Dispatches
TaskReminderEmailService.sendTaskDeadlineReminder()per matching task
Task query:
EventTaskRepository.findTasksDueForReminder(Instant from, Instant to)β non-completed tasks (status != completed) with assigned organizer, due in the tomorrow window
Email service (TaskReminderEmailService):
@Asyncβ loads organizer email viauserApiClient.getUserByUsername()(15-min cached)- DB template (
task-reminder-de/en) viaemailTemplateServicewith classpath fallback - Variables:
recipientName,taskName,eventTitle,eventCode,dueDate,taskNotes,taskBoardLink - Subject DE:
"Aufgabenerinnerung: {taskName} fΓ€llig morgen"/ EN:"Task Reminder: {taskName} due tomorrow" - Email send failures are swallowed (logged only) β scheduler continues
Templates: task-reminder-de.html + task-reminder-en.html β content fragments (no HTML wrapper; batbern-default layout provides shell). Auto-seeded by EmailTemplateSeedService into TASK_REMINDER category.
Key new files:
services/event-management-service/.../service/TaskReminderEmailService.java
services/event-management-service/.../scheduler/TaskDeadlineReminderScheduler.java
services/event-management-service/src/main/resources/email-templates/task-reminder-de.html
services/event-management-service/src/main/resources/email-templates/task-reminder-en.html
services/event-management-service/.../service/TaskReminderEmailServiceTest.java
services/event-management-service/.../scheduler/TaskDeadlineReminderSchedulerTest.java
Key modified files:
services/event-management-service/.../repository/EventTaskRepository.java β findTasksDueForReminder query
Story 10.4: Blob Topic Selector
Story file: _bmad-output/implementation-artifacts/10-4-blob-topic-selector.md
Spec: _bmad-output/implementation-artifacts/blob-topic-selector-spec.md
Brainstorm: _bmad-output/brainstorming/brainstorming-session-2026-02-24.md
Status: ready-for-dev
Prerequisite: None (independent of 10.1β10.3)
User Story: As an organizer, I want a full-screen physics-based blob visualization for topic selection, accessible via a button on the topic management page, so that during our Teams meeting I can intuitively feel how well a proposed topic aligns with partner interests and event history β without reading a single number.
Concept: Three types of blobs interact through D3 force simulation physics:
- π΅ Blue blobs β proposed topics, summoned by typing
- π’ Green blobs β partner interests (up to 20, with company logos), attracted to similar blue blobs
- β Red star blobs β past BATbern event topics (57 events), dormant background constellation; ignite and repel when a blue blob covers similar recent territory (within 6 events β 2 years)
- π» Ghost candidates β latent ideas from organizer backlog, partner suggestions, and AI-fetched trending topics; click to awaken as a blue blob
The organizer team watches a single driver share their screen on Teams. The driver types topics, drags blobs, overrides AI-detected similarities manually, and double-clicks to accept β generating a session note attached to the event.
Scope:
Entry Point:
- βBlob Selectorβ button added to
TopicManagementPage(only wheneventCodepresent in query params) - Navigates to new route
/organizer/events/:eventCode/topic-blob TopicBacklogManagerand its existing 3 view modes are untouched
Full-Screen Page (BlobTopicSelectorPage):
- No sidebar/nav β full viewport SVG canvas (
100vw Γ 100vh, dark navy#0d1b2a) - Fixed back button (top-left) with unsaved-changes warning dialog
- Fixed βFit Allβ + βSnap to Activeβ zoom controls (top-right)
- Infinite canvas via
d3.zoom()(scale[0.1, 4]) - 10-second scripted onboarding animation on first visit (localStorage-gated)
Physics Engine:
- D3 v7 force simulation (new dependency:
npm install d3 @types/d3) forceManyBody(repulsion) +forceCollide(no overlaps) +forceLink(partner attraction) +forceCenter(soft pull to center)- Custom forces for red star repulsion and orbit mechanics
Interaction Language:
- Summon β type anywhere β text input β Enter β blue blob slides in from right edge
- Awaken β click ghost β becomes blue blob, physics activates
- Override β drag-merge (1.5s hold β merge halo β confirm). Red star dragged near blue blob β becomes orbiting satellite (βacknowledged warningβ)
- Accept β double-click blue blob β confirm dialog β writes
topicSelectionNoteto event β navigate to speakers tab
Backend (new endpoints in event-management-service):
GET /api/v1/events/{eventCode}/topic-session-dataβ aggregates partner topics, past events with cluster, organizer backlog topics (from existing topics table,status=AVAILABLE), AI-fetched trending topics (LLM call, 1h cache, hardcoded fallback)POST /api/v1/events/{eventCode}/topic-similarityβ returns{ cluster, similarityScore, relatedPastEventNumbers[] }. Pre-hardcoded 7-cluster map for all 57 BATbern events; OpenAI embeddings for novel topics; keyword fallback.PATCH /api/v1/events/{eventCode}β addtopicSelectionNote: TEXTfield (Flyway V64)BatbernTopicClusterServiceβ hardcoded cluster map (AI_ML, SECURITY, ARCHITECTURE, DATA, CLOUD_INFRA, MOBILE, BUSINESS_OTHER)
Existing infrastructure reused (no new CRUD):
- Topic backlog: existing
/api/v1/topics(5 categories, CRUD already at/organizer/topics) - Past events: existing events table (eventNumber + title)
- Partner topics: existing partner coordination data
TopicSelectionResponseflow: existing PATCH on event
Key new files:
web-frontend/src/pages/organizer/BlobTopicSelectorPage.tsx
web-frontend/src/components/BlobTopicSelector/BlobTopicSelector.tsx
web-frontend/src/components/BlobTopicSelector/useBlobSimulation.ts
web-frontend/src/components/BlobTopicSelector/useTopicSessionData.ts
web-frontend/src/components/BlobTopicSelector/types.ts
web-frontend/src/components/BlobTopicSelector/OnboardingOverlay.tsx
web-frontend/src/components/BlobTopicSelector/AcceptTopicDialog.tsx
web-frontend/src/services/blobTopicService.ts
services/event-management-service/.../BatbernTopicClusterService.java
services/event-management-service/.../TopicSimilarityController.java
services/event-management-service/.../TopicSessionDataController.java
services/event-management-service/.../TrendingTopicsService.java
services/event-management-service/src/main/resources/db/migration/V64__add_topic_selection_note.sql
Key modified files:
web-frontend/src/App.tsx β new route /organizer/events/:eventCode/topic-blob
web-frontend/src/pages/organizer/TopicManagementPage.tsx β add Blob Selector button
public/locales/de/events.json + en/events.json β navigation.blobSelector key
public/locales/de/organizer.json + en/organizer.json β blobSelector.* keys
docs/api/events.openapi.yml β topic-session-data + topic-similarity endpoints
Definition of Done (Story 10.4):
- βBlob Selectorβ button visible on
/organizer/topics?eventCode=BATbernXX - Accepting a topic writes
topicSelectionNote - i18n:
navigation.blobSelector(events) +blobSelector.*
Story 10.5: Analytics Dashboard
Story file: _bmad-output/implementation-artifacts/10-5-analytics-dashboard.md
Brainstorm: _bmad-output/brainstorming/brainstorming-session-2026-02-25.md
Status: ready-for-dev
Prerequisite: None (independent β no backend changes conflict with other stories)
User Story: As an organizer or partner, I want a dedicated Analytics page with rich, tabbed visualizations of BATbern event statistics, so that during partner meetings I can showcase community growth, speaker contributions, and company engagement with live, data-driven charts.
Scope:
Page Shell:
- Route
/organizer/analyticsalready exists (stub page to replace) - 4 tabs: Overview (default) Β· Attendance Β· Topics Β· Companies
- Global time range filter: All Time / Last 5 Years / Last 2 Years (top-right, cascades to all time-sensitive charts)
- Chart library: Recharts (already installed
^3.5.0) - Color palette: BATbern brand colors (from
theme.ts) - Per-chart layout: chart on top +
βΌ Show data tablecollapsible MUI Table
Tab 1 β Overview:
- 4 KPI cards: Total Events Β· Total Attendees Β· Companies Represented Β· Total Sessions (all-time, not time-filtered)
- Event cadence timeline: all events as colored bars on a time axis; colored by topic category; always shows all events
Tab 2 β Attendance:
- Attendees per event:
ComposedChart(bars + trend line); label toggle (title/category/both) - Returning vs. New attendees per event: stacked bar chart (warm=returning, cool=new)
Tab 3 β Topics:
- Events per category: horizontal bar chart
- Topic popularity vs. attendee count: scatter plot (X=event count, Y=avg attendees per event on topic)
Tab 4 β Companies:
- Partnerβs own company (from
user.company) auto-highlighted and pinned in all three charts - Top N toggle: 5 / 10 / All (default 10); own company always shown
- Attendees per company over time: stacked bar by year
- Sessions per company: bar chart with unique speaker count label
- Attendee distribution per company: pie chart with per-event filter dropdown
Backend β New AnalyticsController in event-management-service:
GET /api/v1/analytics/overviewβ KPI totals + timeline dataGET /api/v1/analytics/attendance?fromYear={year}β per-event attendance with returning/new breakdownGET /api/v1/analytics/topics?fromYear={year}β events per category + topic scatter dataGET /api/v1/analytics/companies?fromYear={year}β company attendance over time, sessions, distributionGET /api/v1/analytics/companies/distribution?eventCode={code}β distribution for a single event- ORGANIZER + PARTNER roles; aggregate data only (no individual names)
Key data model:
Registration.attendeeCompanyIdβ denormalized company name (indexed), used for all company-level attendance analyticsSpeakerPool.companyβ denormalized company name for session countingEvent.topicCode β Topic.categoryjoin for category-based charts
Key new files:
web-frontend/src/pages/organizer/OrganizerAnalyticsPage.tsx β REPLACE stub
web-frontend/src/components/organizer/Analytics/ β new folder
ChartCard.tsx, DataTable.tsx, EmptyChartState.tsx, KpiCard.tsx
CHART_COLORS.ts, OverviewTab.tsx, EventCadenceTimeline.tsx
AttendanceTab.tsx, AttendeesPerEventChart.tsx, ReturningVsNewChart.tsx
TopicsTab.tsx, EventsPerCategoryChart.tsx, TopicScatterChart.tsx
CompaniesTab.tsx, CompanyAttendanceOverTimeChart.tsx
SessionsPerCompanyChart.tsx, CompanyDistributionPieChart.tsx
web-frontend/src/services/analyticsService.ts
web-frontend/src/hooks/useAnalytics.ts
services/event-management-service/.../controller/AnalyticsController.java
services/event-management-service/.../service/AnalyticsService.java
services/event-management-service/.../repository/AnalyticsRepository.java
Key modified files:
docs/api/events.openapi.yml β analytics endpoints (FIRST β ADR-006)
public/locales/en/organizer.json + de/organizer.json β analytics.* keys
Definition of Done (Story 10.5):
- TDD: integration tests written first (
AnalyticsControllerIntegrationTest - i18n:
analytics.*keys in bothen/andde/
Story 10.7: Newsletter Subscription & Sending
Story file: _bmad-output/implementation-artifacts/10-7-newsletter-subscription-and-sending.md
Status: done
Prerequisites: Story 10.2 (Email Template Management β provides EmailTemplateService, EmailTemplateSeedService, NEWSLETTER category, batbern-default layout)
User Story: As an organizer, I want to send event newsletters and reminder emails to subscribed community members, so that we can replace Meetup.com / Hostpoint mailing lists with a fully in-house system. As a community member, I want to subscribe without an account and unsubscribe instantly from any email link, so that I can stay informed about events without friction. As a logged-in user, I want to manage my subscription in the public account settings page, not the organizer portal.
Scope:
Backend (event-management-service):
- Flyway V67: 3 new tables β
newsletter_subscribers(email, username, unsubscribe_token, language, source, unsubscribed_at),newsletter_sends,newsletter_recipients NewsletterSubscriberServiceβ subscribe/upsert/reactivate/unsubscribe-by-tokenNewsletterEmailServiceβ build + send newsletter; loads template from DB/classpath; per-recipient unsubscribe link injectionNewsletterControllerβ public + authenticated + organizer-only endpoints- Wire in
RegistrationService: ifnewsletterSubscribed=trueon registration β auto-subscribe silently - Two new classpath templates (content-only fragments):
newsletter-event-de.html,newsletter-event-en.html - Single template, dual use:
{{reminderPrefix}}variable is""for newsletter,"Erinnerung: "/"Reminder: "for reminder β no separate reminder template needed - Newsletter templates seeded by
EmailTemplateSeedServiceunderNEWSLETTERcategory; editable in admin Email Templates tab
API Endpoints:
# Public (permitAll in EMS + API gateway)
POST /api/v1/newsletter/subscribe
GET /api/v1/newsletter/unsubscribe/verify?token=
POST /api/v1/newsletter/unsubscribe
# Authenticated (any role)
GET /api/v1/newsletter/my-subscription
PATCH /api/v1/newsletter/my-subscription
# Organizer only
GET /api/v1/newsletter/subscribers
GET /api/v1/events/{eventCode}/newsletter/history
POST /api/v1/events/{eventCode}/newsletter/preview
POST /api/v1/events/{eventCode}/newsletter/send
Frontend:
NewsletterSubscribeWidget.tsxβ email input + subscribe button in homepage footer (shadcn Input + Button, i18n, success/409 states)UnsubscribePage.tsxat/unsubscribe?token={token}β shows email to confirm, confirm button, success/error states (PublicLayout, shadcn Card)EventNewsletterTab.tsxβ new tab on EventPage: subscriber count, send history table, language selector, preview iframe, βSend Newsletterβ + βSend Reminderβ buttons with confirmation dialogUserSettingsTab.tsxβ Newsletter toggle in Notifications sub-tab, wired toPATCH /my-subscriptionApp.tsxβ add/unsubscribepublic route
GDPR: Every sent email contains {{unsubscribeLink}} β {baseUrl}/unsubscribe?token={subscriber.unsubscribeToken} (unique per subscriber, never expires).
Key new files:
services/event-management-service/src/main/resources/db/migration/V67__create_newsletter_tables.sql
services/event-management-service/.../domain/NewsletterSubscriber.java
services/event-management-service/.../domain/NewsletterSend.java
services/event-management-service/.../repository/NewsletterSubscriberRepository.java
services/event-management-service/.../repository/NewsletterSendRepository.java
services/event-management-service/.../service/NewsletterSubscriberService.java
services/event-management-service/.../service/NewsletterEmailService.java
services/event-management-service/.../controller/NewsletterController.java
services/event-management-service/src/main/resources/email-templates/newsletter-event-de.html
services/event-management-service/src/main/resources/email-templates/newsletter-event-en.html
web-frontend/src/services/newsletterService.ts
web-frontend/src/hooks/useNewsletter/useNewsletter.ts
web-frontend/src/components/public/NewsletterSubscribeWidget.tsx
web-frontend/src/pages/public/UnsubscribePage.tsx
web-frontend/src/components/organizer/EventPage/EventNewsletterTab.tsx
Key modified files:
services/event-management-service/.../service/RegistrationService.java
services/event-management-service/.../config/SecurityConfig.java β permitAll newsletter public paths
api-gateway/.../DomainRouter.java β route /api/v1/newsletter/** β EMS
api-gateway/.../SecurityConfig.java β same permitAll
web-frontend/src/components/organizer/EventPage/EventPage.tsx β add Newsletter tab
web-frontend/src/components/user/UserSettingsTab/UserSettingsTab.tsx β newsletter toggle
web-frontend/src/App.tsx β /unsubscribe public route
web-frontend/src/components/public/HomePage.tsx β footer subscribe widget
web-frontend/src/i18n/en.json + de.json β newsletter.* keys
docs/api/events.openapi.yml β newsletter endpoints (FIRST)
Definition of Done (Story 10.7):
-
/unsubscribe?token=valid -
/account - Every sent email contains
{{unsubscribeLink}} - All tests pass:
NewsletterSubscriberServiceTest,NewsletterControllerIntegrationTest,NewsletterEmailServiceTest - i18n:
newsletter.*keys in bothen.jsonand
Robustness Addendum (2026-03-10): Fixed P0 thread-pool overflow bug (only ~110/3000 emails actually sent).
Redesigned sendNewsletter() as fire-and-forget with single @Async background job + paginated processing (50/page).
Added: V87 migration (status tracking), duplicate-send prevention (409), SES rate limiting (70ms/email),
retry for PARTIAL/FAILED sends, progress polling endpoint, frontend LinearProgress UI.
See docs/plans/newsletter-robustness-v87-plan.md for full details.
Story 10.8a: Moderator Presentation Page β Functional
Story file: _bmad-output/implementation-artifacts/10-8a-moderator-presentation-page.md
Status: ready-for-dev
Prerequisite: None (independent β uses existing public event APIs)
User Story: As an event moderator, I want to open a single fullscreen webpage on the projector laptop and navigate through the evening using my presentation remote, so that I can guide the audience through the BATbern event without ever touching PowerPoint β and the content is always live from the platform.
Scope: Full functional presenter page β all sections, navigation, break flow, sidebar, 60s poll, error handling, backend settings API. No Framer Motion. Animations and polish delivered in Story 10.8b.
Route & Entry Point:
- New public route
/present/:eventCodeβ no authentication required - All data sourced from existing public APIs
Section Flow (dynamically generated from event data):
| Section | Type | Content |
|---|---|---|
| 1 | Welcome | BATbern logo, hashtag #BATbernXX, topic title, date, venue |
| 2 | About BATbern | Admin-configurable Verein purpose text + partner count |
| 3 | Committee | All active organizer photo cards (no stagger in this story) |
| 4 | Topic Reveal | Topic title large + image emphasis |
| 5 | Agenda Preview | Full agenda centered, all sessions neutral/upcoming |
| 6β¦N | Speaker Sessions | One section per PRESENTATION/KEYNOTE/WORKSHOP/PANEL_DISCUSSION session |
| (Break) | Break | βPauseβ + resume time; inserted if break/lunch session exists |
| (Recap) | Agenda Recap | Pre-break sessions greyed (β), post-break sessions lit |
| N+1 | Upcoming Events | Next 3 future BATbern events |
| N+2 | ApΓ©ro | Closing visual with BATbern ~ text |
New Backend:
GET /api/v1/public/settings/presentation β { aboutText, partnerCount } (public)
PUT /api/v1/settings/presentation (organizer auth required)
Single-row presentation_settings table β Flyway V10 in company-user-management-service.
Definition of Done (Story 10.8a):
- OpenAPI spec committed before implementation (ADR-006);
PresentationSettingsControllerIntegrationTest - Route
/present/BATbernXX - Break flow:
β
Story 10.8b: Moderator Presentation Page β Animations
Story file: _bmad-output/implementation-artifacts/10-8b-moderator-presentation-page-animations.md
Status: ready-for-dev
Prerequisite: Story 10.8a complete and deployed
User Story: As an event moderator, I want the presentation page to have polished, fluid animations, so that the audience experience feels dynamic and professional β not like a static slide deck.
Scope: Framer Motion animation layer added on top of Story 10.8a. No new data, routing, or business logic. All changes are additive.
- FLIP animation: Agenda transitions centerβsidebar via Framer Motion
layoutprop (ACs #1β4) - Section spring transitions: Directional
AnimatePresencebetween sections (ACs #5β7) - Ken Burns: Topic background
scale 1.0 β 1.06, 30s loop viamotion.img(AC #8) - BlankOverlay fade: AnimatePresence 0.3s fade on B-key (AC #9)
- Committee stagger: Cards fly-in with
delay: index Γ 0.12s(AC #10) - BreakSlide animations:
~spinner, coffee cup + steam, floating beans (ACs #11β13) - AperoSlide spinner:
~CSS @keyframes rotation (AC #14)
Definition of Done (Story 10.8b):
-
framer-motioninpackage.json;npm run build
Story 10.9: i18n Cleanup β Deduplication, Hardcoded Text, Test Resilience & Unused Key Removal
Story file: _bmad-output/implementation-artifacts/10-9-i18n-cleanup.md
Plan: docs/plans/i18n-cleanup-plan.md
Status: ready-for-dev
Prerequisite: None (independent β frontend-only, no backend or DB changes)
User Story: As a frontend developer and future contributor, I want the translation system to be clean, consistent, and test-resilient, so that adding a new language or renaming a translation value never causes surprises in tests or the UI.
Scope:
Four phases executed in dependency order (do not parallelize):
| Phase | Goal | Scope |
|---|---|---|
| 1 β Deduplicate | Consolidate cross-namespace duplicate values into common |
89 redirect-to-common + 80 new common keys; rename awkward key paths |
| 2 β Hardcoded text | Replace hardcoded UI strings with t() calls |
78 prod files, 254 hits (excl. PrivacyPage, SupportPage) |
| 3 β Test resilience | Add data-testid anchors; refactor unit + E2E tests off text literals |
180 unit test files (~4,400 assertions); 32 E2E spec files (~320 brittle patterns) |
| 4 β Unused key analysis | Smart automated analysis of 830 flagged unused keys; delete confirmed dead keys | Scripted tooling + deletion |
Phase 1 β Consolidate Duplicates:
- 302 unique values appear in 2+ keys; 169 span multiple namespaces
- Sub-task 1.1: 89 duplicates already have a canonical
commonkey β redirect callers, delete duplicates from other namespaces - Sub-task 1.2: 80 duplicates not yet in
commonβ add undercommon:labels.*,common:filters.*,common:actions.*hierarchy - Rename awkward existing
commonkeys to semantic generics (e.g.,common:company.batchImport.columns.statusβcommon:labels.status) - All 9 locale files must stay in sync (same keys removed/added in same commit)
- Full duplicates list:
/tmp/duplicates_report.txt
Phase 2 β Hardcoded Text:
- Groups: A (public pages, 41 hits), B (speaker portal, 50 hits), C (organizer, 56 hits), D (shared/user, 45 hits)
- EN translation value must be a character-for-character copy of the hardcoded string replaced (same casing, punctuation, whitespace) β this is what keeps co-located tests passing
- Non-EN locales: add key with
[MISSING]prefix as placeholder - Excluded:
PrivacyPage.tsx,SupportPage.tsx(intentionally German) - Full hardcoded detail:
/tmp/hardcoded_prod_detail.txt
Phase 3 β Test Resilience:
- 3A: Add
data-testidattributes to components (naming:[feature]-[component]-[element]kebab-case) - 3B: Refactor Vitest unit tests β replace
getByText('...')withgetByRole(..., { name: /regex/i })orgetByTestId - 3C: Refactor Playwright E2E β replace
button:has-text('...'),text=/regex/i,locator('h1')withgetByRoleorgetByTestId - Keep data-content assertions intact (e.g.,
text=BATbern57)
Phase 4 β Unused Key Analysis:
- Detect dynamic
t()patterns (t(`prefix.${var}`)) to build prefix exclusion list - Cross-reference prop-drilling patterns (
label: t(...),title: t(...)) - Classify 830 flagged keys into: Definitely unused / Possibly dynamic / Needs manual check (target: <50 in last bucket)
- Delete confirmed unused keys from all 9 locale files
- Commit
scripts/i18n/analyze-unused.pyanddocs/plans/i18n-unused-keys-report.md
Critical test context:
Tests run with real i18n (not mocks) β src/test/setup.ts initialises src/i18n/config and calls i18n.changeLanguage('en'). Tests assert on rendered EN text. If an EN translation value differs from the hardcoded string it replaces β even in capitalisation β co-located getByText tests will break.
Key new files:
scripts/i18n/scan-hardcoded.py β Phase 2 scanner
scripts/i18n/analyze-unused.py β Phase 4 analyzer
docs/plans/i18n-unused-keys-report.md β Phase 4 output report
Key modified files:
web-frontend/public/locales/en/*.json β canonical EN locale (all 10 namespaces)
web-frontend/public/locales/{de,fr,it,rm,es,fi,nl,ja}/*.json β all other locales
web-frontend/src/components/**/*.tsx β data-testid additions (Phase 3A)
web-frontend/src/**/__tests__/**/*.test.tsx β unit test refactors (Phase 3B)
web-frontend/e2e/**/*.spec.ts β E2E refactors (Phase 3C)
Definition of Done (Story 10.9):
- Phase 1: All 169 cross-namespace duplicates resolved; awkward
commonkey paths renamed; all 9 locales in sync;npm run test -- --run0 failures;npm run type-checkpasses; Playwrightchromium - Phase 3: 0
button:has-text(), 0text=/...translation.../i, 0locator('h1/h2/label/tbody tr')in any spec file; 0getByText('EN label')in unit tests; all 3 Playwright projects pass;data-testid - Phase 4:
analyze-unused.pycommitted and runnable; βDefinitely unusedβ keys deleted; βNeeds manual checkβ β€ 50 keys;i18n-unused-keys-report.md - No
i18next: missing key -
npm run lint
Story 10.10: Registration Status Indicator for Logged-in Users
Status: ready-for-dev Prerequisite: None (independent β uses existing registration + auth APIs)
User Story: As a logged-in attendee, I want to see my registration status for an upcoming event directly on the public homepage and event cards, so that I donβt accidentally try to register twice and I always know my current status at a glance.
Background: Currently, a logged-in user who has already registered for an event sees the same public homepage as an anonymous visitor β there is no visual indication that they are already registered. The only way to discover this is by clicking βRegisterβ and submitting the form, at which point the backend returns an error. This creates confusion and poor UX for returning attendees.
The RegistrationService.createRegistration() already handles duplicate detection (returns existing pending registration or throws IllegalStateException for confirmed). A new read-only status endpoint will allow the frontend to efficiently check the current userβs status without side effects.
Scope:
Backend β New Read-Only Status Endpoint:
GET /api/v1/events/{eventCode}/my-registrationβ returns the current authenticated userβs registration status for this event (or 404 if not registered)- Response:
{ registrationCode, status, registrationDate }β minimal DTO, no PII duplication - Auth: requires
ROLE_USER(any authenticated role); anonymous requests β 401 - Implementation:
RegistrationRepository.findByEventCodeAndAttendeeUsername(eventCode, username)β new query method - No new DB migration needed β reads from existing
registrationstable - Cache: 5-minute Caffeine cache keyed by
(eventCode, username)β invalidated on registration mutation events
Frontend β Public Homepage Status Banner:
- After authentication check resolves,
useMyRegistration(eventCode)hook fires for the βcurrent eventβ (most recentAGENDA_PUBLISHED/AGENDA_FINALIZED/EVENT_LIVEevent) - If status =
registeredβ amber info banner below hero: βYou are registered for this event. [Manage Registration]β - If status =
confirmedβ green success banner: βYour registration is confirmed. Weβll see you there! [Manage Registration]β - If status =
waitlistβ blue info banner: βYou are on the waitlist for this event. Weβll notify you if a spot opens. [Manage Registration]β - If status =
cancelledβ grey banner: βYour registration was cancelled. [Register again]β - If not registered or anonymous β no banner (existing CTA βRegister Nowβ shown as before)
- βManage Registrationβ links to
/register/{eventCode}(RegistrationWizard shows status; deregistration from Story 10.12)
Frontend β Event Cards (Archive / Upcoming List):
EventCard.tsxreceives optionalmyRegistrationStatus?: stringprop- If status present, a small status chip overlays the top-right corner of the card:
- β confirmed (green) Β· π registered (amber) Β· β³ waitlist (blue) Β· β cancelled (grey)
- Parent component (
ArchivePage,UpcomingEventsSection) passes status only for past 12 months (to avoid N+1 on full archive)
Frontend β Registration Wizard (RegistrationWizard.tsx):
- On step 1 load, if
useMyRegistrationreturns data, show βYou are already registeredβ with current status + option to continue to deregistration (Story 10.12) - This replaces the current confusing error-on-submit behavior
Key new files:
web-frontend/src/hooks/useMyRegistration.ts
web-frontend/src/components/public/RegistrationStatusBanner.tsx
Key modified files:
docs/api/events.openapi.yml β GET /events/{eventCode}/my-registration (FIRST)
services/event-management-service/.../repository/RegistrationRepository.java β new query method
services/event-management-service/.../service/RegistrationService.java β getMyRegistration()
services/event-management-service/.../controller/EventController.java β new endpoint
web-frontend/src/pages/public/HomePage.tsx β RegistrationStatusBanner integration
web-frontend/src/components/public/EventCard.tsx β status chip overlay
web-frontend/src/components/public/RegistrationWizard.tsx β already-registered guard
public/locales/de/registration.json + en/registration.json β registrationStatus.* keys
Definition of Done (Story 10.10):
- TDD:
RegistrationStatusIntegrationTest - i18n:
registrationStatus.* - Type-check passes; Checkstyle passes;
npm run lint
Story 10.11: Venue Capacity Enforcement & Waitlist Management
Status: done Prerequisite: Story 10.10 (registration status indicator) recommended first for UX coherence; technically independent
User Story: As an organizer, I want to set a maximum capacity for an event based on venue size, so that we never overbook the venue. When the event is full, new registrations automatically go to a waitlist. When a registered attendee cancels, the next person on the waitlist is automatically promoted and notified.
As an attendee, I want to know my position on the waitlist and be automatically promoted if a spot opens up.
Background:
Currently, Registration.status has a waitlist value in the enum but there is no enforcement logic β any number of registrations are accepted. The Event entity has a venueCapacity field that is already stored (from venue management) but is not used for registration gating. This story wires the two together.
Scope:
Backend β Capacity Enforcement:
- Flyway V73: Add
waitlist_positioncolumn toregistrationstable (nullable INTEGER; NULL for non-waitlist registrations); addregistration_capacitytoeventstable (nullable INTEGER; NULL = unlimited, preserving backward compatibility) RegistrationService.createRegistration()extended:- Count
registrationswith status IN (registered,confirmed) for the event - Load eventβs
registrationCapacity(nullable) - If capacity is NULL or count < capacity β create with status
registered(existing behavior) - If count >= capacity β create with status
waitlist; assignwaitlistPosition = nextWaitlistPosition(eventId)
- Count
nextWaitlistPosition(eventId):MAX(waitlist_position) + 1for the event (0 if first)- New
WaitlistPromotionService:promoteFromWaitlist(eventCode): finds the registration with the lowestwaitlistPositionfor this event, updates status toregistered, clearswaitlist_position, sends promotion email- Called by
RegistrationService.cancelRegistration()(Story 10.12) after every successful cancellation WaitlistPromotionEmailService: sendswaitlist-promotion-de/en.htmlemail template (new classpath templates seeded byEmailTemplateSeedService)
Backend β Capacity Management API:
PATCH /api/v1/events/{eventCode}already exists β extend request DTO to includeregistrationCapacity: Integer(nullable)GET /api/v1/events/{eventCode}response extended withregistrationCapacity,confirmedCount,waitlistCount,spotsRemaining(computed: capacity - confirmedCount, null if unlimited)- Organizer-only endpoints; public read access to
spotsRemainingandwaitlistCountonly (no PII)
Frontend β Organizer Attendees Tab:
EventParticipantsTab.tsxextended:- Capacity bar at top:
[ββββββββββ] 42/60 confirmed Β· 3 on waitlist - Two sections in
EventParticipantList: βRegistered / Confirmedβ (existing table) + collapsible βWaitlistβ section (table withwaitlistPositioncolumn shown) RegistrationActionsMenufor waitlist rows: βPromote to Registeredβ (manual promotion, bypasses auto logic), βRemove from Waitlistβ
- Capacity bar at top:
EventSettingsTab.tsxor event edit form: βRegistration Capacityβ numeric field (blank = unlimited)- Capacity editable only when event status is not
ARCHIVED
Frontend β Public Homepage & Registration Wizard:
RegistrationWizard.tsxStep 1: ifspotsRemaining === 0, show βThis event is full β you will be added to the waitlistβ info alert before form submission; user must acknowledgeRegistrationStatusBanner.tsx(Story 10.10): add waitlist position display: βYou are #3 on the waitlistβHomePage.tsx: βX spots remainingβ or βFull β join waitlistβ badge on event hero if capacity is set
Email Templates (new classpath content fragments):
services/event-management-service/src/main/resources/email-templates/waitlist-promotion-de.html
services/event-management-service/src/main/resources/email-templates/waitlist-promotion-en.html
services/event-management-service/src/main/resources/email-templates/waitlist-confirmation-de.html
services/event-management-service/src/main/resources/email-templates/waitlist-confirmation-en.html
All use batbern-default layout. Variables: recipientName, eventTitle, eventCode, eventDate, venueAddress, registrationCode.
Key new files:
services/event-management-service/src/main/resources/db/migration/V73__add_capacity_and_waitlist.sql
services/event-management-service/.../service/WaitlistPromotionService.java
services/event-management-service/.../service/WaitlistPromotionEmailService.java
services/event-management-service/.../service/WaitlistPromotionServiceTest.java
web-frontend/src/components/organizer/EventPage/WaitlistSection.tsx
web-frontend/src/components/public/CapacityIndicator.tsx
Key modified files:
docs/api/events.openapi.yml β capacity fields + waitlist response (FIRST)
services/event-management-service/.../domain/Registration.java β waitlistPosition field
services/event-management-service/.../domain/Event.java β registrationCapacity field
services/event-management-service/.../service/RegistrationService.java β capacity enforcement
services/event-management-service/.../repository/RegistrationRepository.java β waitlist queries
web-frontend/src/components/organizer/EventPage/EventParticipantsTab.tsx β capacity bar + waitlist section
web-frontend/src/components/public/RegistrationWizard.tsx β waitlist acknowledgment
web-frontend/src/pages/public/HomePage.tsx β capacity badge
public/locales/de/events.json + en/events.json β waitlist.* keys
public/locales/de/registration.json + en/registration.json β waitlist.* keys
Definition of Done (Story 10.11):
- V73 migration runs cleanly;
waitlist_positionnullable;registration_capacity - TDD:
WaitlistPromotionServiceTestandRegistrationCapacityIntegrationTest - Cancelling a registration when waitlist exists β first waitlisted person auto-promoted + email sent (Story 10.12 β
cancelRegistrationendpoint implemented there;WaitlistPromotionService.promoteFromWaitlist()ready) - Manual promotion by organizer works from attendees tab (
POST /{eventCode}/registrations/{registrationCode}/promote - Waitlist email templates seeded (
registration-waitlist-confirmation-de/en,waitlist-promotion-de/en - Public homepage shows capacity badge when capacity is set (
CapacityIndicator -
RegistrationStatusBannershows βYou are #N on the waitlistβ whenwaitlistPosition - i18n:
waitlist.*andcapacity.* -
RegistrationStatustype corrected:WAITLISTEDβWAITLIST
Story 10.12: Self-Service Deregistration
Status: ready-for-dev Prerequisite: Story 10.11 (waitlist promotion must fire on cancellation)
User Story: As a registered attendee, I want to cancel my event registration easily without contacting an organizer β either by clicking a link in my confirmation email or by entering my email address on the event page β so that my spot can be given to someone else.
Background:
Currently there is no self-service cancellation path. The Registration.status enum includes cancelled but no endpoint or UI supports transitioning to it from the attendee side. Organizers can manually cancel from the attendees tab but this requires contacting the organizer. The todo explicitly requests:
- A non-expiring magic link embedded in the confirmation email
- An email-input form on the public portal for deregistration
Scope:
Backend β Deregistration Token:
- Flyway V74: Add
deregistration_tokencolumn toregistrations(UUID, NOT NULL, unique β generated on creation, never rotated, never expires) RegistrationService.createRegistration()extended: generate UUID deregistration token and persist- Migration backfill:
UPDATE registrations SET deregistration_token = gen_random_uuid() WHERE deregistration_token IS NULL
Backend β Deregistration Endpoints:
# Public (no auth required β token IS the auth)
GET /api/v1/registrations/deregister/verify?token={uuid}
β 200: { registrationCode, eventCode, eventTitle, eventDate, attendeeFirstName }
β 404: { error: "invalid_token" } (token not found or already cancelled)
POST /api/v1/registrations/deregister
body: { token: uuid }
β 200: success; fires waitlist promotion (Story 10.11)
β 404: invalid token
β 409: already cancelled
# Public (no auth required β email verification flow)
POST /api/v1/registrations/deregister/by-email
body: { email: string, eventCode: string }
β 200: always (anti-enumeration: "if registered, you'll receive a deregistration email")
Sends email with deregistration link (containing token) if registration found
Email Template:
deregistration-link-de.html+deregistration-link-en.htmlβ content fragments withbatbern-defaultlayout- Variables:
recipientName,eventTitle,eventCode,eventDate,deregistrationLink deregistrationLink={baseUrl}/deregister?token={deregistrationToken}- Also embed deregistration link in the existing registration confirmation email: βTo cancel: [Cancel Registration]β
Frontend β Deregistration Page (/deregister?token=):
- Public route (no auth required)
- Step 1 (token verify): shows event title, date, attendee name β βAre you sure you want to cancel your registration?β
- βConfirm Cancellationβ button β POST /deregister β success state
- Invalid/used token β error state with contact info
- Already cancelled β βYour registration was already cancelledβ state
- Component:
DeregistrationPage.tsx(new public page, same pattern asUnsubscribePage.tsx)
Frontend β Deregistration via Email Form:
- On
HomePage.tsx(when event is inAGENDA_PUBLISHED/AGENDA_FINALIZED/EVENT_LIVEstate):- Secondary link βCancel your registrationβ below registration CTA
- Opens
DeregistrationByEmailModal.tsxβ email + eventCode input β submit β shows βCheck your inboxβ message
- On
RegistrationWizard.tsx(when user is already registered β Story 10.10 guard):- βCancel my registrationβ button alongside status display β opens same modal
Frontend β Organizer Attendees Tab:
- Cancelled registrations shown in table with grey
CANCELLEDchip - Existing
RegistrationActionsMenualready has cancel action for organizers β ensure it uses the sameRegistrationService.cancelRegistration()that triggers waitlist promotion
Key new files:
services/event-management-service/src/main/resources/db/migration/V74__add_deregistration_token.sql
services/event-management-service/src/main/resources/email-templates/deregistration-link-de.html
services/event-management-service/src/main/resources/email-templates/deregistration-link-en.html
services/event-management-service/.../service/DeregistrationService.java
services/event-management-service/.../controller/DeregistrationController.java
services/event-management-service/.../service/DeregistrationServiceTest.java
services/event-management-service/.../controller/DeregistrationControllerIntegrationTest.java
web-frontend/src/pages/public/DeregistrationPage.tsx
web-frontend/src/components/public/DeregistrationByEmailModal.tsx
web-frontend/src/hooks/useDeregistration.ts
Key modified files:
docs/api/events.openapi.yml β deregistration endpoints (FIRST)
services/event-management-service/.../domain/Registration.java β deregistrationToken field
services/event-management-service/.../service/RegistrationService.java β generate token on create; cancelRegistration() triggers waitlist promotion
services/event-management-service/.../service/RegistrationEmailService.java β add deregistrationLink variable to confirmation email
services/event-management-service/.../config/SecurityConfig.java β permitAll /deregister/** public paths
api-gateway/.../SecurityConfig.java β permitAll /api/v1/registrations/deregister/**
web-frontend/src/App.tsx β /deregister public route
web-frontend/src/pages/public/HomePage.tsx β "Cancel registration" link
web-frontend/src/components/public/RegistrationWizard.tsx β cancel button when already registered
public/locales/de/registration.json + en/registration.json β deregistration.* keys
Definition of Done (Story 10.12):
- TDD:
DeregistrationServiceTest+DeregistrationControllerIntegrationTest -
/deregister?token=valid - i18n:
deregistration.*
Story 10.13: Registration & Portal Email Templates β Editable in Admin
Status: ready-for-dev Prerequisite: Story 10.2 (Email Template Management β provides DB storage, seed service, admin UI)
User Story: As an organizer, I want the registration confirmation email and all speaker portal emails to be editable from the Email Templates admin tab β just like speaker invitation emails β so that I can update their subject and content without a code deploy.
Background: Story 10.2 seeded and made editable 22 classpath email templates (speaker invitations, task reminders, newsletter, etc.). However, two important categories were explicitly left out of the initial UI:
- Registration emails:
registration-confirmation-de/en.html(sent byRegistrationEmailService) - Speaker portal registration emails: Emails sent when a speaker creates their portal account or responds to an invitation via the portal (
SpeakerAcceptanceEmailService,PortalRegistrationEmailService)
Additionally, organizers have requested a shortcut to jump directly to an event-relevant email template from the event page (e.g., βEdit Registration Templateβ from the event attendees tab) without navigating to the admin page manually.
Scope:
Backend β Seed Additional Templates:
EmailTemplateSeedService.@PostConstructextended to seed:registration-confirmation-de/en(REGISTRATION category) β from existing classpath filesregistration-waitlist-confirmation-de/en(REGISTRATION category) β from Story 10.11 filesderegistration-link-de/en(REGISTRATION category) β from Story 10.12 fileswaitlist-promotion-de/en(REGISTRATION category) β from Story 10.11 filesportal-registration-de/en(SPEAKER category) β speaker portal account creation email
- All new templates: system templates (editable, not deletable)
- New
REGISTRATIONcategory added toEmailTemplateCategoryenum (if not already present)
Frontend β Email Templates Tab Enhancement:
EmailTemplatesTab.tsx: AddREGISTRATIONcategory to the category filter (alongside Speaker, Task Reminders, Newsletter)- Registration templates visible and editable in same TinyMCE editor as other content templates
- Preview merges with
batbern-defaultlayout and renders branded email preview
Frontend β Quick Template Access from Event Pages:
EventParticipantsTab.tsx: βEdit Registration Email Templateβ icon-button (top-right) β opensEmailTemplateQuickEditDrawer.tsxwithregistration-confirmation-{locale}pre-loadedEventParticipantsTab.tsx: βEdit Waitlist Confirmation Emailβ icon-button β opens drawer withregistration-waitlist-confirmation-{locale}EventParticipantsTab.tsx: βEdit Deregistration Emailβ icon-button βderegistration-link-{locale}EmailTemplateQuickEditDrawer.tsx: Right-side MUI Drawer (480px); embeds the sameEmailTemplateEditModalcontent but without navigating away from the event page. Header shows βEmail Template: [name]β + locale toggle + βOpen in Adminβ link
Key new files:
web-frontend/src/components/organizer/EventPage/EmailTemplateQuickEditDrawer.tsx
Key modified files:
services/event-management-service/.../service/EmailTemplateSeedService.java β seed REGISTRATION category templates
services/event-management-service/.../domain/EmailTemplateCategory.java β add REGISTRATION enum value
web-frontend/src/components/organizer/Admin/EmailTemplatesTab.tsx β add REGISTRATION category filter
web-frontend/src/components/organizer/EventPage/EventParticipantsTab.tsx β quick template access buttons
public/locales/de/organizer.json + en/organizer.json β emailTemplates.categories.registration key
Definition of Done (Story 10.13):
- Existing
RegistrationEmailService - i18n:
emailTemplates.categories.registration
Story 10.14: Newsletter Sending with Template Selection
Status: ready-for-dev Prerequisite: Story 10.2 (email template management), Story 10.7 (newsletter sending infrastructure)
User Story: As an organizer, I want to choose which email template to use when sending a newsletter for an event, so that I can use different templates for different types of communications (general newsletter, event reminder, partner announcement) without needing separate template hardcoding.
Background:
Story 10.7 implemented newsletter sending from the EventNewsletterTab with a single hardcoded template key (newsletter-event-{locale}). The admin EmailTemplatesTab (Story 10.2) supports multiple templates in the NEWSLETTER category, but the newsletter sending UI doesnβt let organizers pick which one to use β it always uses the hardcoded default. This story adds template selection to the newsletter send flow.
Scope:
Backend β Newsletter Send API Extension:
POST /api/v1/events/{eventCode}/newsletter/sendrequest body extended with optionaltemplateKey: string- If
templateKeyis provided β use that template from DB - If omitted β use default
newsletter-event-{locale}(backward compatible)
- If
NewsletterEmailService.sendNewsletter()updated to accepttemplateKeyparameterPOST /api/v1/events/{eventCode}/newsletter/previewsame extension for preview
Backend β Newsletter Template Listing:
GET /api/v1/email-templates?category=NEWSLETTERβ already works via existingEmailTemplateController(no changes needed)
Frontend β EventNewsletterTab:
- Before βSend Newsletterβ and βSend Reminderβ buttons: add a βTemplateβ select dropdown
- Populated by
GET /api/v1/email-templates?category=NEWSLETTERresponse - Default selection:
newsletter-event-{currentLocale}(same as current hardcoded behavior) - Options: all NEWSLETTER category templates (display: template name + language badge)
- Populated by
- Locale toggle (DE/EN) now also affects which templates are shown in the dropdown (filtered by locale suffix)
- βPreviewβ iframe uses selected template for preview render
- βCreate new templateβ shortcut link β opens admin Email Templates tab with NEWSLETTER filter pre-applied
- Confirmation dialog for βSend Newsletterβ shows selected template name: βSend newsletter using β[Template Name]β to 234 subscribers?β
Key modified files:
docs/api/events.openapi.yml β extend newsletter send request (FIRST)
services/event-management-service/.../service/NewsletterEmailService.java β accept templateKey param
services/event-management-service/.../controller/NewsletterController.java β pass templateKey through
web-frontend/src/components/organizer/EventPage/EventNewsletterTab.tsx β template selector + preview
web-frontend/src/hooks/useNewsletter/useNewsletter.ts β pass templateKey to API
public/locales/de/organizer.json + en/organizer.json β newsletter.templateSelect.* keys
Definition of Done (Story 10.14):
- Newsletter send with explicit
templateKeyuses that template; send withouttemplateKey -
EventNewsletterTabshows template dropdown populated from DB templates; default = - i18n:
newsletter.templateSelect.*
Story 10.15: Newsletter Subscription Integrity & Language Fix
Status: ready-for-dev Prerequisite: Story 10.7 (newsletter subscriber tables + service)
User Story: As a community member, I want my newsletter subscription language preference to match the language I was browsing in when I registered, so that I receive newsletters in my preferred language β not always in German.
As an organizer, I want to confirm that all newsletter opt-in paths (public registration wizard, speaker portal registration) are wired correctly so that no subscriber is silently lost.
Background: Two gaps identified in the todo:
Gap 1 β Language hardcoding in RegistrationService:
RegistrationService.createRegistration() (line 146) hardcodes "de" as the newsletter subscription language when a user opts in during registration:
newsletterSubscriberService.subscribe(request.getEmail(), request.getFirstName(), "de", "registration", username);
This should use the userβs communicationPreferences.preferredLanguage (already in the CreateRegistrationRequest DTO) or fall back to the browser Accept-Language header.
Gap 2 β Speaker portal registration:
When a speaker creates their BATbern account via the self-service portal (Epic 9 / Speaker Authentication), there is no newsletter opt-in checkbox or auto-subscription. The SpeakerPortalRegistrationService (if it exists) should respect the speakerβs language preference for any newsletter subscription.
Scope:
Backend β Language Fix (RegistrationService):
CreateRegistrationRequestalready hascommunicationPreferences.preferredLanguage(String, nullable)RegistrationService.createRegistration(): replace hardcoded"de"with:javaString lang = Optional.ofNullable(request.getCommunicationPreferences()) .map(p -> p.getPreferredLanguage()) .filter(l -> l != null && !l.isBlank()) .orElse("de"); // fallback to German- Unit test:
RegistrationServiceTestβ assert newsletter subscription usespreferredLanguagefrom request
Backend β Speaker Portal Newsletter Opt-in (if 9.x speaker auth service exists):
- If
SpeakerPortalAccountServiceor equivalent exists inspeaker-coordination-service:- Add
newsletterOptIn: booleanfield to speaker portal registration request DTO - On account creation: call
NewsletterSubscriberService.subscribe()using speakerβs language preference ifnewsletterOptIn = true - If speaker-coordination-service cannot call newsletter service directly (cross-service boundary): emit
SpeakerPortalRegisteredEventvia shared-kernel domain event; EMS consumes and subscribes
- Add
- If Epic 9 speaker auth is not yet deployed: scaffold the opt-in field in the DTO + stub the wiring; the feature becomes active when Story 9.x (Cognito account creation) is implemented
Frontend β Registration Wizard:
RegistrationWizard.tsxStep 3 (Communication Preferences): ensurepreferredLanguagefield value is passed inCreateRegistrationRequestpayload- Currently the wizard likely sends the i18n language from the UI β verify and fix if not
Frontend β Speaker Portal Registration (Epic 9 alignment):
SpeakerPortalRegistrationPage.tsx(if exists): add βSubscribe to BATbern newsletterβ checkbox in account creation form, defaulting to checked
Testing β Newsletter Opt-in Integration Tests:
NewsletterOptInIntegrationTest: POST registration withnewsletterSubscribed=true+preferredLanguage=enβ subscriber created withlanguage=en- POST registration with
newsletterSubscribed=true+ no language β subscriber created withlanguage=de(fallback) - POST registration with
newsletterSubscribed=falseβ no subscriber created - POST registration with
newsletterSubscribed=truefor already-subscribed email β idempotent (no error, subscriber unchanged)
Key modified files:
services/event-management-service/.../service/RegistrationService.java β language from request
services/event-management-service/.../service/RegistrationServiceTest.java β new language assertions
services/speaker-coordination-service/.../ (if exists) β newsletter opt-in on portal registration
web-frontend/src/components/public/RegistrationWizard.tsx β ensure lang passed in payload
Definition of Done (Story 10.15):
-
RegistrationServiceusespreferredLanguage - Integration test: registration with
preferredLanguage=frβ newsletter subscriber created with - Registration with
newsletterSubscribed=false
Story 10.16: AI-Assisted Event Content Creation
Status: ready-for-dev Prerequisite: Story 10.2 (email template management introduces OpenAI dependency pattern)
User Story: As an organizer, I want to use AI assistance to generate a polished event description from a topic title, create a themed event image, and get a quality summary of a speakerβs abstract β so that I can produce professional event content in minutes instead of hours.
Background:
The codebase has keyword-based topic classification (BatbernTopicClusterService) but no generative AI integration. The TrendingTopicsService has a comment noting βOptional β falls back gracefully when API key absentβ, indicating OpenAI integration was always planned. This story introduces a controlled, optional AI layer:
- All AI calls are gated by
batbern.ai.enabled: ${AI_ENABLED:false}β disabled by default; enabled in staging/prod via environment variable - All AI calls have graceful fallbacks β if disabled or if the API call fails, the UI degrades to manual input
- OpenAI API (GPT-4o for text, DALL-E 3 for images) via the
openai-javaSDK
Scope:
Backend β AI Service Infrastructure:
- New dependency:
com.theokanning.openai-gpt3-java:service:{version}(orcom.openai:openai-java:1.x.xβ latest official SDK) AiConfig.java: bean configuration; reads${OPENAI_API_KEY:}and${batbern.ai.enabled:false}; returns no-op stub when disabledBatbernAiService.java: central service wrapping OpenAI SDK:generateEventDescription(topicTitle, topicCategory, eventNumber): Stringβ GPT-4o prompt: βWrite a 2-paragraph German event description for BATbern#{n}, a Swiss software architecture conference. Topic: {topicTitle}. Style: professional, enthusiastic, 150-200 words.βgenerateThemeImage(topicTitle, topicCategory): String (S3 URL)β DALL-E 3 prompt: βAbstract illustration for a software architecture conference themed β{topicTitle}β, dark navy and blue tones, Swiss minimalist style, no textβ; result uploaded to S3; returns presigned URLanalyzeAbstract(abstract, speakerName): AbstractAnalysisResultβ GPT-4o: returns{ noPromotionScore: 1-10, noPromotionFeedback: string, lessonsLearnedScore: 1-10, lessonsLearnedFeedback: string, wordCount: integer, shortenedAbstract: string|null }β scores absence-of-promotion and presence-of-lessons-learned; provides shortened German abstract when >160 words
- All methods:
if (!aiEnabled) return Optional.empty()β callers handle absent result gracefully - Results cached 1 hour by input hash via Caffeine (max 500 entries); OpenAI-side rate limits apply for burst protection
- Flyway V75:
ai_generation_logtable β(id, event_code, type, input_hash, generated_at, tokens_used, was_accepted)β for cost monitoring
Backend β New AI Endpoints:
# Organizer only
POST /api/v1/events/{eventCode}/ai/description
body: { topicTitle, topicCategory }
response: { description: string } | 503 (if AI disabled)
POST /api/v1/events/{eventCode}/ai/theme-image
body: { topicTitle, topicCategory }
response: { imageUrl: string, s3Key: string } | 503 (if AI disabled)
POST /api/v1/speakers/{speakerId}/ai/analyze-abstract
body: { abstract: string, speakerName?: string }
response: { noPromotionScore, noPromotionFeedback, lessonsLearnedScore, lessonsLearnedFeedback, wordCount, shortenedAbstract } | 503 (if AI disabled)
POST /api/v1/events/{eventCode}/ai/theme-image/apply
body: { imageUrl: string }
response: 200 | 404 (event not found) β persists imageUrl on event record
Frontend β AI Assist Buttons (Organizer):
Event Description (EventSettingsTab or EventOverviewTab):
- βEvent Descriptionβ textarea: right-aligned ββ¨ Generate with AIβ ghost button (only if
aiEnabled=truefeature flag from/api/v1/public/settingsresponse) - Click β loading spinner β inserts generated text into textarea (user can edit before saving)
- βRegenerateβ button appears after first generation
Theme Image (EventSettingsTab):
- βTheme Imageβ section: ββ¨ Generate theme image with AIβ button alongside existing file upload
- Click β βGenerating imageβ¦β skeleton β generated image preview with βUse this imageβ / βRegenerateβ / βUpload my ownβ choices
- βUse this imageβ β stores S3 key on event record via PATCH event endpoint
Abstract Analysis (QualityReviewDrawer β reached from EventSpeakersTab):
- βAnalyze Abstractβ button inside
QualityReviewDrawer(visible to organizers only, gated onaiContentEnabled, appears only when abstract is non-empty) - Click β inline results: two score badges (
noPromotionScore,lessonsLearnedScore, each 1-10 color-coded), per-score German feedback text, optional shortened abstract accordion (when original >160 words) with copy button - Original abstract unchanged
Frontend β AI Feature Flag:
useFeatureFlags()hook reads from/api/v1/public/settings/presentation(or new/api/v1/public/settings/features) response{ aiContentEnabled: boolean }β all AI UI only shown when flag is true- Feature flag endpoint returns false when
batbern.ai.enabled=false(zero frontend changes for on/off toggle)
Key new files:
services/event-management-service/src/main/resources/db/migration/V77__create_ai_generation_log.sql
services/event-management-service/.../config/AiConfig.java
services/event-management-service/.../service/BatbernAiService.java
services/event-management-service/.../service/BatbernAiServiceTest.java
services/event-management-service/.../controller/AiAssistController.java
services/event-management-service/.../controller/AiAssistControllerIntegrationTest.java
web-frontend/src/hooks/useAiAssist.ts
web-frontend/src/hooks/useFeatureFlags.ts
web-frontend/src/components/organizer/EventPage/AiAssistDrawer.tsx
Key modified files:
docs/api/events-api.openapi.yml β AI assist endpoints + /apply (FIRST)
services/event-management-service/.../security/SecurityConfig.java β permitAll for /public/settings/features
web-frontend/src/components/organizer/EventPage/EventOverviewTab.tsx β AI description + image buttons
web-frontend/src/components/organizer/SpeakerStatus/QualityReviewDrawer.tsx β abstract analysis (design pivot: merged here)
public/locales/de/organizer.json + en/organizer.json + 8 others β aiAssist.* keys (all 10 locales)
Definition of Done (Story 10.16): β COMPLETE (2026-03-03)
-
AI_ENABLED=false(default): all AI endpoints return 503; AI buttons hidden from UI (gated onuseFeatureFlags().aiContentEnabled -
AI_ENABLED=true+ validOPENAI_API_KEY: description generation returns German text within 10s; image generation returns S3 URL;/apply -
ai_generation_log - Abstract analysis (in
QualityReviewDrawer - TDD:
BatbernAiServiceTest(6 unit tests);AiAssistControllerIntegrationTest - i18n:
aiAssist.*
Story 10.17: Email Reply-based Unsubscribe & Deregistration (Inbound Email Handler)
Status: ready-for-dev Prerequisite: Story 10.12 (deregistration service), Story 10.7 (newsletter unsubscribe)
User Story: As a subscriber or attendee, I want to unsubscribe from the newsletter or cancel my event registration simply by replying βUNSUBSCRIBEβ or βCANCELβ to any email from BATbern, so that I can manage my preferences without clicking any links β especially useful on mobile.
Background:
All outbound emails currently use noreply@batbern.ch. There is no inbound email processing. This story sets up AWS SES inbound email receiving and routes replies to the appropriate service action. The implementation uses the standard AWS pattern: SES Receiving β S3 (raw email storage) β SQS β Spring @SqsListener.
Scope:
AWS Infrastructure (CDK changes in infrastructure/):
- SES receiving rule set for
replies@batbern.ch(separate fromnoreply@batbern.chsending address) - SES receipt rule: all mail to
replies@batbern.chβ store to S3 bucketbatbern-inbound-emails-{env}β send notification to SQS queuebatbern-inbound-email-{env} - IAM: EMS task role gains
sqs:ReceiveMessage+sqs:DeleteMessageon inbound queue +s3:GetObjecton inbound bucket - CDK stack:
InboundEmailStack(new) ininfrastructure/lib/
Backend β Inbound Email Processor (event-management-service):
- New dependency:
io.awspring.cloud:spring-cloud-aws-starter-sqs:{version}(already in project for other SQS work, likely) InboundEmailConfig.java: SQS listener bean configuration; reads${AWS_INBOUND_EMAIL_QUEUE_URL}InboundEmailListenerService.java(@SqsListener):- Receive SQS message (S3 event notification β raw email S3 key)
- Fetch raw email from S3 via
S3Client.getObject() - Parse MIME message with
jakarta.mail.internet.MimeMessage(Java Mail API) - Extract: sender email (
From:header),In-Reply-To/Referencesheaders (to match original email), and first non-quoted line of plain-text body (normalized: trim, lowercase, remove punctuation)
InboundEmailRouter.java: routes parsed email to action handler based on body content:- Body contains
unsubscribe/abmelden/dΓ©sinscriptionβNewsletterSubscriberService.unsubscribeByEmail(senderEmail) - Body contains
cancel/deregister/abmelden/absagen+ event code (parsed from reply subject βRe: BATbernXX Registration Confirmationβ) βDeregistrationService.cancelByEmail(senderEmail, eventCode) - Unrecognized β log and discard; no action
- Body contains
- Anti-abuse: max 10 inbound emails per sender per hour (Caffeine rate limiter)
- Reply confirmation: After successful action, send a brief confirmation email (βYour newsletter subscription has been cancelledβ)
- From header: Update all outbound emails to use
Reply-To: replies@batbern.ch(keepingFrom: noreply@batbern.ch)
Key new files:
infrastructure/lib/inbound-email-stack.ts
services/event-management-service/.../config/InboundEmailConfig.java
services/event-management-service/.../service/InboundEmailListenerService.java
services/event-management-service/.../service/InboundEmailRouter.java
services/event-management-service/.../service/InboundEmailRouterTest.java
services/event-management-service/src/main/resources/email-templates/unsubscribe-confirmation-de.html
services/event-management-service/src/main/resources/email-templates/unsubscribe-confirmation-en.html
Key modified files:
infrastructure/lib/batbern-stack.ts β add InboundEmailStack
shared-kernel/.../service/EmailService.java β add Reply-To: replies@batbern.ch header
services/event-management-service/build.gradle β spring-cloud-aws-starter-sqs (if not present)
services/event-management-service/.../service/NewsletterSubscriberService.java β unsubscribeByEmail() method
services/event-management-service/.../service/DeregistrationService.java β cancelByEmail() method
Definition of Done (Story 10.17):
- CDK
InboundEmailStackdeploys cleanly to staging;replies@batbern.ch - All outbound emails include
Reply-To: replies@batbern.ch - TDD:
InboundEmailRouterTest
Story 10.18: Event Archival Task & Notification Cleanup
Status: ready-for-dev Prerequisite: None (bug fix / data cleanup β independent)
User Story: As an organizer, I want all pending tasks and in-app notifications for an event to be automatically cleaned up when the event is archived, so that my task board and notification center are not cluttered with stale items from past events.
Background (Error from todo.md):
When an event transitions to ARCHIVED state via the event state machine, currently:
- Open tasks (
status != completed) remain open and appear in organizer task boards - Any queued task reminder emails continue to fire (if the scheduler window includes past-due tasks from archived events)
- In-app notifications referencing the archived event linger in the notification center
This is a data integrity bug β the EventLifecycleService (or equivalent state machine handler) does not include a cleanup step on the ARCHIVED transition.
Scope:
Backend β Archival Cleanup:
EventArchivalCleanupService.javaβ called from event state machineonEntry(ARCHIVED)orEventStateTransitionHandler:- Task cancellation:
EventTaskRepository.findByEventIdAndStatusNot(eventId, "completed")β bulk update all to statuscancelled+ setcancelledReason = "Event archived"+cancelledAt = now() - Task reminder suppression:
ShedLock-based scheduler already queries for non-completed tasks due tomorrow β now task status =cancelledso they are naturally excluded. VerifyTaskDeadlineReminderScheduler.findTasksDueForReminder()query excludescancelledstatus (add if missing) - Notification dismissal:
NotificationRepository.findByEventCodeAndReadFalse(eventCode)β bulk mark asread = true+dismissedAt = now(); if no notification table exists, skip this step and log a warning - Registration cleanup: Waitlisted registrations (
status = waitlist) for the archived event β update tocancelled,cancelledReason = "Event archived". Confirmed/registered registrations β no change (historical data preserved)
- Task cancellation:
- All 4 steps in a single
@Transactionalmethod; failures in steps 2-4 are caught and logged but do not roll back step 1 (task cancellation is most important) - Idempotent: running cleanup twice has no side effects (already-cancelled tasks are skipped)
Backend β Scheduler Safety Net:
TaskDeadlineReminderScheduler.findTasksDueForReminder()JPQL query: ensurestatus NOT IN ('completed', 'cancelled')(addcancelledif not already excluded)- Unit test: scheduler does NOT send reminder for tasks belonging to ARCHIVED events
Frontend β No Changes Required:
- Task board already filters by event; once tasks are cancelled, they naturally fall into the βCancelledβ bucket (or are hidden if the task board filters by
pending|in_progressonly) - Notification dismissal is backend-side; frontend already respects
read=true
Key new files:
services/event-management-service/.../service/EventArchivalCleanupService.java
services/event-management-service/.../service/EventArchivalCleanupServiceTest.java
Key modified files:
services/event-management-service/.../service/EventLifecycleService.java (or EventStateTransitionHandler.java)
β call EventArchivalCleanupService.cleanup(eventCode) on ARCHIVED transition
services/event-management-service/.../scheduler/TaskDeadlineReminderScheduler.java
β ensure 'cancelled' excluded from reminder query
services/event-management-service/.../repository/EventTaskRepository.java
β bulk cancel query + findByEventIdAndStatusNot
services/event-management-service/.../repository/NotificationRepository.java (if exists)
β bulk dismiss query
Definition of Done (Story 10.18):
- TDD:
EventArchivalCleanupServiceTest - Archiving an event with 5 open tasks β all 5 tasks transition to
cancelled - Task reminder scheduler does not send emails for tasks with
status=cancelledor belonging toARCHIVED
Story 10.19: Remove Organizer Email from Public Presenter & About Pages
Status: ready-for-dev Prerequisite: None (pure UI cleanup β independent)
User Story: As an organizer, I want our personal email addresses removed from the public-facing About page and the moderator presentation page, so that we donβt expose team membersβ private contact details to every event attendee.
Background:
The OrganizerDisplay.tsx component (used on /about) renders a clickable mailto: link for every organizer whose email field is populated. The same presenter view (/present/:eventCode) displays organizer bios. The general contact email info@berner-architekten-treffen.ch already exists in the About page Contact section, making individual emails redundant.
Scope:
Frontend β OrganizerDisplay.tsx:
- Remove the
{organizer.email && (<a href={mailto:β¦}>)}block (lines 69β76) - The organizer portrait, name, title, and bio remain unchanged
- No backend change needed β the email field still exists in the DB/API for organizer-internal use (e.g., task notifications); it simply must not be rendered on public pages
Frontend β PresentationPage.tsx (if email is rendered):
- Audit
PresentationPage.tsxand all child components (e.g., speaker bio sections, organizer credit slides) for anyemailrenders - Remove any public-facing
mailto:or email text renders found; retain email in organizer-only internal views
Frontend β About i18n:
- If any
t('contact.email')or similar keys were used solely to label the email display, remove those uses (keys themselves may remain for Contact section) - No new i18n keys needed
Key modified files:
web-frontend/src/components/public/About/OrganizerDisplay.tsx β remove email block
web-frontend/src/pages/PresentationPage.tsx β remove any email renders (if present)
Definition of Done (Story 10.19):
-
/about -
/present/:eventCode - The Contact section on
/aboutstill showsinfo@berner-architekten-treffen.ch
Story 10.20: Legacy BAT Format Data Export & Import (Admin Tool)
Status: ready-for-dev Prerequisite: Story 10.1 (Admin page must exist to add new tab)
User Story: As an organizer, I want to export all BATbern data (events, sessions, speakers, companies, attendees, portraits, logos, and presentations) in the legacy BAT JSON format, and to import data in that same format, so that we can migrate data between system versions and maintain interoperability with the old BATspa platform.
Background:
The legacy BATspa application uses a proprietary JSON export format for events, sessions, speakers, bios, companies, and attendees. Binary assets (speaker portraits, company logos, presentation files) are stored in S3. This story adds a new βExport / Importβ tab (Tab 5) to the existing /organizer/admin Administration page that allows full data snapshots and restores using this format.
Scope:
Backend β Export API (event-management-service):
- New endpoint:
GET /api/v1/admin/export/legacy(organizer-only)- Collects all events with their sessions, speaker pools, registrations (attendees)
- Collects all companies (via HTTP call to company-user-management-service)
- Collects all users with role SPEAKER (bio, portrait S3 key, links)
- Serializes to the legacy BAT JSON envelope:
{ version, exportedAt, events[], companies[], speakers[], attendees[] } - Returns as
application/jsondownload (Content-Disposition: attachment; filename=batbern-export-{date}.json)
- New endpoint:
GET /api/v1/admin/export/assets(organizer-only)- Returns a JSON manifest of presigned S3 GET URLs for all portraits, logos, and presentation PDFs/files (one URL per asset, valid 1 hour) β chosen approach: presigned URL manifest (simpler, no async complexity, no Lambda needed) (IR fix 2026-03-02: resolved βasync ZIP vs manifestβ ambiguity in favour of manifest)
- Assets identified via S3 keys stored on User (portrait), Company (logo), and SessionMaterials (presentation) entities
- Response:
{ exportedAt, assetCount, assets: [{ type, entityId, filename, presignedUrl }] }
Backend β Import API (event-management-service):
- New endpoint:
POST /api/v1/admin/import/legacy(organizer-only,multipart/form-data)- Accepts
file(JSON in legacy BAT format) - Parses and validates the envelope; returns 400 with structured error list on validation failure
- Import strategy: upsert by legacy ID / event code β existing records updated, new records inserted, nothing deleted
- Speaker bios and company data delegated via internal service calls (company-user-management-service)
- Attendee registrations imported as
registeredstatus (no waitlist logic during import) - Returns
{ imported: { events: N, sessions: N, speakers: N, companies: N, attendees: N }, skipped: [...], errors: [...] }
- Accepts
- Portrait / logo / asset import: separate
POST /api/v1/admin/import/assetsendpoint- Accepts a ZIP file; unpacks to S3 under
imports/{timestamp}/prefix - Links assets to entities by filename convention matching the legacy export manifest
- Accepts a ZIP file; unpacks to S3 under
Frontend β New Admin Tab 5 βExport / Importβ:
web-frontend/src/components/organizer/Admin/ExportImportTab.tsx- Export section:
- βExport All Data (JSON)β button β triggers
GET /api/v1/admin/export/legacydownload - βExport Assets (Portraits, Logos, Presentations)β button β triggers
GET /api/v1/admin/export/assets(opens presigned URL or shows ZIP progress)
- βExport All Data (JSON)β button β triggers
- Import section:
- JSON import: file picker (
.json) + βImportβ button βPOST /api/v1/admin/import/legacyβ shows result summary table (imported counts + skipped/error list) - Asset import: file picker (
.zip) + βImport Assetsβ button βPOST /api/v1/admin/import/assets - Confirmation dialog before import: βThis will upsert data. Existing records will be updated. This cannot be undone automatically.β
- JSON import: file picker (
- Tab added to
EventManagementAdminPage.tsxas Tab 5 (index 5)
Key new files:
services/event-management-service/.../controller/AdminExportImportController.java
services/event-management-service/.../service/LegacyExportService.java
services/event-management-service/.../service/LegacyImportService.java
services/event-management-service/.../dto/LegacyExportEnvelope.java
services/event-management-service/.../dto/LegacyImportResult.java
web-frontend/src/components/organizer/Admin/ExportImportTab.tsx
Key modified files:
docs/api/events-api.openapi.yml β export/import endpoints (FIRST)
web-frontend/src/pages/organizer/EventManagementAdminPage.tsx β add Tab 5
public/locales/de/common.json + en/common.json β admin.exportImport.* keys
Definition of Done (Story 10.20):
-
GET /api/v1/admin/export/legacy -
POST /api/v1/admin/import/legacy - Asset export endpoint returns JSON presigned URL manifest (
{ exportedAt, assetCount, assets: [...] } - TDD:
LegacyExportServiceTestandLegacyImportServiceTest - i18n:
admin.exportImport.*
Story 10.21: Event Photos Gallery
Status: ready-for-dev
Prerequisite: None β EventPhotosTab is added to EventPage.tsx (organizer event detail), not to the Admin page. Independent of Story 10.1. (IR fix 2026-03-02)
User Story: As an organizer, I want to upload and manage photos from each event directly in the event detail page, so that we can preserve visual memories of our events.
As a visitor, I want to see real photos from past BATbern events on the homepage β replacing the dummy testimonials β and I want to see each archived eventβs own photos when I browse the archive.
Background:
The homepage TestimonialSection currently shows 20 hardcoded fake testimonials in an infinite marquee. This story replaces that row with up to 20 real event photos randomly selected from the last 5 events. Each archived event detail page also gets an image marquee showing that eventβs photos. Photos are stored in S3 under events/{eventCode}/photos/{filename}.
Scope:
Backend β Event Photos API (event-management-service):
- Flyway V75: New
event_photostable:id (UUID PK),event_code (VARCHAR FK β events.event_code),s3_key (TEXT NOT NULL),display_url (TEXT NOT NULL),filename (TEXT),uploaded_at (TIMESTAMPTZ),uploaded_by (VARCHAR),sort_order (INT DEFAULT 0)(V74 is already taken byV74__migrate_waitlisted_to_waitlist.sqlβ IR fix 2026-03-02) - New endpoints (organizer-only for write; public for read):
GET /api/v1/events/{eventCode}/photosβList<EventPhotoResponse>(public)POST /api/v1/events/{eventCode}/photos/upload-urlβ presigned S3 upload URL +photoId(organizer)POST /api/v1/events/{eventCode}/photos/confirm(body:photoId) β marks upload complete, storesdisplay_url(organizer)DELETE /api/v1/events/{eventCode}/photos/{photoId}β deletes DB record + S3 object (organizer)
- New endpoint for homepage use:
GET /api/v1/events/recent-photos?limit=20&lastNEvents=5β returns up tolimitphoto URLs randomly sampled from the lastlastNEventsevents (by event date)
Frontend β Organizer Event Page: Photos Tab:
- New tab βPhotosβ added to
EventPage.tsxtab list (after existing tabs) web-frontend/src/components/organizer/EventPage/EventPhotosTab.tsx:- Photo grid (3-4 columns, thumbnails)
- βUpload Photoβ button β triggers presigned URL flow (same pattern as logo/presentation uploads)
- Each photo card has βDeleteβ icon; confirmation before delete
- Drag-to-reorder for sort_order (optional enhancement)
Frontend β Public Homepage: Replace Dummy Testimonials:
TestimonialSection.tsx:- First marquee row: replace hardcoded
testimonials[]array withuseRecentEventPhotos(limit=20, lastNEvents=5)hook - If < 3 photos available (not enough data), fall back to a subset of the current testimonial cards
- Photo cards use
<img src={photo.displayUrl} alt="BATbern event" className="...rounded-lg object-cover h-48 w-64" /> - Remove
TestimonialCardcomponent if unused after this change (or keep for fallback)
- First marquee row: replace hardcoded
Frontend β Archive Event Detail: Photos Marquee:
- Archive event detail page (public) gets a βPhotosβ section below the sessions list:
InfiniteMarqueeshowing that eventβs photos (fetched viaGET /api/v1/events/{eventCode}/photos) - Only shown if the event has β₯ 1 photo; section hidden otherwise
Key new files:
services/event-management-service/src/main/resources/db/migration/V75__create_event_photos.sql
services/event-management-service/.../domain/EventPhoto.java
services/event-management-service/.../repository/EventPhotoRepository.java
services/event-management-service/.../service/EventPhotoService.java
services/event-management-service/.../controller/EventPhotoController.java
web-frontend/src/components/organizer/EventPage/EventPhotosTab.tsx
web-frontend/src/hooks/useRecentEventPhotos.ts
web-frontend/src/hooks/useEventPhotos.ts
Key modified files:
docs/api/events-api.openapi.yml β photo endpoints (FIRST)
web-frontend/src/components/organizer/EventPage/EventPage.tsx β add Photos tab
web-frontend/src/components/public/Testimonials/TestimonialSection.tsx β replace dummy data with real photos
web-frontend/src/pages/public/ArchiveEventDetailPage.tsx (or similar) β add photos marquee section
public/locales/de/events.json + en/events.json β photos.* keys
Definition of Done (Story 10.21):
- V75 migration runs cleanly;
event_photos -
GET /api/v1/events/recent-photos - TDD:
EventPhotoServiceTest - i18n:
photos.* - β Drag-to-reorder (
sort_order) is explicitly NOT in scope
Story 10.22: Event Teaser Images on Moderator Presentation Page
Status: ready-for-dev Prerequisite: Story 10.8a (PresentationPage must exist) Updated: 2026-03-03 β expanded from single-image to multi-image design (IR review)
User Story: As an organizer, I want to upload one or more teaser images for an event from the event detail page, so that each image appears as its own full-screen slide on the moderator presentation screen β after the topic reveal and before the agenda β giving the audience a visual mood-setter sequence.
Background:
The moderator presentation page (/present/:eventCode) currently shows slides: intro β topic β agenda. This story inserts one full-screen image slide per uploaded teaser image, consecutively between the topic introduction and the first agenda slide. Images are stored in S3 and managed from the event detail Settings tab. Maximum 10 images per event.
Scope:
Backend β Teaser Images (child table):
- Flyway migration: create
event_teaser_imagestable (id UUID PK,event_code TEXT FK β events,s3_key TEXT,image_url TEXT,display_order INTEGER,created_at TIMESTAMPTZ) (check latest migration version before coding) EventResponseDTO: addteaserImages: List<TeaserImageItem>(empty list if none set);TeaserImageItem { id, imageUrl, displayOrder }- New endpoints (organizer-only, max 10 images enforced at confirm):
POST /api/v1/events/{eventCode}/teaser-images/upload-urlβ{ uploadUrl, s3Key, expiresIn }; S3 key:events/{eventCode}/teaser/{uuid}.{ext}POST /api/v1/events/{eventCode}/teaser-images/confirm(body:{ s3Key }) βTeaserImageItem; persists new row; returns 422 if already at 10 imagesDELETE /api/v1/events/{eventCode}/teaser-images/{imageId}β 204; deletes DB row + S3 object
Frontend β Event Settings Tab (Organizer):
EventSettingsTab.tsx: new βTeaser Imagesβ subsection (below Registration Capacity, above Notifications)- Thumbnail gallery of all uploaded images; each thumbnail has a per-image remove button
- β+ Add Teaser Imageβ button β presigned URL flow β on completion, gallery refetches
- Upload button disabled when
isArchivedor image count β₯ 10; limit message shown at cap - Inline error message on upload/remove failure; gallery state unchanged on error
Frontend β Presentation Page:
usePresentationSections.ts: loop overevent.teaserImagesarray; push oneteaser-imagesection per image (unique key:teaser-image-{id},imageUrlon section object)- New
TeaserImageSlide.tsx: full-screen<img>withobject-fit: cover, no overlay, no animation PresentationPage.tsxSectionRenderer:case 'teaser-image'β<TeaserImageSlide imageUrl={section.imageUrl} />- If
teaserImagesis empty, no slides inserted; existing behaviour preserved - Slide navigation (
ArrowRight/ArrowLeft) already handles dynamic slide counts
Key new files:
services/event-management-service/src/main/resources/db/migration/V??__create_event_teaser_images.sql
services/event-management-service/.../domain/EventTeaserImage.java
services/event-management-service/.../repository/EventTeaserImageRepository.java
services/event-management-service/.../service/EventTeaserImageService.java
services/event-management-service/.../controller/EventTeaserImageController.java
web-frontend/src/pages/presentation/slides/TeaserImageSlide.tsx
Key modified files:
docs/api/events-api.openapi.yml β commit FIRST (ADR-006)
services/event-management-service/.../service/EventService.java β populate teaserImages in toDto()
web-frontend/src/hooks/usePresentationSections.ts β PresentationSection.imageUrl + loop
web-frontend/src/pages/PresentationPage.tsx β SectionRenderer case
web-frontend/src/components/organizer/EventPage/EventSettingsTab.tsx β gallery upload UI
web-frontend/public/locales/de/events.json + en/events.json β teaserImage.* keys
web-frontend/src/types/generated/events-api.types.ts β regenerate after spec change
Definition of Done (Story 10.22):
- Migration runs cleanly;
event_teaser_images - When
teaserImages - TDD:
EventTeaserImageServiceTest - i18n:
teaserImage.*
Story 10.23: Event Description Section on Public Homepage
Status: ready-for-dev Prerequisite: None (field already exists in DB and API)
User Story: As a visitor, I want to read the event description on the public homepage right below the hero section, so that I can understand what this BATbern event is about before deciding to register.
Background:
The Event entity already has a description (TEXT) field stored in the DB and returned in EventResponse.description. The field is settable from the organizerβs event settings panel. However, it is currently not displayed anywhere on the public-facing pages β the homepage hero goes straight from the event title/date into the speakers section. This story surfaces the description as a distinct styled section between the hero and the speakers/content sections.
Scope:
Frontend β Public Homepage:
- In
HomePage.tsx(or whichever component renders the active event public view): add anEventDescriptionSectioncomponent rendered after theHeroSectionand before the speaker/agenda sections - Only rendered when
activeEvent.descriptionis non-null and non-empty; hidden otherwise (no placeholder shown to anonymous visitors) - Component:
web-frontend/src/components/public/Event/EventDescriptionSection.tsx- Styled card or prose block (consistent with site dark theme)
- Renders description as formatted paragraph text (plain text stored in DB; render with
white-space: pre-wrapor convert\nto<br/>) - Optional section heading:
t('events.description.heading')β βAbout This Eventβ / βΓber diese Veranstaltungβ
Frontend β Archive Event Detail Page:
- If
ArchiveEventDetailPage(or equivalent) renders event details, also displaydescriptionin a similar prose block (if non-empty) below the event metadata and above the sessions list - Consistent styling with homepage section
No backend changes required β EventResponse.description already populated from the existing description column (confirmed at EventResponse.java:37).
Key new files:
web-frontend/src/components/public/Event/EventDescriptionSection.tsx
Key modified files:
web-frontend/src/pages/public/HomePage.tsx β render EventDescriptionSection after hero
web-frontend/src/pages/public/ArchiveEventDetailPage.tsx (or equivalent) β add description block
public/locales/de/events.json + en/events.json β description.heading key
Definition of Done (Story 10.23):
- Homepage shows event description below the hero section when
activeEvent.description - i18n:
events.description.heading
Story 10.24: AWS Cognito User Provisioning from User Management
Status: ready-for-dev Prerequisite: Epic 9 Story 9.1 (Cognito integration patterns established)
User Story: As an organizer, I want to create an AWS Cognito account for a BATbern user directly from the User Management list β either as part of creating a new user or for an existing user who is not yet linked to AWS β so that I can onboard people without requiring them to self-register.
Architectural Clarification β Who Actually Calls AWS:
Background:
The User domain entity has a nullable cognitoUserId field (cognito_user_id column). A user can exist in the BATbern DB without a Cognito account (e.g., imported from legacy data or pre-created by an organizer). This story detects that state and allows the organizer to trigger Cognito provisioning on demand. AWS Cognitoβs AdminCreateUser creates the user with a temporary password and FORCE_CHANGE_PASSWORD status; the user must set a new password on first login. If a Cognito account with the same email already exists, the API returns UsernameExistsException.
Scope:
Backend β Cognito User Provisioning (company-user-management-service):
- New service:
CognitoAdminService.java- Wraps
AdminCreateUser(usingsoftware.amazon.awssdk:cognitoidentityproviderSDK, already present for Cognito sync) - Call parameters:
email,givenName,familyName;MessageAction = SUPPRESS(no Cognito default email β BATbern sends a custom one) - Adds user to the appropriate Cognito group via
AdminAddUserToGroupbased on their BATbern role(s) - Sends custom welcome email via
EmailServiceusing templateuser-welcome-temp-password-{de,en}.html(new classpath templates seeded byEmailTemplateSeedService) - Catches
UsernameExistsExceptionβ propagates as structured 409; does not create duplicate - On success: updates
users.cognito_user_idwith the new Cognitosub(linking DB β Cognito)
- Wraps
- New endpoint:
POST /api/v1/users/{userId}/provision-cognito(organizer-only)- Works for both new-user flows and existing users without a cognitoUserId
- Body:
{ language }(role inferred from DB user record) - Returns:
{ cognitoUsername, temporaryPasswordSent: true }on success - Returns 409 if
UsernameExistsExceptionfrom Cognito, or if user already has acognitoUserId
- IAM: ECS task role for company-user-management-service must have:
cognito-idp:AdminCreateUseron the user poolcognito-idp:AdminAddUserToGroupon the user pool- Both added to the existing CDK IAM inline policy for this service
Frontend β User Table: Row Action Menu (UserTable.tsx):
- Add new action
"linkCognito"in theMenu(alongside existingview,editRoles,delete) - Action is only rendered when
user.cognitoUserIdis null/undefined:tsx{!user.cognitoUserId && ( <MenuItem onClick={() => handleAction('linkCognito')}> {t('actions.createAwsAccount')} </MenuItem> )} UserList.tsxhandles the'linkCognito'action by opening a newLinkCognitoDialogfor the selected user
Frontend β Link Cognito Dialog (LinkCognitoDialog.tsx):
- Simple confirmation dialog (not the full create/edit modal):
- Title: βCreate AWS Account for {firstName} {lastName}β
- Body: βThis will create an AWS Cognito account for {email}. They will receive a welcome email with a temporary password and must reset it on first login.β
- Language selector (dropdown: DE / EN, defaults to userβs stored language)
- βCreate Accountβ + βCancelβ buttons
- On confirm: calls
POST /api/v1/users/{userId}/provision-cognito - Success state: inline success message βAWS account created. Welcome email sent to {email}.β β dialog auto-closes after 2s
- Error (409 UsernameExistsException): βAn AWS account already exists for this email address. Use the user sync function to link it.β
- Error (other): generic error alert within dialog
Frontend β Create/Edit User Modal: βAlso Create AWS Accountβ toggle (UserCreateEditModal.tsx):
- In create mode only (
!isEditMode): add aFormControlLabelwith aSwitch(orCheckbox) below the role selector:- Label: βAlso create an AWS Cognito account (send temporary password)β
- Default: unchecked
- When checked: language selector appears (DE / EN)
- On form submit with toggle enabled: first calls the existing create-user endpoint, then (on success) calls
POST /api/v1/users/{newUserId}/provision-cognitoautomatically - If Cognito provisioning fails after user creation: user is still created in BATbern; a warning toast appears: βUser created in BATbern but AWS account creation failed. Use the βCreate AWS Accountβ action from the user list to retry.β
Note on UserResponse DTO: The cognitoUserId field does not need to be returned in the public API response (it is an internal identifier). Instead, expose a boolean hasCognitoAccount: boolean in UserResponse / the user list endpoint so the frontend can conditionally show/hide the βCreate AWS Accountβ action without exposing the raw Cognito sub.
Email Templates (new classpath content fragments, seeded by EmailTemplateSeedService in company-user-management-service):
services/company-user-management-service/src/main/resources/email-templates/user-welcome-temp-password-de.html
services/company-user-management-service/src/main/resources/email-templates/user-welcome-temp-password-en.html
Variables: recipientName, loginUrl, temporaryPasswordNote (text block explaining first-login reset requirement).
Key new files:
services/company-user-management-service/.../service/CognitoAdminService.java
services/company-user-management-service/.../service/CognitoAdminServiceTest.java
services/company-user-management-service/.../dto/ProvisionCognitoResponse.java
web-frontend/src/components/organizer/UserManagement/LinkCognitoDialog.tsx
services/company-user-management-service/src/main/resources/email-templates/user-welcome-temp-password-de.html
services/company-user-management-service/src/main/resources/email-templates/user-welcome-temp-password-en.html
Key modified files:
docs/api/users-api.openapi.yml
β POST /api/v1/users/{userId}/provision-cognito endpoint + hasCognitoAccount in UserResponse (FIRST)
infrastructure/lib/company-user-management-stack.ts
β IAM policy: AdminCreateUser + AdminAddUserToGroup on user pool ARN
services/company-user-management-service/.../controller/UserController.java
β new provision-cognito endpoint
services/company-user-management-service/.../service/UserService.java
β update cognitoUserId after successful provisioning
services/company-user-management-service/.../dto/UserResponse.java (or generated DTO)
β add hasCognitoAccount boolean
services/company-user-management-service/.../service/EmailTemplateSeedService.java
β seed welcome-temp-password templates
web-frontend/src/components/organizer/UserManagement/UserTable.tsx
β conditional "Create AWS Account" menu item based on hasCognitoAccount
web-frontend/src/components/organizer/UserManagement/UserList.tsx
β handle 'linkCognito' action β open LinkCognitoDialog
web-frontend/src/components/organizer/UserManagement/UserCreateEditModal.tsx
β "Also create AWS account" toggle in create mode
web-frontend/src/types/user.types.ts
β add hasCognitoAccount to User type
public/locales/de/userManagement.json + en/userManagement.json
β actions.createAwsAccount, linkCognito.* keys
Definition of Done (Story 10.24):
-
POST /api/v1/users/{userId}/provision-cognitocreates Cognito user withFORCE_CHANGE_PASSWORD; linkscognitoUserId - Backend uses ECS IAM task role for
AdminCreateUserβ organizerβs own Cognito token has no AWS IAM rights; this is confirmed in aCognitoAdminServiceTest - 409 returned if email already exists in Cognito (
UsernameExistsException - 409 returned if user already has a
cognitoUserId -
UserResponseincludeshasCognitoAccount: boolean(does not expose rawcognitoUserId - User table row action menu shows βCreate AWS Accountβ only for users with
-
LinkCognitoDialog - Create-user modal toggle βAlso create AWS accountβ triggers provisioning after DB user is created; when Cognito provisioning fails: (a) the BATbern user record is still persisted, (b)
cognitoUserId - IAM: CDK grants
AdminCreateUser+AdminAddUserToGroup - TDD:
CognitoAdminServiceTestcovers success,UsernameExistsException - i18n:
actions.createAwsAccount,linkCognito.*
Story 10.25: Partner Meeting iCal Auto-creation & Year-End Reminders
Status: ready-for-dev
Prerequisite: Story 8.3 (all prerequisites below verified β
); Story 10.2 (DB-backed email templates with admin edit UI)
Note (IR 2026-03-02): ShedLock is already configured in event-management-service (V31__Add_shedlock_table.sql + shedlock-spring:5.10.0 dependency) β no setup needed. Transitive block: Story 10.2 depends on Story 10.1; if 10.1 is delayed, 10.25 is also delayed.
Audit of Story 8.3 β What is ALREADY implemented (verified by code inspection):
| Capability | Status | Location |
|---|---|---|
| Partner meeting CRUD (create, list, get, update) | β Fully implemented | PartnerMeetingController + PartnerMeetingService |
| Date auto-populated from event on creation | β Implemented | PartnerMeetingService.createMeeting() |
| RFC 5545 iCal generation (2 VEVENTs) | β Implemented | IcsGeneratorService.generate() |
βSend inviteβ endpoint (POST /{id}/send-invite) |
β Implemented | PartnerMeetingController returns 202 |
| Actual email sent via AWS SES with ICS attachment | β Implemented | PartnerInviteEmailService β EmailService.sendHtmlEmailWithAttachments() β sesClient.sendRawEmail() |
| Frontend βSend Inviteβ button with confirmation dialog | β Implemented | MeetingDetailPanel.tsx |
inviteSentAt timestamp shown as status chip |
β Implemented | PartnerMeetingsPage.tsx + MeetingDetailPanel.tsx |
What was missing in Story 8.3 and is the subject of THIS story:
| Gap | This Story Adds |
|---|---|
| Partner meetings must be created manually | Auto-create on event creation |
Email body is hardcoded German HTML in buildEmailBody() |
Replace with DB-backed, editable template (Story 10.2 pattern) |
| No year-end reminder to coordinate partners for next year | YearEndReminderScheduler creates tasks on 31 December |
Local dev: sesClient == null β email only logged, not sent |
Not a story gap β expected local behavior; SES works in staging/prod |
Note on βI only saw a status change, no real emailβ: In local development, when
sesClientisnull(no SES configured locally),EmailServicelogs"Would send email with N attachment(s) to: ..."and skips the send. The status chip (inviteSentAt) is still set, making it look like only a status change happened. On staging/production with a real SES client, the email with .ics attachment is actually sent. The behavior is correct β this is the expected local fallback.
User Stories:
As an organizer, I want a partner meeting to be automatically created whenever I create a new BATbern event, so that I donβt have to set it up manually each time.
As an organizer, I want the partner meeting invite email body to be editable from the admin Email Templates section (like all other system emails), so that I can customize the content without a code deploy.
As an organizer, I want a task to be automatically created on 31 December each year reminding me to send iCal invitations to partners for the coming yearβs event, so we never forget partner coordination.
Scope:
Backend β Auto-create Partner Meeting on Event Creation:
- When a new BATbern event is created (saved with status
CREATED),event-management-servicemakes a best-effort HTTP call topartner-coordination-serviceto auto-create the linked partner meeting - Use the existing
EventManagementClient/ inter-service HTTP client pattern (partner-coordination-service already calls event-management-service viaEventManagementClientImpl; the reverse direction needs a newPartnerCoordinationClientin event-management-service, or trigger from partner-service side by listening to an event) - Simplest pattern (recommended):
EventController.createEvent()orEventService.createEvent()fires a SpringApplicationEventEventCreatedEvent(eventCode, eventDate). A new@EventListenerin event-management-service callsPartnerCoordinationClient.createMeetingForEvent(eventCode)β a one-way best-effort HTTP call (try/catch, failure only logged, does not roll back event creation) - Auto-created
PartnerMeetingfields:eventCode= new eventβs codemeetingDate= event date (populated by partner service via its ownEventManagementClient.getEventSummary())startTime=12:00(frompartner.meeting.default-start-timeproperty, default12:00)endTime=14:00(frompartner.meeting.default-end-timeproperty, default14:00)location=""(empty; organizer fills in later)meetingType=SPRING(default; organizer can update)agenda=null,notes=null
inviteSentAtremains null (invite not yet sent β organizer triggers manually via existing βSend Inviteβ button)- Idempotent: if a
PartnerMeetingwitheventCodealready exists, skip creation (no duplicate)
Backend β Editable Partner Meeting Invite Email Template:
- Currently
PartnerInviteEmailService.buildEmailBody()returns a hardcoded German HTML string - Replace with DB-backed template loading (Story 10.2 pattern):
- New classpath template files seeded by
EmailTemplateSeedServicein partner-coordination-service (the service that sends this email):services/partner-coordination-service/src/main/resources/email-templates/partner-meeting-invite-de.html services/partner-coordination-service/src/main/resources/email-templates/partner-meeting-invite-en.html - Template variables:
{{meetingDate}},{{meetingStartTime}},{{meetingEndTime}},{{location}},{{eventTitle}},{{organizerName}} PartnerInviteEmailService.sendCalendarInvites(): replacebuildEmailBody()call withemailTemplateService.loadAndRender("partner-meeting-invite", language, variables)with fallback to classpath- Templates appear in Admin β Email Templates tab under category
PARTNER
- New classpath template files seeded by
- Language: since partner users may have different language preferences, send in DE by default (or in the userβs stored language if available from
UserServiceClient)
Backend β Year-End Reminder Task (Scheduler in event-management-service):
- New
YearEndReminderScheduler.java(ShedLock-protected, cron0 0 8 31 12 *β 08:00 on 31 December):- Creates two organizer tasks if they donβt already exist for
{nextYear}:"Send partner meeting iCal invitations for {nextYear}"β due: 31 January of next year"Create BATbern event calendar entry for {nextYear}"β due: 31 January of next year
- Assigned to all users with ORGANIZER role (fetched via
UserServiceClientor internal query) eventCode= null (global tasks, not tied to a specific event)- Idempotent: checks for existing tasks matching title + year before inserting
- Creates two organizer tasks if they donβt already exist for
Frontend β No New Components Required:
- Auto-creation is fully server-side; the existing
PartnerMeetingsPagelist will show the auto-created meeting - Email template appears automatically in Email Templates admin tab (seeded by
EmailTemplateSeedService) - Year-end tasks appear in the existing task board
Key new files:
services/partner-coordination-service/src/main/resources/email-templates/partner-meeting-invite-de.html
services/partner-coordination-service/src/main/resources/email-templates/partner-meeting-invite-en.html
services/event-management-service/.../scheduler/YearEndReminderScheduler.java
services/event-management-service/.../scheduler/YearEndReminderSchedulerTest.java
services/event-management-service/.../client/PartnerCoordinationClient.java (if HTTP trigger pattern used)
Key modified files:
services/event-management-service/.../service/EventService.java (or EventController)
β fire EventCreatedEvent after successful event save
services/event-management-service/.../listener/PartnerMeetingAutoCreateListener.java (new)
β @EventListener on EventCreatedEvent β call PartnerCoordinationClient
services/partner-coordination-service/.../service/PartnerInviteEmailService.java
β replace buildEmailBody() with template load + variable substitution
services/partner-coordination-service/.../service/EmailTemplateSeedService.java (new, mirrors EMS pattern)
β seed partner-meeting-invite-de/en on startup
services/partner-coordination-service/.../repository/PartnerMeetingRepository.java
β add existsByEventCode() for idempotent auto-creation guard
Definition of Done (Story 10.25):
-
PartnerInviteEmailServiceloads invite body from DB template with classpath fallback; hardcodedbuildEmailBody() -
partner-meeting-invite-de/en -
YearEndReminderSchedulerfires on 31 December (verified by unit test with@SpyBean - TDD:
PartnerMeetingAutoCreateListenerTest(mocked client, idempotency, failure isolation);YearEndReminderSchedulerTest(task creation, dedup);PartnerInviteEmailServiceTest
Story 10.26: SES Email Forwarding & Role-Based Distribution Lists
Story file: _bmad-output/implementation-artifacts/10-26-ses-email-forwarding-distribution-lists.md
Status: draft
Prerequisites: Story 10.17 (InboundEmailStack, SES receipt rules)
User Story:
As an organizer, I want ok@batbern.ch, info@batbern.ch, events@batbern.ch, partner@batbern.ch, support@batbern.ch, and batbern{N}@batbern.ch to automatically forward emails to the right recipients based on user roles and event registrations, so that we have zero-cost distribution lists without needing any mailbox provider.
Scope:
- Extend existing
InboundEmailStackwith SES receipt rules for forwarding addresses - New Lambda forwarder (Node.js 20.x) triggered by S3 events under
forwarding/prefix - Address resolution via existing APIs (no new service endpoints):
ok@batbern.chβ all ORGANIZER users (organizers only can send)info@batbern.ch/events@batbern.chβ all ORGANIZER users (anyone can send)partner@batbern.chβ all PARTNER users (organizers only can send)support@batbern.chβ configurable contacts from Admin Settings (anyone can send)batbern{N}@batbern.chβ confirmed registrants of event N (organizers only can send)
- Sender authorization: organizer-only for restricted addresses; 5-min TTL cache
- Admin Settings tab on
/adminpage for configuringsupport@forwarding recipients - New
app_settingstable (key-value) in Event Management Service - Rate-limited sending (70ms delay, matching newsletter pattern) β 300 recipients β 21s, fits 60s Lambda timeout
- Route 53 MX record for
batbern.chβ SES inbound SMTP - CloudWatch metrics (
EmailsForwarded,EmailsRejected,EmailsUnresolved) + alarm
Key files (estimated):
infrastructure/lib/stacks/inbound-email-stack.ts β extended with forwarding receipt rules + Lambda
infrastructure/lambda/email-forwarder/index.ts β new Lambda forwarder
infrastructure/lambda/email-forwarder/package.json β new
infrastructure/test/unit/inbound-email-stack.test.ts β extended
services/event-management-service/.../domain/AppSetting.java β new entity
services/event-management-service/.../repository/AppSettingRepository.java β new repository
services/event-management-service/.../service/AdminSettingsService.java β new service
services/event-management-service/.../controller/AdminSettingsController.java β new controller
services/event-management-service/.../resources/db/migration/V*.sql β new migration
web-frontend/src/components/organizer/admin/AdminSettingsTab.tsx β new component
web-frontend/src/services/adminSettingsService.ts β new service
Definition of Done (Story 10.26):
- Sending email to
ok@batbern.ch - Sending email to
info@batbern.chorevents@batbern.ch - Sending email to
partner@batbern.ch - Sending email to
support@batbern.ch - Sending email to
batbern58@batbern.ch - Non-organizer sending to
ok@/partner@/batbern{N}@ - Admin Settings tab visible on
/admin - From rewritten to
{name} via BATbern <noreply@batbern.ch> - MX record for
batbern.ch
Story 10.29: SES Bounce Processing & Newsletter List Hygiene
Story file: _bmad-output/implementation-artifacts/10-29-ses-bounce-processing-newsletter-list-hygiene.md
Status: draft
Prerequisites: Story 10.7 (newsletter sending), Story 10.28 (subscriber management page)
User Story: As an organizer, I want emails that permanently bounce or generate spam complaints to be automatically suppressed from future newsletter sends, so that BATbernβs SES sender reputation stays healthy and the platformβs email delivery is not disrupted.
Context:
All subscribers from the old Mailman 3 mailing list (lists.hostpoint.ch) have been imported. Many of these emails likely no longer exist β email lists degrade ~2-3%/year. AWS SES flags accounts at 5% hard bounce rate and can suspend sending at 10%. Since the SES account handles ALL platform email (registrations, partner invitations, speaker coordination), a suspension would break the entire platform.
Scope:
- SES Account-Level Suppression List β enable via AWS CLI (zero code, immediate safety net)
- CDK Infrastructure β SES Configuration Set (
batbern-newsletter), SNS Topic for BOUNCE + COMPLAINT events, SQS Queue for reliable processing, event destination routing - Database Migration (V91) β add
bounce_type,bounce_count,last_bounced_at,suppressed_attonewsletter_subscribers; addbounce_type,bounced_attonewsletter_recipients - EmailService β optional
configurationSetNameonSendEmailRequest, controlled by Spring property - BounceProcessingService β new
@SqsListenerthat processes SNS-wrapped SES bounce/complaint notifications; hard bounce β immediate suppression; soft bounce β increment count, suppress after 3; complaint β immediate suppression - Exclude suppressed from sends β modify
findByUnsubscribedAtIsNullqueries to also filtersuppressed_at IS NULL - Batched canary send mode β optional
maxRecipientsparameter + configurable inter-page delay for safe first sends to imported lists - Admin visibility β expose bounce status (bounceType, bounceCount, suppressedAt) in subscriber list; add
?status=suppressedfilter; add unsuppress action - Monitoring β CloudWatch alarms on SES Configuration Set metrics: bounce rate >3% warning, >5% critical; complaint rate >0.05% critical
Key files (estimated):
infrastructure/lib/stacks/ses-stack.ts β SES Config Set, SNS, SQS
infrastructure/lib/stacks/monitoring-stack.ts β bounce rate alarms
shared-kernel/.../service/EmailService.java β configurationSetName support
services/event-management-service/.../domain/NewsletterSubscriber.java β bounce fields
services/event-management-service/.../repository/NewsletterSubscriberRepository.java β suppress filter
services/event-management-service/.../repository/NewsletterRecipientRepository.java β bounce update
services/event-management-service/.../service/NewsletterEmailService.java β exclude suppressed, canary mode
services/event-management-service/.../service/BounceProcessingService.java β NEW: SQS bounce listener
services/event-management-service/.../controller/NewsletterController.java β expose bounce status in API
services/event-management-service/src/main/resources/db/migration/V91__*.sql β NEW: bounce columns
web-frontend/src/components/organizer/NewsletterSubscribers/ β suppressed badge, filter
Definition of Done (Story 10.29):
- SES Configuration Set
batbern-newsletter - Newsletter sends use configuration set (via
configurationSetNameonSendEmailRequest - Hard bounce β subscriber
suppressed_atset, - Soft bounce β
bounce_count - Complaint β subscriber immediately suppressed (
bounce_type = 'complaint' - Suppressed subscribers excluded from
findByUnsubscribedAtIsNull - Canary send mode:
maxRecipients - Admin subscriber list shows bounce status badge, filterable by
Story 10.30: Speaker Drawer Redesign β Tabbed Layout, Organizer Assignment, Mobile UX
Story file: _bmad-output/implementation-artifacts/10-30-speaker-drawer-redesign.md
Status: ready-for-dev
User Story: As an organizer, I want the speaker detail drawer to be organized into tabs (Overview, Details, Activity) with the ability to edit the assigned organizer inline, so that I can efficiently manage speaker outreach without excessive scrolling, and the drawer works properly on mobile devices.
Scope:
Backend β PATCH endpoint (Phase 1, already committed):
PATCH /api/v1/events/{eventCode}/speakers/pool/{speakerId}β partial update forassignedOrganizerId,notes,emailPatchSpeakerPoolRequestDTO,SpeakerPoolService.patchEntry(), controller endpoint- Frontend service (
patchSpeakerPool), type,usePatchSpeakerPoolhook (Phase 2, already committed)
Frontend β Unified Tabbed Drawer (Phase 3):
- Replace 823-line
SpeakerOutreachDetailsDrawermonolith with 7 decomposed components inweb-frontend/src/components/organizer/SpeakerDrawer/ - Three tabs: Overview (organizer assignment, action buttons), Details (response/decline/content info), Activity (contact history + mark-contacted form)
- Default tab selection by speaker workflow state
- Content submission and quality review rendered as in-drawer sub-views (eliminates βdrawer spawns drawerβ pattern)
- Responsive: 520px desktop, full-width mobile, scrollable tabs, sticky action footer
Wiring & Cleanup (Phase 4β5):
EventSpeakersTab.tsxsimplified from 3 drawers + 6 state vars to 1 drawer + 2 state vars- Delete old
SpeakerOutreachDetailsDrawer.tsx,ContentSubmissionDrawer.tsx,QualityReviewDrawer.tsx - i18n keys for tab labels
- Preserve
data-testidattributes for Playwright compatibility
Acceptance Criteria:
- AC1: Organizer can click a speaker card and see a tabbed drawer with Overview, Details, and Activity tabs
- AC2: Default tab matches speaker state (IDENTIFIED/CONTACTED β Activity, DECLINED/CONTENT_SUBMITTED β Details, others β Overview)
- AC3: Organizer can edit the assigned organizer via inline autocomplete on the Overview tab, saved via PATCH endpoint
- AC4: βSubmit Contentβ and βReview Contentβ render as in-drawer sub-views with back navigation (no separate drawer)
- AC5: Contact history and mark-contacted form are in the Activity tab with full-height scrolling (no nested scroll)
- AC6: Drawer is fully functional on mobile (full-width, scrollable tabs, sticky action footer)
- AC7: All existing
data-testidattributes preserved for Playwright E2E tests - AC8: Frontend builds with zero TypeScript errors; existing tests pass
Source tree (changed/new):
web-frontend/src/components/organizer/SpeakerDrawer/ β NEW directory
SpeakerDetailDrawer.tsx β Container with tabs + sub-view state
SpeakerDrawerHeader.tsx β Header: name, company, status, close
OverviewTabPanel.tsx β Organizer field, action buttons, quick info
DetailsTabPanel.tsx β Response/decline/tentative/content sections
ActivityTabPanel.tsx β Mark-contacted form + contact history
AssignedOrganizerField.tsx β Editable organizer autocomplete + PATCH
index.ts β Barrel export
web-frontend/src/components/organizer/EventPage/EventSpeakersTab.tsx β Simplified orchestrator
web-frontend/src/components/organizer/SpeakerOutreach/SpeakerOutreachDetailsDrawer.tsx β DELETED
web-frontend/src/components/organizer/SpeakerStatus/ContentSubmissionDrawer.tsx β DELETED
web-frontend/src/components/organizer/SpeakerStatus/QualityReviewDrawer.tsx β DELETED
Definition of Done (Story 10.30):
- All existing
data-testid -
npm run build
Story 10.31: Bot Protection β Cloudflare Turnstile for Newsletter & Event Registration
Story file: _bmad-output/implementation-artifacts/10-31-bot-protection-turnstile.md
Status: ready-for-dev
Source: GH#582
User Story: As a platform operator, I want newsletter subscriptions and event registrations protected by Cloudflare Turnstile, so that bot submissions are rejected before they reach domain services without degrading UX for legitimate users.
Why Turnstile:
- Privacy-first β no tracking cookies, no cross-site data collection β no cookie consent banner needed
- GDPR / Swiss nFADP compliant out of the box
- Invisible to most users (no puzzles)
- Free tier: 1M verifications/month
- No Cloudflare CDN/DNS required
Endpoints protected:
POST /api/v1/newsletter/subscribe(NewsletterSubscribeWidget)POST /api/v1/events/{eventCode}/registrations(RegistrationWizard)
Cognito signup is out of scope (AWS handles its own bot protection).
Architecture:
Gateway-centralized, endpoint-selective filter:
A new TurnstileVerificationFilter in api-gateway intercepts only configured endpoint patterns. Follows the existing RateLimitingFilter pattern (@Component, @Order, jakarta.servlet.Filter). Domain service controllers are untouched.
Token transport: X-Turnstile-Token HTTP header (not request body) β avoids DTO / OpenAPI spec changes.
Fail-open: If Cloudflare siteverify is unreachable, log a warning and allow the request. Legitimate users are never blocked by a third-party outage.
Feature flag: turnstile.enabled=false by default β existing behaviour is fully preserved when disabled.
Scope:
Phase 1 β Backend Config Layer:
TurnstileProperties(@ConfigurationProperties(prefix="turnstile")) withenabled,siteKey,secretKey,verifyUrl,protectedEndpointsTurnstileConfigDTOβ{ siteKey: String }(never expose secretKey)FeatureFlagsDTO.turnstile: boolean+FrontendConfigDTO.turnstile: TurnstileConfigDTOConfigControllerconditionally includes site key and feature flagapplication.ymlturnstile block with${TURNSTILE_ENABLED:false}default
Phase 2 β Backend Verification Filter:
TurnstileVerificationFilter:@Order(Ordered.LOWEST_PRECEDENCE - 1), runs beforeRateLimitingFilter- AntPathMatcher for wildcard endpoint matching (
events/*/registrations) - Missing header β 403
turnstile_required; invalid token β 403turnstile_failed; Cloudflare unreachable β pass (fail-open) - Full unit test coverage (6 scenarios)
Phase 3 β Frontend Hook:
useTurnstilehook: readsuseConfig(), loads Turnstile script from CDN (no npm dependency), renders invisible widget viawindow.turnstile- Exports
{ getToken, resetWidget, widgetRef }; when disabledgetToken()returnsnull
Phase 4 β Frontend Form Integration:
AppConfigextended withfeatures.turnstile+ optionalturnstile.siteKeynewsletterService.subscribe()andeventApiClient.createRegistration()accept optionalturnstileTokenparam β passed as headerNewsletterSubscribeWidgetand publicRegistrationWizardintegrated withuseTurnstile- 403 error handling: show user-friendly message +
resetWidget()
Phase 5 β Environment Activation:
| Environment | TURNSTILE_ENABLED | Site Key | Secret Key |
|---|---|---|---|
| Development | false |
β | β |
| Staging | true |
1x00000000000000000000AA (always-pass test) |
1x0000000000000000000000000000000AA |
| Production | true |
Real key from Cloudflare dashboard | Real secret |
New files (7):
api-gateway/.../config/TurnstileProperties.java
api-gateway/.../config/dto/TurnstileConfigDTO.java
api-gateway/.../security/TurnstileVerificationFilter.java
api-gateway/src/test/.../security/TurnstileVerificationFilterTest.java
web-frontend/src/hooks/useTurnstile/useTurnstile.ts
web-frontend/src/hooks/useTurnstile/index.ts
web-frontend/src/hooks/useTurnstile/useTurnstile.test.ts
Modified files (12):
api-gateway/.../config/dto/FeatureFlagsDTO.java β add boolean turnstile
api-gateway/.../config/dto/FrontendConfigDTO.java β add TurnstileConfigDTO turnstile
api-gateway/.../config/ConfigController.java β inject TurnstileProperties, set flags
api-gateway/src/main/resources/application.yml β add turnstile.* block
web-frontend/src/config/runtime-config.ts β extend AppConfig
web-frontend/src/services/newsletterService.ts β add turnstileToken param
web-frontend/src/services/eventApiClient.ts β add turnstileToken param
web-frontend/src/hooks/useNewsletter/useNewsletter.ts β update mutation type
web-frontend/.../NewsletterSubscribeWidget.tsx β integrate useTurnstile
web-frontend/.../Registration/RegistrationWizard.tsx β integrate useTurnstile
web-frontend/.../__tests__/NewsletterSubscribeWidget.test.tsx β verify header
web-frontend/.../Registration/__tests__/RegistrationWizard.test.tsx β verify header
+ CDK / ECS task definition (staging + production env vars)
Acceptance Criteria:
- AC1:
TurnstileVerificationFilterintercepts onlyPOST /api/v1/newsletter/subscribeandPOST /api/v1/events/*/registrationswhen enabled; all other endpoints unaffected - AC2: Missing
X-Turnstile-Tokenon protected endpoints β 403turnstile_required - AC3: Invalid token (Cloudflare
success: false) β 403turnstile_failed - AC4: Cloudflare unreachable β log warning, let request through (fail-open)
- AC5:
turnstile.enabled=false(default) β filter is a no-op; all existing behaviour preserved - AC6:
GET /api/v1/configincludesfeatures.turnstile: booleanand optionalturnstile.siteKey - AC7:
useTurnstilehook: disabled βgetToken()returnsnull; enabled β loads widget, returns token - AC8:
NewsletterSubscribeWidgetcallsgetToken()before subscribe mutation, passes header - AC9:
RegistrationWizardcallsgetToken()beforecreateRegistration(), passes header - AC10: 403 with
turnstile_required/turnstile_failedβ user-friendly error +resetWidget() - AC11:
TurnstileVerificationFilterTestcovers 6 scenarios;./gradlew :api-gateway:testpasses - AC12:
useTurnstile.test.tsand updated component tests pass;npm run buildpasses - AC13: All existing tests pass; no regressions
Definition of Done (Story 10.31):
-
TurnstileVerificationFilter -
GET /api/v1/configincludesfeatures.turnstileand optional -
useTurnstile -
NewsletterSubscribeWidgetsendsX-Turnstile-Token -
RegistrationWizardsendsX-Turnstile-Token -
turnstile.enabled=false -
./gradlew :api-gateway:test -
npm run build
Story 10.32: Additional Email Addresses per User Profile
Story file: _bmad-output/implementation-artifacts/10-32-additional-user-emails.md
Status: ready-for-dev
Prerequisites: Story 10.26 (SES email forwarding + sender-auth Lambda)
Trigger: 2026-05-20 incident β Nissimβs iPhone-Mail forward from legacy info@berner-architekten-treffen.ch to ok@batbern.ch was silently dropped because the shared mailbox is not in role_assignments for ORGANIZER.
User Story:
As a logged-in user (primarily an organizer), I want to register one or more additional email addresses on my profile, so that mail forwarded or sent to me by BATbern (ok@batbern.ch fan-out, event-registration confirmations, etc.) reaches all of my addresses AND mail I send to BATbern from any of those addresses is treated as authorised β without needing my legacy/shared mailbox to be a separate BATbern user.
Scope:
- Data model: new
user_additional_emailstable (one row per (user, email)), unique onLOWER(email)across bothuser_profiles.emailanduser_additional_emails.email; per-user cap of 5 (configurable) - API (CUMS):
POST /users/me/additional-emails,DELETE /users/me/additional-emails/{email};UserResponse(onGET /users/meANDGET /users) gainsadditionalEmails[] - Frontend: new βAdditional emailsβ section in
UserSettingsTab.tsxβ Account sub-tab (below the read-only primary email); inline add form + chip-list + delete; 13 new i18n keys in all 10 locales - Email Forwarder Lambda (Story 10.26):
sender-auth.tsgetOrganizerEmails()andaddress-resolver.tsfetchUsersByRole()flatten primary + additional emails β closes the 2026-05-20 regression - Registration confirmation:
RegistrationEmailService.sendRegistrationConfirmationCCs additional emails when the registration belongs to a known user (anonymous registrations unchanged) - Partner meeting calendar invites (Phase 6, added 2026-05-22):
PartnerMeetingService.fetchEmailsByRoleflattens primary + additional emails for bothPOST /partner-meetings/{id}/send-inviteand theMETHOD:CANCELnotice inDELETE /partner-meetings/{id}. Dedup is case-insensitive via the existingdistinct()step. A nulladditionalEmailsfield on the generated client DTO is tolerated for rolling-deploy backwards-compat. - EMS transactional senders (Phase 7, added 2026-05-22): every EMS email-sender that delivers a user-addressed transactional message now CCs the recipientβs additional emails β speaker invitations (
SpeakerInvitationEmailService), speaker acceptance confirmations (SpeakerAcceptanceEmailService), speaker reminders (SpeakerReminderEmailService), quality-review revision requests (QualityReviewService.notifySpeakerOfRejection), waitlist promotion (WaitlistPromotionEmailService.sendPromotionEmail), waitlist confirmation (WaitlistPromotionEmailService.sendWaitlistConfirmationEmail). Newsletter and inbound-email confirmation replies are explicitly excluded. - Shared-kernel
EmailServiceplumbing (Phase 7): newsendHtmlEmail(to, cc, subject, html)async +sendHtmlEmailSync(to, cc, subject, html, configSet)overloads using SESDestination.ccAddresses. CC entries matchingtocase-insensitively are dropped. 3-argsendHtmlEmailretained as a thin delegate so existing callers compile unchanged. PrimarySpeakerResolver.PrimarySpeakerProfile(Phase 7): 6th record fieldList<String> additionalEmails(compact constructor null-coerces toList.of()); backwards-compatible 5-arg secondary constructor preserves the 11 existing test fixtures.- Audit log:
ActivityHistoryEntityrows on add/remove - Out of scope (v1): ownership verification of additional emails (column reserved); newsletter auto-routing to additional emails; Cognito primary-email change; admin-managed editing of other usersβ additional emails;
address-resolver.fetchEventRegistrants(event-registration distribution stays attendee-email-driven per AC15);InboundEmailConfirmationEmailService(recipients are arbitrary external senders, not registered users)
Key files (estimated):
services/company-user-management-service/.../db/migration/V16__create_user_additional_emails.sql β NEW
services/company-user-management-service/.../domain/UserAdditionalEmail.java β NEW entity
services/company-user-management-service/.../repository/UserAdditionalEmailRepository.java β NEW
services/company-user-management-service/.../domain/User.java β extend with @OneToMany collection
services/company-user-management-service/.../service/UserService.java β add/remove methods + audit
services/company-user-management-service/.../controller/UserController.java β 2 new endpoints under /me/additional-emails
docs/api/users-api.openapi.yml β AdditionalEmail schema + 2 paths + UserResponse extension
web-frontend/src/components/user/UserSettingsTab/UserSettingsTab.tsx β Account-tab section
web-frontend/src/hooks/useUserAccount/useAdditionalEmails.ts β NEW hook
web-frontend/public/locales/{de,en,...,gsw-BE}/userManagement.json β 13 new keys Γ 10 locales
infrastructure/lambda/email-forwarder/sender-auth.ts β flatten in getOrganizerEmails
infrastructure/lambda/email-forwarder/address-resolver.ts β flatten in fetchUsersByRole
services/event-management-service/.../service/RegistrationEmailService.java β CC additional emails
bruno-tests/users/add-additional-email.bru + list + delete β NEW contract tests
web-frontend/e2e/organizer/user-settings-additional-emails.spec.ts β NEW Playwright spec
Definition of Done (Story 10.32):
-
POST /users/me/additional-emails -
DELETE /users/me/additional-emails/{email} -
GET /users/meandGET /usersresponses include - Organizer adds
info@berner-architekten-treffen.chβ sends mail took@batbern.chfrom that mailbox β CloudWatch showsoutcome: 'forwarded', all organizers (including sender at both addresses) receive a copy. Regression test for the 2026-05-20 incident. -
UserSettingsTab - Backwards-compatible: Lambda continues to authorise organizers by primary email if the CUMS response is missing
additionalEmails - Phase 6 (AC23):
POST /partner-meetings/{id}/send-invitereturnedrecipientCount - Phase 7 (AC24): speaker invitation, speaker acceptance, speaker reminder, quality-review revision, waitlist promotion, and waitlist confirmation emails all CC the recipientβs additional emails when present (verified by
verify(emailService).sendHtmlEmail(to, cc, ...)assertions inSpeakerInvitationEmailServiceTest
Resolved Decisions (PM 2026-05-22, all 6 OQs): no ownership verification in v1 (verified_at column reserved); separate table (not JSONB / not @ElementCollection); cap = 5 (configurable); newsletter NOT auto-routed to additional emails (UI helper text explicit); additionalEmails included on every GET /users response the caller can already see; audit-log entries include the email value.
END OF EPIC 10