Backend Architecture

This document outlines the core backend architecture for the BATbern Event Management Platform, including service patterns, authentication, authorization, and error handling strategies.

Last updated: June 21, 2026 Reading time: 19 minutes File size: 33.45 KB

This document outlines the core backend architecture for the BATbern Event Management Platform, including service patterns, authentication, authorization, and error handling strategies.

For detailed information on specific subsystems, see:


Service Architecture Pattern

services/{domain-service}/
β”œβ”€β”€ src/main/java/ch/batbern/{domain}/
β”‚   β”œβ”€β”€ controller/                     # REST API controllers
β”‚   β”œβ”€β”€ service/                        # Business logic layer
β”‚   β”œβ”€β”€ repository/                     # Data access layer
β”‚   β”œβ”€β”€ domain/                         # Domain models and entities
β”‚   β”œβ”€β”€ dto/                           # Data transfer objects
β”‚   β”œβ”€β”€ exception/                     # Custom exceptions
β”‚   └── security/                      # Security components
β”œβ”€β”€ src/main/resources/
β”‚   β”œβ”€β”€ application.yml                # Configuration
β”‚   └── db/migration/                  # Flyway migrations
└── build.gradle                      # Build configuration

Authentication and Authorization

JWT Authentication Flow

sequenceDiagram participant C as Client participant GW as API Gateway participant Cognito as AWS Cognito participant Service as Domain Service C->>GW: POST /auth/login GW->>Cognito: Authenticate User Cognito-->>GW: Access Token + Roles GW-->>C: JWT Token with Claims C->>GW: API Request with Bearer Token GW->>GW: Validate JWT + Extract Roles GW->>Service: Forward Request + User Context Service->>Service: Apply Role-Based Logic Service-->>GW: Response Data GW-->>C: API Response

Role-Based Security Configuration

Important: Domain services do not validate JWTs themselves. JWT signature verification is performed exclusively by the API Gateway. Domain services trust all requests forwarded by the gateway and do not call jwtDecoder() or validate token signatures. Role-based access control at the service level is enforced via method-level annotations (@PreAuthorize) where needed, using the user context header injected by the gateway.

Public Endpoints (no authentication required)

The following endpoints are accessible without any token at the domain-service level:

Pattern Notes
/actuator/health, /actuator/info Health probes
/swagger-ui/**, /v3/api-docs/** API documentation
GET /api/v1/events/** Event details, including /current
GET /api/v1/events/{code}/sessions/** Session listings
GET /api/v1/events/{code}/speakers/** Speaker listings (read-only)
POST /api/v1/events/{code}/registrations Attendee registration creation
POST /api/v1/events/{code}/registrations/confirm Registration confirmation

VPC-Internal Endpoints (no JWT required):

Some endpoints are designed for cross-service communication within the VPC only. These are protected by VpcInternalAuthorizationManager (which validates the request originates from within the VPC) rather than JWT-based role checks. Example:

Pattern Service Notes
GET /api/v1/users/by-company Company-User Management Used by partner-coordination-service to fetch contacts without requiring an organizer JWT

This pattern avoids the need to forward end-user JWTs for internal service-to-service calls that don’t require user-level authorization.

Role-gated endpoints (e.g. POST /api/v1/events, organizer-only mutations) are enforced at the API Gateway layer before requests reach domain services.

java
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // Health & docs β€” always public
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                // Public read endpoints β€” no auth required
                .requestMatchers(HttpMethod.GET, "/api/v1/events/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/v1/events/*/sessions/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/v1/events/*/speakers/**").permitAll()
                // Public write endpoints β€” registration flow
                .requestMatchers(HttpMethod.POST, "/api/v1/events/*/registrations").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/v1/events/*/registrations/confirm").permitAll()
                // Everything else: trust the gateway-injected user context
                .anyRequest().permitAll()
            )
            .build();
        // No oauth2ResourceServer / jwtDecoder β€” JWT validation is the gateway's responsibility
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(this::extractAuthorities);
        return converter;
    }

    private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
        // Extract custom:role claim from JWT (populated by PreTokenGeneration Lambda)
        String rolesString = jwt.getClaimAsString("custom:role");

        if (rolesString == null || rolesString.isEmpty()) {
            return Collections.emptyList();
        }

        // Split comma-separated roles and map to Spring Security authorities
        // "ATTENDEE,SPEAKER" β†’ [ROLE_ATTENDEE, ROLE_SPEAKER]
        return Arrays.stream(rolesString.split(","))
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.trim()))
            .collect(Collectors.toList());
    }
}

User Context Management

java
@Component
public class SecurityContextHelper {

    public UserContext getCurrentUser() {
        // Implementation details - see source code
    }

    public boolean hasRole(String role) {
        // Implementation details - see source code
    }

    public boolean isOrganizer() {
        // Implementation details - see source code
    }

    public boolean canAccessCompany(String companyId) {
        // Implementation details - see source code
    }
}

Speaker authentication (ADR-009)

Speakers are full Cognito users with the SPEAKER role. Provisioning is wired into the speaker workflow state machine β€” not into a separate auth flow.

Provisioning lifecycle (Story 11.E.2 two-endpoint design β€” Resolved Q#1 Variant B):

  1. CONTACTED β†’ READY transition (SpeakerWorkflowService.transition() side-effect hook): the backend calls UserApiClient.provisionUserWithRole(email, firstName, lastName, SPEAKER). The company-user-management-service performs, in order:

    1. cognito-idp:AdminCreateUser with MessageAction=SUPPRESS and a throwaway temporary password. The user is created in Cognito with status FORCE_CHANGE_PASSWORD.
    2. SPEAKER role grant in role_assignments (database β€” NOT Cognito groups, per CLAUDE.md β€œAuthentication & Roles”).
    3. INSERT INTO user_profiles (idempotent on existing user).
    4. Returns { username, created } to the caller. The throwaway temporary password is never returned (the temporaryPassword field was removed from ProvisionUserResponse in Story 11.E.2) and is discarded from CUMS memory.

    Re-calling provisionUserWithRole for an already-provisioned user is idempotent and skips the Cognito call entirely (the Cognito user already exists).

  2. READY β†’ INVITED transition: EMS’s runInvitedHook calls the new sibling endpoint POST /api/v1/users/{username}/issue-invitation-credentials. CUMS branches on the Cognito user’s current status (via AdminGetUser):

    • FORCE_CHANGE_PASSWORD / RESET_REQUIRED / UNCONFIRMED β†’ generate a fresh temp password, call AdminSetUserPassword(Permanent=false), return { action: "FRESH_TEMP_PASSWORD", temporaryPassword: "..." }.
    • CONFIRMED β†’ no Cognito mutation; return { action: "USE_EXISTING_PASSWORD", temporaryPassword: null }.
    • ARCHIVED / COMPROMISED β†’ return HTTP 422 (UnprocessableInvitationStateException).

    The invitation-email service then sends a templated HTML email containing the speaker portal login URL + the speaker’s email as the Cognito username + either the temporary password block or the β€œuse existing password / Forgot password” block (selected by the action discriminator). Templates ship in de + en only per CLAUDE.md Β§β€œLocalization β€” Email Templates: DE + EN Only; UI i18n: All 10 Locales”; other locales fall back to en. HTML-only (no .txt parity) per Story 11.E.2 Resolved Q#3.

  3. First login: speaker enters email + temporary password on the standard Cognito hosted/SDK login page. Cognito challenges them with NEW_PASSWORD_REQUIRED; the speaker sets a new password. From then on they are a regular Cognito user β€” custom:role JWT claim includes SPEAKER (populated by the PreTokenGeneration Lambda from role_assignments).

  4. Portal access: every /api/v1/speaker-portal/** endpoint is @PreAuthorize("hasRole('SPEAKER')"). The speaker-portal frontend (web-frontend/src/pages/speaker/**) reads the Cognito session via useAuth() β€” same pattern as organizer/partner pages β€” and calls backend endpoints with a Bearer token.

Components NOT present (deleted by ADR-009 Decision 3 / Epic 11 Phase F):

  • MagicLinkService, SpeakerPortalTokenController, SpeakerMagicLoginController, JwtConfig (speaker-side).
  • The magic_link_tokens table.
  • The speaker_jwt HTTP-only cookie.
  • ?token= and ?jwt= URL parsing in pages/speaker/**.

No Lambda triggers are required. Standard Cognito invitation flow covers the entire path β€” no DefineAuthChallenge / CreateAuthChallenge / VerifyAuthChallengeResponse Lambdas; no Custom Auth flow.

See 06b-user-lifecycle-sync.md (β€œPattern N: Speaker Provisioning at CONTACTED β†’ READY”) for the cross-service sequence diagram.

Error Handling Strategy

Error Flow

sequenceDiagram participant FE as Frontend participant GW as API Gateway participant Service as Domain Service participant DB as Database FE->>GW: API Request GW->>Service: Forward Request Service->>DB: Database Query DB-->>Service: Database Error Service->>Service: Transform to Domain Error Service-->>GW: Structured Error Response GW->>GW: Add Request ID & Timestamp GW-->>FE: Standard Error Format FE->>FE: Display User-Friendly Message

Error Response Format

typescript
interface ApiError {
  error: {
    code: string;
    message: string;
    details?: Record<string, any>;
    timestamp: string;
    requestId: string;
    path: string;
    severity: string;
  };
}

Comprehensive Exception Hierarchy

java
// Base domain exception
public abstract class BATbernException extends RuntimeException {
    // Implementation details - see source code
}

// Domain-specific exceptions
@ResponseStatus(HttpStatus.NOT_FOUND)
public class EventNotFoundException extends BATbernException {
    // Implementation details - see source code
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
public class InvalidEventStateException extends BATbernException {
    // Implementation details - see source code
}

@ResponseStatus(HttpStatus.CONFLICT)
public class SpeakerAlreadyInvitedException extends BATbernException {
    // Implementation details - see source code
}

@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public class BusinessValidationException extends BATbernException {
    // Implementation details - see source code
}

@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public class RateLimitExceededException extends BATbernException {
    // Implementation details - see source code
}

Global Exception Handler

java
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    private final ErrorMessageResolver messageResolver;
    private final MetricRegistry metricRegistry;

    @ExceptionHandler(BATbernException.class)
    public ResponseEntity<ErrorResponse> handleBATbernException(BATbernException ex, HttpServletRequest request) {
        // Implementation details - see source code
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex, HttpServletRequest request) {
        // Implementation details - see source code
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex, HttpServletRequest request) {
        // Implementation details - see source code
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex, HttpServletRequest request) {
        // Implementation details - see source code
    }
}

Circuit Breaker Pattern Implementation

The platform uses Resilience4j circuit breakers (not a bespoke CircuitBreakerService). Configuration is rate-based, not count-based:

yaml
# application-shared.yml (shared across services)
resilience4j:
  circuitbreaker:
    instances:
      eventBridgePublisher:
        failureRateThreshold: 60          # open after 60% failure rate (not a fixed count)
        waitDurationInOpenState: 10s       # 10 seconds in open state (not 60 000 ms)
        permittedNumberOfCallsInHalfOpenState: 5
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
java
// Usage via Resilience4j annotation
@CircuitBreaker(name = "eventBridgePublisher", fallbackMethod = "storeForRetry")
public void publishEvent(DomainEvent event) {
    // Implementation details - see source code
}

private void storeForRetry(DomainEvent event, Exception ex) {
    // Store event for later retry
    failedEventStore.store(event);
}

Retry Mechanism with Exponential Backoff

java
@Component
public class RetryService {

    @Retryable(
        value = {TransientException.class, TemporaryUnavailableException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000)
    )
    public void executeWithRetry(Runnable operation) {
        // Implementation details - see source code
    }

    @Recover
    public void recover(Exception ex) {
        // Implementation details - see source code
    }
}

Request Correlation and Context

java
@Component
@Slf4j
public class RequestCorrelationFilter implements Filter {
    private static final String CORRELATION_ID_HEADER = "X-Correlation-ID";

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // Implementation details - see source code
    }
}

public class RequestContext {
    private static final ThreadLocal<String> correlationId = new ThreadLocal<>();
    private static final ThreadLocal<UserContext> userContext = new ThreadLocal<>();

    public static String getCurrentRequestId() {
        // Implementation details - see source code
    }

    public static void setCorrelationId(String id) {
        // Implementation details - see source code
    }

    public static UserContext getCurrentUser() {
        // Implementation details - see source code
    }

    public static void setCurrentUser(UserContext user) {
        // Implementation details - see source code
    }

    public static void clear() {
        // Implementation details - see source code
    }
}

Service Communication Patterns

Domain Events

java
@Component
@Slf4j
public class DomainEventPublisher {

    private final ApplicationEventPublisher eventPublisher;
    private final EventBridge eventBridge;

    public void publishEvent(DomainEvent event) {
        // Publish locally for same-service subscribers
        eventPublisher.publishEvent(event);

        // Publish to EventBridge for cross-service communication
        try {
            eventBridge.publishEvent(event);
            log.debug("Published domain event: {} with ID: {}", event.getEventType(), event.getId());
        } catch (Exception ex) {
            log.error("Failed to publish domain event to EventBridge: {}", event.getId(), ex);
            // Store for retry
            failedEventStore.store(event);
        }
    }
}

// Event listeners
@EventListener
@Async
public void handleSpeakerInvited(SpeakerInvitedEvent event) {
    log.info("Processing speaker invitation for speaker {} to event {}",
             event.getSpeakerId(), event.getEventId());

    // Implementation details - see source code
}

Data Validation and Business Rules

java
@Component
public class EventBusinessRules {

    public void validateEventCreation(CreateEventRequest request) {
        // Business rule: Event date must be at least 30 days in the future
        if (request.getEventDate().isBefore(LocalDateTime.now().plusDays(30))) {
            throw new BusinessValidationException(
                "eventDate",
                "Event date must be at least 30 days in the future",
                request.getEventDate()
            );
        }

        // Business rule: Only one event per quarter
        if (eventRepository.existsByQuarter(getQuarter(request.getEventDate()))) {
            throw new BusinessValidationException(
                "eventDate",
                "Only one event is allowed per quarter",
                getQuarter(request.getEventDate())
            );
        }
    }

    public void validateSpeakerInvitation(String speakerId, String eventId) {
        // Business rule: Speaker cannot be invited to multiple sessions in same time slot
        List<Session> conflictingSessions = sessionRepository.findConflictingSessions(speakerId, eventId);
        if (!conflictingSessions.isEmpty()) {
            throw new BusinessValidationException(
                "speakerId",
                "Speaker has conflicting sessions",
                Map.of("conflictingSessions", conflictingSessions.stream()
                      .map(Session::getId).collect(Collectors.toList()))
            );
        }
    }
}

Conflict Detection Severities

The ConflictDetectionService distinguishes between hard errors and soft warnings:

Conflict Type Severity Blocking
ROOM_OVERLAP β€” two sessions in the same room at overlapping times ERROR Yes β€” slot assignment is rejected
SPEAKER_DOUBLE_BOOKED β€” same speaker assigned to overlapping sessions ERROR Yes β€” slot assignment is rejected
PREFERENCE_MISMATCH β€” session scheduled outside speaker’s preferred time window WARNING No β€” informational only, assignment proceeds

Workflow State Management

The BATbern platform implements sophisticated state machines to manage the complex 9-state event workflow. These include:

  • Event Workflow State Machine - Manages event lifecycle from draft to published
  • Speaker Workflow Management - Tracks speaker states from invitation to final agenda
  • Slot Assignment Algorithm - Optimally assigns speakers to presentation slots
  • Quality Review Workflow - Manages content review and approval process
  • Overflow Management & Voting - Handles speaker overflow situations with voting

See Workflow State Machines for complete implementation details.

User Lifecycle and Synchronization

The platform maintains user data across AWS Cognito (authentication) and PostgreSQL (business logic) using a database-centric approach per ADR-001:

  1. PostConfirmation Lambda - Creates database user on Cognito email verification
  2. PreTokenGeneration Lambda - Adds custom:role JWT claim from database
  3. Spring Security - Extracts roles from JWT for authorization
  4. Unidirectional Sync - Cognito β†’ Database only (NO Cognito Groups, NO reverse sync)

Key Point: Roles are stored as a Set<Role> directly on the User entity (not in a separate role_assignments table). Roles are synced to the JWT at login time, cached for request duration. Role updates take effect on the next login.

See User Lifecycle Sync Patterns for complete implementation details.

Role Management Service

Handles user role promotion, demotion, and approval workflows while enforcing business rules.

Deployed implementation: The production RoleService stores roles as a Set<Role> on the User entity and injects UserRepository only β€” there is no separate UserRoleRepository or role_assignments table. Role lookups use userRepository.findByRolesContaining(Role.ORGANIZER).

Idempotency: addRole and removeRole are idempotent. A UserRoleChangedEvent is only published when the role set actually changes (i.e. if the role was already present on add, or already absent on remove, no event is published).

Note: The snippet below is a design reference that documents the intended approval-workflow shape. The deployed RoleService is simpler; refer to source for the exact implementation.

Role Management Service Implementation (design reference)

java
@Service
@RequiredArgsConstructor
public class RoleManagementService {

    private final UserRoleRepository userRoleRepository;
    private final RoleChangeRequestRepository roleChangeRequestRepository;
    private final RoleChangeApprovalRepository roleChangeApprovalRepository;
    private final CognitoIdentityProviderClient cognitoClient;
    private final ApplicationEventPublisher eventPublisher;

    @Value("${aws.cognito.user-pool-id}")
    private String cognitoUserPoolId;

    /**
     * Promote user to a higher role
     * Story 1.16.2: Uses String username instead of UUID
     */
    @Transactional
    public RoleChange promoteUser(String username, UserRole targetRole, String promotedByUsername, String reason) {
        // Validate promotion eligibility
        validatePromotion(username, targetRole);

        // Create role record
        UserRoleEntity roleEntity = UserRoleEntity.builder()
            .username(username)  // Story 1.16.2: Store username, not UUID
            .role(targetRole)
            .grantedBy(promotedByUsername)  // Story 1.16.2: Store username, not UUID
            .reason(reason)
            .isActive(true)
            .build();

        userRoleRepository.save(roleEntity);

        // NO Cognito sync - roles fetched from database on next login via PreTokenGeneration Lambda
        // Per ADR-001: Database is single source of truth for roles

        // Publish domain event
        // Story 1.16.2: Event uses String username as aggregateId
        RoleChange roleChange = mapToRoleChange(roleEntity);
        eventPublisher.publishEvent(new UserRolePromotedEvent(roleChange));

        return roleChange;
    }

    /**
     * Demote user from role - immediate for Speaker, requires approval for Organizer
     * Story 1.16.2: Uses String username instead of UUID
     */
    @Transactional
    public RoleChangeResult demoteUser(String username, UserRole currentRole, String demotedByUsername, String reason) {
        if (currentRole == UserRole.ORGANIZER) {
            // Check minimum organizers rule
            if (!canDemoteOrganizer(username, null)) {
                throw new BusinessRuleException("Cannot demote: minimum 2 organizers required");
            }

            // Create approval request
            RoleChangeRequest request = RoleChangeRequest.builder()
                .username(username)  // Story 1.16.2: Store username, not UUID
                .currentRole(currentRole)
                .requestedRole(UserRole.ATTENDEE)
                .requestedBy(demotedByUsername)  // Story 1.16.2: Store username, not UUID
                .requiresApprovalFrom(username)
                .reason(reason)
                .status(RequestStatus.PENDING)
                .build();

            roleChangeRequestRepository.save(request);

            return RoleChangeResult.pendingApproval(request);
        } else {
            // Immediate demotion for non-organizers
            UserRoleEntity roleEntity = userRoleRepository
                .findActiveRole(username, currentRole)
                .orElseThrow(() -> new NotFoundException("Active role not found"));

            roleEntity.setIsActive(false);
            roleEntity.setRevokedBy(demotedBy);
            roleEntity.setRevokedAt(Instant.now());

            userRoleRepository.save(roleEntity);

            // NO Cognito sync - roles fetched from database on next login
            // Per ADR-001: Database is single source of truth for roles

            RoleChange roleChange = mapToRoleChange(roleEntity);
            eventPublisher.publishEvent(new UserRoleDemotedEvent(roleChange));

            return RoleChangeResult.completed(roleChange);
        }
    }

    /**
     * Approve organizer demotion request
     * Story 1.16.2: Uses String username instead of UUID
     */
    @Transactional
    public RoleChange approveRoleChange(UUID requestId, String approverUsername, boolean approved, String comments) {
        // Implementation details - see source code
    }

    /**
     * Check if organizer can be demoted (minimum 2 organizers rule)
     * Story 1.16.2: Uses String username instead of UUID
     */
    public boolean canDemoteOrganizer(String username, String eventCode) {
        long activeOrganizerCount = userRoleRepository.countActiveOrganizers(eventCode);
        return activeOrganizerCount >= 2;
    }

    // syncRoleToCognito removed - per ADR-001, roles NOT synced to Cognito
    // Roles stored in database only, added to JWT via PreTokenGeneration Lambda

    private UserRole determineNewRole(String username) {
        // Implementation details - see source code
    }

    private void validatePromotion(String username, UserRole targetRole) {
        // Implementation details - see source code
    }

    private RoleChange mapToRoleChange(UserRoleEntity entity) {
        // Implementation details - see source code
    }
}

Newsletter Async Processing

The newsletter send system uses an asynchronous fire-and-forget pattern to handle large recipient lists without blocking the API or exhausting thread pools:

  • Trigger: POST /api/v1/events/{code}/newsletter/send returns 202 Accepted immediately
  • Processing: @Async method sends emails sequentially with SES rate limiting (~70ms/email to stay within SES quotas)
  • Progress tracking: newsletter_send_status table tracks state (PENDING β†’ IN_PROGRESS β†’ COMPLETED / PARTIAL / FAILED), with sent_count and total_count for progress polling
  • Duplicate prevention: A second send attempt while IN_PROGRESS returns 409 Conflict
  • Retry: Dedicated retry endpoint to re-send only failed recipients from a previous partial/failed send
  • Orphan recovery: On service startup, any stuck IN_PROGRESS records (from a crash mid-send) are reset to FAILED for manual retry

Email Forwarding (Serverless)

Inbound email forwarding for platform mailboxes (ok@, info@, events@, partner@, support@, batbernNN@batbern.ch) is handled entirely serverlessly:

SES Receipt Rule β†’ S3 (raw email) β†’ Lambda (email-forwarder)
                                          β”œβ”€ address-resolver: maps inbox β†’ recipients via role/registration APIs
                                          β”œβ”€ sender-auth: validates sender
                                          β”œβ”€ email-rewriter: rewrites From/Reply-To headers
                                          └─ SES sendRawEmail β†’ forwarded to resolved recipients
  • Environment isolation: Staging uses replies@staging.batbern.ch; production uses replies@batbern.ch (separate MX records and SES receipt rules)
  • Outbound sender: Staging uses noreply@berner-architekten-treffen.ch (verified SES domain); production uses noreply@batbern.ch
  • Sender exclusion: The original sender is excluded from forwarding recipients to prevent bounce loops
  • Admin configuration: Organizers can manage forwarding rules via Admin Settings UI

Auto-Enrollment on Event Creation

When a new event is created, an EventCreatedEvent listener automatically enrolls all active organizers and partner contacts as stakeholders. A manual quick-action button is also available for idempotent re-enrollment (POST endpoint returns success even if already enrolled).

Real-time Notifications and Escalation

The platform provides multi-channel notifications (email, WebSocket) with intelligent escalation based on workflow state, user preferences, and deadline proximity.

Key Features:

  • Real-time WebSocket notifications
  • Email notifications with templating
  • Automatic escalation for approaching deadlines
  • User-configurable notification preferences
  • Notification history and audit trail

See Notification System for complete implementation details.

Registration & Sign-up Confirmation Lifecycle

Two distinct confirmation flows exist, with different token mechanics β€” a frequent source of confusion:

Flow Token Validity Configurable?
Event registration confirmation (status registered β†’ confirmed) Our own signed JWT (ConfirmationTokenService) 4 days (app.registration.confirmation-token-validity-hours, default 96h) βœ… yes β€” we issue it
Cognito account confirmation (sign-up email verification) Cognito’s native sign-up code 24h, fixed ❌ no β€” Cognito limitation

Coupling invariant (registration): RegistrationCleanupService deletes still-registered rows whose most-recent confirmation link is older than app.registration.cleanup-after-hours (default 120h / 5 days). The deletion clock is keyed off COALESCE(confirmation_resent_at, created_at) β€” i.e. the last time a link was minted β€” not created_at alone. This is required because the resend job (below) issues a fresh full-validity token on each nudge: keying cleanup off created_at would orphan a just-resent, still-valid link (the 2026-06-08 β€œConfirmation Failed” incident). The window must exceed the confirmation-token validity, otherwise a still-valid 4-day link could point at an already-deleted row; the service enforces this defensively (effectiveCleanupHours() = max(configured, tokenValidity + 24h)) and warns at startup if misconfigured.

Daily auto-resend jobs (both Spring @Scheduled + @SchedulerLock/ShedLock):

  • RegistrationResendService (event-management-service, 08:30) re-sends the registration-confirmation email β€” with fresh JWTs β€” to attendees unconfirmed for > after-hours (default 48h), capped at max-attempts (default 2) via confirmation_resend_count / confirmation_resent_at on registrations.
  • CognitoConfirmationResendJob (company-user-management-service, 08:15) re-sends a fresh Cognito sign-up code to accounts unconfirmed within a bounded age window (stateless anti-spam, since UNCONFIRMED users have no DB row) β€” see 06b Β§Unconfirmed Sign-up Resend Job. Requires cognito-idp:ListUsers + cognito-idp:ResendConfirmationCode on the CUMS task role.

Together these close the β€œfirst confirmation email missed/expired β†’ recovery dead-end β†’ locked out” failure mode without manual admin intervention.

Testing Strategy

The BATbern backend uses Testcontainers PostgreSQL for all integration tests to ensure production parity. This approach catches PostgreSQL-specific issues (JSONB, functions, constraints) that would be missed with H2.

Testing Layers:

  • E2E Tests - Full user journeys with Playwright
  • Integration Tests - REST API + PostgreSQL via Testcontainers
  • Unit Tests - Business logic with mocked dependencies

See Testing Strategy for complete implementation details and examples.


Summary

This backend architecture provides:

βœ… Role-based security with JWT authentication via AWS Cognito βœ… Comprehensive error handling with circuit breakers and retry logic βœ… Sophisticated workflow state machines for the 9-state event process βœ… Robust user synchronization across Cognito and PostgreSQL βœ… Real-time notifications with intelligent escalation βœ… Production-parity testing with Testcontainers PostgreSQL

The implementation follows domain-driven design principles with clear separation of concerns and robust error handling throughout the system.