Story 1.15a.10: Notifications API Consolidation

Linear Issue: [BAT-7](https://linear.app/batbern/issue/BAT-7/notifications-api-consolidation)

Last updated: January 5, 2026 Reading time: 10 minutes File size: 18.17 KB

Status

Draft

Story

As a user, I want consolidated Notification APIs with preferences and delivery tracking, so that I can manage how and when I receive notifications.

Domain Context

Primary Domain

Cross-Cutting Concern - Notification management (not a separate microservice)

Implementation Pattern

Notifications follow the Task System pattern (Story 5.5):

  • Single Table: notifications table in shared public schema
  • Service Owner: Event Management Service (by convention)
  • Storage Strategy: Hybrid (email audit trail + in-app queries)
  • ADR-003 Compliant: Uses meaningful IDs (recipient_username, event_code)
  • Infrastructure Services: AWS SES (email), AWS SNS (push), EventBridge (events)

Why This Pattern?

  • ✅ Matches BATbern’s actual shared-database architecture (single public schema)
  • ✅ Consistent with existing patterns (Task System in Story 5.5)
  • ✅ Simple, fast queries (no cross-service aggregation needed)
  • ✅ ADR-003 compliant (meaningful IDs, no foreign keys)

BATbern Database Architecture (CRITICAL)

Actual Architecture (verified from codebase):

PostgreSQL Instance: batbern (dev: batbern_development)
└── Schema: public (DEFAULT - no custom schemas)
    ├── companies (owned by company-user-management-service)
    ├── user_profiles (owned by company-user-management-service)
    ├── events (owned by event-management-service)
    ├── event_tasks (owned by event-management-service) ← Story 5.5
    ├── notifications (owned by event-management-service) ← THIS STORY
    └── ... (all tables in SAME schema)

Key Facts:

  • All services connect to same database (jdbc:postgresql://localhost:5432/batbern)
  • No schema isolation - all tables in public schema
  • Table ownership by convention - services own specific tables
  • Flyway separation - each service has separate flyway_schema_history_<service> table

Involved Services

  • Event Management Service (primary owner of notifications table)
  • Company-User Management Service (owns user preferences via existing user_profiles)
  • Shared Kernel (notification DTOs, event types)
  • AWS SES (email delivery)
  • AWS SNS (push notifications - future)
  • EventBridge (domain events trigger notifications)

Cross-Domain Dependencies

  • Depends on: Story 1.15a (API Consolidation Foundation), Story 5.5 (Task System pattern)
  • Used by: All domain services for notification triggering via domain events
  • Pattern: Single table owned by Event Management Service, queried via REST API

Requirements Context

  • FR7: Email notification workflows
  • FR14: Granular notification preference controls
  • FR20: Intelligent notification system with role-based alerts
  • NFR-Reliability: Notification delivery tracking

Acceptance Criteria Source

API Consolidation Analysis (docs/api-consolidation-analysis.md), Phase 3 - Notifications Domain

Architecture Context

Architecture Patterns

Pattern: Single Table, Event Service Ownership (follows Task System)

  • Storage: Single notifications table in public schema
  • Owner: Event Management Service
  • Access: REST API endpoints (no API Gateway aggregation)
  • Performance: Direct SQL queries, no cross-service HTTP calls
  • Consistency: Matches Task System pattern (Story 5.5)

Hybrid Storage Strategy

Problem: With 3000 users, creating 3000 notification rows for each event is inefficient.

Solution: Hybrid approach based on channel

For Email/SMS (Eager - Create Records):

  • Create notification record when email sent
  • Track delivery status (audit trail, compliance)
  • Storage: ~150,000 rows/year (3000 users × 50 emails/year) - manageable

For In-App Notifications (Lazy - Query Dynamically):

  • Don’t create rows in advance
  • Query when user opens app: “Show events published since last login”
  • Storage: Zero rows for in-app (computed dynamically)

Benefits:

  • ✅ Audit trail for email/SMS (compliance, delivery tracking)
  • ✅ Zero storage cost for in-app notifications
  • ✅ Scalable with user growth
  • ✅ Fast queries (indexed by recipient_username)

ADR-003 Compliance

Meaningful IDs (NOT Foreign Keys):

sql
-- ✅ CORRECT (ADR-003 compliant)
CREATE TABLE notifications (
    recipient_username VARCHAR(100) NOT NULL,  -- Meaningful ID
    event_code VARCHAR(50),                    -- Meaningful ID
    -- NO FOREIGN KEY to user_profiles(id)
    -- NO FOREIGN KEY to events(id)
);

-- ❌ WRONG (violates ADR-003)
CREATE TABLE notifications (
    user_id UUID REFERENCES user_profiles(id),  -- Cross-service FK
    event_id UUID REFERENCES events(id),        -- Within-service FK (acceptable)
);

Why?

  • Follows existing pattern (event_tasks.assigned_organizer_username)
  • Services access user/event data via HTTP APIs when enriching responses
  • No database-level coupling between services

Why NOT Distributed Storage?

Original Proposal (REJECTED):

  • Each service stores its own notification tables
  • API Gateway aggregates across services
  • 5 HTTP calls + in-memory merge + pagination

Why Rejected:

  1. ❌ BATbern has no schema-per-service (only public schema)
  2. ❌ Over-engineered for shared-database architecture
  3. ❌ Inconsistent with Task System pattern (Story 5.5)
  4. ❌ Slower (5 HTTP calls vs. 1 SQL query)
  5. ❌ More complex to maintain

Current Approach (ACCEPTED):

  • Single notifications table in public schema
  • Event Management Service owns it
  • Simple REST API, no aggregation needed
  • 10-100x faster than distributed approach

Wireframe Context

Wireframe References

  • User notification preferences screens (uses existing user preferences in user_profiles)
  • In-app notification center (queries notifications dynamically)

Acceptance Criteria

  1. List Notifications: GET /api/v1/notifications?username={}&status=unread|read|all&page={}
  2. Mark as Read: PUT /api/v1/notifications/{id}/read or batch
  3. Notification Preferences: GET/PUT /api/v1/users/me/preferences (existing endpoint in Company-User Service)
  4. Delivery History: GET /api/v1/notifications/history?username={}&channel=email|sms
  5. Delete Notifications: DELETE /api/v1/notifications/{id} or batch delete
  6. Notification Count: GET /api/v1/notifications/count?username={}&status=unread

Consolidated Endpoints (6 Total)

# Notification Management (Event Management Service)
GET    /api/v1/notifications?username={}&status={}&page={}
GET    /api/v1/notifications/count?username={}&status={}
PUT    /api/v1/notifications/{id}/read
PUT    /api/v1/notifications/batch-read    # Bulk mark as read
DELETE /api/v1/notifications/{id}
DELETE /api/v1/notifications/batch-delete  # Bulk delete

# Delivery History (Event Management Service)
GET    /api/v1/notifications/history?username={}&channel={}

# Preferences (Company-User Management Service - EXISTING)
GET    /api/v1/users/me/preferences
PUT    /api/v1/users/me/preferences

Note: Notification preferences already exist in user_profiles.user_preferences JSONB column (fields: emailNotifications, inAppNotifications, pushNotifications, notificationFrequency, quietHoursStart, quietHoursEnd).

Database Schema

Migration: V25__Create_notifications_table.sql

File Location: services/event-management-service/src/main/resources/db/migration/V25__Create_notifications_table.sql

Schema:

sql
-- Notifications table (follows Task System pattern)
CREATE TABLE IF NOT EXISTS notifications (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

    -- ADR-003: Meaningful IDs (NOT foreign keys)
    recipient_username VARCHAR(100) NOT NULL,
    event_code VARCHAR(50),  -- Nullable (non-event notifications)

    -- Notification details
    notification_type VARCHAR(50) NOT NULL,  -- SPEAKER_INVITED, DEADLINE_WARNING, etc.
    channel VARCHAR(20) NOT NULL,            -- EMAIL, SMS, WEBHOOK
    priority VARCHAR(20) DEFAULT 'NORMAL',   -- LOW, NORMAL, HIGH, URGENT
    subject VARCHAR(255) NOT NULL,
    body TEXT NOT NULL,

    -- Delivery tracking
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',  -- PENDING, SENT, FAILED, READ
    sent_at TIMESTAMP,
    read_at TIMESTAMP,
    failed_at TIMESTAMP,
    failure_reason TEXT,

    -- Metadata
    metadata JSONB,  -- Flexible storage (task_id, speaker_id, etc.)

    -- Timestamps
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Indexes for performance
CREATE INDEX idx_notifications_recipient ON notifications(recipient_username);
CREATE INDEX idx_notifications_event ON notifications(event_code) WHERE event_code IS NOT NULL;
CREATE INDEX idx_notifications_status ON notifications(status);
CREATE INDEX idx_notifications_created ON notifications(created_at DESC);
CREATE INDEX idx_notifications_recipient_created ON notifications(recipient_username, created_at DESC);
CREATE INDEX idx_notifications_recipient_status ON notifications(recipient_username, status);

-- Comment for documentation
COMMENT ON TABLE notifications IS 'Notification delivery tracking (email/SMS audit trail). In-app notifications queried dynamically. Follows Task System pattern (Story 5.5). ADR-003 compliant (meaningful IDs).';

Flyway Table: Uses existing flyway_schema_history_event_management

Storage Pattern

Email/SMS Notifications (Create Rows):

java
// When sending email, create audit trail record
notificationRepository.save(Notification.builder()
    .recipientUsername("john.doe")
    .eventCode("EVT-2024-Q1")
    .notificationType("EVENT_PUBLISHED")
    .channel("EMAIL")
    .subject("Event BATbern Q1 2024 is now published")
    .body("...")
    .status("PENDING")
    .build());

// Send via AWS SES
emailService.send(notification);

// Update status after delivery
notification.setStatus("SENT");
notification.setSentAt(Instant.now());
notificationRepository.save(notification);

In-App Notifications (Query Dynamically):

java
// Don't create rows - query when user opens app
@GetMapping("/api/v1/notifications/in-app")
public List<InAppNotification> getInAppNotifications(@RequestParam String username) {
    // Find events published since user's last login
    Instant lastLogin = userService.getLastLogin(username);

    List<Event> newEvents = eventRepository.findPublishedAfter(lastLogin);

    // Convert to notifications on-the-fly
    return newEvents.stream()
        .map(event -> InAppNotification.builder()
            .type("EVENT_PUBLISHED")
            .title(event.getTitle() + " is now published")
            .eventCode(event.getEventCode())
            .createdAt(event.getPublishedAt())
            .build())
        .collect(Collectors.toList());
}

Service Architecture

NotificationService (Event Management Service)

Responsibilities:

  • Create notification records (email/SMS audit trail)
  • Send notifications via AWS SES/SNS
  • Track delivery status
  • Provide REST API for querying/managing notifications

Integration with Domain Events:

java
@Service
@RequiredArgsConstructor
public class NotificationService {

    @EventListener
    @Async
    public void onEventPublished(EventPublishedEvent event) {
        // Get all registered attendees
        List<String> attendees = registrationRepository
            .findUsernamesByEventId(event.getEventId());

        // Create email notifications (audit trail)
        for (String username : attendees) {
            createAndSendNotification(NotificationRequest.builder()
                .recipientUsername(username)
                .eventCode(event.getEventCode())
                .type("EVENT_PUBLISHED")
                .channel("EMAIL")
                .subject("Event " + event.getTitle() + " is now published")
                .body(buildEmailBody(event))
                .build());
        }
    }

    @EventListener
    @Async
    public void onTaskAssigned(TaskAssignedEvent event) {
        // Notify organizer of task assignment
        createAndSendNotification(NotificationRequest.builder()
            .recipientUsername(event.getAssignedUsername())
            .eventCode(event.getEventCode())
            .type("TASK_ASSIGNED")
            .channel("EMAIL")
            .subject("New task assigned: " + event.getTaskName())
            .metadata(Map.of("taskId", event.getTaskId()))
            .build());
    }

    @Transactional
    public void createAndSendNotification(NotificationRequest request) {
        // Check user preferences (via HTTP call to User Service)
        UserPreferences prefs = userServiceClient
            .getPreferences(request.getRecipientUsername());

        if (!shouldSend(prefs, request)) {
            return;  // Skip if user opted out or in quiet hours
        }

        // Create notification record
        Notification notification = notificationRepository.save(
            toEntity(request)
        );

        // Send asynchronously
        notificationSender.sendAsync(notification);
    }
}

REST API (Event Management Service)

NotificationController:

java
@RestController
@RequestMapping("/api/v1/notifications")
@RequiredArgsConstructor
public class NotificationController {

    @GetMapping
    public Page<NotificationResponse> listNotifications(
        @RequestParam String username,
        @RequestParam(required = false) String status,
        Pageable pageable
    ) {
        // Query notifications for user
        Page<Notification> notifications = status != null
            ? notificationRepository.findByRecipientUsernameAndStatus(username, status, pageable)
            : notificationRepository.findByRecipientUsername(username, pageable);

        // Enrich with event/user details via HTTP clients (if needed)
        return notifications.map(this::toResponse);
    }

    @GetMapping("/count")
    public NotificationCountResponse getUnreadCount(
        @RequestParam String username,
        @RequestParam(defaultValue = "unread") String status
    ) {
        long count = notificationRepository.countByRecipientUsernameAndStatus(username, status);
        return NotificationCountResponse.builder().count(count).build();
    }

    @PutMapping("/{id}/read")
    public void markAsRead(@PathVariable UUID id) {
        Notification notification = notificationRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Notification not found"));

        notification.setStatus("READ");
        notification.setReadAt(Instant.now());
        notificationRepository.save(notification);
    }
}

Integration with Task System

Task System (Story 5.5) and Notification System (this story) are complementary:

Aspect Task System (event_tasks) Notification System (notifications)
Purpose Work items with due dates Inform users of events/changes
Examples “Send newsletter”, “Book venue” “Task assigned to you”, “Deadline approaching”
Creation Workflow transitions, manual Domain events, scheduled jobs
Interaction Task dashboard, Kanban board Notification center, email
Storage Always create rows Hybrid (email: rows, in-app: queries)

Integration Example:

java
// When task assigned, create notification
@EventListener
public void onTaskAssigned(TaskAssignedEvent event) {
    notificationService.createNotification(
        recipient: event.getAssignedUsername(),
        type: "TASK_ASSIGNED",
        title: "New task: " + event.getTaskName(),
        metadata: {taskId: event.getTaskId()}
    );
}

// When deadline approaching, create notification
@Scheduled(cron = "0 0 9 * * *")  // Daily at 9 AM
public void checkDeadlines() {
    List<EventTask> upcomingTasks = taskRepository.findDueSoon(3);  // Due in 3 days

    for (EventTask task : upcomingTasks) {
        notificationService.createNotification(
            recipient: task.getAssignedOrganizerUsername(),
            type: "DEADLINE_WARNING",
            title: "Task due soon: " + task.getTaskName(),
            priority: "HIGH"
        );
    }
}

Performance Requirements

  • Notification list: <200ms (P95) - Single SQL query with index
  • Mark as read: <100ms (P95) - Simple UPDATE statement
  • Preferences update: <150ms (P95) - Existing endpoint in User Service
  • Notification count: <50ms (P95) - Indexed COUNT query

Definition of Done

  • V25 migration creates notifications table in public
  • User preference integration (existing user_profiles

Change Log

Date Version Description Author
2025-12-25 3.0 MAJOR REWRITE: Aligned with actual BATbern architecture (single public schema, no schema-per-service). Changed from distributed storage to single table pattern following Task System (Story 5.5). Added ADR-003 compliance, hybrid storage strategy, and removed API Gateway aggregation. Winston (Architect)
2025-10-12 2.0 Clarified as cross-cutting concern, not separate microservice; documented shared module pattern Winston (Architect)
2024-10-04 1.0 Initial draft for notifications API consolidation (Phase 3) Winston (Architect)