Epic 6: Automated Speaker Onboarding - Architecture Plan
Created: 2026-01-22
Created: 2026-01-22 Author: Winston (Architect Agent) Status: DRAFT - Pending Review Key Decision: Simplified 6-state workflow model (reduced from 11 states)
Executive Summary
This document outlines the architecture for refactoring speaker onboarding to an automated, self-service workflow. The goal is to reduce organizer workload by ~40% while enabling speakers to independently:
- Respond to event invitations (via magic link - no login required)
- Provide/update their speaker profile
- Submit presentation title and abstract (required, triggers quality review)
- Upload presentation files - PPTX, PDF (optional, can be added anytime)
Simplified Workflow Model
6 Core States: IDENTIFIED β INVITED β ACCEPTED β CONFIRMED
β DECLINED β WITHDREW
Content tracked separately: PENDING β SUBMITTED β APPROVED (with REVISION_NEEDED loop)
This simplified model replaces the previous 11-state machine, providing:
- Clearer mental model for organizers and speakers
- Decoupled content workflow allowing multiple revision cycles
- Easier validation of state transitions
Current State Analysis
What Exists Today
| Component | Status | Location |
|---|---|---|
| Speaker Entity | β Complete | event-management-service/domain/Speaker.java |
| Speaker CRUD API | β Complete | GET/POST/PATCH/DELETE /api/v1/speakers/{username} |
| Speaker Pool (Event-specific) | β Complete | SpeakerPool.java - brainstorming phase |
| Speaker Workflow States | β Complete | SpeakerWorkflowState enum (shared-kernel) |
| File Upload Infrastructure | β Complete | ADR-002 presigned URL pattern |
| Session Materials Upload | β Complete | Story 5.9 - SessionMaterialsController |
| Email Service | β Complete | shared-kernel/EmailService.java (AWS SES) |
| Domain Events | β Complete | SpeakerCreatedEvent, SpeakerInvitedEvent |
Whatβs Missing (Gap Analysis)
| Component | Gap | Epic 6 Story |
|---|---|---|
| Automated Invitations | Manual contact only | Story 6.1 |
| Speaker Response Portal | No self-service | Story 6.2 |
| Material Self-Submission | Organizer uploads only | Story 6.3 |
| Speaker Dashboard | No speaker view | Story 6.4 |
| Deadline Reminders | Manual follow-up | Story 6.5 |
| Magic Link Auth | No passwordless auth | Story 6.1/6.4 |
Target Architecture
Simplified Speaker Workflow (6 States)
Design Decision: Use 6 core workflow states for speaker lifecycle, track content progress separately with timestamps/status fields. This simplifies the state machine while maintaining full functionality.
State Machine
ββββββββββββββββ
β IDENTIFIED β Speaker added to pool
ββββββββ¬ββββββββ
β Send invitation (auto/manual)
βΌ
ββββββββββββββββ
β INVITED β Awaiting response
ββββββββ¬ββββββββ
β
ββββββββ΄βββββββ
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β ACCEPTED β β DECLINED β (terminal)
ββββββββ¬ββββββββ ββββββββββββββββ
β
βββββββββββββββββββ
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β CONFIRMED β β WITHDREW β (terminal)
ββββββββββββββββ ββββββββββββββββ
(terminal)
State Definitions
| # | State | Description | Triggered By | Next States |
|---|---|---|---|---|
| 1 | IDENTIFIED |
Speaker added to pool, not yet contacted | Organizer adds to brainstorm list | β INVITED |
| 2 | INVITED |
Invitation email sent with magic link | Automated email or manual outreach | β ACCEPTED, DECLINED |
| 3 | ACCEPTED |
Speaker confirmed participation | Speaker clicks βAcceptβ in portal | β CONFIRMED, WITHDREW |
| 4 | DECLINED |
Speaker rejected invitation (terminal) | Speaker clicks βDeclineβ in portal | β |
| 5 | CONFIRMED |
Speaker in final published agenda (terminal) | Event workflow reaches βPublishedβ | β |
| 6 | WITHDREW |
Speaker backed out after accepting (terminal) | Speaker or organizer cancels | β |
Handling TENTATIVE Responses
Design Decision: TENTATIVE is handled as a flag on INVITED state, not a separate state.
When a speaker responds βMaybe/Tentativeβ (backend-only as of 2026-02-11):
- Workflow state remains
INVITED is_tentative = TRUEtentative_reasonstores their constraints (e.g., βNeed to check travel datesβ)- Organizer can follow up and speaker can later Accept or Decline
INVITED (is_tentative=false) βββΆ Speaker clicks "Maybe" βββΆ INVITED (is_tentative=true)
β
ββββββββββββ΄βββββββββββ
βΌ βΌ
ACCEPTED DECLINED
This keeps the workflow at 6 states while supporting the tentative use case. (Note: UI only shows Accept/Decline as of 2026-02-11)
Session Assignment
Design Decision: Session is assigned at invite time or later by organizer.
| Scenario | Session Assignment |
|---|---|
| Invite with specific session | session_id set at invite |
| Invite without session (general pool) | session_id NULL, assigned later |
| Speaker submits content | Requires session_id (organizer must assign first) |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SESSION ASSIGNMENT FLOW β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Option A: Invite with session β
β βββββββββββββββββββββββββββββββ β
β Organizer selects session β Invite sent with sessionId β
β Speaker accepts β Can immediately submit content β
β β
β Option B: Invite to general pool β
β βββββββββββββββββββββββββββββββββ β
β Organizer invites without session β sessionId = NULL β
β Speaker accepts β Organizer assigns session later β
β Session assigned β Speaker can now submit content β
β β
β Content submission blocked until session_id is set β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Content Progress (Tracked Separately)
Content submission is decoupled from workflow state to allow multiple submission/revision cycles:
| Field | Type | Description |
|---|---|---|
content_status |
ENUM | PENDING, SUBMITTED, APPROVED, REVISION_NEEDED |
content_submitted_at |
TIMESTAMP | When speaker submitted content |
content_approved_at |
TIMESTAMP | When organizer approved content |
content_approved_by |
VARCHAR | Username of approving organizer |
is_overflow |
BOOLEAN | Speaker accepted but event is full |
is_tentative |
BOOLEAN | Speaker responded βmaybeβ (still in INVITED state) |
tentative_reason |
TEXT | Why speaker is tentative (constraints, etc.) |
response_deadline |
TIMESTAMP | Deadline for speaker to respond |
content_deadline |
TIMESTAMP | Deadline for content submission |
High-Level Flow
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SPEAKER ONBOARDING WORKFLOW β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ βββββββββββββ β
β β IDENTIFIED βββββΆβ INVITED βββββΆβ ACCEPTED βββββΆβ CONFIRMED β β
β β (Pool) β β (Email) β β (Portal) β β (Final) β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ βββββββββββββ β
β β β β β β
β βΌ βΌ βΌ βΌ β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ βββββββββββββ β
β β SpeakerPool β β AWS SES β β Portal UI β β Profile β β
β β (Organizer) β β Magic Link β β Response β β Content β β
β ββββββββββββββββ ββββββββββββββββ β Form β β Upload β β
β ββββββββββββββββ βββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββ
β CONTENT WORKFLOW β
β (Parallel to State) β
ββββββββββββββββββββββββ€
β β
β ββββββββββββββββββ β
β β Profile Update β β
β β - Bio β β
β β - Photo β β
β β - Expertise β β
β ββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββ β
β β Session Info β β
β β - Title β β
β β - Abstract β β
β ββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββ β
β β File Upload β β
β β - PPTX/PDF β β
β β - Handouts β β
β ββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββ β
β β Quality Review β β
β β (Organizer) β β
β ββββββββββββββββββ β
β β
β Status: PENDING β β
β SUBMITTED β APPROVEDβ
β β
ββββββββββββββββββββββββ
Architecture Components
1. Magic Link Authentication System
Purpose: Allow speakers to access the portal without creating an account.
Technical Design:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MAGIC LINK AUTHENTICATION β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Email Link Structure: β
β https://portal.batbern.ch/speaker/respond/{token} β
β β
β Token Properties: β
β - JWT with speaker_invitation claim β
β - Contains: speakerId, eventCode, sessionId, action β
β - Expiry: 30 days (configurable) β
β - Single-use for state-changing actions (Accept/Decline) β
β - Reusable for read-only actions (View dashboard) β
β β
β Database Schema: β
β CREATE TABLE speaker_invitation_tokens ( β
β id UUID PRIMARY KEY, β
β speaker_id UUID NOT NULL, β
β event_id UUID NOT NULL, β
β session_id UUID, β
β token_hash VARCHAR(255) UNIQUE NOT NULL, -- SHA-256 β
β action VARCHAR(50) NOT NULL, -- RESPOND, SUBMIT, VIEW β
β expires_at TIMESTAMP NOT NULL, β
β used_at TIMESTAMP, β
β created_at TIMESTAMP DEFAULT NOW() β
β ); β
β β
β Security: β
β - Token stored as hash (SHA-256) β
β - Rate limiting: 5 requests/minute per token β
β - IP logging for audit trail β
β - HTTPS only β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Implementation Location: services/event-management-service/src/main/java/ch/batbern/events/auth/
2. Automated Invitation System
Purpose: Send automated email invitations with unique response links.
Technical Design:
// Email Template Structure
public record SpeakerInvitationEmail(
String speakerName,
String speakerEmail,
String eventName,
String eventDate,
String sessionTitle,
String sessionDescription,
String responseDeadline,
String acceptLink, // Magic link for acceptance
String declineLink, // Magic link for decline
String organizerName,
String organizerEmail
) {}
// Email Service Extension
@Service
public class SpeakerInvitationService {
@Async
public void sendInvitation(SpeakerInvitation invitation) {
// 1. Generate magic links (accept/decline)
String acceptToken = generateMagicToken(invitation, Action.ACCEPT);
String declineToken = generateMagicToken(invitation, Action.DECLINE);
// 2. Build email from template
String emailBody = templateEngine.process("speaker-invitation", Map.of(
"acceptLink", buildPortalUrl(acceptToken),
"declineLink", buildPortalUrl(declineToken),
// ... other variables
));
// 3. Send via AWS SES
emailService.sendHtmlEmail(
invitation.speakerEmail(),
"BATbern Invitation: " + invitation.eventName(),
emailBody
);
// 4. Emit domain event
eventPublisher.publish(new SpeakerInvitationSentEvent(...));
// 5. Update speaker status
updateSpeakerStatus(invitation.speakerId(), CONTACTED);
}
}
Email Templates Location: services/event-management-service/src/main/resources/templates/email/
3. Speaker Self-Service Portal
Purpose: Web interface for speakers to respond, update profile, and submit materials.
Frontend Architecture:
web-frontend/src/pages/speaker-portal/
βββ SpeakerPortalLayout.tsx # Layout with minimal nav (no full auth)
βββ InvitationResponsePage.tsx # Accept/Decline (Tentative removed from UI 2026-02-11)
βββ ProfileUpdatePage.tsx # Bio, photo, expertise
βββ ContentSubmissionPage.tsx # Title, abstract, files
βββ SpeakerDashboardPage.tsx # View upcoming/past events
βββ components/
βββ ResponseForm.tsx # Availability, constraints
βββ ProfileForm.tsx # Speaker profile editor
βββ ContentWizard.tsx # Multi-step submission
βββ MaterialUpload.tsx # Reuse FileUpload component
API Endpoints (Speaker-facing):
# Speaker Portal API (public with magic link auth)
# Respond to invitation
POST /api/v1/speaker-portal/respond
Body: {
token: string,
response: "ACCEPT" | "DECLINE" | "TENTATIVE",
reason?: string, # Required if DECLINE or TENTATIVE
constraints?: string # Optional: travel, schedule constraints
}
Response: 200 OK {
speakerName: string,
eventName: string,
eventDate: string,
sessionTitle?: string, # If session assigned
nextSteps: string[], # ["Complete your profile", "Submit title and abstract"]
contentDeadline?: string # If accepted
}
Notes:
- ACCEPT β workflow_state = ACCEPTED
- DECLINE β workflow_state = DECLINED
- TENTATIVE β workflow_state stays INVITED, is_tentative = true
# Get speaker profile (combined User + Speaker data)
GET /api/v1/speaker-portal/profile?token={token}
Response: 200 OK {
username: string,
email: string,
firstName: string,
lastName: string,
bio?: string,
profilePictureUrl?: string,
expertiseAreas?: string[],
speakingTopics?: string[],
linkedInUrl?: string,
languages?: string[]
}
# Update speaker profile (syncs to User entity per ADR-004)
PATCH /api/v1/speaker-portal/profile
Body: {
token: string,
bio?: string, # Updates User.bio in Company Service
firstName?: string, # Updates User.firstName
lastName?: string, # Updates User.lastName
expertiseAreas?: string[], # Updates Speaker.expertiseAreas
speakingTopics?: string[], # Updates Speaker.speakingTopics
linkedInUrl?: string, # Updates Speaker.linkedInUrl
languages?: string[] # Updates Speaker.languages
}
Response: 200 OK { updated profile }
Notes:
- Profile changes sync to User entity (single source of truth)
- Changes apply to ALL events (not event-specific)
# Submit content (title + abstract)
POST /api/v1/speaker-portal/content
Body: {
token: string,
title: string, # Max 200 chars
abstract: string # Max 1000 chars
}
Response: 201 Created {
submissionId: string,
version: number,
status: "SUBMITTED",
sessionTitle: string
}
Error 400: "Session not assigned - contact organizer"
Notes:
- Requires session_id on SpeakerPool (organizer must assign first)
- Sets content_status = SUBMITTED
- Creates ContentSubmission record with version
# Get presigned URL for file upload
POST /api/v1/speaker-portal/materials/presigned-url
Body: {
token: string,
fileName: string,
contentType: string # application/pdf, application/vnd.ms-powerpoint, etc.
}
Response: 200 OK {
uploadUrl: string, # S3 presigned PUT URL
uploadId: string,
expiresIn: number # seconds until URL expires (900)
}
# Confirm file upload
POST /api/v1/speaker-portal/materials/confirm
Body: {
token: string,
uploadId: string,
materialType: "PRESENTATION" | "DOCUMENT" | "VIDEO" | "OTHER"
}
Response: 200 OK {
materialId: string,
fileName: string,
cloudfrontUrl: string
}
4. Content Submission Workflow
Purpose: Collect presentation title, abstract, and files with quality review.
Design Decision: Content progress is tracked separately from workflow state via content_status field. This allows:
- Multiple submission/revision cycles without changing workflow state
- Speaker remains in
ACCEPTEDstate while iterating on content - Cleaner separation of concerns
Content Status Flow:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CONTENT STATUS (Independent of Workflow) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Speaker in ACCEPTED state can submit/revise content: β
β β
β βββββββββββ βββββββββββββ ββββββββββββ β
β β PENDING βββββΆβ SUBMITTED βββββΆβ APPROVED β β
β βββββββββββ βββββββ¬ββββββ ββββββββββββ β
β β β
β β β¬
βββ QUALITY REVIEW HAPPENS HERE β
β β (Organizer reviews content) β
β βΌ β
β ββββββββββββββββββ β
β βREVISION_NEEDED ββββΆ Speaker fixes βββΆ SUBMITTEDβ
β ββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Content Status Definitions:
| Status | Description | Actor | Action |
|---|---|---|---|
PENDING |
Speaker accepted but hasnβt submitted content yet | β | Speaker submits |
SUBMITTED |
Content awaiting quality review | Speaker | Organizer reviews |
APPROVED |
Content passed quality review | Organizer | Done |
REVISION_NEEDED |
Quality review failed, changes requested | Organizer | Speaker revises β SUBMITTED |
Quality Review Process (performed when status = SUBMITTED):
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β QUALITY REVIEW CHECKLIST β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Organizer reviews content when content_status = SUBMITTED: β
β β
β REQUIRED: β
β β‘ Title: Clear, descriptive, not promotional β
β β‘ Abstract: β€1000 chars, includes lessons learned β
β β‘ Abstract: Not product promotion or sales pitch β
β β
β OPTIONAL (can be uploaded later): β
β β Presentation file (PPTX/PDF) β
β β Handouts β
β β
β Actions: β
β ββ Title + Abstract OK β Set status to APPROVED β
β ββ Issues found β Set status to REVISION_NEEDED β
β + Add reviewer_feedback β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
File Upload: Presentation files can be uploaded at any time after acceptance, independent of the quality review. Files are tracked via SessionMaterial (Story 5.9) and are not required for content approval.
Database Schema Extension:
Migration Sequence
| Migration | Purpose |
|---|---|
| V43 | Add speaker content tracking fields to speaker_pool |
| V44 | Create speaker_invitation_tokens table |
| V45 | Migrate existing workflow states (CONTACTEDβINVITED, remove obsolete states) |
-- ============================================
-- Flyway Migration: V43__Add_speaker_content_tracking.sql
-- ============================================
-- Extend speaker_pool with simplified workflow + content tracking
-- NOTE: workflow_state column may already exist - use IF NOT EXISTS pattern
-- New columns for 6-state workflow
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS content_status VARCHAR(20) DEFAULT 'PENDING';
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS content_submitted_at TIMESTAMP;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS content_approved_at TIMESTAMP;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS content_approved_by VARCHAR(255);
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS is_overflow BOOLEAN DEFAULT FALSE;
-- Timestamps for state transitions
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS invited_at TIMESTAMP;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMP;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS declined_at TIMESTAMP;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS withdrew_at TIMESTAMP;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMP;
-- Deadline tracking
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS response_deadline TIMESTAMP;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS content_deadline TIMESTAMP;
-- Tentative response tracking (speaker interested but not committed)
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS is_tentative BOOLEAN DEFAULT FALSE;
ALTER TABLE speaker_pool ADD COLUMN IF NOT EXISTS tentative_reason TEXT;
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_speaker_pool_content_status ON speaker_pool(content_status);
CREATE INDEX IF NOT EXISTS idx_speaker_pool_overflow ON speaker_pool(is_overflow) WHERE is_overflow = TRUE;
CREATE INDEX IF NOT EXISTS idx_speaker_pool_response_deadline ON speaker_pool(response_deadline)
WHERE response_deadline IS NOT NULL;
-- Content submissions (supports versioning for revision cycles)
CREATE TABLE IF NOT EXISTS speaker_content_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
speaker_pool_id UUID NOT NULL REFERENCES speaker_pool(id) ON DELETE CASCADE,
session_id UUID REFERENCES sessions(id),
title VARCHAR(200) NOT NULL,
abstract TEXT NOT NULL, -- Max 1000 chars enforced in app
abstract_char_count INTEGER NOT NULL,
submission_version INTEGER DEFAULT 1,
reviewer_feedback TEXT,
submitted_at TIMESTAMP DEFAULT NOW(),
reviewed_at TIMESTAMP,
reviewed_by VARCHAR(255),
CONSTRAINT chk_abstract_length CHECK (abstract_char_count <= 1000)
);
CREATE INDEX IF NOT EXISTS idx_content_submissions_speaker ON speaker_content_submissions(speaker_pool_id);
CREATE INDEX IF NOT EXISTS idx_content_submissions_session ON speaker_content_submissions(session_id);
-- ============================================
-- Flyway Migration: V44__Create_speaker_invitation_tokens.sql
-- ============================================
CREATE TABLE speaker_invitation_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
speaker_pool_id UUID NOT NULL REFERENCES speaker_pool(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash
action VARCHAR(20) NOT NULL, -- RESPOND, SUBMIT, VIEW
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_token_action CHECK (action IN ('RESPOND', 'SUBMIT', 'VIEW'))
);
CREATE INDEX idx_invitation_tokens_hash ON speaker_invitation_tokens(token_hash);
CREATE INDEX idx_invitation_tokens_speaker ON speaker_invitation_tokens(speaker_pool_id);
CREATE INDEX idx_invitation_tokens_expires ON speaker_invitation_tokens(expires_at)
WHERE used_at IS NULL;
-- ============================================
-- Flyway Migration: V45__Migrate_workflow_states.sql
-- ============================================
-- Migrate existing workflow states to simplified 6-state model
-- Old state β New state mapping:
-- CONTACTED β INVITED
-- READY β INVITED (with is_tentative = true)
-- CONTENT_SUBMITTED β ACCEPTED (content_status = SUBMITTED)
-- QUALITY_REVIEWED β ACCEPTED (content_status = APPROVED)
-- SLOT_ASSIGNED β ACCEPTED (content_status = APPROVED)
-- OVERFLOW β ACCEPTED (is_overflow = true)
UPDATE speaker_pool SET status = 'INVITED' WHERE status = 'CONTACTED';
UPDATE speaker_pool SET status = 'INVITED', is_tentative = TRUE WHERE status = 'READY';
UPDATE speaker_pool SET status = 'ACCEPTED', content_status = 'SUBMITTED' WHERE status = 'CONTENT_SUBMITTED';
UPDATE speaker_pool SET status = 'ACCEPTED', content_status = 'APPROVED' WHERE status = 'QUALITY_REVIEWED';
UPDATE speaker_pool SET status = 'ACCEPTED', content_status = 'APPROVED' WHERE status = 'SLOT_ASSIGNED';
UPDATE speaker_pool SET status = 'ACCEPTED', is_overflow = TRUE WHERE status = 'OVERFLOW';
-- Add constraint to enforce new valid states only
ALTER TABLE speaker_pool ADD CONSTRAINT chk_workflow_state
CHECK (status IN ('IDENTIFIED', 'INVITED', 'ACCEPTED', 'DECLINED', 'CONFIRMED', 'WITHDREW'));
Workflow + Content Status Combinations:
| Workflow State | Content Status | Meaning | Next Action |
|---|---|---|---|
| IDENTIFIED | β | Not yet invited | Organizer sends invitation |
| INVITED | β | Awaiting response | Speaker responds |
| ACCEPTED | PENDING | Accepted, no content yet | Speaker submits content |
| ACCEPTED | SUBMITTED | Quality review queue | Organizer reviews |
| ACCEPTED | REVISION_NEEDED | Changes requested | Speaker revises content |
| ACCEPTED | APPROVED | Content approved, ready | Wait for event finalization |
| CONFIRMED | APPROVED | In final agenda | Done |
Note: Quality review only happens when content_status = SUBMITTED. This is when the organizer:
- Reviews title and abstract (required for submission)
- Either approves (β
APPROVED) or requests revisions (βREVISION_NEEDEDwith feedback)
File upload is separate: Presentation files (PPTX/PDF) can be uploaded anytime after acceptance and are tracked independently via SessionMaterial. Not required for quality review approval.
5. Automated Deadline Reminders
Purpose: Send configurable reminders before deadlines.
Technical Design:
// Reminder Configuration
public record ReminderRule(
EventType eventType, // Conference, Workshop, etc.
ReminderTier tier, // TIER_1 (friendly), TIER_2 (urgent), TIER_3 (escalate)
int daysBeforeDeadline, // e.g., 30, 14, 3
boolean autoEscalate, // Escalate to organizer after TIER_3
String emailTemplate // Template name
) {}
// Scheduler Job
@Scheduled(cron = "0 0 9 * * *") // Daily at 9 AM
public void processDeadlineReminders() {
// 1. Find speakers with upcoming deadlines
List<SpeakerDeadline> deadlines = findUpcomingDeadlines();
// 2. Match against reminder rules
for (SpeakerDeadline deadline : deadlines) {
ReminderRule rule = findApplicableRule(deadline);
if (rule != null && !alreadySent(deadline, rule)) {
sendReminder(deadline, rule);
logReminder(deadline, rule);
}
}
}
Reminder Templates:
reminder-tier1.html- Friendly reminder (30 days before)reminder-tier2.html- Urgent reminder (14 days before)reminder-tier3.html- Final reminder with escalation warning (3 days before)
Data Architecture
Entity Relationships (Simplified Model)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SPEAKER DOMAIN MODEL (6-State Workflow) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ β
β β User ββββββββββ Speaker β β SpeakerPool β β
β β (Company Svc)β via β (Global Profile) β β (Event-specific) β β
β ββββββββββββββββ usernameββββββββββββββββββββ ββββββββββ¬ββββββββββ β
β β β β β
β βΌ βΌ β β
β ββββββββββββββββ ββββββββββββββββββββ β β
β β Profile Data β β Speaker-specific β β β
β β - name β β - expertise[] β β β
β β - email β β - topics[] β β β
β β - bio β β - languages[] β β β
β β - photo β β - linkedin β β β
β ββββββββββββββββ ββββββββββββββββββββ β β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β β SpeakerPool (Event-Speaker Association) β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β workflow_state: IDENTIFIED|INVITED|ACCEPTED|DECLINED|CONFIRMED|WITHDREWβ
β β β content_status: PENDING|SUBMITTED|APPROVED|REVISION_NEEDED β β
β β β is_overflow: boolean, is_tentative: boolean, tentative_reason: textβ β
β β β response_deadline, content_deadline β β
β β β invited_at, accepted_at, declined_at, withdrew_at, confirmed_at β β
β β β content_submitted_at, content_approved_at, content_approved_by β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β β β
β β βΌ βΌ β
β β ββββββββββββββββββββ ββββββββββββββββββββ β
β β β ContentSubmissionβ β SessionMaterial β β
β β β - title β β - file (S3) β β
β β β - abstract β β - type (PPTX, β β
β β β - version β β PDF, VIDEO) β β
β β β - reviewer_ β β - cloudfront_urlβ β
β β β feedback β ββββββββββββββββββββ β
β β ββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Magic Link Tokens β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β speaker_invitation_tokens β β β
β β β - token_hash (SHA-256) β β β
β β β - speaker_pool_id β β β
β β β - action: RESPOND|SUBMIT|VIEW β β β
β β β - expires_at, used_at β β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key Design Decisions
- 6 Workflow States - Simplified from 11 states for clearer mental model
- Content Status Decoupled - Allows multiple revision cycles without state changes
- Timestamps per State - Track when each transition occurred
- Overflow as Flag - Boolean instead of separate state, applied to ACCEPTED speakers
- Versioned Submissions - Support multiple content revisions with reviewer feedback
Speaker-User Assignment
How is a Speaker linked to a User?
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SPEAKER-USER RELATIONSHIP β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Speaker.username ββreferencesβββΆ User.username β
β (Event Service) (Company Service) β
β β
β - No database FK (cross-service, ADR-004) β
β - User data fetched via HTTP enrichment β
β - username is the meaningful identifier (ADR-003) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Speaker Assignment Flow (for Self-Service Portal):
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INVITE SPEAKER (Auto-Create User) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β 1. Organizer enters speaker email (+ optional name) β
β β
β 2. System checks: Does User with this email exist? β
β ββ YES: Link SpeakerPool to existing username β
β ββ NO: Create User with role=SPEAKER β
β ββ Generate username from email (john.doe@x.com β
β β john.doe or john.doe.2 if taken) β
β β
β 3. Create SpeakerPool entry: β
β - event_id: current event β
β - username: linked user β
β - workflow_state: IDENTIFIED β
β β
β 4. Send invitation email with magic link β
β - workflow_state β INVITED β
β β
β 5. Speaker clicks link, can: β
β - Complete profile (updates User entity) β
β - Accept/Decline (updates SpeakerPool) β
β - Submit content (creates ContentSubmission) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
User Creation on Invite:
// When organizer invites a speaker by email
public SpeakerPool inviteSpeaker(String eventId, String email, String name) {
// 1. Find or create User
User user = userService.findByEmail(email)
.orElseGet(() -> userService.createSpeakerUser(email, name));
// 2. Create SpeakerPool entry
SpeakerPool speakerPool = SpeakerPool.builder()
.eventId(eventId)
.username(user.getUsername()) // Link via username
.workflowState(IDENTIFIED)
.build();
return speakerPoolRepository.save(speakerPool);
}
// User creation for new speakers
public User createSpeakerUser(String email, String name) {
String username = generateUsername(email); // john.doe@x.com β john.doe
return User.builder()
.username(username)
.email(email)
.firstName(extractFirstName(name))
.lastName(extractLastName(name))
.role(Role.SPEAKER)
.status(UserStatus.PENDING) // Activated on first portal access
.build();
}
Benefits of Auto-Create:
- Organizer just needs email to invite anyone
- Speaker completes profile themselves via portal
- No manual user creation step
- Consistent model (all speakers have User accounts)
API Design (ADR-003 Compliance)
All APIs use meaningful identifiers:
- Speaker:
username(not UUID) - Event:
eventCode(not UUID) - Session:
sessionSlug(not UUID)
Security Considerations
Magic Link Security
| Concern | Mitigation |
|---|---|
| Token theft | Short expiry (30 days), single-use for state changes |
| Replay attacks | Token invalidated after sensitive actions |
| Enumeration | Rate limiting (5 req/min), no error differentiation |
| MITM | HTTPS-only, HSTS enabled |
| Phishing | Clear sender identity, consistent branding |
RBAC for Speaker Portal
// Speaker can only access their own data
@PreAuthorize("@speakerPortalSecurity.validateToken(#token)")
public ResponseEntity<?> getProfile(@RequestParam String token) {
// Token validation extracts speakerId
// Only returns data for that speaker
}
// Organizer override for assistance
@PreAuthorize("hasRole('ORGANIZER')")
public ResponseEntity<?> getAnyProfile(@PathVariable String username) {
// Full access for organizers
}
Implementation Phases
Phase 1: Foundation (Week 1-2)
Story 6.1a: Magic Link Infrastructure
- Create
speaker_invitation_tokens - Implement
MagicLinkService
Story 6.1b: Email Template System
- Extend
EmailService
Phase 2: Response Portal (Week 3-4)
Story 6.2a: Invitation Response
-
InvitationResponsePage.tsx- Accept/Decline UI (Tentative removed from UI 2026-02-11) -
POST /speaker-portal/respond
Story 6.2b: Profile Update
-
ProfileUpdatePage.tsx
Phase 3: Content Submission (Week 5-6)
Story 6.3a: Title & Abstract
-
ContentSubmissionPage.tsx
Story 6.3b: File Upload
- Reuse
MaterialUpload - Speaker-specific S3 path:
Phase 4: Dashboard & Reminders (Week 7-8)
Story 6.4: Speaker Dashboard
-
SpeakerDashboardPage.tsx
Story 6.5: Automated Reminders
-
ReminderSchedulerJob
Integration Points
Existing Services
| Service | Integration | Pattern |
|---|---|---|
| Company User Management | User data enrichment | HTTP client (ADR-004) |
| Event Management | Session/Event data | Direct (same service) |
| AWS SES | Email sending | Shared-kernel EmailService |
| AWS S3 | File storage | Presigned URLs (ADR-002) |
| CloudFront | File delivery | CDN distribution |
Domain Events
// Domain Events for Epic 6 (Simplified 6-State Model)
// Workflow State Changes
public record SpeakerWorkflowStateChangedEvent(
String speakerPoolId,
String username,
String eventCode,
String sessionId,
WorkflowState previousState, // IDENTIFIED, INVITED, ACCEPTED, DECLINED, CONFIRMED, WITHDREW
WorkflowState newState,
String changedBy,
Instant changedAt
) implements DomainEvent {}
// Content Status Changes (separate from workflow)
public record SpeakerContentStatusChangedEvent(
String speakerPoolId,
String username,
String eventCode,
ContentStatus previousStatus, // PENDING, SUBMITTED, APPROVED, REVISION_NEEDED
ContentStatus newStatus,
String changedBy,
String feedback, // Optional: reviewer feedback for REVISION_NEEDED
Instant changedAt
) implements DomainEvent {}
// Specific Events for Automation
public record SpeakerInvitationSentEvent(
String speakerPoolId,
String username,
String email,
String eventCode,
String sessionId,
String magicLinkToken,
Instant sentAt,
Instant expiresAt
) implements DomainEvent {}
public record SpeakerResponseReceivedEvent(
String speakerPoolId,
String username,
String eventCode,
WorkflowState response, // ACCEPTED or DECLINED
String declineReason, // Optional: if DECLINED
Instant respondedAt
) implements DomainEvent {}
public record SpeakerContentSubmittedEvent(
String speakerPoolId,
String username,
String eventCode,
String sessionId,
String submissionId,
int submissionVersion,
Instant submittedAt
) implements DomainEvent {}
public record SpeakerReminderSentEvent(
String speakerPoolId,
String username,
String eventCode,
ReminderTier tier, // TIER_1, TIER_2, TIER_3
ReminderType type, // RESPONSE_DEADLINE, CONTENT_DEADLINE
Instant sentAt
) implements DomainEvent {}
public record SpeakerOverflowDetectedEvent(
String eventCode,
long acceptedCount,
int maxSlots,
List<String> overflowSpeakers, // usernames marked as overflow
Instant detectedAt
) implements DomainEvent {}
Workflow State Enum (Simplified)
// Update shared-kernel: SpeakerWorkflowState.java
public enum SpeakerWorkflowState {
/**
* Speaker added to pool, not yet contacted.
*/
IDENTIFIED,
/**
* Invitation email sent with magic link.
* Speaker has been contacted and is awaiting response.
*/
INVITED,
/**
* Speaker confirmed participation.
* Can now submit content (profile, title, abstract, files).
*/
ACCEPTED,
/**
* Speaker rejected invitation.
* Terminal state - no further transitions.
*/
DECLINED,
/**
* Speaker locked in final published agenda.
* Terminal state - event is published.
*/
CONFIRMED,
/**
* Speaker backed out after accepting.
* Terminal state - need replacement speaker.
*/
WITHDREW
}
// Content status tracked separately
public enum ContentStatus {
PENDING, // No content submitted yet
SUBMITTED, // Content awaiting review
APPROVED, // Content approved by organizer
REVISION_NEEDED // Organizer requested changes
}
Testing Strategy
Test Categories
| Category | Coverage Target | Tools |
|---|---|---|
| Unit Tests | >85% | JUnit 5, Mockito |
| Integration Tests | >80% | Testcontainers (PostgreSQL) |
| E2E Tests | Critical paths | Playwright, Bruno |
| Security Tests | OWASP Top 10 | OWASP ZAP, manual review |
Key Test Scenarios
- Magic Link Flow: Generate β Send β Validate β Expire
- Response Flow: Receive email β Click link β Submit response β Notification sent
- Content Flow: Login β Edit profile β Submit content β Upload files β Review
- Reminder Flow: Configure rules β Scheduler runs β Email sent β Not duplicated
Success Metrics
| Metric | Target | Measurement |
|---|---|---|
| Self-service adoption | 70%+ | Speakers using portal vs manual |
| Response time | <3 days avg | Time from invitation to response |
| Material submission | <1 week avg | Time from acceptance to content submitted |
| Organizer workload reduction | 40% | Hours spent on speaker coordination |
Design Decisions (Resolved)
| # | Question | Decision | Rationale |
|---|---|---|---|
| 1 | Profile Sync | Sync to User entity | Single source of truth (ADR-004). Profile changes apply to all events. |
| 2 | TENTATIVE Response | Flag on INVITED state | Keeps 6 states. is_tentative=true + tentative_reason track βmaybeβ responses. |
| 3 | Session Assignment | At invite or later | Flexible: invite with session OR assign after acceptance. Content blocked until assigned. |
| 4 | Migration Strategy | V45 migrates old states | Maps 11 states to 6 states with content_status and is_overflow flags. |
Open Questions for Discussion
-
Magic Link Expiry: Should tokens expire after 30 days or 60 days? Whatβs the typical speaker response window?
- Current default: 30 days
-
Quality Review Automation: Should we implement AI-assisted abstract quality checking (length, promotional content detection)?
- Recommendation: Defer to future enhancement, start with manual review
-
Hybrid Mode: How should the system behave when some speakers use portal and others prefer email/phone communication?
- Decision: Organizer can manually transition IDENTIFIED β INVITED β ACCEPTED for speakers who respond offline
- Manual transitions allowed for organizers via existing status management UI
-
Undo Declined: If a speaker accidentally declines, can they re-accept?
- Decision: Organizer can transition DECLINED β INVITED to re-send invitation
- Speaker receives new magic link email
-
Overflow Handling: When a speaker is marked as overflow, should they be automatically promoted if another speaker withdraws?
- Decision: Send notification to organizer, let them decide manually
- Organizer sets
is_overflow=falseto promote speaker
Next Steps
- Review this plan with product owner and development team
- Prioritize stories based on MVP requirements
- Create detailed story specs following template in
/docs/templates/ - Set up feature branch
feature/epic-6-speaker-portal - Begin Phase 1 implementation with TDD approach
References
- Epic 6 PRD:
docs/prd/epic-6-speaker-portal-support.md - Story 6.0 (Foundation):
docs/stories/6.0.speaker-profile-foundation.md - Story 5.9 (Materials):
docs/stories/5.9-session-materials-upload.md - Speaker API Design:
docs/architecture/04-api-speaker-coordination.md - ADR-002 (File Uploads):
docs/architecture/ADR-002-generic-file-upload.md - ADR-003 (Meaningful IDs):
docs/architecture/ADR-003-meaningful-identifiers-public-apis.md - ADR-004 (User Fields):
docs/architecture/ADR-004-factor-user-fields-from-domain-entities.md