Story 1.15a.10: Notifications API Consolidation
Linear Issue: [BAT-7](https://linear.app/batbern/issue/BAT-7/notifications-api-consolidation)
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:
notificationstable in sharedpublicschema - 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
publicschema) - ✅ 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
publicschema - 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
notificationstable) - 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
Related Functional Requirements
- 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
notificationstable inpublicschema - 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):
-- ✅ 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:
- ❌ BATbern has no schema-per-service (only
publicschema) - ❌ Over-engineered for shared-database architecture
- ❌ Inconsistent with Task System pattern (Story 5.5)
- ❌ Slower (5 HTTP calls vs. 1 SQL query)
- ❌ More complex to maintain
Current Approach (ACCEPTED):
- Single
notificationstable inpublicschema - 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
- List Notifications:
GET /api/v1/notifications?username={}&status=unread|read|all&page={} - Mark as Read: PUT
/api/v1/notifications/{id}/reador batch - Notification Preferences: GET/PUT
/api/v1/users/me/preferences(existing endpoint in Company-User Service) - Delivery History:
GET /api/v1/notifications/history?username={}&channel=email|sms - Delete Notifications: DELETE
/api/v1/notifications/{id}or batch delete - 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:
-- 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):
// 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):
// 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:
@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:
@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:
// 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
notificationstable inpublic - User preference integration (existing
user_profiles
Related Documentation
- Pattern Reference: Story 5.5 - Task System (same pattern)
- Architecture: 06d-notification-system.md
- ADR-003: ADR-003-meaningful-identifiers-public-apis.md
- Database Architecture: 03-data-architecture.md
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) |