Story 4.1.5b: Backend Implementation for Anonymous Event Registration

Last updated: November 13, 2025 Reading time: 15 minutes File size: 24.13 KB

Story 4.1.5b: Backend Implementation for Anonymous Event Registration

Dependencies

Depends on: Story 4.1.5a (Registration Architecture Foundation) Blocks: Story 4.1.5 (Registration Wizard Frontend)

Status

Not Started

⚡ ADR-006 Simplification

Unified user profile approach reduces implementation effort by ~50% vs dual-table (ADR-005):

  • ❌ NO anonymous_* fields in Registration entity (7 fields removed)
  • ❌ NO account linking logic in registrations (claimed_by, claimed_at removed)
  • ✅ Simple: attendee_id always references user_profiles (anonymous users have cognito_user_id = NULL)
  • ✅ Cleaner: Account linking is 3 lines of code (UPDATE user_profiles.cognito_user_id)
  • ✅ Better: Company as FK to companies table (not free text)

Estimated Effort: 2-3 hours (vs 4-6 hours for ADR-005)

Story

As a backend developer, I want to implement anonymous event registration support in the Event Management Service, so that the frontend can support both authenticated and anonymous registration flows per ADR-006 (unified user profile approach).

Domain Context

Primary Domain

Attendee Experience Domain (Backend support for registration)

Involved Services

  • Event Management Service (Java/Spring Boot)
  • PostgreSQL (registrations table)
  • API Gateway (public endpoint security configuration)

Cross-Domain Dependencies

  • Depends on: Story 4.1.5a (Migrations V9 + V11, ADR-006, OpenAPI spec)
  • Integrates with: Company-User-Management Service (user_profiles for account creation and linking)
  • Security Pattern: Public endpoints using confirmation code as access token
  • Key ADR-006 Benefit: 85% code reduction vs dual-table (ADR-005 rejected)

Acceptance Criteria

  1. Migrations Applied: V9 (registrations) + V11 (user_profiles with nullable cognito_user_id)
  2. User Profile Creation: POST /api/v1/events/{eventCode}/registrations creates user_profile with cognito_user_id = NULL for anonymous users
  3. Confirmation Code Format: Generated in BAT-YYYY-NNNNNN format using PostgreSQL sequence
  4. Public Retrieval: GET /api/v1/events/{eventCode}/registrations/{registrationCode} works without authentication, returns user data from user_profiles
  5. Public Update: PATCH /api/v1/events/{eventCode}/registrations/{registrationCode} updates preferences without authentication
  6. Public Cancellation: DELETE /api/v1/events/{eventCode}/registrations/{registrationCode} cancels registration without authentication
  7. Company FK Integrity: Registration request references company_id (FK to companies table), not free text
  8. Unified User Reference: registrations.attendee_id always NOT NULL (references user_profiles)
  9. Security Configuration: Anonymous endpoints use permitAll(), admin endpoints remain authenticated()
  10. Component Tests Pass: All registration flows tested with real PostgreSQL (Testcontainers)
  11. Account Linking Works: When anonymous user creates Cognito account, user_profile.cognito_user_id updated (3 lines of code)

Tasks / Subtasks (TDD Workflow)

Phase 1: Database Migrations (RED Phase)

  • Task 1: Apply Migrations V9 + V11 to Dev Databases
    • Event Management Service:
      • Verify registrations
      • Verify attendee_id
    • Company-User-Management Service:
      • Verify cognito_user_id
      • Verify claimed_at

Phase 2: Entity Layer Updates (GREEN Phase)

  • Task 2: Update Registration Entity (Simplified - ADR-006)

    • Keep @Table(name = "registrations")
    • Rename field: registrationCode →
    • Keep attendeeId as NOT NULL: @Column(nullable = false)
    • Remove old fields: attendeeName,
    • NO anonymous_ fields
    • NO account linking fields
      • newsletterSubscribed
      • eventReminders
      • specialRequests
      • sessionPreferences
      • attendanceConfirmed
      • actualAttendance
    • Add @PrePersist
    • Simplification
  • Task 3: Update RegistrationRepository (Simplified - ADR-006)

    • Rename method: findByEventCodeAndRegistrationCode() →
    • Add method:
    • NO anonymous email methods
    • Simplification

Phase 3: DTO Layer (GREEN Phase)

  • Task 4: Create New CreateRegistrationRequest DTO

    • Required fields: email, firstName, lastName,
    • Optional fields: company, role, communicationPreferences,
    • Breaking Change: Remove attendeeUsername, attendeeName
  • Task 5: Create New PatchRegistrationRequest DTO

    • Fields: specialRequests,
  • Task 6: Update Registration Response DTO

    • Add registrationCode field (rename from registrationCode
    • Conditional logic: Show anonymousEmail XOR attendeeId

Phase 4: Service Layer (GREEN Phase)

  • Task 7: Create RegistrationService
    • Method:
      • Check for duplicate:
    • Method:
    • Method:
    • Method:
    • Method:

Phase 5: Controller Layer (GREEN Phase)

  • Task 8: Update RegistrationController

    • BREAKING CHANGE: Rename all {registrationCode} path params →
      • Use new CreateRegistrationRequest
      • Call
    • Add GET endpoint:
      • Call
      • Change param: {registrationCode} →
      • Use new PatchRegistrationRequest
      • Call
      • Change param: {registrationCode} →
      • Call
  • Task 9: Add QR Code Endpoint (Story 4.1.6 dependency)

    • Add GET endpoint:
    • Support optional ?size=300

Phase 6: Security Configuration (GREEN Phase)

  • Task 10: Update SecurityConfig
    • File:
    • Add to permitAll()
      java
      .requestMatchers(HttpMethod.POST, "/api/v1/events/*/registrations").permitAll()  // Anonymous registration
      .requestMatchers(HttpMethod.GET, "/api/v1/events/*/registrations/*").permitAll()  // Public lookup by confirmation code
      .requestMatchers(HttpMethod.PATCH, "/api/v1/events/*/registrations/*").permitAll()  // Public update
      .requestMatchers(HttpMethod.DELETE, "/api/v1/events/*/registrations/*").permitAll()  // Public cancel
      .requestMatchers(HttpMethod.GET, "/api/v1/events/*/registrations/*/qr").permitAll()  // QR code
      
    • Keep admin endpoints authenticated: GET /api/v1/events/*/registrations

Phase 7: Component Tests (RED-GREEN-REFACTOR)

  • Task 11: Write Component Tests for Anonymous Registration

    • Test 11.1:
    • Test 11.2:
    • Test 11.3:
  • Task 12: Write Component Tests for Public Retrieval

    • Test 12.1:
    • Test 12.2:
  • Task 13: Write Component Tests for Public Update

    • Test 13.1:
    • Test 13.2:
  • Task 14: Write Component Tests for Public Cancellation

    • Test 14.1:
    • Test 14.2:
  • Task 15: Write Component Tests for Mutual Exclusivity

    • Test 15.1:
    • Test 15.2:
  • Task 16: Write Component Tests for Backward Compatibility

    • Test 16.1:

Phase 8: Integration & Documentation

  • Task 17: Run All Tests

    • Execute:
  • Task 18: Update Story Documentation

Dev Notes - Implementation Guide

Confirmation Code Generation

Database Sequence (already in V9 migration):

sql
CREATE SEQUENCE IF NOT EXISTS registration_code_seq START 1;

Entity @PrePersist Method:

java
@PrePersist
protected void onCreate() {
    if (this.registrationCode == null) {
        // Generate confirmation code: BAT-YYYY-NNNNNN
        int year = LocalDate.now().getYear();
        Long sequenceValue = // fetch NEXTVAL('registration_code_seq') via native query
        this.registrationCode = String.format("BAT-%d-%06d", year, sequenceValue);
    }
    this.createdAt = Instant.now();
    this.updatedAt = Instant.now();
}

Alternative (simpler): Let database DEFAULT generate confirmation code, then read it back after INSERT.

Mutual Exclusivity Validation

Database Level (V9 migration):

sql
ADD CONSTRAINT check_registration_type
  CHECK (
    (attendee_id IS NOT NULL AND anonymous_email IS NULL) OR
    (attendee_id IS NULL AND anonymous_email IS NOT NULL)
  );

Application Level (optional additional validation):

java
@PrePersist
@PreUpdate
protected void validateRegistrationType() {
    boolean hasAttendee = attendeeId != null;
    boolean hasAnonymous = anonymousEmail != null;
    if (hasAttendee && hasAnonymous) {
        throw new IllegalStateException("Registration must be EITHER authenticated OR anonymous, not both");
    }
    if (!hasAttendee && !hasAnonymous) {
        throw new IllegalStateException("Registration must have either attendeeId or anonymousEmail");
    }
}

Security Configuration Example

SecurityConfig.java:

java
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .authorizeHttpRequests(auth -> auth
            // Public registration endpoints (anonymous access)
            .requestMatchers(HttpMethod.POST, "/api/v1/events/*/registrations").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/v1/events/*/registrations/*").permitAll()
            .requestMatchers(HttpMethod.PATCH, "/api/v1/events/*/registrations/*").permitAll()
            .requestMatchers(HttpMethod.DELETE, "/api/v1/events/*/registrations/*").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/v1/events/*/registrations/*/qr").permitAll()

            // Admin endpoints (authentication required)
            .requestMatchers(HttpMethod.GET, "/api/v1/events/*/registrations").authenticated()

            // All other requests require authentication
            .anyRequest().authenticated()
        );
    return http.build();
}

DTO Mapping Examples

Anonymous Registration Request → Entity:

java
Registration registration = new Registration();
registration.setEventId(event.getId());  // Internal UUID
registration.setEventCode(eventCode);    // Public identifier
registration.setAttendeeId(null);        // Anonymous registration
registration.setAnonymousEmail(request.getEmail());
registration.setAnonymousFirstName(request.getFirstName());
registration.setAnonymousLastName(request.getLastName());
registration.setAnonymousCompany(request.getCompany());
registration.setAnonymousRole(request.getRole());
registration.setNewsletterSubscribed(request.getCommunicationPreferences().getNewsletterSubscribed());
registration.setEventReminders(request.getCommunicationPreferences().getEventReminders());
registration.setSpecialRequests(request.getSpecialRequests());
registration.setStatus("confirmed");
// registrationCode auto-generated by @PrePersist

Entity → Registration Response:

java
RegistrationResponse response = new RegistrationResponse();
response.setConfirmationCode(registration.getConfirmationCode());
response.setEventCode(registration.getEventCode());

// Conditional: show EITHER anonymous OR authenticated fields
if (registration.getAnonymousEmail() != null) {
    response.setAnonymousEmail(registration.getAnonymousEmail());
    response.setAnonymousFirstName(registration.getAnonymousFirstName());
    response.setAnonymousLastName(registration.getAnonymousLastName());
    response.setAnonymousCompany(registration.getAnonymousCompany());
    response.setAnonymousRole(registration.getAnonymousRole());
} else {
    response.setAttendeeId(registration.getAttendeeId());
}

response.setStatus(registration.getStatus());
response.setNewsletterSubscribed(registration.getNewsletterSubscribed());
response.setEventReminders(registration.getEventReminders());
response.setSpecialRequests(registration.getSpecialRequests());
response.setRegistrationDate(registration.getRegistrationDate());

QR Code Generation (Story 4.1.6 dependency)

Add ZXing dependency to build.gradle:

gradle
dependencies {
    implementation 'com.google.zxing:core:3.5.1'
    implementation 'com.google.zxing:javase:3.5.1'
}

QRCodeService.java:

java
@Service
public class QRCodeService {
    public byte[] generateQRCode(String data, int width, int height) throws IOException {
        QRCodeWriter qrCodeWriter = new QRCodeWriter();
        BitMatrix bitMatrix = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, width, height);

        ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream();
        MatrixToImageWriter.writeToStream(bitMatrix, "PNG", pngOutputStream);
        return pngOutputStream.toByteArray();
    }
}

Controller Method:

java
@GetMapping("/registrations/{registrationCode}/qr")
public ResponseEntity<byte[]> getRegistrationQR(
    @PathVariable String eventCode,
    @PathVariable String registrationCode,
    @RequestParam(defaultValue = "300") int size
) throws IOException {
    Registration registration = registrationService.getRegistrationByConfirmationCode(eventCode, registrationCode);

    // Generate QR code with registration data (JSON format)
    String qrData = String.format(
        "{\"registrationCode\":\"%s\",\"eventCode\":\"%s\"}",
        registrationCode, eventCode
    );

    byte[] qrCodeImage = qrCodeService.generateQRCode(qrData, size, size);

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.IMAGE_PNG);
    headers.setContentLength(qrCodeImage.length);

    return ResponseEntity.ok()
        .headers(headers)
        .body(qrCodeImage);
}

Architecture Alignment

This story implements the backend portion of ADR-005 (Anonymous Event Registration):

ADR-005 Key Decisions Implemented:

  • ✅ Anonymous registration without authentication (public POST endpoint)
  • ✅ Confirmation code as access token (public GET/PATCH/DELETE by confirmation code)
  • ✅ Email-based account linking foundation (anonymous_email field, future UserCreatedEvent handler)
  • ✅ Mutually exclusive patterns (CHECK constraint enforced)
  • ✅ Privacy-first design (minimal PII storage, no unnecessary authentication)

OpenAPI Spec Alignment:

  • ✅ All endpoints match events-api.openapi.yml (Story 4.1.5a update)
  • ✅ DTOs match schemas in lines 2111-2190
  • ✅ Security patterns match security: [] annotations
  • ✅ Confirmation code format matches pattern ^BAT-[0-9]{4}-[0-9]{6}$

Breaking Changes

1. Table Rename

Before: @Table(name = "registrations") After: @Table(name = "registrations") Impact: All JPA queries, native SQL, and references must update

2. Field Rename

Before: registrationCode (String) After: registrationCode (String) Impact: All controller paths, DTOs, service methods must update

3. DTO Changes

Before:

java
CreateRegistrationRequest {
  attendeeUsername (required)
  attendeeName (required)
  attendeeEmail (required)
}

After:

java
CreateRegistrationRequest {
  email (required)
  firstName (required)
  lastName (required)
  company (optional)
  role (optional)
  termsAccepted (required)
}

Impact: Frontend must update API client calls

4. Path Parameter Changes

Before: /api/v1/events/{eventCode}/registrations/{registrationCode} After: /api/v1/events/{eventCode}/registrations/{registrationCode} Impact: Frontend routing, API client methods must update

Testing Strategy

Component Tests (Testcontainers)

  • PostgreSQL: Use real database with V9 migration applied
  • Test Data Builders: RegistrationTestDataBuilder for both patterns
  • Coverage Target: >90% for new registration service methods

Manual Testing Checklist

  1. Run migration V9: ./gradlew :services:event-management-service:flywayMigrate
  2. Verify table structure: \d registrations in psql
  3. Test anonymous registration via curl (no auth header)
  4. Test public retrieval with confirmation code
  5. Test update preferences without auth
  6. Test cancellation without auth
  7. Test backward compatibility with authenticated registrations

Definition of Done Checklist

Development Complete

Infrastructure Complete

Documentation Complete

Review Ready

Change Log

Date Version Description Author
2025-11-08 1.0 Initial story creation - Backend implementation for anonymous registration (Story 4.1.5a implementation) James (Developer Agent)
2025-11-08 2.0 Simplified for ADR-006 - Unified user profile approach reduces effort by 50%, removes anonymous_* fields, simplifies account linking Winston (Architect Agent)