# FloDoc Brownfield Enhancement PRD **3-Portal Cohort Management System for Training Institutions** *Version: v2.0* *Date: 2026-01-10* *Status: Section 6 of 6 - In Progress* --- ## Table of Contents 1. [Intro Project Analysis and Context](#intro-project-analysis-and-context) 2. [Requirements](#requirements) 3. [User Interface Enhancement Goals](#user-interface-enhancement-goals) 4. [Technical Constraints and Integration](#technical-constraints-and-integration) 5. [Epic and Story Structure](#epic-and-story-structure) 6. [Epic Details](#epic-details) --- ## 1. Intro Project Analysis and Context ### 1.1 SCOPE ASSESSMENT **⚠️ SIGNIFICANT ENHANCEMENT - System-Wide Impact** This PRD documents a **Major Feature Addition** that transforms the single-portal DocuSeal platform into a specialized 3-portal cohort management system for South African private training institutions. **Enhancement Complexity Analysis:** - **Type**: Major Feature Addition (3-Portal Cohort Management) - **Impact**: Significant Impact (substantial existing code changes required) - **Timeline**: Multiple development cycles - **Risk Level**: High (touches core DocuSeal workflows) **Why This Requires Full PRD Process:** This is NOT a simple feature addition. The enhancement requires: - New multi-tenant institution architecture - Complex 3-party signature workflows (TP → Students → Sponsor → TP Review) - Three separate portal interfaces with custom UI/UX - State management across multiple entities - Integration with existing DocuSeal form builder and signing infrastructure - Bulk operations and email management rules --- ### 1.2 EXISTING PROJECT OVERVIEW **Analysis Source**: IDE-based analysis + User requirements clarification **Current Project State**: FloDoc is built on **DocuSeal** - an open-source document filling and signing platform. The base system provides: - **Document Form Builder**: WYSIWYG PDF form field creation with 12 field types (Signature, Date, File, Checkbox, etc.) - **Multi-Submitter Workflows**: Support for multiple signers per document - **Authentication & User Management**: Devise-based authentication with 2FA support - **Email Automation**: SMTP-based automated email notifications - **File Storage**: Flexible storage options (local disk, AWS S3, Google Cloud Storage, Azure Cloud) - **PDF Processing**: HexaPDF for PDF generation, manipulation, and signature embedding - **API & Webhooks**: RESTful API with webhook support for integrations - **Mobile-Optimized UI**: Responsive interface supporting 7 UI languages and signing in 14 languages - **Role-Based Access**: User roles and permissions system (via Cancancan) - **Tech Stack**: Ruby on Rails 3.4.2, Vue.js 3, TailwindCSS 3.4.17, DaisyUI 3.9.4, Sidekiq for background jobs **Key Existing Architecture for FloDoc Integration:** - **Templates** = Document templates with form fields - **Submissions** = Document workflows with multiple signers - **Submitters** = Individual participants who sign documents - **Completed Documents** = Final signed PDFs --- ### 1.3 AVAILABLE DOCUMENTATION ANALYSIS **Available Documentation**: - ✅ API Documentation (Node.js, Ruby, Python, PHP, Java, Go, C#, TypeScript, JavaScript) - ✅ Webhook Documentation (Submission, Form, Template webhooks) - ✅ Embedding Documentation (React, Vue, Angular, JavaScript form builders and signing forms) - ✅ Architecture Documentation (docs/current-app-sitemap.md - comprehensive analysis) - ✅ Existing PRD (v1.0) - being replaced by this version - ⚠️ Coding Standards (not present - **requires documentation**) - ⚠️ Technical Debt Analysis (not present - **requires analysis**) **Recommendation**: This PRD will serve as the comprehensive planning document. Architecture analysis already completed in separate document. --- ### 1.4 ENHANCEMENT SCOPE DEFINITION **Enhancement Type**: ✅ **Major Feature Addition** (3-Portal Cohort Management System) **Enhancement Description**: Transform the single-portal DocuSeal platform into a specialized **3-portal cohort management system** for South African private training institutions (Training Providers). The system manages cohorts through a **3-party signature workflow**: TP → Students → Sponsor → TP Review. **Core Architecture**: - **Templates = Cohorts**: Each cohort is a DocuSeal template containing all documents and signatory mappings - **Submissions = Students**: Each student within a cohort is a submission with their own document workflow **Complete Workflow**: 1. **Training Provider (TP) Onboarding**: TP creates account with name, surname, email 2. **Cohort Creation** (5-step multi-form): - Step 1: Cohort name - Step 2: Program type (learnership/internship/candidacy) - Step 3: Student emails (manual entry or bulk upload) - Step 4: Sponsor email (required - single email for all cohort documents) - Step 5: Upload main SETA agreement + additional supporting docs + specify required student uploads (ID, Matric, Tertiary Qualifications) 3. **Document Mapping Phase**: TP maps signatories (Learner, Sponsor, TP) to document sections using DocuSeal's existing mapping with tweaks 4. **TP Signing Phase**: TP signs first student → system auto-fills/signs remaining students 5. **Student Enrollment**: Bulk invite emails sent → students complete assigned docs + upload required files 6. **Sponsor Review**: Single sponsor link (one email regardless of multiple assignments) → 3-panel portal (student list | document viewer | student info) → individual or bulk completion 7. **TP Review**: TP reviews all completed documents from students and sponsor → finalizes 3-party agreements 8. **Download**: Bulk ZIP with structure: Cohort_Name/Student_Name/All_Docs.pdf + Audit_Trail.pdf **Key System Behaviors**: - **Single Email Rule**: Sponsor receives ONE email per cohort, regardless of how many students they're assigned to - **TP Initiates Signing**: TP starts the signing workflow BEFORE students and sponsor - **Bulk Operations**: TP can fill once and replicate for all students **Impact Assessment**: ✅ **Significant Impact** (substantial existing code changes) **Rationale for Impact Level**: - **Single Institution Model**: One training institution manages multiple cohorts (NOT multi-tenant) - **Ad-hoc Access**: Students and sponsors access via email links without creating accounts - **New Domain Models**: Cohort, CohortEnrollment, Institution (single), Sponsor (ad-hoc) - **Complex Workflow State Management**: TP → Students → Sponsor → TP Review with state tracking - **Three Portal Interfaces**: Custom portals for TP (admin), Students, and Sponsor - **Integration with DocuSeal**: Leverages existing form builder and signing infrastructure - **Email Management Rules**: Single email per sponsor (no duplicates), bulk operations - **Dashboard & Analytics**: Real-time cohort status tracking --- ### 1.5 GOALS AND BACKGROUND CONTEXT **Goals**: - Enable private training institutions to digitally manage training program cohorts from creation to completion - Streamline multi-party document workflows (TP → Students → Sponsor → TP Review) - Provide role-based portals tailored to each participant's specific needs and permissions - Maintain 100% backward compatibility with core DocuSeal form builder and signing capabilities - Reduce document processing time from weeks to days through automated workflows - Provide real-time visibility into cohort and student submission status - Implement single-email rule for sponsors (no duplicate emails) - Enable bulk operations for TP and Sponsor to reduce repetitive work **Background Context**: South African private training institutions currently manage learnerships, internships, and candidacy programs through manual, paper-intensive processes. Each program requires collecting student documents (matric certificates, IDs, disability docs, qualifications), getting program agreements filled and signed by multiple parties (student, sponsor, institution), and tracking completion across dozens of students per cohort. This manual process is time-consuming (taking weeks), error-prone, lacks visibility into status, and requires physical document handling. FloDoc leverages DocuSeal's proven document signing platform to create a specialized workflow that automates this process while maintaining the flexibility and power of DocuSeal's core form builder and signing engine. The enhancement adds a cohort management layer on top of DocuSeal, creating three specialized portals that work with the existing document infrastructure rather than replacing it. Institutions continue using DocuSeal's form builder to create agreement templates, but now have a structured workflow for managing batches of students through the document submission and signing process. **Critical Requirements from User Clarification**: - Templates represent cohorts, submissions represent students - TP initiates signing BEFORE students and sponsor - Sponsor receives ONE email per cohort (no duplicates) - TP Review phase after sponsor completion (not TP Finalization) - Bulk operations: fill once, replicate for all students --- ### 1.6 CHANGE LOG | Change | Date | Version | Description | Author | |--------|------|---------|-------------|--------| | Initial PRD Creation | 2025-01-01 | v1.0 | Brownfield enhancement for 3-portal cohort management | PM Agent | | **PRD v2.0 - Fresh Start** | 2026-01-10 | v2.0 | Complete rewrite with clarified workflow requirements | User + PM | | **Section 1 Complete** | 2026-01-10 | v2.0 | Intro Analysis with validated understanding | PM | --- ## 2. Requirements ### 2.1 FUNCTIONAL REQUIREMENTS **FR1**: The system shall support a **single training institution** that can manage multiple training cohorts independently. **FR2**: The system shall provide three distinct portal interfaces: TP Portal (Training Provider admin), Student Portal (for enrolled students), and Sponsor Portal (for program sponsors). **FR3**: The TP Portal shall support **cohort creation** via a 5-step multi-form: - Step 1: Cohort name - Step 2: Program type (learnership/internship/candidacy) - Step 3: Student emails (manual entry or bulk upload) - Step 4: Sponsor email (required - single email for all cohort documents) - Step 5: Upload main SETA agreement + additional supporting docs + specify required student uploads (ID, Matric, Tertiary Qualifications) **FR4**: The system shall allow TP to **map signatories** (Learner, Sponsor, TP) to document sections using DocuSeal's existing mapping capabilities with tweaks for bulk operations. **FR5**: The system shall enable **TP Signing Phase** where: - TP signs the first student's document - System **duplicates the completed submission** (not empty template) to remaining students - TP's fields and signatures are **auto-filled across all student submissions** - This eliminates the need for TP to sign each submission individually - Prevents duplicate sponsor emails through workflow state management - Note: DocuSeal's native multi-submission duplicates empty templates; FloDoc will duplicate the signed submission instead **FR6**: The system shall generate **unique invite links** for students via bulk email invitations. **FR7**: The system shall allow students to **upload required documents** (ID, Matric, Tertiary Qualifications) as specified during cohort creation. **FR8**: The system shall allow students to **fill and sign assigned documents** using DocuSeal's existing form builder. **FR9**: The system shall implement **state management** for each student enrollment with states: "Waiting", "In Progress", "Complete". **FR10**: The system shall **prevent sponsor access** until all students in a cohort have completed their submissions. **FR11**: The system shall provide **sponsor portal** with 3-panel layout: - Left: List of all students in cohort - Middle: Document viewer (currently selected document) - Right: Vertical list of thumbnail representations of all documents for the currently selected student **FR12**: The system shall allow sponsor to **review and sign** each student's documents individually OR bulk sign after first completion. **FR13**: The system shall enforce **single email rule**: Sponsor receives ONE email per cohort, regardless of how many students they're assigned to. **FR14**: The system shall allow sponsor to **submit all signatures** to finalize their portion of the workflow. **FR15**: The system shall allow TP to **review all completed documents** from students and sponsor after sponsor submission. **FR16**: The system shall enable TP to **finalize 3-party agreements** after review. **FR17**: The system shall provide **bulk download** functionality with ZIP structure: ``` Cohort_Name/ ├── Student_1/ │ ├── Main_Agreement_Signed.pdf │ ├── ID_Document.pdf │ ├── Matric_Certificate.pdf │ ├── Tertiary_Qualifications.pdf │ └── Audit_Trail.pdf ├── Student_2/ │ └── ... ``` **FR18**: The system shall provide **email notifications** for: - Cohort creation (TP only) - Student invitations (bulk email) - Submission reminders (configurable) - Sponsor access notification (when all students complete) - State change updates **FR19**: The system shall provide **real-time dashboard** showing cohort completion status for all three portals. **FR20**: The system shall maintain **audit trail** for all document actions with timestamps. **FR21**: The system shall store all documents using **DocuSeal's existing storage infrastructure**. **FR22**: The system shall maintain **100% backward compatibility** with existing DocuSeal form builder and signing workflows. **FR23**: The system shall allow TP to **export cohort data to Excel** format containing: cohort name, student name, student surname, student age, student race, student city, program type, sponsor company name, disability status, and gender. ### 2.2 NON-FUNCTIONAL REQUIREMENTS **NFR1**: The system must maintain existing performance characteristics and not exceed current memory usage by more than 20%. **NFR2**: The system must be **mobile-optimized** and support all existing DocuSeal UI languages. **NFR3**: The system must leverage **existing DocuSeal authentication infrastructure** (Devise + JWT) with role-based access control. **NFR4**: The system must integrate seamlessly with **existing DocuSeal email notification system**. **NFR5**: The system must support **concurrent cohort management** without data leakage between cohorts. **NFR6**: The system must provide **audit trails** for all document verification actions (rejections, approvals). **NFR7**: The system must maintain **document integrity and signature verification** capabilities. **NFR8**: The system must support **background processing** for email notifications and document operations via Sidekiq. **NFR9**: The system must comply with **South African electronic document and signature regulations**. **NFR10**: The system must provide **comprehensive error handling and user feedback** for all portal interactions. **NFR11**: The system must implement **single email rule** for sponsors (no duplicate emails regardless of multiple student assignments). **NFR12**: The system must support **bulk operations** to minimize repetitive work for TP and Sponsor. ### 2.3 COMPATIBILITY REQUIREMENTS **CR1: API Compatibility**: All new endpoints must follow existing DocuSeal API patterns and authentication mechanisms. No breaking changes to existing public APIs. **CR2: Database Schema Compatibility**: New tables and relationships must not modify existing DocuSeal core schemas. Extensions should use foreign keys and new tables only. **CR3: UI/UX Consistency**: All three portals must use **custom TailwindCSS design system** (replacing DaisyUI) while maintaining mobile-first responsive design principles. **CR4: Integration Compatibility**: The system must work with existing DocuSeal integrations (webhooks, API, embedded forms) without requiring changes to external systems. --- ## 3. User Interface Enhancement Goals ### 3.1 Integration with Existing UI **Design System Migration**: The three portals will use a **custom TailwindCSS design system** replacing DaisyUI (CR3), while maintaining the same responsive design principles and mobile-first approach as the existing DocuSeal interface. The new design system will: - **Preserve Core UX Patterns**: Maintain familiar interaction patterns from DocuSeal (form builders, signing flows, modal dialogs) - **Enhance Accessibility**: WCAG 2.1 AA compliance for all portals - **Support Dark/Light Mode**: Consistent with existing DocuSeal theme support - **Language Support**: Maintain existing i18n infrastructure for 7 UI languages **Visual Consistency**: - **Color Palette**: Extend DocuSeal's existing brand colors with cohort-specific accent colors for status indicators - **Typography**: Use existing font stack for consistency - **Iconography**: Leverage existing icon library or extend with cohort-specific icons - **Spacing & Layout**: Follow existing 8px grid system and spacing conventions **Development Mandate - Design System Compliance**: **CRITICAL**: During frontend development, the Dev Agent (James) MUST strictly adhere to the FloDoc design system specification located at `.claude/skills/frontend-design/SKILL.md` and the visual assets in `.claude/skills/frontend-design/design-system/`. This includes: - **Color System**: Extract primary, secondary, neutral, and accent colors from `design-system/Colors and shadows/Brand colors/` and `Complementary colors/` SVG/JPG specifications - **Typography**: Follow `design-system/Typography/typoraphy.txt` and `design-system/Fonts/fonts.txt` for font families, sizes, weights, and line heights - **Component Library**: Use atomic design components from `design-system/Atoms/` (Buttons, Inputs, Checkboxes, Menus, Progress Tags, etc.) - **Iconography**: Source all icons from `design-system/Icons/` organized by category (security, users, files, notifications, etc.) - **Brand Assets**: Reference `design-system/Logo/` for all logo variations - **Shadows & Elevation**: Apply shadow styles from `design-system/Colors and shadows/Shadows/` **Agent Coordination**: - **Dev Agent (James)**: Must reference the design system folder before writing any frontend code. All Vue components, TailwindCSS classes, and styling decisions must align with the design system specifications. - **Scrum Master (Bob)**: Must be aware of this design system requirement during story creation and acceptance criteria definition. Frontend stories should include verification that all UI elements conform to the design system specifications. **Consequences of Non-Compliance**: UI elements not derived from the design system will be rejected during code review. The design system is the single source of truth for all visual decisions. ### 3.2 Modified/New Screens and Views #### TP Portal (Admin Interface) **New Screens**: 1. **Institution Onboarding** - Single-page form for initial TP setup 2. **Cohort Dashboard** - Main landing with cohort list, status cards, and quick actions 3. **Cohort Creation Wizard** - 5-step multi-form: - Step 1: Basic Info (name, program type) - Step 2: Student Management (email entry/bulk upload) - Step 3: Sponsor Configuration (single email, notification settings) - Step 4: Document Upload (SETA agreement + supporting docs) - Step 5: Student Upload Requirements (ID, Matric, Tertiary Qualifications) 4. **Document Mapping Interface** - Visual drag-and-drop for signatory assignment 5. **TP Signing Interface** - Single signing flow with "apply to all students" option 6. **Student Enrollment Status** - Bulk invite management and tracking 7. **Sponsor Access Monitor** - Real-time dashboard showing which sponsors have accessed their portal, when they last logged in, which students they've reviewed, and current pending actions. Prevents duplicate email sends and allows TP to intervene if sponsor hasn't accessed after notification. 8. **TP Review Dashboard** - 3-panel review interface: - **Left Panel**: Student list with completion status (Waiting for Student, Waiting for Sponsor, Complete) - **Middle Panel**: Full document viewer showing the selected student's completed documents - **Right Panel**: Verification controls - approve/reject individual documents, add verification notes, mark student as verified 9. **Cohort Analytics** - Completion rates, timeline, bottlenecks 10. **Excel Export Interface** - Data selection and export configuration **Modified Existing Screens**: - **Template Builder** - Enhanced with cohort-specific metadata fields - **User Settings** - Institution role management added #### Student Portal **New Screens**: 1. **Student Invitation Landing** - Accept cohort invitation, view requirements 2. **Document Upload Interface** - Multi-file upload with validation 3. **Student Signing Flow** - DocuSeal signing form with document preview 4. **Submission Status** - Real-time progress tracking 5. **Completion Confirmation** - Summary of submitted documents **Modified Existing Screens**: - **Submission Form** - Rebranded for cohort context, simplified navigation #### Sponsor Portal **New Screens**: 1. **Cohort Dashboard** - Overview of all students in cohort with bulk signing capability 2. **Student List View** - Searchable, filterable list of students with status indicators 3. **Signature Capture Interface** - Two methods for signature: draw on canvas or type name 4. **Bulk Signing Preview** - Confirmation modal showing all affected students before signing 5. **Success Confirmation** - Post-signing summary with next steps **Modified Existing Screens**: - **Signing Form** - Enhanced for bulk cohort signing workflow ### 3.3 UI Consistency Requirements **Portal-Specific Requirements**: **TP Portal**: - **Admin-First Design**: Complex operations made simple through progressive disclosure - **Bulk Operations**: Prominent "fill once, apply to all" patterns - **Status Visualization**: Color-coded cohort states (Pending, In Progress, Ready for Sponsor, Complete) - **Action History**: Audit trail visible within interface **What is Progressive Disclosure?** This is a UX pattern that hides complexity until the user needs it. For the TP Portal, this means: - **Default View**: Show only essential actions (Create Cohort, View Active Cohorts, Export Data) - **On-Demand Complexity**: Advanced features (detailed analytics, bulk email settings, custom document mappings) are revealed only when users click "Advanced Options" or navigate to specific sections - **Example**: The Cohort Creation Wizard (5 steps) uses progressive disclosure - each step shows only the fields needed for that step, preventing overwhelming the user with all 20+ fields at once - **Benefit**: Reduces cognitive load for new users while keeping power features accessible for experienced admins **Student Portal**: - **Mobile-First**: Optimized for smartphone access - **Minimal Steps**: Maximum 3 clicks to complete any document - **Clear Requirements**: Visual checklist of required vs. optional documents - **Progress Indicators**: Step-by-step completion tracking **Sponsor Portal**: - **Review-Optimized**: Keyboard shortcuts for document navigation - **Bulk Actions**: "Sign All" and "Bulk Review" modes - **Document Comparison**: Side-by-side view capability - **No Account Required**: Email-link only access pattern - **Progress Tracking**: Persistent progress bar showing completion status (e.g., "3/15 students completed - 20%") with visual indicator - **Tab-Based Navigation**: Pending/Completed tabs for clear workflow separation **Cross-Portal Consistency**: - **Navigation**: All portals use consistent header/navigation patterns - **Notifications**: Toast notifications for state changes - **Error Handling**: Consistent error message formatting and recovery options - **Loading States**: Skeleton screens and spinners for async operations - **Empty States**: Helpful guidance when no cohorts/students/documents exist **Mobile Responsiveness**: - **Breakpoints**: 640px (sm), 768px (md), 1024px (lg), 1280px (xl) - **Touch Targets**: Minimum 44x44px for all interactive elements - **Tablet Optimization**: 3-panel sponsor portal collapses to 2-panel on tablets - **Vertical Layout**: All portals stack vertically on mobile devices **Accessibility Standards**: - **Keyboard Navigation**: Full keyboard support for all portals - **Screen Readers**: ARIA labels and semantic HTML throughout - **Focus Management**: Clear focus indicators and logical tab order - **Color Contrast**: Minimum 4.5:1 ratio for all text - **Reduced Motion**: Respect user's motion preferences --- ## 4. Technical Constraints and Integration Requirements ### 4.1 Existing Technology Stack **Based on Architecture Analysis** (docs/current-app-sitemap.md): **Languages:** - Ruby 3.4.2 - JavaScript (Vue.js 3) - HTML/CSS (TailwindCSS 3.4.17) **Frameworks:** - Ruby on Rails 7.x (with Shakapacker 8.0) - Vue.js 3 with Composition API - Devise for authentication - Cancancan for authorization - Sidekiq for background processing **Database:** - PostgreSQL/MySQL/SQLite (configured via DATABASE_URL) - Redis for Sidekiq job queue **Infrastructure:** - Puma web server - Active Storage (S3, Google Cloud, Azure, or local disk) - SMTP server for email delivery **External Dependencies:** - HexaPDF (PDF generation and signing) - PDFium (PDF rendering) - rubyXL (Excel export - **to be added**) - Ngrok (for local testing with public URLs) **Key Libraries & Gems:** - `devise` - Authentication - `devise-two-factor` - 2FA support - `cancancan` - Authorization - `sidekiq` - Background jobs - `hexapdf` - PDF processing - `prawn` - PDF generation (alternative) - `rubyXL` - Excel file generation (**required for FR23**) ### 4.2 Integration Approach **Database Integration Strategy:** - **New Tables Only**: Create `cohorts`, `cohort_enrollments`, `institutions`, `sponsors` tables - **Foreign Keys**: Link to existing `templates`, `submissions`, `users` tables - **No Schema Modifications**: Existing DocuSeal tables remain unchanged - **Migration Safety**: All migrations must be reversible - **Data Isolation**: Use `institution_id` scoping for all FloDoc queries **API Integration Strategy:** - **Namespace Extension**: Add `/api/v1/flodoc/` namespace for new endpoints - **Pattern Consistency**: Follow existing DocuSeal REST conventions - **Authentication**: Reuse existing Devise + JWT infrastructure - **Rate Limiting**: Apply existing rate limits to new endpoints - **Webhook Compatibility**: New cohort events trigger existing webhook infrastructure **Frontend Integration Strategy:** - **Vue.js Architecture**: Extend existing Vue 3 app with new portal components - **Design System**: Replace DaisyUI with custom TailwindCSS (per CR3) - **Component Structure**: Create new portal-specific components in `app/javascript/cohorts/` - **Routing**: Use existing Vue Router with new portal routes - **State Management**: Vuex or Pinia for cohort state (to be determined) - **No Breaking Changes**: Existing DocuSeal UI remains functional **Testing Integration Strategy:** - **RSpec**: Extend existing test suite with new model/request specs - **System Tests**: Add Capybara tests for 3-portal workflows - **Vue Test Utils**: Component tests for new portal interfaces - **FactoryBot**: Create factories for new models - **Existing Tests**: All DocuSeal tests must continue passing ### 4.3 Code Organization and Standards **File Structure Approach:** ``` app/ ├── models/ │ ├── cohort.rb # New: Cohort management │ ├── cohort_enrollment.rb # New: Student enrollment tracking │ ├── institution.rb # New: Single institution model │ ├── sponsor.rb # New: Ad-hoc sponsor model │ └── concerns/ │ └── user_flo_doc_additions.rb # New: User model extension │ ├── controllers/ │ ├── api/ │ │ └── v1/ │ │ ├── flodoc/ │ │ │ ├── cohorts_controller.rb │ │ │ ├── enrollments_controller.rb │ │ │ └── excel_export_controller.rb │ │ └── admin/ │ │ ├── invitations_controller.rb │ │ └── security_events_controller.rb │ └── cohorts/ │ └── admin_controller.rb # Web interface │ ├── services/ │ ├── invitation_service.rb # Admin invitation logic │ ├── cohort_service.rb # Cohort lifecycle management │ ├── sponsor_service.rb # Sponsor access management │ └── excel_export_service.rb # Excel generation (FR23) │ ├── jobs/ │ ├── cohort_admin_invitation_job.rb │ ├── sponsor_access_job.rb │ └── excel_export_job.rb │ ├── mailers/ │ └── cohort_mailer.rb # Cohort-specific emails │ └── javascript/ └── cohorts/ ├── portals/ │ ├── tp_portal/ # Admin interface │ ├── student_portal/ # Student interface │ └── sponsor_portal/ # Sponsor interface └── components/ # Shared Vue components ``` **Naming Conventions:** - **Models**: `Cohort`, `CohortEnrollment`, `Institution`, `Sponsor` (PascalCase, singular) - **Controllers**: `CohortsController`, `Cohorts::AdminController` (namespaced) - **Services**: `CohortService`, `InvitationService` (PascalCase, descriptive) - **Jobs**: `CohortInvitationJob` (PascalCase, ends with Job) - **Vue Components**: `CohortDashboard.vue`, `SponsorPanel.vue` (PascalCase) - **Variables**: `cohort_enrollments` (snake_case, plural for collections) - **Routes**: `/flodoc/cohorts`, `/admin/invitations` (kebab-case in URLs) **Coding Standards:** - **Ruby**: Follow existing RuboCop configuration - **JavaScript**: Follow existing ESLint configuration - **Vue.js**: Use Composition API, ` ``` **Pinia Store:** ```typescript // app/javascript/tp_portal/stores/cohort.ts import { defineStore } from 'pinia' import api from '@/services/api' export const useCohortStore = defineStore('cohort', { state: () => ({ cohorts: [], stats: { total: 0, active: 0, completed: 0, pending_sponsor: 0 }, totalPages: 1, loading: false }), actions: { async fetchCohorts(params = {}) { this.loading = true try { const response = await api.get('/cohorts', { params }) this.cohorts = response.data.data this.stats = this.calculateStats(response.data.data) this.totalPages = response.data.meta.total_pages } finally { this.loading = false } }, async exportCohort(cohortId) { await api.get(`/cohorts/${cohortId}/export`, { responseType: 'blob' }) .then(response => { const url = window.URL.createObjectURL(new Blob([response.data])) const link = document.createElement('a') link.href = url link.setAttribute('download', `cohort_${cohortId}_export.xlsx`) document.body.appendChild(link) link.click() link.remove() }) }, calculateStats(cohorts) { return { total: cohorts.length, active: cohorts.filter(c => ['tp_signing', 'student_enrollment', 'ready_for_sponsor', 'sponsor_review', 'tp_review'].includes(c.status)).length, completed: cohorts.filter(c => c.status === 'completed').length, pending_sponsor: cohorts.filter(c => c.status === 'ready_for_sponsor').length } } } }) ``` **Design System Compliance:** Per FR28, all components must use design system assets from: - `@.claude/skills/frontend-design/SKILL.md` - `@.claude/skills/frontend-design/design-system/` Specifically: colors, icons, typography, and layout patterns from the design system. ##### Acceptance Criteria **Functional:** 1. ✅ Dashboard displays cohort list with pagination 2. ✅ Metrics cards show accurate statistics 3. ✅ Filters work (status, program type, search) 4. ✅ Status badges display correctly 5. ✅ Progress bars show student completion 6. ✅ Actions (open/edit/export) work 7. ✅ Empty state displays when no cohorts 8. ✅ Pagination works **Integration:** 1. ✅ IV1: API calls use correct endpoints 2. ✅ IV2: Pinia store manages state properly 3. ✅ IV3: Components follow design system **Security:** 1. ✅ Only authenticated TP users can access 2. ✅ Users only see their institution's cohorts 3. ✅ Export button calls correct authorization **Quality:** 1. ✅ Follows Vue 3 best practices 2. ✅ Components are reusable 3. ✅ TypeScript types defined 4. ✅ Design system compliance 5. ✅ 80% test coverage ##### Integration Verification (IV1-3) **IV1: API Integration** - Verify all API calls are made to correct endpoints - Verify error handling works - Verify loading states display correctly - Verify data renders correctly from API **IV2: Pinia Store** - Verify state is managed correctly - Verify actions update state - Verify getters return computed values - Verify no memory leaks **IV3: Design System** - Verify all colors from design system - Verify spacing units consistent - Verify typography matches spec - Verify icons from design library ##### Test Requirements **Component Specs:** ```javascript // spec/javascript/tp_portal/components/MetricCard.spec.js import { mount } from '@vue/test-utils' import MetricCard from '@/tp_portal/components/MetricCard.vue' describe('MetricCard', () => { it('renders correct value and label', () => { const wrapper = mount(MetricCard, { props: { value: 42, label: 'Total Cohorts', icon: 'folder', color: 'blue' } }) expect(wrapper.find('.metric-value').text()).toBe('42') }) }) ``` ##### Rollback Procedure **If dashboard fails:** 1. Remove TP Portal routes and components 2. Revert to existing DocuSeal UI 3. User data remains intact **Data Safety**: Frontend code doesn't affect data. ##### Risk Assessment **Low Risk because:** - Single-page application with no backend changes - All operations use already-tested API endpoints - Design system components are pre-built - State management is straightforward **Specific Risks:** 1. **API Performance**: Many API calls could slow UI 2. **Browser Compatibility**: Vue 3 may not work on old browsers 3. **Design System Gaps**: Missing components may need creation 4. **Bundle Size**: Large Vue app could load slowly **Mitigation:** - API query caching - Polyfill for older browsers - Lazy-load components - Code splitting and tree shaking --- #### Story 4.2: Cohort Creation & Bulk Import **Status**: Draft **Priority**: High **Epic**: Phase 4 - Frontend - TP Portal **Estimated Effort**: 2-3 days **Risk Level**: Medium ##### User Story **As a** TP administrator, **I want** to create new cohorts and bulk-import students via Excel, **So that** I can efficiently onboard large groups without manual data entry. ##### Background The cohort creation process needs to support: - Basic cohort information (name, dates, description) - Optional bulk student import via Excel spreadsheet - Validation and preview before committing data - Seamless navigation to cohort detail after creation This enables TP administrators to quickly set up new training cohorts with minimal effort. ##### Technical Implementation Notes **Frontend - Vue 3 Components:** ```typescript // app/javascript/tp_portal/views/CohortCreateView.vue ``` **Pinia Store:** ```typescript // app/javascript/student/stores/form.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { SubmissionAPI } from '@/student/api/submission' import type { FormField, FormSubmissionData } from '@/student/types' export const useStudentFormStore = defineStore('studentForm', { state: () => ({ fields: [] as FormField[], signatureData: {} as Record, signatureType: {} as Record, isLoading: false, error: null as string | null, submissionId: null as number | null }), getters: { completedFieldCount: (state) => { return state.fields.filter(field => { const value = state.signatureData[field.id] return value !== undefined }).length }, totalFieldCount: (state) => { return state.fields.length }, isFormComplete: (state) => { return state.fields.every(field => { if (field.required) { return state.signatureData[field.id] !== undefined } return true }) } }, actions: { async fetchFields(submissionId: number, token: string): Promise { this.isLoading = true this.error = null try { const response = await SubmissionAPI.getFormFields(submissionId, token) this.fields = response.fields this.submissionId = submissionId // Restore existing data if (response.existing_data) { Object.keys(response.existing_data).forEach(key => { if (key.startsWith('signature_')) { const fieldId = key.replace('signature_', '') this.signatureData[fieldId] = response.existing_data[key] } }) } } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to fetch fields' console.error('Fetch fields error:', error) throw error } finally { this.isLoading = false } }, async saveDraft( submissionId: number, token: string, data: FormSubmissionData ): Promise { try { await SubmissionAPI.saveFormDraft(submissionId, token, data) } catch (error) { console.error('Save draft error:', error) throw error } }, async saveFormData( submissionId: number, token: string, data: FormSubmissionData ): Promise { try { await SubmissionAPI.saveFormData(submissionId, token, data) } catch (error) { console.error('Save form data error:', error) throw error } }, async completeFormStep(submissionId: number, token: string): Promise { try { await SubmissionAPI.completeFormStep(submissionId, token) } catch (error) { console.error('Complete form step error:', error) throw error } }, clearError(): void { this.error = null } } }) ``` **API Layer:** ```typescript // app/javascript/student/api/submission.ts (extended) export interface FormField { id: string type: 'text' | 'email' | 'date' | 'number' | 'checkbox' | 'radio' | 'select' | 'signature' | 'textarea' label: string required: boolean placeholder?: string description?: string options?: Array<{ value: string; label: string }> rows?: number min?: number max?: number existing_value?: any } export interface FormFieldsResponse { fields: FormField[] existing_data?: Record } export interface FormSubmissionData { form_data: Record signature_data: Record } export const SubmissionAPI = { // ... existing methods async getFormFields(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/form-fields`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (!response.ok) { if (response.status === 403) { throw new Error('Access denied or token expired') } if (response.status === 404) { throw new Error('Submission not found') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() }, async saveFormDraft( submissionId: number, token: string, data: FormSubmissionData ): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/form-draft`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } }, async saveFormData( submissionId: number, token: string, data: FormSubmissionData ): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/form-data`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } }, async completeFormStep(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/complete-form`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } } } ``` **Type Definitions:** ```typescript // app/javascript/student/types/index.ts (extended) export interface FormField { id: string type: 'text' | 'email' | 'date' | 'number' | 'checkbox' | 'radio' | 'select' | 'signature' | 'textarea' label: string required: boolean placeholder?: string description?: string options?: Array<{ value: string; label: string }> rows?: number min?: number max?: number existing_value?: any } export interface FormFieldsResponse { fields: FormField[] existing_data?: Record } export interface FormSubmissionData { form_data: Record signature_data: Record } ``` **Design System Compliance:** Per FR28, all Form Completion components must use design system assets from: - `@.claude/skills/frontend-design/SKILL.md` - Design tokens - `@.claude/skills/frontend-design/design-system/` - SVG assets Specific requirements: - **Colors**: - Primary (Blue-600): `#2563EB` for buttons and focus states - Success (Green-600): `#16A34A` for completed fields - Danger (Red-600): `#DC2626` for errors - Neutral (Gray-300): `#D1D5DB` for borders - **Spacing**: 4px base unit, 1.5rem (24px) for sections, 0.75rem (12px) for field gaps - **Typography**: - Labels: 14px, medium weight - Input text: 16px - Error messages: 12px - Helper text: 12px, gray-500 - **Icons**: Use SVG icons from design system for: - Signature (pen) - Clear (trash) - Type (keyboard) - Save (floppy disk) - Checkmark (completed) - **Layout**: - Max width: 3xl (48rem / 768px) - Input padding: 0.5rem (8px) vertical, 0.75rem (12px) horizontal - Focus ring: 2px solid blue-500 - Signature canvas: 600x150px - **Accessibility**: - All inputs have associated labels - Required fields marked with asterisk - Error messages linked to inputs - Keyboard navigation support - Screen reader announcements for auto-save - Color contrast ratio minimum 4.5:1 ##### Acceptance Criteria **Functional:** 1. ✅ Component loads and fetches form fields on mount 2. ✅ Renders all field types correctly (text, email, date, number, checkbox, radio, select, signature, textarea) 3. ✅ Validates each field type appropriately 4. ✅ Shows error messages below invalid fields 5. ✅ Signature canvas supports drawing with mouse/touch 6. ✅ Signature can be cleared and replaced with text 7. ✅ Progress bar updates as fields are completed 8. ✅ Auto-save triggers every 30 seconds if changes detected 9. ✅ Save Draft button works immediately 10. ✅ Continue button disabled until all required fields valid 11. ✅ Back button returns to previous step 12. ✅ Existing data is restored if user returns to draft **UI/UX:** 1. ✅ All inputs show focus states with blue ring 2. ✅ Required fields marked with red asterisk 3. ✅ Signature canvas shows drawing feedback 4. ✅ Text signature modal appears centered 5. ✅ Auto-save indicator shows when saving 6. ✅ Success indicator appears briefly after save 7. ✅ Mobile-responsive design (inputs stack properly) 8. ✅ Radio buttons and checkboxes are clickable and accessible 9. ✅ Date picker uses native browser date selector 10. ✅ Textarea auto-expands or has scroll for long text **Integration:** 1. ✅ API endpoint: `GET /api/student/submissions/{id}/form-fields` 2. ✅ API endpoint: `POST /api/student/submissions/{id}/form-draft` 3. ✅ API endpoint: `POST /api/student/submissions/{id}/form-data` 4. ✅ API endpoint: `POST /api/student/submissions/{id}/complete-form` 5. ✅ Token authentication in headers 6. ✅ Form data and signature data sent to server 7. ✅ State persistence in Pinia store 8. ✅ Data restored on page reload **Security:** 1. ✅ Token-based authentication required 2. ✅ Authorization check: student can only fill their submission 3. ✅ Input validation (client-side + server-side) 4. ✅ XSS prevention (sanitized input) 5. ✅ Rate limiting on save endpoints 6. ✅ Audit log of all form saves **Quality:** 1. ✅ Auto-save doesn't interfere with user typing 2. ✅ Network errors handled gracefully 3. ✅ No memory leaks (clean up intervals) 4. ✅ Performance: handles 50+ fields without lag 5. ✅ Browser compatibility: Chrome, Firefox, Safari, Edge ##### Integration Verification (IV1-4) **IV1: API Integration** - `FormFieldCompletion.vue` calls `SubmissionAPI.getFormFields()` on mount - `FormFieldCompletion.vue` calls `SubmissionAPI.saveFormDraft()` on save - `FormFieldCompletion.vue` calls `SubmissionAPI.saveFormData()` before continue - `FormFieldCompletion.vue` calls `SubmissionAPI.completeFormStep()` on continue - All endpoints use `Authorization: Bearer {token}` header **IV2: Pinia Store** - `studentFormStore.fields` holds all form field definitions - `studentFormStore.signatureData` tracks signature data - `studentFormStore.saveDraft()` persists data - `studentFormStore.saveFormData()` saves final state - `studentFormStore.completeFormStep()` marks step complete **IV3: Getters** - `completedFieldCount()` counts filled fields - `totalFieldCount()` counts all fields - `isFormComplete()` checks if all required fields filled **IV4: Token Routing** - FormCompletion receives `token` prop from parent - Parent loads token from URL param (`?token=...`) - All API calls pass token to store actions ##### Test Requirements **Component Specs:** ```javascript // spec/javascript/student/views/FormFieldCompletion.spec.js import { mount, flushPromises } from '@vue/test-utils' import FormFieldCompletion from '@/student/views/FormFieldCompletion.vue' import { useStudentFormStore } from '@/student/stores/form' import { createPinia, setActivePinia } from 'pinia' describe('FormFieldCompletion', () => { const mockFields = [ { id: 'name', type: 'text', label: 'Full Name', required: true }, { id: 'email', type: 'email', label: 'Email', required: true }, { id: 'signature', type: 'signature', label: 'Signature', required: true } ] beforeEach(() => { setActivePinia(createPinia()) vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('renders all field types correctly', async () => { const wrapper = mount(FormFieldCompletion, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentFormStore() store.fields = mockFields await flushPromises() expect(wrapper.find('input[type="text"]').exists()).toBe(true) expect(wrapper.find('input[type="email"]').exists()).toBe(true) expect(wrapper.find('canvas').exists()).toBe(true) }) it('validates required fields', async () => { const wrapper = mount(FormFieldCompletion, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentFormStore() store.fields = mockFields await flushPromises() // Try to continue without filling fields const continueButton = wrapper.find('button').filter(n => n.text().includes('Continue')) await continueButton.trigger('click') // Should show validation errors expect(wrapper.text()).toContain('This field is required') }) it('updates progress bar as fields are filled', async () => { const wrapper = mount(FormFieldCompletion, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentFormStore() store.fields = mockFields await flushPromises() // Fill first field const nameInput = wrapper.find('input[type="text"]') await nameInput.setValue('John Doe') await wrapper.vm.$nextTick() // Progress should be 33% expect(wrapper.text()).toContain('1 of 3 completed') }) it('enables continue button when all required fields filled', async () => { const wrapper = mount(FormFieldCompletion, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentFormStore() store.fields = mockFields await flushPromises() const continueButton = wrapper.find('button').filter(n => n.text().includes('Continue')) expect(continueButton.element.disabled).toBe(true) // Fill all fields await wrapper.find('input[type="text"]').setValue('John Doe') await wrapper.find('input[type="email"]').setValue('john@example.com') store.signatureData['signature'] = 'data:image/png;base64,signature' await wrapper.vm.$nextTick() expect(continueButton.element.disabled).toBe(false) }) it('auto-saves every 30 seconds', async () => { const wrapper = mount(FormFieldCompletion, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentFormStore() store.fields = mockFields const saveSpy = vi.spyOn(store, 'saveDraft') await flushPromises() // Fill a field await wrapper.find('input[type="text"]').setValue('John Doe') // Fast-forward 30 seconds vi.advanceTimersByTime(30000) expect(saveSpy).toHaveBeenCalled() }) it('handles text signature', async () => { const wrapper = mount(FormFieldCompletion, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentFormStore() store.fields = mockFields await flushPromises() // Click "Type Instead" on signature const typeButton = wrapper.find('button').filter(n => n.text() === 'Type Instead') await typeButton.trigger('click') expect(wrapper.findComponent({ name: 'TextSignatureModal' }).exists()).toBe(true) }) it('validates email format', async () => { const wrapper = mount(FormFieldCompletion, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentFormStore() store.fields = mockFields await flushPromises() const emailInput = wrapper.find('input[type="email"]') await emailInput.setValue('invalid-email') await emailInput.trigger('blur') expect(wrapper.text()).toContain('Invalid email format') }) }) ``` **Integration Tests:** ```javascript // spec/javascript/student/integration/form-flow.spec.js describe('Form Completion Flow', () => { it('completes full form workflow', async () => { // 1. Load form page // 2. Fetch fields // 3. Fill text fields // 4. Draw signature // 5. Auto-save triggers // 6. Save draft manually // 7. Validate all fields // 8. Continue to next step }) }) ``` **E2E Tests:** ```javascript // spec/system/student_form_completion_spec.rb RSpec.describe 'Student Form Completion', type: :system do let(:cohort) { create(:cohort, status: :in_progress) } let(:student) { create(:student, cohort: cohort) } let(:submission) { create(:submission, student: student, status: :upload_complete) } let(:token) { submission.token } scenario 'student fills form and signs' do visit "/student/submissions/#{submission.id}/form?token=#{token}" # Fill text fields fill_in 'Full Name', with: 'John Doe' fill_in 'Email', with: 'john@example.com' select 'United States', from: 'Country' # Draw signature canvas = find('canvas') page.driver.browser.action.move_to(canvas.native).click_and_hold.perform page.driver.browser.action.move_by(50, 0).perform page.driver.browser.action.release.perform # Verify progress expect(page).to have_content('3 of 5 completed') # Save draft click_button 'Save Draft' expect(page).to have_content('Saved') # Continue click_button 'Continue to Review' expect(page).to have_content('Step 3 of 3') end scenario 'auto-save works while typing', do visit "/student/submissions/#{submission.id}/form?token=#{token}" fill_in 'Full Name', with: 'John Doe' # Wait for auto-save sleep 30 # Reload page visit "/student/submissions/#{submission.id}/form?token=#{token}" # Should restore data expect(find_field('Full Name').value).to eq('John Doe') end scenario 'validates all required fields', do visit "/student/submissions/#{submission.id}/form?token=#{token}" # Try to continue without filling click_button 'Continue to Review' expect(page).to have_content('This field is required') end end ``` ##### Rollback Procedure **If form submission fails:** 1. Show error message with retry option 2. Preserve all form data in store 3. Allow user to save draft and try later 4. Log error to monitoring service **If auto-save fails:** 1. Show "Save failed" indicator 2. Continue attempting auto-save 3. Warn user before leaving page 4. Offer manual save option **If signature canvas is corrupted:** 1. Clear canvas and signature data 2. Allow re-draw or text signature 3. Show error message with retry option **If user navigates away accidentally:** 1. Check for unsaved changes 2. Show confirmation dialog 3. Auto-save if user confirms 4. Restore state on return **Data Safety:** - All form data stored in Pinia store - Auto-save creates backup every 30 seconds - Draft saves preserve all data - No data loss on page refresh ##### Risk Assessment **Medium Risk** because: - Complex form validation across multiple field types - Signature capture requires canvas API - Auto-save mechanism must not interfere with user input - State management for 50+ fields - Mobile signature capture is challenging **Specific Risks:** 1. **Signature Canvas Compatibility**: Mobile browsers handle touch events differently - **Mitigation**: Use `signature_pad` library, provide text fallback 2. **Auto-save Interference**: Auto-save while user is typing - **Mitigation**: Debounce auto-save, save only after 2 seconds of inactivity 3. **Large Forms**: Performance issues with 50+ fields - **Mitigation**: Virtual scrolling, lazy load non-visible fields 4. **Data Loss**: Network failure during auto-save - **Mitigation**: Store data in localStorage as backup, retry mechanism 5. **Validation Complexity**: Different rules for different field types - **Mitigation**: Centralized validation library, comprehensive tests **Mitigation Strategies:** - Use `signature_pad` library for cross-browser signature support - Debounce auto-save to 2 seconds after last input - Implement localStorage backup for critical data - Comprehensive validation test suite - Performance testing with large forms ##### Success Metrics - **Form Completion Rate**: 95% of students who start complete the form - **Auto-save Success**: 99.5% of auto-save attempts succeed - **Zero Data Loss**: 100% of drafts restore correctly - **Validation Accuracy**: 100% of invalid inputs caught - **Mobile Compatibility**: 90% of mobile users can sign successfully - **Average Completion Time**: <5 minutes for typical 10-field form - **Support Tickets**: <2% of issues related to form completion --- #### Story 5.3: Student Portal - Progress Tracking & Save Draft **Status**: Draft/Pending **Priority**: High **Epic**: Student Portal - Frontend Development **Estimated Effort**: 2 days **Risk Level**: Low ##### User Story **As a** Student, **I want** to see my overall progress and save my work as a draft at any time, **So that** I can complete the submission at my own pace without losing work. ##### Background Students may need multiple sessions to complete their submission: 1. Upload documents (Story 5.1) 2. Fill form fields (Story 5.2) 3. Review and submit (Story 5.4) The student portal must provide: - **Progress Dashboard**: Visual overview of all steps and their status - **Draft Management**: Save and resume capability - **Session Persistence**: Data survives browser refresh - **Multi-step Navigation**: Jump between steps - **Completion Indicators**: Clear visual feedback This story implements the progress tracking UI that shows all three steps (Upload, Form, Review) with their completion status, and provides a persistent "Save Draft" mechanism accessible from any step. **Integration Point**: This ties together Stories 5.1 and 5.2, providing the navigation layer that orchestrates the complete student workflow. ##### Technical Implementation Notes **Vue 3 Component Structure:** ```vue ``` **Pinia Store:** ```typescript // app/javascript/student/stores/progress.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { SubmissionAPI } from '@/student/api/submission' import type { ProgressData, SaveDraftResponse, SubmitResponse } from '@/student/types' export const useStudentProgressStore = defineStore('studentProgress', { state: () => ({ cohortName: '', uploadProgress: 0, formProgress: 0, step1Completed: false, step2Completed: false, step3Completed: false, submitted: false, lastSaved: null as number | null, hasUnsavedChanges: false, isLoading: false, error: null as string | null }), getters: { overallProgressPercent: (state) => { const total = 3 const completed = [ state.step1Completed, state.step2Completed, state.step3Completed ].filter(Boolean).length return Math.round((completed / total) * 100) } }, actions: { async fetchProgress(submissionId: number, token: string): Promise { this.isLoading = true this.error = null try { const response = await SubmissionAPI.getProgress(submissionId, token) this.cohortName = response.cohort_name this.uploadProgress = response.upload_progress this.formProgress = response.form_progress this.step1Completed = response.step1_completed this.step2Completed = response.step2_completed this.step3Completed = response.step3_completed this.submitted = response.submitted this.lastSaved = response.last_saved ? new Date(response.last_saved).getTime() : null } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to fetch progress' console.error('Fetch progress error:', error) throw error } finally { this.isLoading = false } }, async saveDraft(submissionId: number, token: string): Promise { try { const response = await SubmissionAPI.saveDraft(submissionId, token) this.lastSaved = Date.now() this.hasUnsavedChanges = false return response } catch (error) { console.error('Save draft error:', error) throw error } }, async submitForReview(submissionId: number, token: string): Promise { try { const response = await SubmissionAPI.submitForReview(submissionId, token) this.submitted = true this.step3Completed = true return response } catch (error) { console.error('Submit error:', error) throw error } }, markUnsaved(): void { this.hasUnsavedChanges = true }, clearError(): void { this.error = null } } }) ``` **API Layer:** ```typescript // app/javascript/student/api/submission.ts (extended) export interface ProgressData { cohort_name: string upload_progress: number form_progress: number step1_completed: boolean step2_completed: boolean step3_completed: boolean submitted: boolean last_saved: string | null } export interface SaveDraftResponse { success: boolean saved_at: string } export interface SubmitResponse { success: boolean submission_id: number status: string } export const SubmissionAPI = { // ... existing methods async getProgress(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/progress`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (!response.ok) { if (response.status === 403) { throw new Error('Access denied or token expired') } if (response.status === 404) { throw new Error('Submission not found') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() }, async saveDraft(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/save-draft`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() }, async submitForReview(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/submit`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }) if (!response.ok) { if (response.status === 409) { throw new Error('Submission already submitted or incomplete') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() } } ``` **Type Definitions:** ```typescript // app/javascript/student/types/index.ts (extended) export interface ProgressData { cohort_name: string upload_progress: number form_progress: number step1_completed: boolean step2_completed: boolean step3_completed: boolean submitted: boolean last_saved: string | null } export interface SaveDraftResponse { success: boolean saved_at: string } export interface SubmitResponse { success: boolean submission_id: number status: string } ``` **Design System Compliance:** Per FR28, all Progress Tracking components must use design system assets from: - `@.claude/skills/frontend-design/SKILL.md` - Design tokens - `@.claude/skills/frontend-design/design-system/` - SVG assets Specific requirements: - **Colors**: - Success (Green-600): `#16A34A` for completed steps - Primary (Blue-600): `#2563EB` for active/in-progress - Neutral (Gray-500): `#6B7280` for pending - Warning (Purple-600): `#7C3AED` for submitted state - **Spacing**: 4px base unit, 1.5rem (24px) for card gaps, 1rem (16px) for internal padding - **Typography**: - Headings: 20px (h2), 16px (h3) - Body: 14px for descriptions - Labels: 12px uppercase, letter-spacing 0.05em - **Icons**: Use SVG icons from design system for: - Checkmark (completed) - Document (upload step) - Form (form step) - Eye (review step) - Floppy disk (save) - Paper airplane (submit) - Question mark (help) - **Layout**: - Max width: 4xl (896px) - Card corners: rounded-lg (8px) - Shadow: shadow-md for cards - Border accent: 4px left border on step cards - **Accessibility**: - ARIA labels on all buttons - Keyboard navigation for step cards - Screen reader announcements for progress updates - Focus indicators on interactive elements - Color contrast ratio minimum 4.5:1 ##### Acceptance Criteria **Functional:** 1. ✅ Dashboard loads progress data on mount 2. ✅ Shows correct overall progress percentage 3. ✅ Displays all 3 steps with correct status 4. ✅ Step cards show completion indicators (checkmark, numbers) 5. ✅ Progress bars animate smoothly 6. ✅ Status badge shows correct text and color 7. ✅ Last saved timestamp updates correctly 8. ✅ "Save Draft" button works and shows feedback 9. ✅ "Submit for Review" only enabled when all steps complete 10. ✅ "Resume Workflow" directs to appropriate step 11. ✅ Clicking step cards navigates to that step 12. ✅ Auto-save triggers every 30 seconds if changes detected **UI/UX:** 1. ✅ Step cards have hover effects (lift, shadow) 2. ✅ Active step shows ring highlight 3. ✅ Progress bars animate from 0 to current value 4. ✅ Status badge uses color-coded styling 5. ✅ Last saved time formats correctly (just now, Xm ago, Xh ago) 6. ✅ Success toast appears after save 7. ✅ Loading state during save 8. ✅ Mobile-responsive layout 9. ✅ Step cards are clickable and show cursor pointer 10. ✅ Help modal provides clear instructions **Integration:** 1. ✅ API endpoint: `GET /api/student/submissions/{id}/progress` 2. ✅ API endpoint: `POST /api/student/submissions/{id}/save-draft` 3. ✅ API endpoint: `POST /api/student/submissions/{id}/submit` 4. ✅ Token authentication in headers 5. ✅ Navigation emits events to parent 6. ✅ Progress data reflects uploads and form completion **Security:** 1. ✅ Token-based authentication required 2. ✅ Authorization check: student can only view their submission 3. ✅ Submit endpoint validates all steps complete 4. ✅ Rate limiting on save endpoints 5. ✅ Audit log of all saves and submissions **Quality:** 1. ✅ Auto-save doesn't trigger if no changes 2. ✅ Navigation prevents invalid transitions 3. ✅ Error handling for failed API calls 4. ✅ No memory leaks (clean up intervals) 5. ✅ Performance: loads in <2 seconds ##### Integration Verification (IV1-4) **IV1: API Integration** - `ProgressDashboard.vue` calls `SubmissionAPI.getProgress()` on mount - `ProgressDashboard.vue` calls `SubmissionAPI.saveDraft()` on save - `ProgressDashboard.vue` calls `SubmissionAPI.submitForReview()` on submit - All endpoints use `Authorization: Bearer {token}` header - Progress data reflects actual completion from other stories **IV2: Pinia Store** - `studentProgressStore.cohortName` holds cohort information - `studentProgressStore.uploadProgress` tracks upload completion - `studentProgressStore.formProgress` tracks form completion - `studentProgressStore.step1Completed` reflects upload status - `studentProgressStore.step2Completed` reflects form status - `studentProgressStore.step3Completed` reflects review status **IV3: Getters** - `overallProgressPercent()` calculates completion across all steps - State reflects real-time updates from other components **IV4: Token Routing** - ProgressDashboard receives `token` prop from parent - Parent loads token from URL param (`?token=...`) - All API calls pass token to store actions ##### Test Requirements **Component Specs:** ```javascript // spec/javascript/student/views/ProgressDashboard.spec.js import { mount, flushPromises } from '@vue/test-utils' import ProgressDashboard from '@/student/views/ProgressDashboard.vue' import { useStudentProgressStore } from '@/student/stores/progress' import { createPinia, setActivePinia } from 'pinia' describe('ProgressDashboard', () => { const mockProgress = { cohort_name: 'Summer 2025', upload_progress: 100, form_progress: 75, step1_completed: true, step2_completed: false, step3_completed: false, submitted: false, last_saved: new Date().toISOString() } beforeEach(() => { setActivePinia(createPinia()) vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('renders overall progress correctly', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) await flushPromises() expect(wrapper.text()).toContain('33% Complete') expect(wrapper.text()).toContain('1 of 3 Steps') }) it('displays step cards with correct status', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) await flushPromises() expect(wrapper.text()).toContain('Upload Required Documents') expect(wrapper.text()).toContain('Complete Your Information') expect(wrapper.text()).toContain('Review & Submit') // Step 1 should show completed expect(wrapper.text()).toContain('Completed') }) it('enables submit button only when all steps complete', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) await flushPromises() let submitButton = wrapper.find('button').filter(n => n.text().includes('Submit')) expect(submitButton.exists()).toBe(false) // Complete all steps store.step2_completed = true store.step3_completed = true await wrapper.vm.$nextTick() submitButton = wrapper.find('button').filter(n => n.text().includes('Submit')) expect(submitButton.exists()).toBe(true) }) it('saves draft when button clicked', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) const saveSpy = vi.spyOn(store, 'saveDraft') await flushPromises() const saveButton = wrapper.find('button').filter(n => n.text().includes('Save Draft')) await saveButton.trigger('click') expect(saveSpy).toHaveBeenCalledWith(1, 'test-token') }) it('auto-saves every 30 seconds when changes detected', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) store.hasUnsavedChanges = true const saveSpy = vi.spyOn(store, 'saveDraft') await flushPromises() // Fast-forward 30 seconds vi.advanceTimersByTime(30000) expect(saveSpy).toHaveBeenCalled() }) it('navigates to step when card clicked', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) await flushPromises() const stepCards = wrapper.findAll('.cursor-pointer') await stepCards[1].trigger('click') // Click step 2 expect(wrapper.emitted()).toHaveProperty('navigate') expect(wrapper.emitted('navigate')[0]).toEqual([2]) }) it('shows correct status badge', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) await flushPromises() expect(wrapper.text()).toContain('In Progress') // Complete all steps store.step2_completed = true store.step3_completed = true await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('Ready to Submit') // Submit store.submitted = true await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('Submitted for Review') }) it('formats last saved time correctly', async () => { const wrapper = mount(ProgressDashboard, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentProgressStore() Object.assign(store, mockProgress) await flushPromises() expect(wrapper.text()).toContain('Just now') // Test 5 minutes ago store.lastSaved = Date.now() - 5 * 60 * 1000 await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('5m ago') }) }) ``` **Integration Tests:** ```javascript // spec/javascript/student/integration/progress-flow.spec.js describe('Progress Tracking Flow', () => { it('tracks progress across all steps', async () => { // 1. Load dashboard with incomplete steps // 2. Navigate to upload, complete it // 3. Return to dashboard, verify step 1 complete // 4. Navigate to form, partially complete // 5. Return to dashboard, verify progress bar // 6. Save draft // 7. Submit when all complete }) }) ``` **E2E Tests:** ```javascript // spec/system/student_progress_spec.rb RSpec.describe 'Student Progress Tracking', type: :system do let(:cohort) { create(:cohort, status: :in_progress) } let(:student) { create(:student, cohort: cohort) } let(:submission) { create(:submission, student: student, status: :pending) } let(:token) { submission.token } scenario 'student monitors and completes workflow' do # Start at dashboard visit "/student/submissions/#{submission.id}/dashboard?token=#{token}" expect(page).to have_content('Overall Progress') expect(page).to have_content('0% Complete') # Complete upload step click_link 'Upload Required Documents' attach_file('Government ID', Rails.root.join('spec/fixtures/files/test_id.jpg')) click_button 'Continue to Next Step' # Return to dashboard visit "/student/submissions/#{submission.id}/dashboard?token=#{token}" expect(page).to have_content('33% Complete') expect(page).to have_content('Completed', count: 1) # Complete form step click_link 'Complete Your Information' fill_in 'Full Name', with: 'John Doe' # ... complete form click_button 'Continue to Review' # Return to dashboard visit "/student/submissions/#{submission.id}/dashboard?token=#{token}" expect(page).to have_content('67% Complete') expect(page).to have_content('Ready to Submit') # Submit click_button 'Submit for Review' expect(page).to have_content('Submitted for Review') end scenario 'auto-save works in background', do visit "/student/submissions/#{submission.id}/dashboard?token=#{token}" # Navigate to form and start typing click_link 'Complete Your Information' fill_in 'Full Name', with: 'John' # Wait for auto-save sleep 30 # Return to dashboard visit "/student/submissions/#{submission.id}/dashboard?token=#{token}" # Should show last saved time expect(page).to have_content('Last Saved') end end ``` ##### Rollback Procedure **If progress data fails to load:** 1. Show error message with retry button 2. Display cached data if available 3. Allow manual refresh 4. Log error to monitoring **If submission fails:** 1. Show error message with details 2. Preserve all data 3. Allow user to save draft and try later 4. Provide support contact **If navigation fails:** 1. Show error message 2. Keep user on dashboard 3. Allow retry 4. Log navigation errors **Data Safety:** - All progress data stored server-side - Local cache for offline viewing - No data loss on navigation failures - Draft saves preserve all work ##### Risk Assessment **Low Risk** because: - Read-only operations for most of the flow - Simple state management - Standard navigation patterns - No complex business logic **Specific Risks:** 1. **Stale Progress**: Progress doesn't update after step completion - **Mitigation**: Refresh on dashboard load, manual refresh button 2. **Navigation Errors**: User navigates to invalid step - **Mitigation**: Validate step availability before navigation 3. **Auto-save Conflicts**: Multiple save operations overlap - **Mitigation**: Debounce save operations, use locks **Mitigation Strategies:** - Comprehensive testing of navigation flow - Refresh data on every dashboard visit - Clear error messages for navigation failures - Audit trail for all saves ##### Success Metrics - **Dashboard Load Time**: <1 second - **Save Success Rate**: 99.5% - **Auto-save Success**: 99% of scheduled saves - **Zero Data Loss**: 100% of drafts restore correctly - **User Satisfaction**: 90% can monitor progress without confusion - **Navigation Accuracy**: 100% of valid navigations succeed - **Support Tickets**: <1% related to progress tracking --- #### Story 5.4: Student Portal - Submission Confirmation & Status **Status**: Draft/Pending **Priority**: High **Epic**: Student Portal - Frontend Development **Estimated Effort**: 2 days **Risk Level**: Low ##### User Story **As a** Student, **I want** to review my complete submission and receive confirmation of successful submission, **So that** I can verify everything is correct and track when the sponsor signs. ##### Background After completing all three steps (upload, form filling, review), students need to: 1. **Final Review**: See a summary of all uploaded documents and filled fields 2. **Confirmation**: Receive clear confirmation that submission was successful 3. **Status Tracking**: Monitor progress through sponsor signature and TP review phases 4. **Email Notifications**: Get updates when status changes This is the final step in the student workflow. The student portal must provide: - Complete submission summary - Clear success confirmation - Real-time status updates - Email notification settings - Access to final document once complete **Status Flow:** - **Pending**: Student hasn't submitted yet - **In Review**: Submitted, waiting for sponsor - **Sponsor Signed**: Sponsor completed their part - **TP Reviewed**: TP completed final review - **Completed**: All parties finished, document finalized **Integration Point**: This story completes the student portal frontend (Phase 5). It connects to the sponsor portal (Phase 6) and TP review (Phase 4). ##### Technical Implementation Notes **Vue 3 Component Structure:** ```vue ``` **Pinia Store:** ```typescript // app/javascript/student/stores/status.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { SubmissionAPI } from '@/student/api/submission' import type { SubmissionStatus, DocumentSummary, FieldSummary } from '@/student/types' export const useStudentStatusStore = defineStore('studentStatus', { state: () => ({ cohortName: '', submittedAt: null as string | null, studentCompletedAt: null as string | null, sponsorSigned: false, sponsorSignedAt: null as string | null, tpReviewed: false, tpReviewedAt: null as string | null, isCompleted: false, completedAt: null as string | null, uploadedDocuments: [] as DocumentSummary[], completedFields: [] as FieldSummary[], estimatedCompletion: null as string | null, isLoading: false, error: null as string | null }), getters: { currentStatus: (state) => { if (state.isCompleted) return 'completed' if (state.tpReviewed) return 'tp_reviewed' if (state.sponsorSigned) return 'sponsor_signed' if (state.submittedAt) return 'in_review' return 'not_submitted' } }, actions: { async fetchStatus(submissionId: number, token: string): Promise { this.isLoading = true this.error = null try { const response = await SubmissionAPI.getStatus(submissionId, token) this.cohortName = response.cohort_name this.submittedAt = response.submitted_at this.studentCompletedAt = response.student_completed_at this.sponsorSigned = response.sponsor_signed this.sponsorSignedAt = response.sponsor_signed_at this.tpReviewed = response.tp_reviewed this.tpReviewedAt = response.tp_reviewed_at this.isCompleted = response.is_completed this.completedAt = response.completed_at this.uploadedDocuments = response.uploaded_documents || [] this.completedFields = response.completed_fields || [] this.estimatedCompletion = response.estimated_completion } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to fetch status' console.error('Fetch status error:', error) throw error } finally { this.isLoading = false } } } }) ``` **API Layer:** ```typescript // app/javascript/student/api/submission.ts (extended) export interface DocumentSummary { id: number name: string size: number uploaded_at: string } export interface FieldSummary { id: string label: string value: string type: string } export interface SubmissionStatus { cohort_name: string submitted_at: string | null student_completed_at: string | null sponsor_signed: boolean sponsor_signed_at: string | null tp_reviewed: boolean tp_reviewed_at: string | null is_completed: boolean completed_at: string | null uploaded_documents?: DocumentSummary[] completed_fields?: FieldSummary[] estimated_completion?: string } export const SubmissionAPI = { // ... existing methods async getStatus(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/status`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (!response.ok) { if (response.status === 403) { throw new Error('Access denied or token expired') } if (response.status === 404) { throw new Error('Submission not found') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() } } ``` **Type Definitions:** ```typescript // app/javascript/student/types/index.ts (extended) export interface DocumentSummary { id: number name: string size: number uploaded_at: string } export interface FieldSummary { id: string label: string value: string type: string } export interface SubmissionStatus { cohort_name: string submitted_at: string | null student_completed_at: string | null sponsor_signed: boolean sponsor_signed_at: string | null tp_reviewed: boolean tp_reviewed_at: string | null is_completed: boolean completed_at: string | null uploaded_documents?: DocumentSummary[] completed_fields?: FieldSummary[] estimated_completion?: string } ``` **Design System Compliance:** Per FR28, all Status components must use design system assets from: - `@.claude/skills/frontend-design/SKILL.md` - Design tokens - `@.claude/skills/frontend-design/design-system/` - SVG assets Specific requirements: - **Colors**: - Success (Green-600): `#16A34A` for completed states - Primary (Blue-600): `#2563EB` for active elements - Warning (Yellow-600): `#CA8A04` for pending states - Neutral (Gray-500): `#6B7280` for text - Info (Purple-600): `#7C3AED` for sponsor pending - **Spacing**: 4px base unit, 1.5rem (24px) for sections, 1rem (16px) for gaps - **Typography**: - Headings: 24px (h1), 20px (h2), 16px (h3) - Body: 16px base, 14px for secondary - Labels: 12px uppercase, letter-spacing 0.05em - **Icons**: Use SVG icons from design system for: - Checkmark (completed) - Clock (pending) - Document (files) - Signature (sponsor) - Eye (review) - Download (export) - Refresh (update) - Mail (contact) - **Layout**: - Max width: 4xl (896px) - Card corners: rounded-lg (8px) - Shadow: shadow-md for cards - Timeline: vertical with connector lines - **Accessibility**: - ARIA labels on all buttons - Keyboard navigation for interactive elements - Screen reader announcements for status changes - Focus indicators on all interactive elements - Color contrast ratio minimum 4.5:1 ##### Acceptance Criteria **Functional:** 1. ✅ Component loads status data on mount 2. ✅ Shows success banner on fresh submission 3. ✅ Displays timeline with all 4 stages 4. ✅ Updates timeline icons based on status 5. ✅ Shows uploaded documents list with sizes 6. ✅ Shows completed fields with values 7. ✅ Displays estimated completion time 8. ✅ Refresh button fetches latest status 9. ✅ Download button works when completed 10. ✅ Notification settings can be updated 11. ✅ Contact support opens email client 12. ✅ Polling updates status every 60 seconds **UI/UX:** 1. ✅ Success banner can be dismissed 2. ✅ Timeline shows connector lines between stages 3. ✅ Status badges use color-coded styling 4. ✅ Current stage is highlighted 5. ✅ Completed stages show green checkmarks 6. ✅ Pending stages show appropriate icons 7. ✅ Document list shows file sizes formatted 8. ✅ Field values are displayed clearly 9. ✅ Toast notifications show after actions 10. ✅ Mobile-responsive design **Integration:** 1. ✅ API endpoint: `GET /api/student/submissions/{id}/status` 2. ✅ API endpoint: `GET /api/student/submissions/{id}/download` 3. ✅ API endpoint: `POST /api/student/submissions/{id}/notifications` 4. ✅ Token authentication in headers 5. ✅ Polling mechanism with cleanup 6. ✅ Data reflects actual submission state **Security:** 1. ✅ Token-based authentication required 2. ✅ Authorization check: student can only view their submission 3. ✅ Download endpoint validates completion 4. ✅ Rate limiting on status refresh (max 30 per hour) 5. ✅ Audit log of all downloads **Quality:** 1. ✅ Polling stops when component unmounts 2. ✅ Error handling for failed API calls 3. ✅ No duplicate polling intervals 4. ✅ Performance: loads in <1 second 5. ✅ Data consistency across refreshes ##### Integration Verification (IV1-4) **IV1: API Integration** - `SubmissionStatus.vue` calls `SubmissionAPI.getStatus()` on mount - `SubmissionStatus.vue` calls `SubmissionAPI.getStatus()` in polling interval - `SubmissionStatus.vue` calls download endpoint for PDF - `SubmissionStatus.vue` calls notification settings endpoint - All endpoints use `Authorization: Bearer {token}` header **IV2: Pinia Store** - `studentStatusStore.cohortName` holds cohort information - `studentStatusStore.sponsorSigned` tracks sponsor completion - `studentStatusStore.tpReviewed` tracks TP completion - `studentStatusStore.isCompleted` tracks final completion - `studentStatusStore.uploadedDocuments` holds document summaries - `studentStatusStore.completedFields` holds field summaries **IV3: Getters** - `currentStatus()` returns status string for badge display - State reflects real-time updates from polling **IV4: Token Routing** - SubmissionStatus receives `token` prop from parent - Parent loads token from URL param (`?token=...`) - All API calls pass token to store actions ##### Test Requirements **Component Specs:** ```javascript // spec/javascript/student/views/SubmissionStatus.spec.js import { mount, flushPromises } from '@vue/test-utils' import SubmissionStatus from '@/student/views/SubmissionStatus.vue' import { useStudentStatusStore } from '@/student/stores/status' import { createPinia, setActivePinia } from 'pinia' describe('SubmissionStatus', () => { const mockStatus = { cohort_name: 'Summer 2025', submitted_at: '2025-01-15T10:00:00Z', student_completed_at: '2025-01-15T09:30:00Z', sponsor_signed: false, sponsor_signed_at: null, tp_reviewed: false, tp_reviewed_at: null, is_completed: false, completed_at: null, uploaded_documents: [ { id: 1, name: 'ID Card.pdf', size: 1024000, uploaded_at: '2025-01-15T09:00:00Z' } ], completed_fields: [ { id: 'name', label: 'Full Name', value: 'John Doe', type: 'text' } ], estimated_completion: '2 days' } beforeEach(() => { setActivePinia(createPinia()) vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('renders success banner on fresh submission', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' }, global: { mocks: { $route: { query: { new: 'true' } } } } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() expect(wrapper.text()).toContain('Submission Successful!') }) it('displays correct status badge', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() expect(wrapper.text()).toContain('In Review') }) it('renders timeline with correct stages', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() expect(wrapper.text()).toContain('Student Completed') expect(wrapper.text()).toContain('Sponsor Signature') expect(wrapper.text()).toContain('TP Final Review') expect(wrapper.text()).toContain('Final Document Ready') }) it('shows uploaded documents', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() expect(wrapper.text()).toContain('ID Card.pdf') expect(wrapper.text()).toContain('1 MB') }) it('shows completed fields', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() expect(wrapper.text()).toContain('Full Name') expect(wrapper.text()).toContain('John Doe') }) it('refreshes status when button clicked', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) const fetchSpy = vi.spyOn(store, 'fetchStatus') await flushPromises() const refreshButton = wrapper.find('button').filter(n => n.text().includes('Refresh')) await refreshButton.trigger('click') expect(fetchSpy).toHaveBeenCalledWith(1, 'test-token') }) it('polls for updates every 60 seconds', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) const fetchSpy = vi.spyOn(store, 'fetchStatus') await flushPromises() // Fast-forward 60 seconds vi.advanceTimersByTime(60000) expect(fetchSpy).toHaveBeenCalledTimes(2) // Once on mount, once after 60s }) it('hides success banner when dismissed', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' }, global: { mocks: { $route: { query: { new: 'true' } } } } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() expect(wrapper.text()).toContain('Submission Successful!') const dismissButton = wrapper.find('button[aria-label="Close"]') await dismissButton.trigger('click') expect(wrapper.text()).not.toContain('Submission Successful!') }) it('shows download button only when completed', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() let downloadButton = wrapper.find('button').filter(n => n.text().includes('Download')) expect(downloadButton.exists()).toBe(false) // Mark as completed store.is_completed = true store.completed_at = '2025-01-20T10:00:00Z' await wrapper.vm.$nextTick() downloadButton = wrapper.find('button').filter(n => n.text().includes('Download')) expect(downloadButton.exists()).toBe(true) }) it('updates notification settings', async () => { const wrapper = mount(SubmissionStatus, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentStatusStore() Object.assign(store, mockStatus) await flushPromises() const checkbox = wrapper.find('input[type="checkbox"]') await checkbox.trigger('change') // Should make API call expect(wrapper.vm.notificationSettings.statusUpdates).toBe(true) }) }) ``` **Integration Tests:** ```javascript // spec/javascript/student/integration/status-flow.spec.js describe('Submission Status Flow', () => { it('tracks complete submission lifecycle', async () => { // 1. Load status for pending submission // 2. Verify initial state (submitted, waiting for sponsor) // 3. Simulate sponsor signature // 4. Verify status update // 5. Simulate TP review // 6. Verify final completion // 7. Download final document }) }) ``` **E2E Tests:** ```javascript // spec/system/student_submission_status_spec.rb RSpec.describe 'Student Submission Status', type: :system do let(:cohort) { create(:cohort, status: :in_progress) } let(:student) { create(:student, cohort: cohort) } let(:submission) { create(:submission, student: student, status: :submitted) } let(:token) { submission.token } scenario 'student monitors submission through completion' do visit "/student/submissions/#{submission.id}/status?token=#{token}" # Verify initial status expect(page).to have_content('In Review') expect(page).to have_content('Student Completed') expect(page).to have_content('Waiting for sponsor to review and sign') # Simulate sponsor signing (in test, we'd update the submission state) submission.update!(sponsor_signed: true, sponsor_signed_at: Time.current) click_button 'Refresh Status' expect(page).to have_content('Sponsor Signed') # Simulate TP review submission.update!(tp_reviewed: true, tp_reviewed_at: Time.current, status: :completed) click_button 'Refresh Status' expect(page).to have_content('Completed') expect(page).to have_button('Download Signed Document') # Download document click_button 'Download Signed Document' # Verify download initiated end scenario 'auto-polling updates status', do visit "/student/submissions/#{submission.id}/status?token=#{token}" expect(page).to have_content('In Review') # Update in background submission.update!(sponsor_signed: true) # Wait for polling interval sleep 60 expect(page).to have_content('Sponsor Signed') end end ``` ##### Rollback Procedure **If status fails to load:** 1. Show error message with retry button 2. Display cached status if available 3. Allow manual refresh 4. Log error to monitoring **If download fails:** 1. Show error message with retry option 2. Verify document is actually ready 3. Check server-side PDF generation 4. Provide alternative download method **If polling causes issues:** 1. Reduce polling frequency to 2 minutes 2. Implement exponential backoff 3. Stop polling if user navigates away 4. Show manual refresh option **If notification settings fail:** 1. Show error message 2. Preserve current settings 3. Allow retry 4. Log to monitoring **Data Safety:** - All status data is read-only - No data mutation in this component - Download generates fresh PDF from server - Notification settings stored server-side ##### Risk Assessment **Low Risk** because: - Read-only operations (except notifications) - Simple state management - Standard polling mechanism - No complex business logic **Specific Risks:** 1. **Stale Status**: Polling doesn't capture all updates - **Mitigation**: Refresh on visibility change, manual refresh button 2. **Download Failures**: PDF generation may fail - **Mitigation**: Async generation with email notification fallback 3. **Polling Memory**: Interval not cleaned up properly - **Mitigation**: `onUnmounted` hook with `clearInterval` 4. **Token Expiration**: Long-running sessions may expire - **Mitigation**: Token renewal mechanism (Story 4.9) **Mitigation Strategies:** - Comprehensive error handling for all API calls - Clear user feedback for all actions - Fallback mechanisms for downloads - Performance testing with long polling sessions ##### Success Metrics - **Status Accuracy**: 100% match between UI and actual state - **Polling Success**: 99% of polling attempts succeed - **Download Success**: 98% of downloads complete successfully - **Load Time**: <1 second for status page - **User Satisfaction**: 95% can track submission without confusion - **Zero Data Loss**: 100% of status data preserved across refreshes - **Support Tickets**: <1% related to status tracking --- #### Story 5.5: Student Portal - Email Notifications & Reminders **Status**: Draft/Pending **Priority**: High **Epic**: Student Portal - Frontend Development **Estimated Effort**: 2 days **Risk Level**: Low ##### User Story **As a** Student, **I want** to receive email notifications for status updates and reminders to complete my submission, **So that** I can stay informed and complete my work on time without constantly checking the portal. ##### Background Students need to stay informed about their submission progress without manually checking the portal. The system should provide: 1. **Initial Invitation Email**: Sent when cohort is created, contains access link with token 2. **Reminder Emails**: Sent if student hasn't started or hasn't completed after certain time 3. **Status Update Emails**: Sent when key milestones are reached 4. **Final Completion Email**: Sent when document is fully signed and ready **Email Types:** - **Invitation**: "You've been invited to join [Cohort Name]" - **Reminder - Not Started**: "Don't forget to complete your submission" - **Reminder - Incomplete**: "You're almost there! Finish your submission" - **Status - Sponsor Signed**: "Sponsor has signed your document" - **Status - Completed**: "Your document is ready!" - **TP Reminder**: (TP sends to students who haven't completed) **Key Requirements:** - Emails contain secure, time-limited links - Links use JWT tokens (Story 2.2, 2.3) - Unsubscribe option in all emails - Email preferences can be managed - Reminder frequency is configurable - Email templates are customizable **Integration Point**: This story connects to the email system (Story 2.2, 2.3) and provides the student-facing notification preferences. ##### Technical Implementation Notes **Vue 3 Component Structure:** ```vue ``` **Email Template Components:** ```vue ``` **Pinia Store:** ```typescript // app/javascript/student/stores/notifications.ts import { defineStore } from 'pinia' import { ref } from 'vue' import { NotificationAPI } from '@/student/api/notification' export interface NotificationPreferences { status_updates: boolean reminders: boolean sponsor_alerts: boolean completion_alerts: boolean reminder_frequency: string cohort_name: string student_name: string } export const useStudentNotificationStore = defineStore('studentNotifications', { state: () => ({ preferences: null as NotificationPreferences | null, isLoading: false, error: null as string | null }), actions: { async fetchPreferences(submissionId: number, token: string): Promise { this.isLoading = true this.error = null try { const response = await NotificationAPI.getPreferences(submissionId, token) this.preferences = response return response } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to fetch preferences' console.error('Fetch preferences error:', error) throw error } finally { this.isLoading = false } }, async updatePreferences( submissionId: number, token: string, preferences: Partial ): Promise { this.isLoading = true this.error = null try { await NotificationAPI.updatePreferences(submissionId, token, preferences) // Update local state if (this.preferences) { this.preferences = { ...this.preferences, ...preferences } } } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to update preferences' console.error('Update preferences error:', error) throw error } finally { this.isLoading = false } } } }) ``` **API Layer:** ```typescript // app/javascript/student/api/notification.ts export interface NotificationPreferences { status_updates: boolean reminders: boolean sponsor_alerts: boolean completion_alerts: boolean reminder_frequency: string cohort_name: string student_name: string } export interface UpdatePreferencesRequest { status_updates?: boolean reminders?: boolean sponsor_alerts?: boolean completion_alerts?: boolean reminder_frequency?: string } export const NotificationAPI = { async getPreferences(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/notification-preferences`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (!response.ok) { if (response.status === 403) { throw new Error('Access denied or token expired') } if (response.status === 404) { throw new Error('Submission not found') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() }, async updatePreferences( submissionId: number, token: string, preferences: UpdatePreferencesRequest ): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/notification-preferences`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(preferences) }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } }, async sendInvitationEmail(submissionId: number, token: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/send-invitation`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } }, async sendReminderEmail(submissionId: number, token: string, type: string): Promise { const response = await fetch(`/api/student/submissions/${submissionId}/send-reminder`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ type }) }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } } } ``` **Type Definitions:** ```typescript // app/javascript/student/types/index.ts (extended) export interface NotificationPreferences { status_updates: boolean reminders: boolean sponsor_alerts: boolean completion_alerts: boolean reminder_frequency: string cohort_name: string student_name: string } export interface UpdatePreferencesRequest { status_updates?: boolean reminders?: boolean sponsor_alerts?: boolean completion_alerts?: boolean reminder_frequency?: string } export interface EmailTemplate { subject: string body: string variables: string[] } ``` **Design System Compliance:** Per FR28, all Notification components must use design system assets from: - `@.claude/skills/frontend-design/SKILL.md` - Design tokens - `@.claude/skills/frontend-design/design-system/` - SVG assets Specific requirements: - **Colors**: - Primary (Blue-600): `#2563EB` for buttons and links - Warning (Red-600): `#DC2626` for unsubscribe section - Success (Green-600): `#16A34A` for saved indicators - Neutral (Gray-500): `#6B7280` for text - **Spacing**: 4px base unit, 1.5rem (24px) for sections, 1rem (16px) for field gaps - **Typography**: - Headings: 24px (h1), 20px (h2), 16px (h3) - Body: 16px base, 14px for descriptions - Small: 12px for helper text - **Icons**: Use SVG icons from design system for: - Mail (email) - Bell (notifications) - Checkmark (saved) - Trash (unsubscribe) - Refresh (reset) - Settings (preferences) - **Layout**: - Max width: 3xl (48rem / 768px) - Card corners: rounded-lg (8px) - Shadow: shadow-md for cards - Toggle switches: right-aligned - **Accessibility**: - ARIA labels on all toggles - Keyboard navigation for checkboxes - Screen reader announcements for save actions - Focus indicators on all interactive elements - Color contrast ratio minimum 4.5:1 - Warning section clearly marked ##### Acceptance Criteria **Functional:** 1. ✅ Component loads existing preferences on mount 2. ✅ All 4 notification toggles work correctly 3. ✅ Reminder frequency radio buttons work 4. ✅ Save button persists changes to server 5. ✅ Reset to defaults restores original settings 6. ✅ Unsubscribe all disables all notifications 7. ✅ Confirmation dialog before unsubscribe 8. ✅ Email preview updates based on settings 9. ✅ Shows success toast after save 10. ✅ Loading state during API calls **UI/UX:** 1. ✅ Toggles show clear on/off states 2. ✅ Settings organized in logical sections 3. ✅ Warning section visually distinct (red border) 4. ✅ Email preview shows realistic example 5. ✅ Success toast appears for 2 seconds 6. ✅ Loading overlay blocks interaction 7. ✅ Mobile-responsive design 8. ✅ Hover states on all interactive elements **Integration:** 1. ✅ API endpoint: `GET /api/student/submissions/{id}/notification-preferences` 2. ✅ API endpoint: `PUT /api/student/submissions/{id}/notification-preferences` 3. ✅ API endpoint: `POST /api/student/submissions/{id}/send-invitation` 4. ✅ API endpoint: `POST /api/student/submissions/{id}/send-reminder` 5. ✅ Token authentication in headers 6. ✅ Settings persist across sessions **Security:** 1. ✅ Token-based authentication required 2. ✅ Authorization check: student can only manage their preferences 3. ✅ Rate limiting on update endpoint (max 10 per hour) 4. ✅ Validation of reminder frequency values 5. ✅ Audit log of all preference changes **Quality:** 1. ✅ No duplicate API calls on rapid clicks 2. ✅ Error handling for failed API calls 3. ✅ State consistency between UI and server 4. ✅ Performance: loads in <1 second 5. ✅ Browser compatibility: Chrome, Firefox, Safari, Edge ##### Integration Verification (IV1-4) **IV1: API Integration** - `EmailPreferences.vue` calls `NotificationAPI.getPreferences()` on mount - `EmailPreferences.vue` calls `NotificationAPI.updatePreferences()` on save - `EmailPreferences.vue` calls `NotificationAPI.sendInvitationEmail()` (if needed) - All endpoints use `Authorization: Bearer {token}` header **IV2: Pinia Store** - `studentNotificationStore.preferences` holds notification settings - `studentNotificationStore.fetchPreferences()` loads settings - `studentNotificationStore.updatePreferences()` saves changes **IV3: Getters** - Store provides computed properties for UI display - Settings are reactive and update UI immediately **IV4: Token Routing** - EmailPreferences receives `token` prop from parent - Parent loads token from URL param (`?token=...`) - All API calls pass token to store actions ##### Test Requirements **Component Specs:** ```javascript // spec/javascript/student/views/EmailPreferences.spec.js import { mount, flushPromises } from '@vue/test-utils' import EmailPreferences from '@/student/views/EmailPreferences.vue' import { useStudentNotificationStore } from '@/student/stores/notifications' import { createPinia, setActivePinia } from 'pinia' describe('EmailPreferences', () => { const mockPreferences = { status_updates: true, reminders: true, sponsor_alerts: true, completion_alerts: true, reminder_frequency: '48h', cohort_name: 'Summer 2025', student_name: 'John Doe' } beforeEach(() => { setActivePinia(createPinia()) }) it('loads preferences on mount', async () => { const wrapper = mount(EmailPreferences, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentNotificationStore() store.preferences = mockPreferences await flushPromises() expect(wrapper.vm.settings.statusUpdates).toBe(true) expect(wrapper.vm.settings.reminderFrequency).toBe('48h') }) it('toggles notification settings', async () => { const wrapper = mount(EmailPreferences, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentNotificationStore() store.preferences = mockPreferences await flushPromises() const statusToggle = wrapper.find('input[type="checkbox"]') await statusToggle.trigger('click') expect(wrapper.vm.settings.statusUpdates).toBe(false) }) it('saves settings when save button clicked', async () => { const wrapper = mount(EmailPreferences, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentNotificationStore() store.preferences = mockPreferences const updateSpy = vi.spyOn(store, 'updatePreferences') await flushPromises() const saveButton = wrapper.find('button').filter(n => n.text().includes('Save')) await saveButton.trigger('click') expect(updateSpy).toHaveBeenCalledWith(1, 'test-token', expect.any(Object)) }) it('resets to defaults', async () => { const wrapper = mount(EmailPreferences, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentNotificationStore() store.preferences = mockPreferences await flushPromises() // Change some settings wrapper.vm.settings.reminders = false await wrapper.vm.$nextTick() // Reset const resetButton = wrapper.find('button').filter(n => n.text().includes('Reset')) await resetButton.trigger('click') expect(wrapper.vm.settings.reminders).toBe(true) }) it('unsubscribes all with confirmation', async () => { const wrapper = mount(EmailPreferences, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentNotificationStore() store.preferences = mockPreferences const updateSpy = vi.spyOn(store, 'updatePreferences') await flushPromises() // Mock confirm to return true window.confirm = vi.fn(() => true) const unsubscribeButton = wrapper.find('button').filter(n => n.text().includes('Unsubscribe')) await unsubscribeButton.trigger('click') expect(window.confirm).toHaveBeenCalled() expect(wrapper.vm.settings.statusUpdates).toBe(false) expect(wrapper.vm.settings.reminders).toBe(false) expect(updateSpy).toHaveBeenCalled() }) it('shows loading state during save', async () => { const wrapper = mount(EmailPreferences, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentNotificationStore() store.preferences = mockPreferences store.isLoading = true await flushPromises() expect(wrapper.find('.loading-overlay').exists()).toBe(true) }) it('shows success toast after save', async () => { const wrapper = mount(EmailPreferences, { props: { submissionId: 1, token: 'test-token' } }) const store = useStudentNotificationStore() store.preferences = mockPreferences await flushPromises() const saveButton = wrapper.find('button').filter(n => n.text().includes('Save')) await saveButton.trigger('click') await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('Preferences saved successfully') }) }) ``` **Integration Tests:** ```javascript // spec/javascript/student/integration/notification-flow.spec.js describe('Notification Preferences Flow', () => { it('manages complete notification workflow', async () => { // 1. Load preferences // 2. Disable all notifications // 3. Save changes // 4. Reload page to verify persistence // 5. Reset to defaults // 6. Verify all enabled again }) }) ``` **E2E Tests:** ```javascript // spec/system/student_notification_preferences_spec.rb RSpec.describe 'Student Notification Preferences', type: :system do let(:cohort) { create(:cohort, status: :in_progress) } let(:student) { create(:student, cohort: cohort) } let(:submission) { create(:submission, student: student, status: :pending) } let(:token) { submission.token } scenario 'student manages email preferences' do visit "/student/submissions/#{submission.id}/preferences?token=#{token}" expect(page).to have_content('Email Notification Preferences') # Disable status updates uncheck 'Status Updates' click_button 'Save Settings' expect(page).to have_content('Preferences saved successfully') # Reload and verify visit "/student/submissions/#{submission.id}/preferences?token=#{token}" expect(page).not_to have_checked_field('Status Updates') # Unsubscribe all click_button 'Unsubscribe from All' page.driver.browser.switch_to.alert.accept expect(page).to have_content('Preferences saved successfully') expect(page).not_to have_checked_field('Status Updates') expect(page).not_to have_checked_field('Completion Reminders') end scenario 'reset to defaults', do visit "/student/submissions/#{submission.id}/preferences?token=#{token}" # Change settings uncheck 'Status Updates' uncheck 'Completion Reminders' click_button 'Save Settings' # Reset click_button 'Reset to Defaults' expect(page).to have_checked_field('Status Updates') expect(page).to have_checked_field('Completion Reminders') end end ``` ##### Rollback Procedure **If preferences fail to load:** 1. Show error message with retry button 2. Use default settings temporarily 3. Log error to monitoring 4. Allow manual refresh **If save fails:** 1. Show error message with retry option 2. Preserve unsaved changes in UI 3. Check network connection 4. Log failure for investigation **If unsubscribe fails:** 1. Show error message 2. Don't change settings 3. Provide alternative (contact support) 4. Log for security audit **If email preview breaks:** 1. Hide preview section 2. Show fallback text 3. Continue allowing settings changes 4. Log template error **Data Safety:** - All settings stored server-side - No data loss if UI fails - Changes only applied after successful save - Unsubscribe requires confirmation ##### Risk Assessment **Low Risk** because: - Simple CRUD operations - Standard form handling - No complex business logic - Read-mostly operations **Specific Risks:** 1. **Email Deliverability**: Emails may not reach students - **Mitigation**: Use reputable SMTP, monitor delivery rates 2. **Rate Limiting**: Too many emails sent - **Mitigation**: Implement queue system, respect frequency settings 3. **Unsubscribe Compliance**: Legal requirements (CAN-SPAM, GDPR) - **Mitigation**: Clear unsubscribe, honor all requests immediately 4. **Token Expiration**: Links in emails may expire - **Mitigation**: Long expiry (7 days), renewal mechanism **Mitigation Strategies:** - Comprehensive email testing - Monitor email delivery metrics - Implement email queue with retry - Clear unsubscribe in all emails - Regular compliance audits ##### Success Metrics - **Save Success Rate**: 99% of preference updates succeed - **Email Delivery**: 98% of emails reach inbox (not spam) - **User Engagement**: 80% of students enable at least one notification - **Unsubscribe Rate**: <5% (industry standard is 0.2-0.5%) - **Reminder Effectiveness**: 60% of reminders lead to completion - **Support Tickets**: <2% related to email notifications - **Load Time**: <1 second for preferences page --- --- ### 6.6 Phase 6: Frontend - Sponsor Portal **Focus**: Sponsor-facing interface for bulk signing and progress tracking This phase implements the sponsor portal, where sponsors access their assigned cohorts via email links (no account creation required). Sponsors can review all student documents in a cohort and sign once to complete all submissions. The portal emphasizes efficiency, bulk operations, and clear progress indicators. --- #### Story 6.1: Sponsor Portal - Cohort Dashboard & Bulk Signing Interface **Status**: Draft/Pending **Priority**: High **Epic**: Sponsor Portal - Frontend Development **Estimated Effort**: 3 days **Risk Level**: Medium ##### User Story **As a** Sponsor, **I want** to view all pending student documents in a cohort and sign them all at once, **So that** I can efficiently complete my signing responsibility without reviewing each submission individually. ##### Background Sponsors receive a single email per cohort (per FR12) with a secure link to the sponsor portal. Upon accessing the portal, they see: 1. **Cohort Overview**: Name, total students, completion status 2. **Student List**: All students with their completion status 3. **Bulk Signing**: Sign once to apply to all pending submissions 4. **Progress Tracking**: Real-time updates of signing progress **Key Requirements:** - Single signing action for entire cohort - Preview of what will be signed - Clear indication of which students are affected - Confirmation before signing - Immediate status update after signing - Email confirmation to TP after sponsor signs **Workflow:** 1. Sponsor clicks email link with token 2. Portal loads cohort dashboard 3. Sponsor reviews student list 4. Sponsor signs once (signature or typed name) 5. System applies signature to all student submissions 6. Status updates to "Sponsor Signed" 7. TP receives notification 8. Students receive status update email **Integration Point**: This story connects to the email system (Story 2.2, 2.3) and the backend signing workflow (Stories 2.5, 2.6). ##### Technical Implementation Notes **Vue 3 Component Structure:** ```vue ``` **Pinia Store:** ```typescript // app/javascript/sponsor/stores/cohort.ts import { defineStore } from 'pinia' import { ref } from 'vue' import { CohortAPI } from '@/sponsor/api/cohort' import type { Cohort, Student, BulkSignRequest } from '@/sponsor/types' export const useSponsorCohortStore = defineStore('sponsorCohort', { state: () => ({ cohortName: '', cohortId: null as number | null, students: [] as Student[], isLoading: false, error: null as string | null }), actions: { async fetchCohort(cohortId: number, token: string): Promise { this.isLoading = true this.error = null try { const response = await CohortAPI.getById(cohortId, token) this.cohortName = response.name this.cohortId = response.id this.students = response.students } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to fetch cohort' console.error('Fetch cohort error:', error) throw error } finally { this.isLoading = false } }, async bulkSign(cohortId: number, token: string, data: BulkSignRequest): Promise { this.isLoading = true this.error = null try { await CohortAPI.bulkSign(cohortId, token, data) // Update local state this.students = this.students.map(s => ({ ...s, signed: true, signed_at: new Date().toISOString() })) } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to sign documents' console.error('Bulk sign error:', error) throw error } finally { this.isLoading = false } } } }) ``` **API Layer:** ```typescript // app/javascript/sponsor/api/cohort.ts export interface Student { id: number name: string email: string signed: boolean signed_at?: string submitted_at: string } export interface CohortResponse { id: number name: string students: Student[] } export interface BulkSignRequest { signature_method: 'draw' | 'type' signature_data: string | null signature_type: 'canvas' | 'text' } export const CohortAPI = { async getById(cohortId: number, token: string): Promise { const response = await fetch(`/api/sponsor/cohorts/${cohortId}`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (!response.ok) { if (response.status === 403) { throw new Error('Access denied or token expired') } if (response.status === 404) { throw new Error('Cohort not found') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() }, async bulkSign(cohortId: number, token: string, data: BulkSignRequest): Promise { const response = await fetch(`/api/sponsor/cohorts/${cohortId}/bulk-sign`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) if (!response.ok) { if (response.status === 409) { throw new Error('Cohort already signed or has no pending submissions') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } } } ``` **Type Definitions:** ```typescript // app/javascript/sponsor/types/index.ts export interface Student { id: number name: string email: string signed: boolean signed_at?: string submitted_at: string } export interface CohortResponse { id: number name: string students: Student[] } export interface BulkSignRequest { signature_method: 'draw' | 'type' signature_data: string | null signature_type: 'canvas' | 'text' } ``` **Design System Compliance:** Per FR28, all Sponsor Portal components must use design system assets from: - `@.claude/skills/frontend-design/SKILL.md` - Design tokens - `@.claude/skills/frontend-design/design-system/` - SVG assets Specific requirements: - **Colors**: - Primary (Blue-600): `#2563EB` for headers and buttons - Success (Green-600): `#16A34A` for completed/signed states - Warning (Yellow-600): `#CA8A04` for pending states - Info (Purple-600): `#7C3AED` for stats cards - Neutral (Gray-500): `#6B7280` for text - **Spacing**: 4px base unit, 1.5rem (24px) for sections, 0.75rem (12px) for gaps - **Typography**: - Headings: 24px (h1), 20px (h2), 16px (h3) - Body: 16px base, 14px for descriptions - Labels: 12px uppercase, letter-spacing 0.05em - **Icons**: Use SVG icons from design system for: - Pen (signature) - Checkmark (completed) - Clock (pending) - Document (files) - Search (filter) - Refresh (update) - Eye (preview) - **Layout**: - Max width: 7xl (1280px) - Card corners: rounded-lg (8px) - Shadow: shadow-md for cards - Table: grid layout on desktop, cards on mobile - **Accessibility**: - ARIA labels on all buttons - Keyboard navigation for modals - Screen reader announcements for signing actions - Focus indicators on all interactive elements - Color contrast ratio minimum 4.5:1 - Signature canvas has clear instructions ##### Acceptance Criteria **Functional:** 1. ✅ Component loads cohort data on mount 2. ✅ Shows correct student counts (total, pending, completed) 3. ✅ Displays student list with status 4. ✅ Search filters students by name/email 5. ✅ Status filter works correctly 6. ✅ Signature method selection works 7. ✅ Canvas signature drawing works 8. ✅ Typed signature input works 9. ✅ Clear signature button works 10. ✅ Confirmation checkbox required 11. ✅ Preview modal shows affected students 12. ✅ Preview modal shows signature preview 13. ✅ Bulk sign executes successfully 14. ✅ Success modal appears after signing 15. ✅ Data refreshes after signing **UI/UX:** 1. ✅ Desktop: Table layout with 5 columns 2. ✅ Mobile: Card layout with stacked information 3. ✅ Status badges use color-coded styling 4. ✅ Progress bars animate smoothly 5. ✅ Quick stats cards show key metrics 6. ✅ Signature canvas shows drawing feedback 7. ✅ Modals are centered and scrollable 8. ✅ Loading overlay blocks interaction 9. ✅ Success modal shows confirmation 10. ✅ Toast notifications for errors **Integration:** 1. ✅ API endpoint: `GET /api/sponsor/cohorts/{id}` 2. ✅ API endpoint: `POST /api/sponsor/cohorts/{id}/bulk-sign` 3. ✅ Token authentication in headers 4. ✅ Signature data sent correctly 5. ✅ State updates after signing **Security:** 1. ✅ Token-based authentication required 2. ✅ Authorization check: sponsor can only sign their assigned cohorts 3. ✅ Validation: all required fields before signing 4. ✅ Rate limiting on bulk sign (max 5 per hour) 5. ✅ Audit log of all signing actions **Quality:** 1. ✅ No duplicate signing attempts 2. ✅ Error handling for failed API calls 3. ✅ Data consistency after signing 4. ✅ Performance: handles 100+ students 5. ✅ Browser compatibility: Chrome, Firefox, Safari, Edge ##### Integration Verification (IV1-4) **IV1: API Integration** - `CohortDashboard.vue` calls `CohortAPI.getById()` on mount - `CohortDashboard.vue` calls `CohortAPI.bulkSign()` on confirm - All endpoints use `Authorization: Bearer {token}` header - Signature data format matches backend expectations **IV2: Pinia Store** - `sponsorCohortStore.cohortName` holds cohort information - `sponsorCohortStore.students` holds student list - `sponsorCohortStore.bulkSign()` updates student states - State reflects signing completion **IV3: Getters** - `pendingStudents` counts unsigned students - `completedStudents` counts signed students - `filteredStudents` applies search and filter - `pendingStudentsList` shows affected students in preview **IV4: Token Routing** - CohortDashboard receives `token` prop from parent - Parent loads token from URL param (`?token=...`) - All API calls pass token to store actions ##### Test Requirements **Component Specs:** ```javascript // spec/javascript/sponsor/views/CohortDashboard.spec.js import { mount, flushPromises } from '@vue/test-utils' import CohortDashboard from '@/sponsor/views/CohortDashboard.vue' import { useSponsorCohortStore } from '@/sponsor/stores/cohort' import { createPinia, setActivePinia } from 'pinia' describe('CohortDashboard', () => { const mockCohort = { id: 1, name: 'Summer 2025', students: [ { id: 1, name: 'John Doe', email: 'john@example.com', signed: false, submitted_at: '2025-01-15T10:00:00Z' }, { id: 2, name: 'Jane Smith', email: 'jane@example.com', signed: false, submitted_at: '2025-01-15T10:30:00Z' }, { id: 3, name: 'Bob Johnson', email: 'bob@example.com', signed: true, signed_at: '2025-01-16T10:00:00Z', submitted_at: '2025-01-15T10:00:00Z' } ] } beforeEach(() => { setActivePinia(createPinia()) }) it('renders cohort stats correctly', async () => { const wrapper = mount(CohortDashboard, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorCohortStore() Object.assign(store, mockCohort) await flushPromises() expect(wrapper.text()).toContain('Summer 2025') expect(wrapper.text()).toContain('Total Students: 3') expect(wrapper.text()).toContain('Pending: 2') expect(wrapper.text()).toContain('Completed: 1') }) it('displays student list', async () => { const wrapper = mount(CohortDashboard, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorCohortStore() Object.assign(store, mockCohort) await flushPromises() expect(wrapper.text()).toContain('John Doe') expect(wrapper.text()).toContain('Jane Smith') expect(wrapper.text()).toContain('Bob Johnson') }) it('filters students by search', async () => { const wrapper = mount(CohortDashboard, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorCohortStore() Object.assign(store, mockCohort) await flushPromises() const searchInput = wrapper.find('input[type="text"]') await searchInput.setValue('John') await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('John Doe') expect(wrapper.text()).not.toContain('Jane Smith') }) it('enables signing only when requirements met', async () => { const wrapper = mount(CohortDashboard, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorCohortStore() Object.assign(store, mockCohort) await flushPromises() // Initially disabled let signButton = wrapper.find('button').filter(n => n.text().includes('Sign All')) expect(signButton.element.disabled).toBe(true) // Select type method and enter signature wrapper.vm.signatureMethod = 'type' wrapper.vm.typedSignature = 'John Doe' wrapper.vm.confirmed = true await wrapper.vm.$nextTick() signButton = wrapper.find('button').filter(n => n.text().includes('Sign All')) expect(signButton.element.disabled).toBe(false) }) it('executes bulk signing', async () => { const wrapper = mount(CohortDashboard, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorCohortStore() Object.assign(store, mockCohort) const signSpy = vi.spyOn(store, 'bulkSign') await flushPromises() // Setup signing wrapper.vm.signatureMethod = 'type' wrapper.vm.typedSignature = 'John Doe' wrapper.vm.confirmed = true await wrapper.vm.$nextTick() // Click sign const signButton = wrapper.find('button').filter(n => n.text().includes('Sign All')) await signButton.trigger('click') // Should show preview first expect(wrapper.vm.showPreview).toBe(true) // Confirm in preview const confirmButton = wrapper.find('button').filter(n => n.text().includes('Confirm')) await confirmButton.trigger('click') expect(signSpy).toHaveBeenCalledWith(1, 'test-token', expect.any(Object)) }) it('shows success modal after signing', async () => { const wrapper = mount(CohortDashboard, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorCohortStore() Object.assign(store, mockCohort) await flushPromises() // Complete signing flow wrapper.vm.signatureMethod = 'type' wrapper.vm.typedSignature = 'John Doe' wrapper.vm.confirmed = true wrapper.vm.showPreview = true // Mock successful signing store.bulkSign = vi.fn().mockResolvedValue(undefined) await wrapper.vm.confirmSigning() expect(wrapper.vm.showSuccess).toBe(true) }) it('handles canvas signature', async () => { const wrapper = mount(CohortDashboard, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorCohortStore() Object.assign(store, mockCohort) await flushPromises() wrapper.vm.signatureMethod = 'draw' await wrapper.vm.$nextTick() const canvas = wrapper.find('canvas') expect(canvas.exists()).toBe(true) // Simulate drawing wrapper.vm.signatureData = 'data:image/png;base64,signature' wrapper.vm.confirmed = true await wrapper.vm.$nextTick() const signButton = wrapper.find('button').filter(n => n.text().includes('Sign All')) expect(signButton.element.disabled).toBe(false) }) }) ``` **Integration Tests:** ```javascript // spec/javascript/sponsor/integration/bulk-sign-flow.spec.js describe('Bulk Signing Flow', () => { it('completes full signing workflow', async () => { // 1. Load cohort dashboard // 2. Verify student list // 3. Select signature method // 4. Draw or type signature // 5. Confirm checkbox // 6. Preview signing // 7. Confirm and execute // 8. Verify success modal // 9. Refresh and verify all signed }) }) ``` **E2E Tests:** ```javascript // spec/system/sponsor_bulk_signing_spec.rb RSpec.describe 'Sponsor Bulk Signing', type: :system do let(:cohort) { create(:cohort, status: :ready_for_sponsor) } let!(:students) { create_list(:student, 5, cohort: cohort) } let(:token) { cohort.generate_sponsor_token } scenario 'sponsor signs all documents at once' do visit "/sponsor/cohorts/#{cohort.id}?token=#{token}" expect(page).to have_content(cohort.name) expect(page).to have_content('Total Students: 5') expect(page).to have_content('Pending: 5') # Select type signature click_button 'Type Name' fill_in 'Type Your Full Name', with: 'Jane Sponsor' check 'I confirm that I have reviewed' # Preview click_button 'Preview' expect(page).to have_content('Signing Preview') expect(page).to have_content('Jane Sponsor') # Confirm click_button 'Confirm & Sign All' # Should show success expect(page).to have_content('Signing Complete!') expect(page).to have_content('5 student documents') # Close modal and verify click_button 'Done' expect(page).to have_content('All Documents Signed') expect(page).to have_content('Completed: 5') end scenario 'sponsor draws signature', do visit "/sponsor/cohorts/#{cohort.id}?token=#{token}" # Canvas is visible by default expect(page).to have_css('canvas') # Draw on canvas (simplified in test) # In real test, would use browser actions to draw check 'I confirm that I have reviewed' click_button 'Sign All 5 Documents' expect(page).to have_content('Signing Complete!') end scenario 'search and filter students', do visit "/sponsor/cohorts/#{cohort.id}?token=#{token}" # Search for specific student fill_in 'Search students...', with: students.first.name expect(page).to have_content(students.first.name) expect(page).not_to have_content(students.second.name) # Filter by pending select 'Pending', from: 'status' expect(page).to have_content('5') end end ``` ##### Rollback Procedure **If cohort fails to load:** 1. Show error message with retry button 2. Display access instructions 3. Log error to monitoring 4. Provide support contact **If signing fails:** 1. Show error message with details 2. Preserve signature data if possible 3. Allow retry without re-entering signature 4. Log failure for investigation 5. Notify support if persistent **If signature canvas breaks:** 1. Show error message 2. Automatically switch to type method 3. Preserve other form data 4. Log canvas error **If preview modal fails:** 1. Close modal 2. Show error toast 3. Allow retry 4. Preserve signature data **Data Safety:** - No data mutation until final confirmation - Signature data held in memory only - All operations atomic (all or nothing) - No partial signing allowed ##### Risk Assessment **Medium Risk** because: - Bulk operations affect multiple records - Signature capture requires canvas API - Complex state management for 100+ students - Security implications of bulk signing - User error could sign wrong cohort **Specific Risks:** 1. **Accidental Bulk Sign**: Sponsor signs without realizing scope - **Mitigation**: Clear preview modal showing all affected students, confirmation required 2. **Signature Quality**: Canvas signature may be poor quality - **Mitigation**: Provide type fallback, preview before final 3. **Browser Compatibility**: Canvas touch events vary - **Mitigation**: Use signature_pad library, test on all browsers 4. **Token Expiration**: Long signing sessions may expire - **Mitigation**: Token renewal mechanism (Story 4.9) 5. **Concurrent Signing**: Multiple sponsors for same cohort - **Mitigation**: Lock cohort during signing, check state before commit **Mitigation Strategies:** - Comprehensive preview before any signing - Clear scope indication (number of students affected) - Confirmation dialog with details - Rate limiting to prevent abuse - Audit trail for all signing actions - Fallback to type signature if canvas fails ##### Success Metrics - **Signing Success Rate**: 98% of bulk signing attempts succeed - **User Error Rate**: <2% of signers report confusion about scope - **Time to Complete**: Average <2 minutes for full cohort - **Canvas Usage**: 70% use draw, 30% use type - **Preview Usage**: 100% of successful signers view preview - **Support Tickets**: <1% related to bulk signing - **Zero Partial Signatures**: 100% atomic operations --- #### Story 6.2: Sponsor Portal - Email Notifications & Reminders **Status**: Draft/Pending **Priority**: High **Epic**: Sponsor Portal - Frontend Development **Estimated Effort**: 2 days **Risk Level**: Low ##### User Story **As a** Sponsor, **I want** to receive email notifications about signing requests and reminders to complete my cohort signing, **So that** I can stay informed and fulfill my signing responsibility on time without constantly checking the portal. ##### Background Sponsors need to stay informed about their signing responsibilities without manually monitoring the portal. The system should provide: 1. **Initial Invitation Email**: Sent when TP creates cohort and assigns sponsor 2. **Reminder Emails**: Sent if sponsor hasn't accessed the cohort after certain time 3. **Status Update Emails**: Sent when students complete their submissions 4. **Completion Confirmation**: Sent after sponsor completes bulk signing **Email Types:** - **Invitation**: "You've been assigned to sign documents for [Cohort Name]" - **Reminder - Not Accessed**: "Action required: Sign documents for [Cohort Name]" - **Reminder - Partial**: "You're almost done! [X] students still need your signature" - **Status - Student Completed**: "[Student Name] has submitted their documents" - **Status - Signing Complete**: "You've successfully signed all documents" **Key Requirements:** - Emails contain secure, time-limited links with JWT tokens - Unsubscribe option in all emails (per FR12) - Email preferences can be managed by sponsor - Reminder frequency is configurable - Single email per cohort (no duplicates per student) - Immediate notification when students complete submissions **Integration Point**: This story connects to the email system (Stories 2.2, 2.3) and provides sponsor-facing notification management. ##### Technical Implementation Notes **Vue 3 Component Structure:** ```vue ``` **Pinia Store:** ```typescript // app/javascript/sponsor/stores/notifications.ts import { defineStore } from 'pinia' import { ref } from 'vue' import { NotificationAPI } from '@/sponsor/api/notification' export interface NotificationPreferences { signing_requests: boolean student_alerts: boolean reminders: boolean completion_alerts: boolean reminder_frequency: string cohort_name: string } export const useSponsorNotificationStore = defineStore('sponsorNotifications', { state: () => ({ preferences: null as NotificationPreferences | null, isLoading: false, error: null as string | null }), actions: { async fetchPreferences(cohortId: number, token: string): Promise { this.isLoading = true this.error = null try { const response = await NotificationAPI.getPreferences(cohortId, token) this.preferences = response return response } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to fetch preferences' console.error('Fetch preferences error:', error) throw error } finally { this.isLoading = false } }, async updatePreferences( cohortId: number, token: string, preferences: Partial ): Promise { this.isLoading = true this.error = null try { await NotificationAPI.updatePreferences(cohortId, token, preferences) if (this.preferences) { this.preferences = { ...this.preferences, ...preferences } } } catch (error) { this.error = error instanceof Error ? error.message : 'Failed to update preferences' console.error('Update preferences error:', error) throw error } finally { this.isLoading = false } } } }) ``` **API Layer:** ```typescript // app/javascript/sponsor/api/notification.ts export interface NotificationPreferences { signing_requests: boolean student_alerts: boolean reminders: boolean completion_alerts: boolean reminder_frequency: string cohort_name: string } export interface UpdatePreferencesRequest { signing_requests?: boolean student_alerts?: boolean reminders?: boolean completion_alerts?: boolean reminder_frequency?: string } export const NotificationAPI = { async getPreferences(cohortId: number, token: string): Promise { const response = await fetch(`/api/sponsor/cohorts/${cohortId}/notification-preferences`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (!response.ok) { if (response.status === 403) { throw new Error('Access denied or token expired') } if (response.status === 404) { throw new Error('Cohort not found') } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.json() }, async updatePreferences( cohortId: number, token: string, preferences: UpdatePreferencesRequest ): Promise { const response = await fetch(`/api/sponsor/cohorts/${cohortId}/notification-preferences`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(preferences) }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } }, async sendInvitationEmail(cohortId: number, token: string): Promise { const response = await fetch(`/api/sponsor/cohorts/${cohortId}/send-invitation`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } }, async sendReminderEmail(cohortId: number, token: string, type: string): Promise { const response = await fetch(`/api/sponsor/cohorts/${cohortId}/send-reminder`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ type }) }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } }, async sendStudentCompletionAlert(cohortId: number, token: string): Promise { const response = await fetch(`/api/sponsor/cohorts/${cohortId}/send-student-alert`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } } } ``` **Type Definitions:** ```typescript // app/javascript/sponsor/types/index.ts (extended) export interface NotificationPreferences { signing_requests: boolean student_alerts: boolean reminders: boolean completion_alerts: boolean reminder_frequency: string cohort_name: string } export interface UpdatePreferencesRequest { signing_requests?: boolean student_alerts?: boolean reminders?: boolean completion_alerts?: boolean reminder_frequency?: string } ``` **Design System Compliance:** Per FR28, all Sponsor Notification components must use design system assets from: - `@.claude/skills/frontend-design/SKILL.md` - Design tokens - `@.claude/skills/frontend-design/design-system/` - SVG assets Specific requirements: - **Colors**: - Primary (Blue-600): `#2563EB` for buttons and links - Warning (Red-600): `#DC2626` for unsubscribe section - Success (Green-600): `#16A34A` for saved indicators - Neutral (Gray-500): `#6B7280` for text - **Spacing**: 4px base unit, 1.5rem (24px) for sections, 1rem (16px) for field gaps - **Typography**: - Headings: 24px (h1), 20px (h2), 16px (h3) - Body: 16px base, 14px for descriptions - Small: 12px for helper text - **Icons**: Use SVG icons from design system for: - Mail (email) - Bell (notifications) - Checkmark (saved) - Trash (unsubscribe) - Refresh (reset) - Settings (preferences) - **Layout**: - Max width: 3xl (48rem / 768px) - Card corners: rounded-lg (8px) - Shadow: shadow-md for cards - Toggle switches: right-aligned - **Accessibility**: - ARIA labels on all toggles - Keyboard navigation for checkboxes - Screen reader announcements for save actions - Focus indicators on all interactive elements - Color contrast ratio minimum 4.5:1 - Warning section clearly marked ##### Acceptance Criteria **Functional:** 1. ✅ Component loads existing preferences on mount 2. ✅ All 4 notification toggles work correctly 3. ✅ Reminder frequency radio buttons work 4. ✅ Save button persists changes to server 5. ✅ Reset to defaults restores original settings 6. ✅ Unsubscribe all disables all notifications 7. ✅ Confirmation dialog before unsubscribe 8. ✅ Email preview shows realistic example 9. ✅ Shows success toast after save 10. ✅ Loading state during API calls **UI/UX:** 1. ✅ Toggles show clear on/off states 2. ✅ Settings organized in logical sections 3. ✅ Warning section visually distinct (red border) 4. ✅ Email preview shows realistic example 5. ✅ Success toast appears for 2 seconds 6. ✅ Loading overlay blocks interaction 7. ✅ Mobile-responsive design 8. ✅ Hover states on all interactive elements **Integration:** 1. ✅ API endpoint: `GET /api/sponsor/cohorts/{id}/notification-preferences` 2. ✅ API endpoint: `PUT /api/sponsor/cohorts/{id}/notification-preferences` 3. ✅ API endpoint: `POST /api/sponsor/cohorts/{id}/send-invitation` 4. ✅ API endpoint: `POST /api/sponsor/cohorts/{id}/send-reminder` 5. ✅ API endpoint: `POST /api/sponsor/cohorts/{id}/send-student-alert` 6. ✅ Token authentication in headers 7. ✅ Settings persist across sessions **Security:** 1. ✅ Token-based authentication required 2. ✅ Authorization check: sponsor can only manage their preferences 3. ✅ Rate limiting on update endpoint (max 10 per hour) 4. ✅ Validation of reminder frequency values 5. ✅ Audit log of all preference changes **Quality:** 1. ✅ No duplicate API calls on rapid clicks 2. ✅ Error handling for failed API calls 3. ✅ State consistency between UI and server 4. ✅ Performance: loads in <1 second 5. ✅ Browser compatibility: Chrome, Firefox, Safari, Edge ##### Integration Verification (IV1-4) **IV1: API Integration** - `EmailPreferences.vue` calls `NotificationAPI.getPreferences()` on mount - `EmailPreferences.vue` calls `NotificationAPI.updatePreferences()` on save - `EmailPreferences.vue` calls `NotificationAPI.sendInvitationEmail()` (if needed) - All endpoints use `Authorization: Bearer {token}` header **IV2: Pinia Store** - `sponsorNotificationStore.preferences` holds notification settings - `sponsorNotificationStore.fetchPreferences()` loads settings - `sponsorNotificationStore.updatePreferences()` saves changes **IV3: Getters** - Store provides computed properties for UI display - Settings are reactive and update UI immediately **IV4: Token Routing** - EmailPreferences receives `token` prop from parent - Parent loads token from URL param (`?token=...`) - All API calls pass token to store actions ##### Test Requirements **Component Specs:** ```javascript // spec/javascript/sponsor/views/EmailPreferences.spec.js import { mount, flushPromises } from '@vue/test-utils' import EmailPreferences from '@/sponsor/views/EmailPreferences.vue' import { useSponsorNotificationStore } from '@/sponsor/stores/notifications' import { createPinia, setActivePinia } from 'pinia' describe('EmailPreferences', () => { const mockPreferences = { signing_requests: true, student_alerts: true, reminders: true, completion_alerts: true, reminder_frequency: '48h', cohort_name: 'Summer 2025' } beforeEach(() => { setActivePinia(createPinia()) }) it('loads preferences on mount', async () => { const wrapper = mount(EmailPreferences, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorNotificationStore() store.preferences = mockPreferences await flushPromises() expect(wrapper.vm.settings.signingRequests).toBe(true) expect(wrapper.vm.settings.reminderFrequency).toBe('48h') }) it('toggles notification settings', async () => { const wrapper = mount(EmailPreferences, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorNotificationStore() store.preferences = mockPreferences await flushPromises() const statusToggle = wrapper.find('input[type="checkbox"]') await statusToggle.trigger('click') expect(wrapper.vm.settings.signingRequests).toBe(false) }) it('saves settings when save button clicked', async () => { const wrapper = mount(EmailPreferences, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorNotificationStore() store.preferences = mockPreferences const updateSpy = vi.spyOn(store, 'updatePreferences') await flushPromises() const saveButton = wrapper.find('button').filter(n => n.text().includes('Save')) await saveButton.trigger('click') expect(updateSpy).toHaveBeenCalledWith(1, 'test-token', expect.any(Object)) }) it('resets to defaults', async () => { const wrapper = mount(EmailPreferences, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorNotificationStore() store.preferences = mockPreferences await flushPromises() // Change some settings wrapper.vm.settings.reminders = false await wrapper.vm.$nextTick() // Reset const resetButton = wrapper.find('button').filter(n => n.text().includes('Reset')) await resetButton.trigger('click') expect(wrapper.vm.settings.reminders).toBe(true) }) it('unsubscribes all with confirmation', async () => { const wrapper = mount(EmailPreferences, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorNotificationStore() store.preferences = mockPreferences const updateSpy = vi.spyOn(store, 'updatePreferences') await flushPromises() // Mock confirm to return true window.confirm = vi.fn(() => true) const unsubscribeButton = wrapper.find('button').filter(n => n.text().includes('Unsubscribe')) await unsubscribeButton.trigger('click') expect(window.confirm).toHaveBeenCalled() expect(wrapper.vm.settings.signingRequests).toBe(false) expect(wrapper.vm.settings.reminders).toBe(false) expect(updateSpy).toHaveBeenCalled() }) it('shows loading state during save', async () => { const wrapper = mount(EmailPreferences, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorNotificationStore() store.preferences = mockPreferences store.isLoading = true await flushPromises() expect(wrapper.find('.loading-overlay').exists()).toBe(true) }) it('shows success toast after save', async () => { const wrapper = mount(EmailPreferences, { props: { cohortId: 1, token: 'test-token' } }) const store = useSponsorNotificationStore() store.preferences = mockPreferences await flushPromises() const saveButton = wrapper.find('button').filter(n => n.text().includes('Save')) await saveButton.trigger('click') await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('Preferences saved successfully') }) }) ``` **Integration Tests:** ```javascript // spec/javascript/sponsor/integration/notification-flow.spec.js describe('Sponsor Notification Flow', () => { it('manages complete notification workflow', async () => { // 1. Load preferences // 2. Disable all notifications // 3. Save changes // 4. Reload page to verify persistence // 5. Reset to defaults // 6. Verify all enabled again }) }) ``` **E2E Tests:** ```javascript // spec/system/sponsor_notification_preferences_spec.rb RSpec.describe 'Sponsor Notification Preferences', type: :system do let(:cohort) { create(:cohort, status: :ready_for_sponsor) } let(:token) { cohort.generate_sponsor_token } scenario 'sponsor manages email preferences' do visit "/sponsor/cohorts/#{cohort.id}/preferences?token=#{token}" expect(page).to have_content('Email Notification Preferences') # Disable signing requests uncheck 'Signing Requests' click_button 'Save Settings' expect(page).to have_content('Preferences saved successfully') # Reload and verify visit "/sponsor/cohorts/#{cohort.id}/preferences?token=#{token}" expect(page).not_to have_checked_field('Signing Requests') # Unsubscribe all click_button 'Unsubscribe from All' page.driver.browser.switch_to.alert.accept expect(page).to have_content('Preferences saved successfully') end scenario 'reset to defaults', do visit "/sponsor/cohorts/#{cohort.id}/preferences?token=#{token}" # Change settings uncheck 'Signing Requests' uncheck 'Completion Reminders' click_button 'Save Settings' # Reset click_button 'Reset to Defaults' expect(page).to have_checked_field('Signing Requests') expect(page).to have_checked_field('Completion Reminders') end end ``` ##### Rollback Procedure **If preferences fail to load:** 1. Show error message with retry button 2. Use default settings temporarily 3. Log error to monitoring 4. Allow manual refresh **If save fails:** 1. Show error message with retry option 2. Preserve unsaved changes in UI 3. Check network connection 4. Log failure for investigation **If unsubscribe fails:** 1. Show error message 2. Don't change settings 3. Provide alternative (contact support) 4. Log for security audit **If email preview breaks:** 1. Hide preview section 2. Show fallback text 3. Continue allowing settings changes 4. Log template error **Data Safety:** - All settings stored server-side - No data loss if UI fails - Changes only applied after successful save - Unsubscribe requires confirmation ##### Risk Assessment **Low Risk** because: - Simple CRUD operations - Standard form handling - No complex business logic - Read-mostly operations **Specific Risks:** 1. **Email Deliverability**: Emails may not reach sponsors - **Mitigation**: Use reputable SMTP, monitor delivery rates 2. **Rate Limiting**: Too many emails sent - **Mitigation**: Implement queue system, respect frequency settings 3. **Unsubscribe Compliance**: Legal requirements (CAN-SPAM, GDPR) - **Mitigation**: Clear unsubscribe, honor all requests immediately 4. **Token Expiration**: Links in emails may expire - **Mitigation**: Long expiry (7 days), renewal mechanism **Mitigation Strategies:** - Comprehensive email testing - Monitor email delivery metrics - Implement email queue with retry - Clear unsubscribe in all emails - Regular compliance audits ##### Success Metrics - **Save Success Rate**: 99% of preference updates succeed - **Email Delivery**: 98% of emails reach inbox (not spam) - **User Engagement**: 85% of sponsors enable at least one notification - **Unsubscribe Rate**: <3% (industry standard is 0.2-0.5%) - **Reminder Effectiveness**: 70% of reminders lead to signing within 24 hours - **Support Tickets**: <2% related to email notifications - **Load Time**: <1 second for preferences page --- --- ### 6.7 Phase 7: Integration & Testing **Focus**: End-to-end integration testing, performance validation, and security auditing This phase ensures all three portals work together seamlessly, performance meets requirements, and security is maintained. Testing covers the complete workflow from TP cohort creation through student submission to sponsor signing and TP review. --- #### Story 7.1: End-to-End Workflow Testing **Status**: Draft/Pending **Priority**: Critical **Epic**: Integration & Testing **Estimated Effort**: 3 days **Risk Level**: High ##### User Story **As a** QA Engineer, **I want** to test the complete 3-portal workflow from start to finish, **So that** I can verify all integrations work correctly and identify any breaking issues before production deployment. ##### Background This story validates the entire FloDoc system through complete end-to-end testing. The workflow must be tested in sequence: 1. **TP Portal**: Create cohort, configure template, assign sponsor, sign first student 2. **Student Portal**: Receive invitation, upload documents, fill forms, submit 3. **Sponsor Portal**: Receive notification, sign all documents at once 4. **TP Portal**: Review and finalize cohort **Key Testing Scenarios:** - **Happy Path**: All parties complete their steps successfully - **Edge Cases**: Invalid tokens, expired sessions, network failures - **State Transitions**: Proper status updates at each step - **Email Delivery**: All notifications sent and received - **Data Integrity**: No data loss or corruption - **Concurrent Access**: Multiple users accessing same cohort - **Error Recovery**: Graceful handling of failures **Test Data Requirements:** - Multiple cohorts with varying student counts (1, 5, 25, 100) - Different document types (PDF, images) - Various form field types (signature, text, date, checkbox) - Different signature methods (draw, type) **Integration Points to Verify:** - Template → Cohort mapping - Student → Submission mapping - Bulk signing → Individual student updates - Email triggers → Actual email delivery - Token generation → Token validation - State machine transitions - Excel export data accuracy ##### Technical Implementation Notes **Test Framework Setup:** ```ruby # spec/system/end_to_end_workflow_spec.rb require 'rails_helper' require 'capybara/rspec' require 'selenium-webdriver' RSpec.describe 'End-to-End FloDoc Workflow', type: :system do include ActiveJob::TestHelper include EmailSpec::Helpers let(:tp_user) { create(:user, :tp_admin) } let(:cohort) { create(:cohort, status: :draft) } let(:sponsor) { create(:sponsor) } let(:students) { create_list(:student, 5, cohort: cohort) } before do driven_by :selenium, using: :headless_chrome clear_enqueued_jobs clear_delivered_emails end after do clear_enqueued_jobs clear_delivered_emails end # Test scenarios will go here end ``` **Test Data Factory Setup:** ```ruby # spec/factories/cohort.rb FactoryBot.define do factory :cohort do name { "Spring 2025 - #{SecureRandom.hex(4)}" } status { :draft } institution { create(:institution) } sponsor { create(:sponsor) } trait :with_students do after(:create) do |cohort| create_list(:student, 5, cohort: cohort) end end trait :ready_for_sponsor do after(:create) do |cohort| cohort.students.each do |student| student.submissions.update_all(status: :submitted, submitted_at: Time.current) end cohort.update!(status: :ready_for_sponsor) end end end end # spec/factories/student.rb FactoryBot.define do factory :student do name { Faker::Name.name } email { Faker::Internet.email } cohort trait :with_submission do after(:create) do |student| create(:submission, student: student, status: :submitted) end end end end # spec/factories/submission.rb FactoryBot.define do factory :submission do student status { :pending } trait :submitted do status { :submitted } submitted_at { Time.current } end trait :sponsor_signed do status { :sponsor_signed } submitted_at { Time.current } sponsor_signed_at { Time.current } end trait :completed do status { :completed } submitted_at { Time.current } sponsor_signed_at { Time.current } tp_reviewed_at { Time.current } end end end ``` **Test Helper Module:** ```ruby # spec/support/end_to_end_helpers.rb module EndToEndHelpers def complete_tp_workflow(cohort, student_count: 5) # 1. TP creates cohort visit tp_cohorts_path click_button 'Create Cohort' fill_in 'Cohort Name', with: cohort.name click_button 'Next' # 2. Upload template document attach_file 'template_document', Rails.root.join('spec/fixtures/sample.pdf') click_button 'Upload' # 3. Configure fields # (Simulate field configuration) # 4. Assign sponsor fill_in 'Sponsor Email', with: cohort.sponsor.email click_button 'Assign' # 5. Sign first student (TP signing phase) cohort.students.first.submissions.each do |submission| click_link "Sign Submission ##{submission.id}" # Simulate signature submission.update!(tp_signed: true, tp_signed_at: Time.current) end cohort.update!(status: :ready_for_sponsor) end def complete_student_workflow(student, submission) # 1. Access via token visit student_submission_path(submission, token: submission.token) # 2. Upload documents attach_file 'student_documents', Rails.root.join('spec/fixtures/student_id.pdf') click_button 'Upload' # 3. Fill form fields fill_in 'Full Name', with: student.name fill_in 'Date', with: Date.today # 4. Sign click_button 'Sign' submission.update!(student_completed: true, student_completed_at: Time.current) end def complete_sponsor_bulk_sign(cohort) # 1. Access sponsor portal visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) # 2. Verify student list expect(page).to have_content(cohort.students.count) # 3. Draw/type signature fill_in 'Type Your Full Name', with: cohort.sponsor.name check 'I confirm' # 4. Preview click_button 'Preview' # 5. Confirm bulk sign click_button 'Confirm & Sign All' # 6. Verify success expect(page).to have_content('Signing Complete') # Update all submissions cohort.students.each do |student| student.submissions.update_all( sponsor_signed: true, sponsor_signed_at: Time.current, status: :sponsor_signed ) end cohort.update!(status: :in_review) end def complete_tp_review(cohort) # 1. Access TP portal visit tp_cohort_path(cohort) # 2. Review submissions cohort.students.each do |student| click_link "Review #{student.name}" # Verify documents click_button 'Approve' end # 3. Finalize cohort click_button 'Finalize Cohort' cohort.update!(status: :completed, completed_at: Time.current) end end ``` **Test Scenarios Structure:** ```ruby # spec/system/end_to_end_workflow_spec.rb (continued) describe 'Complete 3-Portal Workflow' do include EndToEndHelpers context 'Happy Path - 5 Students' do it 'completes full workflow from TP creation to finalization' do # Setup cohort = create(:cohort, :with_students, student_count: 5) sponsor = create(:sponsor) cohort.update!(sponsor: sponsor) # Test TP Portal sign_in tp_user complete_tp_workflow(cohort, student_count: 5) expect(cohort.reload.status).to eq('ready_for_sponsor') expect(Email.where(to: sponsor.email, subject: /assigned/).count).to eq(1) # Test Student Portals cohort.students.each do |student| submission = student.submissions.first clear_enqueued_jobs complete_student_workflow(student, submission) expect(submission.reload.student_completed).to be true expect(Email.where(to: cohort.sponsor.email, subject: /submitted/).count).to eq(1) end # Test Sponsor Portal clear_enqueued_jobs complete_sponsor_bulk_sign(cohort) cohort.students.each do |student| student.submissions.each do |submission| expect(submission.reload.sponsor_signed).to be true end end expect(cohort.reload.status).to eq('in_review') expect(Email.where(to: tp_user.email, subject: /review/).count).to eq(1) # Test TP Review complete_tp_review(cohort) expect(cohort.reload.status).to eq('completed') expect(cohort.completed_at).not_to be_nil end end context 'Edge Cases' do it 'handles expired token gracefully' do cohort = create(:cohort, :with_students) submission = cohort.students.first.submissions.first # Expire token submission.update!(token_expires_at: 1.day.ago) visit student_submission_path(submission, token: submission.token) expect(page).to have_content('Link Expired') expect(page).to have_button('Request New Link') # Request renewal click_button 'Request New Link' expect(page).to have_content('New link sent to your email') expect(Email.where(to: submission.student.email).count).to eq(1) end it 'handles network failures during bulk signing' do cohort = create(:cohort, :ready_for_sponsor) # Mock network failure allow_any_instance_of(CohortAPI).to receive(:bulk_sign).and_raise(Net::ReadTimeout) visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) fill_in 'Type Your Full Name', with: 'Sponsor Name' check 'I confirm' click_button 'Sign All 5 Documents' expect(page).to have_content('Signing failed') expect(page).to have_button('Retry') # Retry should work allow_any_instance_of(CohortAPI).to receive(:bulk_sign).and_call_original click_button 'Retry' expect(page).to have_content('Signing Complete') end it 'prevents duplicate sponsor emails' do cohort = create(:cohort, :ready_for_sponsor) # Trigger multiple student completions cohort.students.each do |student| student.submissions.update_all(student_completed: true, student_completed_at: Time.current) end # Should only send one email to sponsor expect(Email.where(to: cohort.sponsor.email, subject: /submitted/).count).to eq(1) end it 'handles concurrent access to same cohort' do cohort = create(:cohort, :ready_for_sponsor) # Two sponsors try to access using_session('sponsor1') do visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) expect(page).to have_content(cohort.name) end using_session('sponsor2') do visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) expect(page).to have_content(cohort.name) end # Both can view, but only one can sign # (Locking mechanism tested separately) end it 'validates data integrity throughout workflow' do cohort = create(:cohort, :with_students, student_count: 3) # Record initial state initial_count = Submission.count initial_student_count = Student.count # Run full workflow complete_tp_workflow(cohort) cohort.students.each do |student| submission = student.submissions.first complete_student_workflow(student, submission) end complete_sponsor_bulk_sign(cohort) complete_tp_review(cohort) # Verify no data loss expect(Submission.count).to eq(initial_count) expect(Student.count).to eq(initial_student_count) expect(cohort.submissions.all? { |s| s.status == 'completed' }).to be true end end context 'Performance with Large Cohorts' do it 'handles 100 students efficiently', :slow do cohort = create(:cohort, :with_students, student_count: 100) # Time the sponsor bulk signing start_time = Time.current complete_sponsor_bulk_sign(cohort) elapsed = Time.current - start_time # Should complete within 5 seconds expect(elapsed).to be < 5 # All submissions should be updated expect(cohort.submissions.where(sponsor_signed: true).count).to eq(100) end end context 'Email Delivery Verification' do it 'sends all expected emails in correct sequence' do cohort = create(:cohort, :with_students, student_count: 2) # Track email sequence email_sequence = [] # TP creates cohort complete_tp_workflow(cohort) email_sequence << 'tp_invitation' # Student completes cohort.students.each do |student| submission = student.submissions.first complete_student_workflow(student, submission) end email_sequence << 'student_completion' # Sponsor signs complete_sponsor_bulk_sign(cohort) email_sequence << 'sponsor_confirmation' # Verify email count and content delivered = Email.all expect(delivered.count).to eq(3) # Verify subjects expect(delivered[0].subject).to include('assigned') expect(delivered[1].subject).to include('submitted') expect(delivered[2].subject).to include('completed') end end end ``` **API Integration Tests:** ```ruby # spec/requests/api/v1/end_to_end_spec.rb RSpec.describe 'API End-to-End Integration', type: :request do include ActiveJob::TestHelper describe 'Complete API Workflow' do it 'processes cohort through all stages via API' do # 1. Create cohort via API post '/api/v1/cohorts', params: { name: 'API Test Cohort', sponsor_email: 'sponsor@example.com' }, headers: { 'Authorization' => "Bearer #{tp_token}" } cohort_id = JSON.parse(response.body)['id'] # 2. Upload template post "/api/v1/cohorts/#{cohort_id}/template", params: { file: fixture_file_upload('sample.pdf') }, headers: { 'Authorization' => "Bearer #{tp_token}" } # 3. Add students post "/api/v1/cohorts/#{cohort_id}/students", params: { students: [{ name: 'Student 1', email: 's1@example.com' }] }, headers: { 'Authorization' => "Bearer #{tp_token}" } # 4. TP signs first post "/api/v1/cohorts/#{cohort_id}/tp-sign", params: { signature: 'TP Signature' }, headers: { 'Authorization' => "Bearer #{tp_token}" } # 5. Student submits student_token = cohort.students.first.submissions.first.token post "/api/v1/student/submissions/#{cohort.students.first.submissions.first.id}/submit", params: { documents: [fixture_file_upload('student_doc.pdf')], fields: { name: 'Student 1', date: '2025-01-15' } }, headers: { 'Authorization' => "Bearer #{student_token}" } # 6. Sponsor bulk signs sponsor_token = cohort.sponsor_token post "/api/v1/sponsor/cohorts/#{cohort_id}/bulk-sign", params: { signature: 'Sponsor Signature' }, headers: { 'Authorization' => "Bearer #{sponsor_token}" } # 7. TP reviews and finalizes post "/api/v1/cohorts/#{cohort_id}/finalize", headers: { 'Authorization' => "Bearer #{tp_token}" } # Verify final state cohort = Cohort.find(cohort_id) expect(cohort.status).to eq('completed') expect(cohort.submissions.all? { |s| s.status == 'completed' }).to be true end end end ``` **Database State Verification:** ```ruby # spec/support/database_verifiers.rb module DatabaseVerifiers def verify_cohort_state(cohort, expected_status) cohort.reload expect(cohort.status).to eq(expected_status) # Verify all submissions have correct state case expected_status when 'ready_for_sponsor' expect(cohort.submissions.all? { |s| s.student_completed }).to be true expect(cohort.submissions.none? { |s| s.sponsor_signed }).to be true when 'in_review' expect(cohort.submissions.all? { |s| s.sponsor_signed }).to be true expect(cohort.submissions.none? { |s| s.tp_reviewed }).to be true when 'completed' expect(cohort.submissions.all? { |s| s.status == 'completed' }).to be true end end def verify_email_delivery(expected_emails) delivered = Email.all expect(delivered.count).to eq(expected_emails.count) expected_emails.each do |expected| found = delivered.find { |e| e.subject.include?(expected[:subject]) && e.to == expected[:to] } expect(found).not_to be_nil, "Expected email with subject '#{expected[:subject]}' to '#{expected[:to]}' not found" end end def verify_data_integrity # Check for orphaned records expect(Submission.joins(:student).where(students: { id: nil }).count).to eq(0) expect(Cohort.joins(:submissions).where(submissions: { id: nil }).count).to eq(0) # Check for duplicate tokens tokens = Submission.pluck(:token) expect(tokens.uniq.count).to eq(tokens.count) # Check for invalid state transitions invalid_states = Submission.where.not(status: %w[pending submitted sponsor_signed completed]) expect(invalid_states.count).to eq(0) end end ``` ##### Acceptance Criteria **Functional:** 1. ✅ Complete workflow tested with 5 students 2. ✅ Complete workflow tested with 25 students 3. ✅ Complete workflow tested with 100 students 4. ✅ All edge cases handled (expired tokens, network failures, etc.) 5. ✅ Concurrent access scenarios tested 6. ✅ Data integrity verified throughout 7. ✅ Email delivery verified in correct sequence 8. ✅ State transitions validated 9. ✅ Error recovery tested 10. ✅ Performance benchmarks met **Integration:** 1. ✅ TP Portal → Student Portal handoff verified 2. ✅ Student Portal → Sponsor Portal handoff verified 3. ✅ Sponsor Portal → TP Portal handoff verified 4. ✅ Email system integration verified 5. ✅ Token system integration verified 6. ✅ Database state consistency verified 7. ✅ API endpoints tested end-to-end **Security:** 1. ✅ Token validation enforced throughout 2. ✅ Authorization checks at each step 3. ✅ No data leakage between users 4. ✅ Rate limiting tested 5. ✅ Audit trail completeness verified **Performance:** 1. ✅ 100-student cohort completes within 5 seconds 2. ✅ Email delivery within 10 seconds 3. ✅ API response times < 500ms 4. ✅ No memory leaks in long-running sessions **Quality:** 1. ✅ 100% test coverage for critical paths 2. ✅ All tests pass consistently 3. ✅ No flaky tests 4. ✅ Test data cleanup verified ##### Integration Verification (IV1-4) **IV1: API Integration** - All API endpoints tested in sequence - Token authentication verified at each step - Error responses validated - Rate limiting tested **IV2: Pinia Store** - State updates propagate correctly - Store actions trigger API calls - Error handling in stores verified - Loading states tested **IV3: Getters** - Computed properties reflect correct state - Filtering and sorting work correctly - Performance with large datasets verified **IV4: Token Routing** - Tokens generated correctly - Tokens validated at each access point - Expiration handled gracefully - Renewal mechanism tested ##### Test Requirements **System Specs:** ```ruby # spec/system/end_to_end_workflow_spec.rb # (Full implementation shown in Technical Implementation Notes) ``` **Request Specs:** ```ruby # spec/requests/api/v1/end_to_end_spec.rb # (Full implementation shown in Technical Implementation Notes) ``` **Performance Specs:** ```ruby # spec/performance/workflow_spec.rb require 'rails_helper' require 'benchmark' RSpec.describe 'Workflow Performance', type: :performance do it 'benchmarks full workflow with 100 students' do cohort = create(:cohort, :with_students, student_count: 100) time = Benchmark.measure do # TP workflow complete_tp_workflow(cohort) # Student workflows (parallel) cohort.students.each do |student| submission = student.submissions.first complete_student_workflow(student, submission) end # Sponsor bulk sign complete_sponsor_bulk_sign(cohort) # TP review complete_tp_review(cohort) end expect(time.real).to be < 5.0 # Should complete in under 5 seconds end end ``` **Email Specs:** ```ruby # spec/mailers/workflow_mailer_spec.rb RSpec.describe WorkflowMailer, type: :mailer do it 'sends correct emails in workflow' do cohort = create(:cohort, :with_students) # Test invitation email mail = WorkflowMailer.invitation(cohort, cohort.sponsor) expect(mail.subject).to include('assigned') expect(mail.to).to eq([cohort.sponsor.email]) expect(mail.body).to include(cohort.name) # Test completion email mail = WorkflowMailer.completion(cohort) expect(mail.subject).to include('completed') expect(mail.body).to include(cohort.students.count.to_s) end end ``` ##### Rollback Procedure **If tests fail:** 1. Identify failing scenario 2. Check test data setup 3. Verify database state 4. Review logs for errors 5. Fix underlying issue 6. Re-run tests **If performance tests fail:** 1. Profile database queries 2. Check for N+1 queries 3. Review indexing 4. Optimize slow operations 5. Re-run with profiling **If integration tests fail:** 1. Check API endpoints 2. Verify token generation 3. Test email delivery 4. Review state machine 5. Fix integration points **Data Safety:** - Tests use isolated database (test environment) - No production data affected - Test data automatically cleaned up - Rollback not needed for test failures ##### Risk Assessment **High Risk** because: - Tests entire system end-to-end - Many moving parts to coordinate - External dependencies (email, storage) - Performance requirements must be met - Security vulnerabilities could be exposed **Specific Risks:** 1. **Flaky Tests**: Tests may pass/fail intermittently - **Mitigation**: Use deterministic test data, proper waiting, avoid time-based tests 2. **Performance Bottlenecks**: System may not meet speed requirements - **Mitigation**: Profile early, optimize database queries, implement caching 3. **Email Delivery Failures**: Test emails may not deliver **Mitigation**: Use test email catcher, mock external SMTP 4. **Token Issues**: Token generation/validation may fail - **Mitigation**: Test token library thoroughly, verify expiration logic 5. **State Machine Bugs**: Invalid state transitions - **Mitigation**: Test all transitions, use AASM gem for state management **Mitigation Strategies:** - Run tests in CI/CD pipeline - Use headless browsers for UI tests - Mock external services where appropriate - Implement test retries for flaky scenarios - Use database transactions for test isolation - Monitor test execution time ##### Success Metrics - **Test Pass Rate**: 100% of tests pass consistently - **Test Coverage**: >90% of critical paths covered - **Performance**: 100-student workflow <5 seconds - **Reliability**: <1% flaky test rate - **Email Delivery**: 100% of test emails captured - **Data Integrity**: Zero data corruption in tests - **Security**: All authorization checks pass - **API Coverage**: All endpoints tested end-to-end --- #### Story 7.2: Mobile Responsiveness Testing **Status**: Draft/Pending **Priority**: High **Epic**: Integration & Testing **Estimated Effort**: 2 days **Risk Level**: Medium ##### User Story **As a** QA Engineer, **I want** to test all three portals across different screen sizes and devices, **So that** I can ensure the FloDoc system works perfectly on mobile, tablet, and desktop devices. ##### Background FloDoc must work seamlessly across all device types: - **Mobile**: 320px - 640px (smartphones) - **Tablet**: 641px - 1024px (iPad, Android tablets) - **Desktop**: 1025px+ (laptops, monitors) **Portal-Specific Mobile Requirements:** **TP Portal**: - Complex admin interface must remain usable - Bulk operations need touch-friendly targets - Data tables must be responsive - Navigation must collapse to hamburger menu - Forms must stack vertically - Progress indicators must be visible **Student Portal**: - Mobile-first design (primary use case) - Maximum 3 clicks to complete any action - Touch targets minimum 44x44px - File upload must work with mobile camera - Form fields must be mobile-optimized - Progress tracking must be clear **Sponsor Portal**: - Bulk signing must work on touch devices - Signature canvas must support touch drawing - Student list must be scrollable - Preview modal must be mobile-friendly - Action buttons must be thumb-accessible **Testing Scenarios:** - **Viewport Sizes**: Test at 10+ breakpoints - **Orientation**: Portrait and landscape modes - **Touch Gestures**: Swipe, tap, pinch, scroll - **Input Methods**: Touch, keyboard, mouse - **Browser Compatibility**: Chrome, Safari, Firefox on mobile - **OS Compatibility**: iOS, Android **Critical Components to Test:** - Navigation menus - Forms and inputs - Tables and lists - Modals and dialogs - Buttons and links - File uploads - Signature capture - Progress indicators - Error messages - Loading states ##### Technical Implementation Notes **Test Framework Setup:** ```javascript // spec/javascript/mobile-responsiveness.spec.js import { mount } from '@vue/test-utils' import { describe, it, expect, beforeEach } from 'vitest' describe('Mobile Responsiveness', () => { const viewports = [ { name: 'iPhone SE', width: 375, height: 667 }, { name: 'iPhone 12', width: 390, height: 844 }, { name: 'iPad Mini', width: 768, height: 1024 }, { name: 'iPad Pro', width: 1024, height: 1366 }, { name: 'Desktop HD', width: 1920, height: 1080 } ] const testComponent = (component, props = {}) => { viewports.forEach(viewport => { it(`renders correctly on ${viewport.name}`, () => { // Set viewport window.innerWidth = viewport.width window.innerHeight = viewport.height const wrapper = mount(component, { props }) // Check responsive classes expect(wrapper.classes()).toContain('responsive') // Check touch targets const buttons = wrapper.findAll('button') buttons.forEach(btn => { const styles = window.getComputedStyle(btn.element) expect(parseInt(styles.minWidth)).toBeGreaterThanOrEqual(44) expect(parseInt(styles.minHeight)).toBeGreaterThanOrEqual(44) }) }) }) } }) ``` **Capybara System Tests:** ```ruby # spec/system/mobile_responsiveness_spec.rb require 'rails_helper' RSpec.describe 'Mobile Responsiveness', type: :system do include MobileHelpers before do driven_by :selenium, using: :headless_chrome end # TP Portal Tests describe 'TP Portal', js: true do let(:user) { create(:user, :tp_admin) } let(:cohort) { create(:cohort, :with_students, student_count: 5) } before do sign_in user end context 'Mobile (375px)' do before { resize_window(375, 667) } it 'displays hamburger menu' do visit tp_cohorts_path expect(page).to have_css('.hamburger-menu') expect(page).not_to have_css('.desktop-nav') end it 'stacks cohort cards vertically' do visit tp_cohorts_path within('.cohort-list') do expect(page).to have_css('.cohort-card', count: 1) expect(page).to have_css('.stacked-layout') end end it 'collapses bulk operations' do visit tp_cohort_path(cohort) expect(page).to have_css('.bulk-actions-dropdown') click_button 'Bulk Actions' expect(page).to have_button('Export Excel') expect(page).to have_button('Send Reminders') end it 'makes forms mobile-friendly' do visit new_tp_cohort_path expect(page).to have_css('input[type="text"]', minimum_width: 44) expect(page).to have_css('button[type="submit"]', minimum_height: 44) end end context 'Tablet (768px)' do before { resize_window(768, 1024) } it 'shows split view for cohort management' do visit tp_cohort_path(cohort) expect(page).to have_css('.split-view') expect(page).to have_css('.sidebar') expect(page).to have_css('.main-content') end it 'displays data tables with horizontal scroll' do visit tp_cohort_path(cohort) within('.student-table') do expect(page).to have_css('.table-scroll') end end end context 'Desktop (1280px)' do before { resize_window(1280, 800) } it 'shows full navigation' do visit tp_cohorts_path expect(page).to have_css('.desktop-nav') expect(page).not_to have_css('.hamburger-menu') end end end # Student Portal Tests describe 'Student Portal', js: true do let(:cohort) { create(:cohort, :with_students) } let(:student) { cohort.students.first } let(:submission) { create(:submission, student: student) } context 'Mobile (375px)' do before { resize_window(375, 667) } it 'optimizes document upload for mobile' do visit student_submission_path(submission, token: submission.token) # Camera upload button visible expect(page).to have_button('Use Camera') # File input is touch-friendly file_input = find('input[type="file"]', visible: false) expect(file_input).not_to be_nil end it 'makes form fields mobile-optimized' do visit student_submission_path(submission, token: submission.token) # All inputs have proper types expect(page).to have_css('input[type="text"]') expect(page).to have_css('input[type="date"]') # Labels are above inputs expect(page).to have_css('label.top-aligned') end it 'shows progress as horizontal bar' do visit student_submission_path(submission, token: submission.token) expect(page).to have_css('.progress-bar') expect(page).to have_css('.step-indicator') end it 'handles touch signature' do visit student_submission_path(submission, token: submission.token) # Canvas is touch-enabled canvas = find('canvas') expect(canvas).not_to be_nil # Simulate touch event page.execute_script("document.querySelector('canvas').dispatchEvent(new TouchEvent('touchstart'))") end end context 'Tablet (768px)' do before { resize_window(768, 1024) } it 'shows two-column layout for forms' do visit student_submission_path(submission, token: submission.token) expect(page).to have_css('.two-column-layout') end end end # Sponsor Portal Tests describe 'Student Portal', js: true do let(:cohort) { create(:cohort, :ready_for_sponsor) } context 'Mobile (375px)' do before { resize_window(375, 667) } it 'makes bulk signing touch-friendly' do visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) # Signature canvas is large enough for finger canvas = find('canvas') expect(canvas[:width]).to eq('600') expect(canvas[:height]).to eq('150') # Buttons are thumb-accessible expect(page).to have_css('button.large-touch-target') end it 'scrolls student list vertically' do visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) within('.student-list') do expect(page).to have_css('.scrollable') expect(page).to have_css('.student-card', count: cohort.students.count) end end it 'shows modal full-screen on mobile' do visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) click_button 'Sign All' # Modal takes full screen expect(page).to have_css('.modal.full-screen') end end context 'Tablet (768px)' do before { resize_window(768, 1024) } it 'shows preview alongside list' do visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) expect(page).to have_css('.split-layout') end end end # Cross-Portal Consistency Tests describe 'Cross-Portal Consistency', js: true do it 'maintains consistent touch targets across all portals' do # Test TP Portal resize_window(375, 667) visit tp_cohorts_path tp_buttons = all('button').map { |b| [b.text, b.size] } # Test Student Portal cohort = create(:cohort, :with_students) submission = cohort.students.first.submissions.first visit student_submission_path(submission, token: submission.token) student_buttons = all('button').map { |b| [b.text, b.size] } # Test Sponsor Portal visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) sponsor_buttons = all('button').map { |b| [b.text, b.size] } # All should have minimum 44x44px all_buttons = tp_buttons + student_buttons + sponsor_buttons all_buttons.each do |_, size| expect(size[:width]).to be >= 44 expect(size[:height]).to be >= 44 end end it 'maintains consistent navigation patterns' do # All portals should have clear back/forward navigation # All should show current location # All should have accessible help end end end ``` **Mobile Helper Module:** ```ruby # spec/support/mobile_helpers.rb module MobileHelpers def resize_window(width, height) page.driver.browser.manage.window.resize_to(width, height) end def touch_click(selector) element = find(selector) page.execute_script("arguments[0].dispatchEvent(new TouchEvent('touchstart'))", element) page.execute_script("arguments[0].dispatchEvent(new TouchEvent('touchend'))", element) end def swipe_left(selector) element = find(selector) page.execute_script(" const touch = new TouchEvent('touchstart', { touches: [{ clientX: 200, clientY: 0 }] }); arguments[0].dispatchEvent(touch); ", element) end def check_touch_target(element) style = element.native.css_value('min-width') expect(style).to eq('44px') end end ``` **Visual Regression Testing:** ```javascript // spec/visual/mobile-visual.spec.js import { percySnapshot } from '@percy/playwright' describe('Mobile Visual Regression', () => { const viewports = [ { width: 375, height: 667, name: 'iPhone SE' }, { width: 768, height: 1024, name: 'iPad Mini' } ] it('TP Portal looks correct on mobile', async ({ page }) => { await page.goto('/tp/cohorts') await page.setViewportSize({ width: 375, height: 667 }) await percySnapshot(page, 'TP Portal - Mobile') }) it('Student Portal looks correct on mobile', async ({ page }) => { await page.goto('/student/submissions/1?token=abc') await page.setViewportSize({ width: 375, height: 667 }) await percySnapshot(page, 'Student Portal - Mobile') }) it('Sponsor Portal looks correct on mobile', async ({ page }) => { await page.goto('/sponsor/cohorts/1?token=abc') await page.setViewportSize({ width: 375, height: 667 }) await percySnapshot(page, 'Sponsor Portal - Mobile') }) }) ``` **Accessibility Testing:** ```ruby # spec/accessibility/mobile_a11y_spec.rb require 'axe/rspec' RSpec.describe 'Mobile Accessibility', type: :system do it 'passes WCAG 2.1 AA on mobile', js: true do resize_window(375, 667) # Test TP Portal visit tp_cohorts_path expect(page).to be_axe_clean.according_to(:wcag21aa) # Test Student Portal cohort = create(:cohort, :with_students) submission = cohort.students.first.submissions.first visit student_submission_path(submission, token: submission.token) expect(page).to be_axe_clean.according_to(:wcag21aa) # Test Sponsor Portal visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) expect(page).to be_axe_clean.according_to(:wcag21aa) end it 'maintains proper contrast ratios', js: true do resize_window(375, 667) visit tp_cohorts_path # Check text contrast text_elements = all('p, h1, h2, h3, h4, h5, h6, span, a, button, label') text_elements.each do |element| color = element.style('color') bg = element.style('background-color') # Verify contrast ratio >= 4.5:1 expect(contrast_ratio(color, bg)).to be >= 4.5 end end it 'supports screen readers', js: true do resize_window(375, 667) visit tp_cohorts_path # Check ARIA labels expect(page).to have_css('[aria-label]') expect(page).to have_css('[role="button"]') expect(page).to have_css('[role="navigation"]') # Check semantic HTML expect(page).to have_css('nav') expect(page).to have_css('main') expect(page).to have_css('header') end end ``` **Touch Interaction Tests:** ```javascript // spec/javascript/touch-interactions.spec.js import { mount } from '@vue/test-utils' import { describe, it, expect, vi } from 'vitest' describe('Touch Interactions', () => { it('handles touch events on signature canvas', async () => { const wrapper = mount(SignatureCanvas) const canvas = wrapper.find('canvas') // Simulate touch start await canvas.trigger('touchstart', { touches: [{ clientX: 100, clientY: 100 }] }) // Simulate touch move await canvas.trigger('touchmove', { touches: [{ clientX: 150, clientY: 150 }] }) // Simulate touch end await canvas.trigger('touchend') expect(wrapper.vm.signatureData).not.toBeNull() }) it('handles swipe gestures for navigation', async () => { const wrapper = mount(StudentPortal) // Simulate swipe left await wrapper.trigger('touchstart', { touches: [{ clientX: 300, clientY: 0 }] }) await wrapper.trigger('touchmove', { touches: [{ clientX: 100, clientY: 0 }] }) await wrapper.trigger('touchend') // Should navigate to next step expect(wrapper.vm.currentStep).toBe(2) }) it('handles pinch-to-zoom on document preview', async () => { const wrapper = mount(DocumentPreview) // Simulate two-finger pinch await wrapper.trigger('touchstart', { touches: [ { clientX: 100, clientY: 100 }, { clientX: 200, clientY: 200 } ] }) // Expand fingers await wrapper.trigger('touchmove', { touches: [ { clientX: 50, clientY: 50 }, { clientX: 250, clientY: 250 } ] }) expect(wrapper.vm.zoomLevel).toBeGreaterThan(1) }) it('handles long press for context menu', async () => { const wrapper = mount(StudentList) // Simulate long press const studentCard = wrapper.find('.student-card') await studentCard.trigger('touchstart') // Wait 500ms await new Promise(resolve => setTimeout(resolve, 500)) // Context menu should appear expect(wrapper.find('.context-menu').exists()).toBe(true) }) }) ``` **Browser Compatibility Matrix:** ```yaml # config/mobile-test-matrix.yml browsers: mobile: - name: Safari iOS versions: [15, 16, 17] devices: [iPhone SE, iPhone 12, iPhone 14] - name: Chrome Android versions: [11, 12, 13] devices: [Pixel 5, Samsung Galaxy S21] - name: Samsung Internet versions: [15, 16, 17] devices: [Galaxy S21, Galaxy S22] tablet: - name: Safari iPad versions: [15, 16, 17] devices: [iPad Mini, iPad Air, iPad Pro] - name: Chrome Tablet versions: [11, 12, 13] devices: [Samsung Tab S7, Pixel Tablet] test_scenarios: - viewport_sizes: [375, 768, 1024, 1280] - orientations: [portrait, landscape] - input_methods: [touch, keyboard, mouse] - network_conditions: [3G, 4G, WiFi] ``` ##### Acceptance Criteria **Functional:** 1. ✅ All portals render correctly on 375px (mobile) 2. ✅ All portals render correctly on 768px (tablet) 3. ✅ All portals render correctly on 1280px (desktop) 4. ✅ Touch targets are minimum 44x44px everywhere 5. ✅ Forms are mobile-optimized (proper input types) 6. ✅ Navigation collapses to hamburger on mobile 7. ✅ Tables scroll horizontally on small screens 8. ✅ Modals are full-screen on mobile 9. ✅ Signature canvas works with touch 10. ✅ File upload works with mobile camera **UI/UX:** 1. ✅ Portrait and landscape modes work 2. ✅ Swipe gestures work correctly 3. ✅ Pinch-to-zoom works on document preview 4. ✅ Long press shows context menus 5. ✅ Loading states are visible on all sizes 6. ✅ Error messages are readable on mobile 7. ✅ Buttons are thumb-accessible 8. ✅ Text is readable without zoom **Integration:** 1. ✅ All portals maintain consistent design 2. ✅ Responsive breakpoints work across portals 3. ✅ Touch events propagate correctly 4. ✅ Keyboard navigation works on tablets 5. ✅ Mouse events don't break touch **Security:** 1. ✅ No sensitive data exposed in mobile view 2. ✅ Touch events don't bypass security 3. ✅ Mobile camera upload is secure 4. ✅ Session management works on mobile **Quality:** 1. ✅ Tests pass on all viewport sizes 2. ✅ Visual regression tests pass 3. ✅ Accessibility tests pass (WCAG 2.1 AA) 4. ✅ No horizontal scroll on content 5. ✅ Performance is acceptable on mobile ##### Integration Verification (IV1-4) **IV1: API Integration** - Mobile views call same APIs as desktop - No mobile-specific API endpoints needed - Response data formatted for mobile display **IV2: Pinia Store** - Store logic unchanged for mobile - UI components adapt based on viewport - State management consistent **IV3: Getters** - Computed properties work on all sizes - Filtering/sorting adapted for mobile UI - Performance optimized for mobile **IV4: Token Routing** - Token handling unchanged - Mobile links work correctly - No mobile-specific security issues ##### Test Requirements **System Specs:** ```ruby # spec/system/mobile_responsiveness_spec.rb # (Full implementation shown in Technical Implementation Notes) ``` **Visual Regression Specs:** ```javascript // spec/visual/mobile-visual.spec.js # (Full implementation shown in Technical Implementation Notes) ``` **Accessibility Specs:** ```ruby # spec/accessibility/mobile_a11y_spec.rb # (Full implementation shown in Technical Implementation Notes) ``` **Touch Interaction Specs:** ```javascript // spec/javascript/touch-interactions.spec.js # (Full implementation shown in Technical Implementation Notes) ``` **E2E Mobile Tests:** ```ruby # spec/system/mobile_e2e_spec.rb RSpec.describe 'Mobile End-to-End', type: :system do it 'completes full workflow on mobile', js: true do resize_window(375, 667) # TP creates cohort sign_in tp_user visit new_tp_cohort_path fill_in 'Name', with: 'Mobile Test' click_button 'Create' # Student completes on mobile visit student_submission_path(submission, token: submission.token) attach_file 'Document', Rails.root.join('spec/fixtures/mobile_doc.pdf') fill_in 'Name', with: 'Mobile Student' click_button 'Submit' # Sponsor signs on mobile visit sponsor_cohort_path(cohort, token: cohort.sponsor_token) fill_in 'Type Your Full Name', with: 'Mobile Sponsor' check 'I confirm' click_button 'Sign All' expect(page).to have_content('Complete') end end ``` ##### Rollback Procedure **If mobile tests fail:** 1. Check viewport sizing 2. Verify responsive CSS classes 3. Test touch event handlers 4. Review media queries 5. Fix mobile-specific bugs **If accessibility tests fail:** 1. Check ARIA labels 2. Verify color contrast 3. Test keyboard navigation 4. Fix semantic HTML 5. Re-run accessibility tests **If visual regression fails:** 1. Review screenshot differences 2. Check if change is intentional 3. Update baseline if needed 4. Re-run visual tests **If touch interactions fail:** 1. Check event listeners 2. Verify touch event propagation 3. Test on real device 4. Fix touch handling **Data Safety:** - Tests use test environment only - No production data affected - Visual tests don't affect functionality - Rollback not needed for test failures ##### Risk Assessment **Medium Risk** because: - Many device/browser combinations - Touch events vary across devices - Visual regression can be flaky - Accessibility issues may be complex - Performance on low-end devices **Specific Risks:** 1. **Device Fragmentation**: Too many combinations to test - **Mitigation**: Focus on top 80% of devices, use cloud testing services 2. **Touch Event Inconsistency**: Different devices handle touch differently - **Mitigation**: Use standardized touch libraries, test on real devices 3. **Visual Regression Flakiness**: Slight rendering differences - **Mitigation**: Use percy with 1% threshold, test in consistent environment 4. **Performance on Mobile**: Slow devices may lag - **Mitigation**: Optimize images, reduce animations, lazy load content 5. **Accessibility Complexity**: WCAG compliance is hard - **Mitigation**: Use automated tools, manual testing, axe-core integration **Mitigation Strategies:** - Use BrowserStack or Sauce Labs for device testing - Implement visual regression with Percy - Use axe-core for automated accessibility - Test on real devices when possible - Monitor mobile performance metrics - Use responsive design patterns ##### Success Metrics - **Test Coverage**: 100% of mobile views tested - **Device Coverage**: Top 20 devices covering 90% of users - **Accessibility Score**: 100% WCAG 2.1 AA compliance - **Visual Regression**: <1% difference threshold - **Touch Success Rate**: 99% of touch interactions work - **Performance**: Mobile load time <3 seconds - **Browser Compatibility**: Pass on Chrome, Safari, Firefox mobile - **Orientation Support**: 100% portrait and landscape support --- #### Story 7.3: Performance Testing (50+ Students) **Status**: Draft/Pending **Priority**: Critical **Epic**: Integration & Testing **Estimated Effort**: 3 days **Risk Level**: High ##### User Story **As a** QA Engineer, **I want** to test system performance with large cohorts (50+ students), **So that** I can ensure FloDoc scales efficiently and meets NFR requirements. ##### Background Performance is critical for production success. This story validates: - **Load Time**: Pages must load within acceptable timeframes - **Database Queries**: No N+1 queries, optimized indexes - **Memory Usage**: No memory leaks, efficient garbage collection - **Concurrent Users**: System must handle multiple users simultaneously - **Large Cohorts**: 50, 100, even 500 students per cohort - **Bulk Operations**: Signing 100+ students at once - **Excel Export**: Generate large files without timeout - **Email Delivery**: Queue and send 100+ emails efficiently **Performance Requirements (from NFRs):** - Page load time: <2 seconds - API response time: <500ms - Bulk signing: <5 seconds for 100 students - Excel export: <10 seconds for 100 rows - Email queue: Process 100 emails in <30 seconds - Memory growth: <10% per operation - Database queries: <20 per page load **Test Scenarios:** 1. **Cohort Creation**: Create cohort with 100 students 2. **Student Upload**: 100 students uploading documents simultaneously 3. **Bulk Signing**: Sponsor signs 100 students at once 4. **Excel Export**: Export 100 student records 5. **Email Blast**: Send 100 invitation emails 6. **Concurrent Access**: 10 users accessing same cohort 7. **Database Load**: Complex queries with large datasets 8. **Memory Leak**: Long-running sessions over hours **Tools and Monitoring:** - **Rails Panel**: Query count and time - **Bullet**: N+1 query detection - **rack-mini-profiler**: Performance profiling - **memory_profiler**: Memory usage tracking - **New Relic**: Production monitoring simulation - **JMeter**: Load testing - **PgHero**: Database performance ##### Technical Implementation Notes **Test Framework Setup:** ```ruby # spec/performance/cohort_performance_spec.rb require 'rails_helper' require 'benchmark' require 'memory_profiler' require 'rack-mini-profiler' RSpec.describe 'Cohort Performance', type: :performance do include PerformanceHelpers before do # Enable profiling Rack::MiniProfiler.config.enable = true Rack::MiniProfiler.config.storage = Rack::MiniProfiler::MemoryStore end describe 'Cohort Creation Performance' do it 'creates cohort with 100 students in <2 seconds', :slow do time = Benchmark.measure do perform_enqueued_jobs do post '/api/v1/cohorts', params: { name: 'Large Cohort', student_count: 100, sponsor_email: 'sponsor@example.com' }, headers: { 'Authorization' => "Bearer #{tp_token}" } end end expect(time.real).to be < 2.0 expect(Cohort.last.students.count).to eq(100) end it 'generates 100 student tokens efficiently' do cohort = create(:cohort) time = Benchmark.measure do 100.times do student = create(:student, cohort: cohort) submission = create(:submission, student: student) submission.generate_token! end end expect(time.real).to be < 1.0 expect(Submission.where(cohort: cohort).count).to eq(100) end end describe 'Student Upload Performance' do it 'handles 100 concurrent uploads', :slow do cohort = create(:cohort, :with_students, student_count: 100) time = Benchmark.measure do cohort.students.each do |student| submission = student.submissions.first # Simulate concurrent upload Thread.new do post "/api/v1/student/submissions/#{submission.id}/upload", params: { file: fixture_file_upload('document.pdf') }, headers: { 'Authorization' => "Bearer #{submission.token}" } end end # Wait for all threads sleep 0.1 until cohort.submissions.where.not(document: nil).count == 100 end expect(time.real).to be < 5.0 expect(cohort.submissions.where.not(document: nil).count).to eq(100) end end describe 'Bulk Signing Performance' do it 'signs 100 students in <5 seconds', :slow do cohort = create(:cohort, :ready_for_sponsor, student_count: 100) time = Benchmark.measure do post "/api/v1/sponsor/cohorts/#{cohort.id}/bulk-sign", params: { signature: 'Sponsor Signature', method: 'text' }, headers: { 'Authorization' => "Bearer #{cohort.sponsor_token}" } end expect(time.real).to be < 5.0 expect(cohort.submissions.where(sponsor_signed: true).count).to eq(100) end it 'uses single database transaction for bulk operations' do cohort = create(:cohort, :ready_for_sponsor, student_count: 50) # Monitor queries count_before = ActiveRecord::Base.connection.query_cache.length post "/api/v1/sponsor/cohorts/#{cohort.id}/bulk-sign", params: { signature: 'Signature' }, headers: { 'Authorization' => "Bearer #{cohort.sponsor_token}" } count_after = ActiveRecord::Base.connection.query_cache.length # Should use minimal queries expect(count_after - count_before).to be < 10 end end describe 'Excel Export Performance' do it 'generates Excel file for 100 students in <10 seconds', :slow do cohort = create(:cohort, :with_students, student_count: 100) time = Benchmark.measure do get "/api/v1/cohorts/#{cohort.id}/export", headers: { 'Authorization' => "Bearer #{tp_token}" } end expect(time.real).to be < 10.0 expect(response.headers['Content-Type']).to include('application/vnd.openxmlformats') end it 'does not load all records into memory' do cohort = create(:cohort, :with_students, student_count: 100) # Monitor memory report = MemoryProfiler.report do get "/api/v1/cohorts/#{cohort.id}/export", headers: { 'Authorization' => "Bearer #{tp_token}" } end # Should use less than 50MB expect(report.total_allocated_memsize).to be < 50.megabytes end end describe 'Email Delivery Performance' do it 'queues 100 emails in <30 seconds', :slow do cohort = create(:cohort, :with_students, student_count: 100) time = Benchmark.measure do perform_enqueued_jobs do cohort.students.each do |student| StudentMailer.invitation(student, cohort).deliver_later end end end expect(time.real).to be < 30.0 expect(ActionMailer::Base.deliveries.count).to eq(100) end it 'uses background jobs efficiently' do cohort = create(:cohort, :with_students, student_count: 100) # Should enqueue jobs, not execute immediately assert_enqueued_jobs 100 do cohort.students.each do |student| StudentMailer.invitation(student, cohort).deliver_later end end end end describe 'Concurrent User Performance' do it 'handles 10 concurrent users accessing same cohort' do cohort = create(:cohort, :ready_for_sponsor, student_count: 50) time = Benchmark.measure do threads = 10.times.map do Thread.new do get "/api/v1/sponsor/cohorts/#{cohort.id}", headers: { 'Authorization' => "Bearer #{cohort.sponsor_token}" } end end threads.each(&:join) end expect(time.real).to be < 2.0 end it 'prevents race conditions in bulk signing' do cohort = create(:cohort, :ready_for_sponsor, student_count: 50) # Two threads try to sign simultaneously threads = [ Thread.new do post "/api/v1/sponsor/cohorts/#{cohort.id}/bulk-sign", params: { signature: 'Signature 1' }, headers: { 'Authorization' => "Bearer #{cohort.sponsor_token}" } end, Thread.new do post "/api/v1/sponsor/cohorts/#{cohort.id}/bulk-sign", params: { signature: 'Signature 2' }, headers: { 'Authorization' => "Bearer #{cohort.sponsor_token}" } end ] threads.each(&:join) # Only one should succeed expect(cohort.submissions.where(sponsor_signed: true).count).to eq(50) end end describe 'Database Query Performance' do it 'detects N+1 queries', :slow do cohort = create(:cohort, :with_students, student_count: 50) # Enable Bullet Bullet.enable = true Bullet.raise = true expect { get "/api/v1/cohorts/#{cohort.id}/submissions", headers: { 'Authorization' => "Bearer #{tp_token}" } }.not_to raise_error Bullet.enable = false end it 'uses proper indexes' do # Check query plan cohort = create(:cohort, :with_students, student_count: 50) query = "EXPLAIN SELECT * FROM submissions WHERE cohort_id = #{cohort.id}" result = ActiveRecord::Base.connection.execute(query) # Should use index, not sequential scan expect(result.first['QUERY PLAN']).to include('Index Scan') end it 'avoids SELECT * in large queries' do cohort = create(:cohort, :with_students, student_count: 100) # Monitor queries queries = [] callback = ->(name, start, finish, id, payload) { queries << payload[:sql] } ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do get "/api/v1/cohorts/#{cohort.id}/export", headers: { 'Authorization' => "Bearer #{tp_token}" } end # Check for SELECT * select_star_queries = queries.select { |q| q.include?('SELECT *') } expect(select_star_queries.count).to eq(0) end end describe 'Memory Leak Detection' do it 'does not leak memory over multiple operations', :slow do cohort = create(:cohort, :with_students, student_count: 50) initial_memory = `ps -o rss= -p #{Process.pid}`.to_i 10.times do post "/api/v1/sponsor/cohorts/#{cohort.id}/bulk-sign", params: { signature: 'Signature' }, headers: { 'Authorization' => "Bearer #{cohort.sponsor_token}" } cohort.update!(status: :draft) cohort.submissions.update_all(sponsor_signed: false) end final_memory = `ps -o rss= -p #{Process.pid}`.to_i growth = final_memory - initial_memory # Memory growth should be < 10% expect(growth).to be < (initial_memory * 0.1) end it 'cleans up temporary objects' do cohort = create(:cohort, :with_students, student_count: 100) # Force GC GC.start before_count = ObjectSpace.count_objects[:TOTAL] # Perform operation get "/api/v1/cohorts/#{cohort.id}/export", headers: { 'Authorization' => "Bearer #{tp_token}" } # Force GC again GC.start after_count = ObjectSpace.count_objects[:TOTAL] # Should not create excessive objects expect(after_count - before_count).to be < 1000 end end describe 'Load Testing' do it 'handles sustained load of 100 requests/minute', :slow do cohort = create(:cohort, :ready_for_sponsor, student_count: 50) # Simulate 100 requests over 1 minute start_time = Time.current 100.times do get "/api/v1/sponsor/cohorts/#{cohort.id}", headers: { 'Authorization' => "Bearer #{cohort.sponsor_token}" } end elapsed = Time.current - start_time # Should complete in under 60 seconds expect(elapsed).to be < 60.0 # Average response time should be < 500ms expect(elapsed / 100).to be < 0.5 end it 'maintains performance under memory pressure' do cohort = create(:cohort, :with_students, student_count: 100) # Allocate memory to simulate pressure large_array = 100.times.map { 'x' * 1000 } time = Benchmark.measure do get "/api/v1/cohorts/#{cohort.id}/export", headers: { 'Authorization' => "Bearer #{tp_token}" } end # Should still perform reasonably expect(time.real).to be < 15.0 large_array = nil # Clean up GC.start end end end ``` **Performance Helper Module:** ```ruby # spec/support/performance_helpers.rb module PerformanceHelpers def profile_query(&block) count = 0 callback = ->(name, start, finish, id, payload) { count += 1 } ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do yield end count end def profile_memory(&block) report = MemoryProfiler.report(&block) { total_allocated: report.total_allocated_memsize, total_retained: report.total_retained_memsize, allocated_objects: report.allocated_objects, retained_objects: report.retained_objects } end def profile_time(&block) result = nil time = Benchmark.measure { result = yield } { time: time.real, result: result } end def explain_query(sql) ActiveRecord::Base.connection.execute("EXPLAIN #{sql}").first end end ``` **Database Performance Monitoring:** ```ruby # spec/support/database_monitor.rb module DatabaseMonitor def self.slow_queries ActiveRecord::Base.connection.execute(" SELECT query, mean_exec_time, calls FROM pg_stat_statements WHERE mean_exec_time > 100 ORDER BY mean_exec_time DESC LIMIT 10 ") end def self.index_usage ActiveRecord::Base.connection.execute(" SELECT schemaname, tablename, indexname, idx_scan FROM pg_stat_all_indexes WHERE idx_scan = 0 ORDER BY tablename ") end def self.table_sizes ActiveRecord::Base.connection.execute(" SELECT tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size FROM pg_tables ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC LIMIT 10 ") end end ``` **Rails Performance Test:** ```ruby # spec/rails/performance_test.rb require 'test_helper' require 'rails/performance_test' class CohortPerformanceTest < ActionDispatch::PerformanceTest def setup @cohort = create(:cohort, :with_students, student_count: 100) end def test_cohort_creation post '/api/v1/cohorts', params: { name: 'Performance Test', student_count: 100 }, headers: { 'Authorization' => "Bearer #{tp_token}" } assert_response :success end def test_bulk_signing post "/api/v1/sponsor/cohorts/#{@cohort.id}/bulk-sign", params: { signature: 'Performance Signature' }, headers: { 'Authorization' => "Bearer #{@cohort.sponsor_token}" } assert_response :success end def test_excel_export get "/api/v1/cohorts/#{@cohort.id}/export", headers: { 'Authorization' => "Bearer #{tp_token}" } assert_response :success assert_equal 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', response.content_type end end ``` **JMeter Test Plan (XML):** ```xml 10 60 false localhost 3000 /api/v1/sponsor/cohorts/1/bulk-sign POST {"signature":"Load Test Signature"} localhost 3000 /api/v1/cohorts/1/export GET ``` **New Relic Monitoring Simulation:** ```ruby # spec/support/new_relic_simulator.rb module NewRelicSimulator def self.record_metric(name, value) # Simulate New Relic metric recording Rails.logger.info "[NewRelic] #{name}: #{value}" # Store for assertion @metrics ||= {} @metrics[name] = value end def self.get_metric(name) @metrics&.dig(name) end def self.reset @metrics = {} end end # Usage in tests RSpec.configure do |config| config.before(:each, :new_relic) do NewRelicSimulator.reset end config.after(:each, :new_relic) do # Log all metrics puts "\n=== New Relic Metrics ===" NewRelicSimulator.instance_variable_get(:@metrics)&.each do |k, v| puts "#{k}: #{v}" end end end ``` ##### Acceptance Criteria **Functional:** 1. ✅ Cohort creation with 100 students <2 seconds 2. ✅ Bulk signing 100 students <5 seconds 3. ✅ Excel export 100 rows <10 seconds 4. ✅ Email queue 100 messages <30 seconds 5. ✅ Concurrent access (10 users) <2 seconds 6. ✅ Student uploads (100 concurrent) <5 seconds 7. ✅ Database queries <20 per page load 8. ✅ API responses <500ms average **Performance:** 1. ✅ No N+1 queries detected 2. ✅ Proper database indexes used 3. ✅ Memory growth <10% per operation 4. ✅ No memory leaks over 10 iterations 5. ✅ Object creation <1000 per operation 6. ✅ GC overhead <5% of total time 7. ✅ Sustained load (100 req/min) <60s total **Database:** 1. ✅ All queries use indexes 2. ✅ No SELECT * queries 3. ✅ Transactions used for bulk operations 4. ✅ Query count optimized 5. ✅ Table sizes reasonable **Quality:** 1. ✅ All performance tests pass consistently 2. ✅ No flaky performance tests 3. ✅ Performance metrics documented 4. ✅ Bottlenecks identified and documented 5. ✅ Recommendations provided ##### Integration Verification (IV1-4) **IV1: API Integration** - All API endpoints tested under load - Response times measured and validated - Error rates monitored - Rate limiting verified **IV2: Pinia Store** - Store actions performant with large datasets - No excessive re-renders - Memory usage optimized - State updates efficient **IV3: Getters** - Computed properties optimized - No expensive operations in getters - Caching works correctly - Performance scales with data size **IV4: Token Routing** - Token generation is fast - Token validation doesn't bottleneck - No performance impact from security ##### Test Requirements **Performance Specs:** ```ruby # spec/performance/cohort_performance_spec.rb # (Full implementation shown in Technical Implementation Notes) ``` **Load Test Script:** ```bash # script/load_test.sh #!/bin/bash # Run JMeter load test jmeter -n -t config/jmeter/floDoc_load_test.jmx \ -l results/floDoc_results.jtl \ -e -o results/floDoc_report # Run memory profiling bundle exec ruby -r ./spec/performance/memory_profiler.rb # Run database analysis bundle exec rails runner "DatabaseMonitor.report" ``` **Benchmark Script:** ```ruby # script/benchmark.rb require_relative '../config/environment' puts "=== FloDoc Performance Benchmark ===" # Cohort with 100 students cohort = Cohort.create!(name: 'Benchmark Test') 100.times do |i| student = cohort.students.create!(name: "Student #{i}", email: "s#{i}@test.com") student.submissions.create! end puts "Created cohort with 100 students" # Benchmark bulk signing time = Benchmark.measure do cohort.submissions.update_all(sponsor_signed: true, sponsor_signed_at: Time.current) end puts "Bulk signing: #{time.real.round(3)}s" # Benchmark export time = Benchmark.measure do # Excel generation end puts "Excel export: #{time.real.round(3)}s" puts "=== Complete ===" ``` ##### Rollback Procedure **If performance tests fail:** 1. Identify bottleneck (database, memory, CPU) 2. Profile slow queries 3. Check for N+1 queries 4. Review indexes 5. Optimize code 6. Re-run tests **If memory leaks detected:** 1. Use memory_profiler to find leak source 2. Check for object retention 3. Review long-lived objects 4. Fix leak 5. Re-run memory tests **If database performance poor:** 1. Run EXPLAIN on slow queries 2. Add missing indexes 3. Optimize queries 4. Consider caching 5. Re-run database tests **If load tests fail:** 1. Check server configuration 2. Review concurrency settings 3. Scale resources if needed 4. Optimize for parallel processing 5. Re-run load tests **Data Safety:** - Tests use isolated test database - No production data affected - Performance data is informational only - Rollback not needed for test failures ##### Risk Assessment **High Risk** because: - Performance issues are hard to fix late - Large datasets expose hidden problems - Memory leaks may not be obvious - Database performance critical - Must meet strict NFR requirements **Specific Risks:** 1. **N+1 Queries**: Common Rails problem with large datasets - **Mitigation**: Use Bullet gem, eager loading, test query count 2. **Memory Leaks**: Objects not released, growing memory - **Mitigation**: Use memory_profiler, force GC, monitor object count 3. **Database Bottlenecks**: Slow queries, missing indexes - **Mitigation**: Use pg_stat_statements, EXPLAIN, add indexes 4. **Timeout Issues**: Long operations exceed limits - **Mitigation**: Background jobs, streaming responses, pagination 5. **Concurrent Access**: Race conditions, deadlocks - **Mitigation**: Database locks, transactions, optimistic locking **Mitigation Strategies:** - Profile early and often - Use automated performance monitoring - Set up CI performance gates - Document all performance metrics - Have optimization plan ready - Use caching strategically - Implement background jobs for heavy operations ##### Success Metrics - **Load Time**: All pages <2 seconds - **API Response**: 95% of requests <500ms - **Bulk Operations**: 100 students <5 seconds - **Export Time**: 100 rows <10 seconds - **Email Queue**: 100 emails <30 seconds - **Query Count**: <20 queries per page - **Memory Growth**: <10% per operation - **Concurrent Users**: 10+ users without degradation - **Database Indexes**: 100% of queries use indexes - **Zero N+1**: Bullet reports no issues --- #### Story 7.4: Security Audit & Penetration Testing **Status**: Draft/Pending **Priority**: Critical **Epic**: Integration & Testing **Estimated Effort**: 3 days **Risk Level**: High ##### User Story **As a** Security Engineer, **I want** to perform comprehensive security testing on all three portals, **So that** I can identify and remediate vulnerabilities before production deployment. ##### Background Security is paramount for a document signing platform handling sensitive student data. This story validates: **Authentication Security:** - JWT token generation and validation - Token expiration and renewal mechanisms - Ad-hoc access pattern security (no account creation) - Token leakage prevention - Session management **Authorization Security:** - Role-based access control (TP, Student, Sponsor) - Cross-portal access prevention - Data isolation between cohorts - Proper Cancancan ability definitions - API endpoint protection **Data Security:** - Student PII protection (names, emails, documents) - Document encryption at rest - Secure file uploads (content validation) - GDPR compliance (data retention, deletion) - Audit trail integrity **Input Validation:** - SQL injection prevention - XSS prevention (Vue templates, form inputs) - File upload validation (type, size, content) - API parameter sanitization - Mass assignment protection **Web Security:** - CSRF protection - CORS configuration - HTTPS enforcement - Secure headers (CSP, HSTS, X-Frame-Options) - Clickjacking prevention **Email Security:** - Email spoofing prevention - Link tampering protection - Token expiration in emails - Secure email templates **Third-Party Security:** - DocuSeal API integration security - Webhook signature verification - External service authentication **Compliance:** - OWASP Top 10 coverage - GDPR data protection requirements - Audit logging for all sensitive operations ##### Technical Implementation Notes **Security Test Framework:** ```ruby # spec/security/security_audit_spec.rb require 'rails_helper' require 'owasp_zap' require 'brakeman' RSpec.describe 'Security Audit', type: :security do describe 'Authentication Security' do it 'prevents token tampering' do # Test JWT signature validation original_token = generate_valid_token tampered_token = original_token[0..-5] + 'X' * 4 get '/api/v1/tp/cohorts', headers: { 'Authorization' => "Bearer #{tampered_token}" } expect(response).to have_http_status(:unauthorized) end it 'enforces token expiration' do expired_token = generate_expired_token get '/api/v1/tp/cohorts', headers: { 'Authorization' => "Bearer #{expired_token}" } expect(response).to have_http_status(:unauthorized) expect(JSON.parse(response.body)['error']).to include('expired') end it 'prevents token reuse after renewal' do token = generate_valid_token old_token = token.dup # Renew token post '/api/v1/auth/renew', headers: { 'Authorization' => "Bearer #{old_token}" } expect(response).to have_http_status(:success) # Old token should be invalid get '/api/v1/tp/cohorts', headers: { 'Authorization' => "Bearer #{old_token}" } expect(response).to have_http_status(:unauthorized) end it 'prevents cross-portal access' do sponsor_token = generate_sponsor_token # Sponsor tries to access TP endpoint get '/api/v1/tp/cohorts', headers: { 'Authorization' => "Bearer #{sponsor_token}" } expect(response).to have_http_status(:forbidden) end it 'validates ad-hoc token security' do # Test that tokens are cryptographically secure token = generate_token_for_student # Verify token contains no sensitive data payload = JWT.decode(token, Rails.application.secrets.secret_key_base, true, { algorithm: 'HS256' }) expect(payload[0]).not_to include('password') expect(payload[0]).not_to include('document_content') expect(payload[0]).to include('exp') expect(payload[0]).to include('sub') end end describe 'Authorization Security' do it 'prevents unauthorized cohort access' do cohort = create(:cohort) student_token = generate_student_token_for_different_cohort get "/api/v1/student/cohorts/#{cohort.id}", headers: { 'Authorization' => "Bearer #{student_token}" } expect(response).to have_http_status(:not_found) end it 'enforces ability-based access' do cohort = create(:cohort) user = create(:user) # Not associated with cohort ability = Ability.new(user) expect(ability).not_to be_able_to(:read, cohort) expect(ability).not_to be_able_to(:update, cohort) end it 'prevents sponsor from accessing other cohorts' do cohort1 = create(:cohort) cohort2 = create(:cohort) token1 = cohort1.sponsor_token token2 = cohort2.sponsor_token # Sponsor 1 tries to access cohort 2 get "/api/v1/sponsor/cohorts/#{cohort2.id}", headers: { 'Authorization' => "Bearer #{token1}" } expect(response).to have_http_status(:not_found) end end describe 'Input Validation Security' do it 'prevents SQL injection' do malicious_params = { name: "Cohort'; DROP TABLE cohorts; --", student_emails: ["test@example.com'; DROP TABLE users; --"] } post '/api/v1/tp/cohorts', params: malicious_params, headers: { 'Authorization' => "Bearer #{tp_token}" } expect(response).to have_http_status(:unprocessable_entity) expect(Cohort.count).to eq(0) # No cohort created end it 'prevents XSS in student names' do post '/api/v1/tp/cohorts', params: { name: 'Test Cohort', students: [{ name: '', email: 'test@example.com' }] }, headers: { 'Authorization' => "Bearer #{tp_token}" } expect(response).to have_http_status(:success) # Verify sanitization cohort = Cohort.last expect(cohort.students.first.name).not_to include('