diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index a780b2b5..00000000 --- a/docs/architecture.md +++ /dev/null @@ -1,2163 +0,0 @@ -# FloDoc Brownfield Enhancement Architecture -**3-Portal Cohort Management System for Training Institutions** - -*Version: v1.0* -*Date: 2025-01-02* -*Status: Draft - Architect Review Complete* - ---- - -## Table of Contents -1. [Introduction](#introduction) -2. [Enhancement Scope and Integration Strategy](#enhancement-scope-and-integration-strategy) -3. [Tech Stack](#tech-stack) -4. [Data Models and Schema Changes](#data-models-and-schema-changes) -5. [Component Architecture](#component-architecture) -6. [API Design and Integration](#api-design-and-integration) -7. [Source Tree](#source-tree) -8. [Infrastructure and Deployment Integration](#infrastructure-and-deployment-integration) -9. [Coding Standards](#coding-standards) -10. [Testing Strategy](#testing-strategy) -11. [Security Integration](#security-integration) -12. [Checklist Results Report](#checklist-results-report) -13. [Next Steps](#next-steps) - ---- - -## Introduction - -### Introduction Content - -This document outlines the architectural approach for enhancing **DocuSeal** with the **3-Portal Cohort Management System** for training institutions. Its primary goal is to serve as the guiding architectural blueprint for AI-driven development of new features while ensuring seamless integration with the existing system. - -**Relationship to Existing Architecture:** -This document supplements existing DocuSeal architecture by defining how new cohort management components will integrate with current systems. Where conflicts arise between new and existing patterns, this document provides guidance on maintaining consistency while implementing enhancements. - -### Existing Project Analysis - -#### Current Project State - -**Primary Purpose:** DocuSeal is an open-source document filling and signing platform providing WYSIWYG PDF form building, multi-signer workflows, and secure digital document signing capabilities. - -**Current Tech Stack:** -- **Languages:** Ruby 3.4.2, JavaScript, Vue.js 3, HTML, CSS -- **Frameworks:** Rails 7.x, Shakapacker 8.0, Vue 3.3.2, TailwindCSS 3.4.17, DaisyUI 3.9.4 -- **Database:** SQLite (development), PostgreSQL/MySQL (production via DATABASE_URL) -- **Infrastructure:** Docker, Sidekiq for background jobs, Puma web server -- **External Dependencies:** AWS S3, Google Cloud Storage, Azure Cloud (optional), SMTP for emails - -**Architecture Style:** Monolithic Rails 7 application with Vue.js 3 frontend, following MVC pattern with service objects for complex business logic. - -**Deployment Method:** Docker-based deployment with existing CI/CD pipeline, Shakapacker for asset compilation, Sidekiq workers for background processing. - -#### Available Documentation - -- ✅ **API Documentation** - Complete RESTful API with examples in Node.js, Ruby, Python, PHP, Java, Go, C#, TypeScript, JavaScript -- ✅ **Webhook Documentation** - Submission, form, and template webhooks with event types and payload schemas -- ✅ **Embedding Documentation** - React, Vue, Angular, JavaScript form builders and signing forms -- ⚠️ **Architecture Documentation** - **Created via this document** (previously missing) -- ⚠️ **Coding Standards** - **To be documented** (previously missing) -- ⚠️ **Source Tree Documentation** - **Created via this document** (previously missing) -- ⚠️ **Technical Debt Documentation** - **To be analyzed** (previously missing) - -#### Identified Constraints - -- **Multi-tenancy:** Current system supports single-account or multi-tenant mode via `Docuseal.multitenant?` flag -- **Authentication:** Devise-based with 2FA support, JWT tokens for API access -- **Authorization:** Cancancan with role-based access via `AccountAccess` model -- **Storage:** Active Storage with multiple backend support (S3, GCS, Azure, local) -- **PDF Processing:** HexaPDF for generation/signing, PDFium for rendering -- **Background Jobs:** Sidekiq with Redis dependency -- **UI Framework:** Vue 3 with Composition API, DaisyUI components -- **Mobile Support:** Existing responsive design must be maintained - -### Change Log - -| Change | Date | Version | Description | Author | -|--------|------|---------|-------------|--------| -| Initial Architecture Creation | 2025-01-02 | v1.0 | Brownfield enhancement architecture for 3-portal cohort management | Winston (Architect) | - ---- - -## Enhancement Scope and Integration Strategy - -### Enhancement Overview - -**Enhancement Type:** ✅ **Major Feature Addition** (3-Portal Cohort Management System) - -**Scope:** Transform the single-portal DocuSeal platform into a specialized 3-portal cohort management system for South African private training institutions. The system will manage training cohorts (learnerships, internships, candidacies) through a coordinated workflow involving institution admins, students, and sponsors. - -**Integration Impact:** ✅ **Significant Impact** (substantial existing code changes required) - -### Integration Approach - -**Code Integration Strategy:** -- **Additive Approach:** All new functionality will be added as new models, controllers, and components without modifying existing DocuSeal core logic -- **Extension Pattern:** Extend existing authentication and authorization to support new role types -- **Service Layer:** Create new service objects in `lib/cohorts/` directory for cohort-specific business logic -- **Event-Driven:** Leverage existing webhook infrastructure for cohort workflow notifications - -**Database Integration:** -- **New Tables:** Create 5 new tables (`cohorts`, `cohort_enrollments`, `institutions`, `sponsors`, `document_verifications`) with foreign keys to existing tables -- **No Schema Modifications:** Existing tables remain unchanged, only new relationships added -- **Migration Strategy:** Sequential migrations with rollback capability, tested on production-like data -- **Data Integrity:** Use database transactions for cohort state transitions - -**API Integration:** -- **Endpoint Extension:** New endpoints under `/api/v1/cohorts/*` following existing RESTful patterns -- **Authentication Reuse:** Leverage existing Devise + JWT authentication without modification -- **Submission Integration:** Use existing submission APIs for document signing workflows -- **Versioning:** No new API version needed, endpoints extend v1 - -**UI Integration:** -- **Portal Architecture:** Three separate Vue-based portals (Admin, Student, Sponsor) with custom TailwindCSS design -- **Component Reuse:** Embed existing DocuSeal form builder and signing components within new portal frameworks -- **Navigation:** Role-based portal switching via new navigation layer -- **Design System:** Custom TailwindCSS (replacing DaisyUI) for portals while maintaining mobile responsiveness - -### Compatibility Requirements - -**Existing API Compatibility:** ✅ **MAINTAINED** -- All new endpoints follow existing DocuSeal API patterns -- No breaking changes to existing public APIs -- Existing authentication mechanisms remain unchanged - -**Database Schema Compatibility:** ✅ **MAINTAINED** -- New tables only, no modifications to existing tables -- Foreign key relationships to existing tables (users, submissions, templates) -- Backward compatibility through additive schema changes - -**UI/UX Consistency:** ✅ **ADAPTED** -- **Challenge:** PRD specifies custom UI/UX (not DaisyUI) for portals -- **Solution:** Maintain mobile-first responsive principles, consistent interaction patterns, but allow custom design system -- **Existing UI:** DocuSeal's existing DaisyUI interface remains unchanged for legacy features - -**Performance Impact:** ✅ **ACCEPTABLE** -- **Target:** Not exceed current memory usage by more than 20% -- **Mitigation:** Pagination, lazy loading, background processing for large cohorts -- **Monitoring:** Extend existing metrics to track cohort-specific performance - ---- - -## Tech Stack - -### Existing Technology Stack - -| Category | Current Technology | Version | Usage in Enhancement | Notes | -|----------|-------------------|---------|---------------------|--------| -| **Backend Language** | Ruby | 3.4.2 | ✅ Core backend logic | Existing version maintained | -| **Web Framework** | Rails | 7.x | ✅ Controllers, Models, Views | Existing patterns followed | -| **Frontend Framework** | Vue.js | 3.3.2 | ✅ All three portals | Composition API for new components | -| **CSS Framework** | TailwindCSS | 3.4.17 | ✅ Custom portal styling | Replacing DaisyUI for portals | -| **UI Components** | DaisyUI | 3.9.4 | ⚠️ Legacy DocuSeal UI only | Not used in new portals | -| **Build Tool** | Shakapacker | 8.0 | ✅ Asset compilation | Existing configuration maintained | -| **Database** | PostgreSQL/MySQL/SQLite | Latest | ✅ New cohort tables | DATABASE_URL configuration | -| **Background Jobs** | Sidekiq | Latest | ✅ Email notifications, reminders | Existing queue system | -| **PDF Processing** | HexaPDF | Latest | ✅ Document generation/signing | Core DocuSeal capability | -| **PDF Rendering** | PDFium | Latest | ✅ Document preview | Existing rendering engine | -| **Authentication** | Devise | Latest | ✅ User auth + 2FA | Extended for new roles | -| **Authorization** | Cancancan | Latest | ✅ Role-based access | Extended for cohort permissions | -| **Storage** | Active Storage | Latest | ✅ Document storage | Existing multi-backend support | -| **Job Queue** | Redis | Latest | ✅ Sidekiq backend | Required dependency | -| **API Auth** | JWT | Latest | ✅ API token authentication | Existing mechanism | -| **Email** | SMTP | Latest | ✅ Notifications | Existing infrastructure | - -### New Technology Additions - -**No new technologies required.** The enhancement leverages existing DocuSeal technology stack entirely. All new functionality will be implemented using current frameworks and libraries. - -**Rationale:** Brownfield enhancement should minimize technology changes to reduce risk and maintain compatibility. The existing stack provides all necessary capabilities for the 3-portal cohort management system. - ---- - -## Data Models and Schema Changes - -### New Data Models - -#### **Cohort Model** -**Purpose:** Represents a training program cohort (learnership, internship, candidacy) managed by an institution. Contains program metadata, templates, and workflow state. - -**Integration:** Links to existing `Account` (institution), `Template` (agreement templates), and manages `CohortEnrollment` records. - -**Key Attributes:** -- `name`: string - Cohort identifier (e.g., "Q1 2025 Learnership Program") -- `program_type`: enum [learnership, internship, candidacy] - Fixed program types -- `institution_id`: bigint - Foreign key to new `Institutions` table -- `sponsor_email`: string - Email for sponsor notifications -- `student_count`: integer - Expected number of students -- `main_template_id`: bigint - Foreign key to existing `Template` (main agreement) -- `supporting_templates`: jsonb - Array of supporting document template IDs -- `admin_signed_at`: datetime - When admin signed main agreement -- `state`: enum [draft, active, completed, cancelled] - Workflow state -- `start_date`, `end_date`: datetime - Program timeline - -**Relationships:** -- **With Existing:** `Account` (institution), `Template` (agreement templates), `User` (admin creator) -- **With New:** `CohortEnrollment` (has_many), `DocumentVerification` (has_many) - -#### **CohortEnrollment Model** -**Purpose:** Represents a student's enrollment in a cohort, tracking their document submission progress and state through the workflow. - -**Integration:** Links to existing `User` (student), `Submission` (document signing workflows), and manages verification state. - -**Key Attributes:** -- `cohort_id`: bigint - Foreign key to Cohort -- `user_id`: bigint - Foreign key to existing User (student) -- `submission_id`: bigint - Foreign key to existing Submission (main agreement) -- `supporting_submission_ids`: jsonb - Array of submission IDs for supporting documents -- `state`: enum [waiting, in_progress, complete] - Student workflow state -- `document_verification_state`: enum [pending, verified, rejected] - Admin verification state -- `rejection_reason`: text - Reason for document rejection -- `student_data`: jsonb - Student demographics (age, race, city, gender, disability) -- `uploaded_documents`: jsonb - Metadata about uploaded files (matric, ID, etc.) - -**Relationships:** -- **With Existing:** `User` (student), `Submission` (main agreement), `Template` (supporting docs) -- **With New:** `Cohort` (belongs_to), `DocumentVerification` (has_many) - -#### **Institution Model** -**Purpose:** Represents a private training institution, providing multi-tenancy for the cohort management system. - -**Integration:** Extends existing `Account` concept but adds institution-specific metadata and relationships. - -**Key Attributes:** -- `account_id`: bigint - Foreign key to existing Account (for backward compatibility) -- `name`: string - Institution name -- `registration_number`: string - Industry registration number -- `address`: text - Physical address -- `contact_email`: string - Primary contact -- `contact_phone`: string - Contact number -- `super_admin_id`: bigint - Foreign key to User (institution super admin) -- `settings`: jsonb - Institution-specific configurations - -**Relationships:** -- **With Existing:** `Account` (has_one), `User` (has_many admins) -- **With New:** `Cohort` (has_many), `Sponsor` (has_many) - -#### **Sponsor Model** -**Purpose:** Represents program sponsors (companies/organizations) who sign agreements for cohorts. - -**Integration:** Independent model for sponsor management, linked to cohorts via email and approval workflow. - -**Key Attributes:** -- `company_name`: string - Sponsor organization name -- `contact_email`: string - Primary contact email -- `contact_name`: string - Contact person name -- `contact_phone`: string - Contact number -- `tax_number`: string - Tax/registration number -- `institution_id`: bigint - Foreign key to Institution -- `user_id`: bigint - Foreign key to User (if sponsor creates account) - -**Relationships:** -- **With Existing:** `User` (optional account), `Submission` (signing workflows) -- **With New:** `Institution` (belongs_to), `Cohort` (referenced via email) - -#### **DocumentVerification Model** -**Purpose:** Audit trail for admin document verification actions (approvals/rejections). - -**Integration:** Links to `CohortEnrollment` and existing `User` (admin who performed verification). - -**Key Attributes:** -- `cohort_enrollment_id`: bigint - Foreign key to enrollment -- `admin_id`: bigint - Foreign key to User (admin) -- `document_type`: string - Type of document verified -- `action`: enum [approved, rejected] - Verification decision -- `reason`: text - Rejection reason (if rejected) -- `metadata`: jsonb - Additional verification context - -**Relationships:** -- **With Existing:** `User` (admin), `Submission` (document reference) -- **With New:** `CohortEnrollment` (belongs_to) - -### Schema Integration Strategy - -**Database Changes Required:** - -**New Tables:** -```sql -cohorts -cohort_enrollments -institutions -sponsors -document_verifications -``` - -**Modified Tables:** None (100% backward compatible) - -**New Indexes:** -- `cohorts.account_id` - Institution lookup -- `cohort_enrollments.cohort_id, user_id` - Enrollment uniqueness -- `cohort_enrollments.state` - Workflow state queries -- `institutions.account_id` - Multi-tenancy isolation -- `document_verifications.cohort_enrollment_id` - Audit trail queries - -**Migration Strategy:** -1. **Phase 1:** Create new tables with foreign keys (no data dependencies) -2. **Phase 2:** Add indexes for performance -3. **Phase 3:** Backfill any required default data -4. **Rollback Plan:** Reverse migration order, preserve existing data - -**Backward Compatibility:** -- ✅ Existing tables unchanged -- ✅ Existing relationships preserved -- ✅ No breaking schema changes -- ✅ Additive-only modifications - ---- - -## Component Architecture - -### New Components - -#### **Cohort Management Service Layer** -**Responsibility:** Business logic for cohort lifecycle, state transitions, and workflow orchestration - -**Integration Points:** -- Uses existing `Submission` and `Template` services for document workflows -- Integrates with existing `EmailNotification` system for alerts -- Leverages existing `WebhookDelivery` for external integrations - -**Key Interfaces:** -- `CohortWorkflowService` - Manages cohort state transitions -- `EnrollmentService` - Handles student enrollment and document submission -- `VerificationService` - Admin document verification workflow -- `SponsorService` - Sponsor signing orchestration - -**Dependencies:** -- **Existing Components:** `SubmissionService`, `TemplateService`, `EmailService`, `WebhookService` -- **New Components:** `CohortStateEngine`, `EnrollmentValidator`, `SponsorAccessManager` - -**Technology Stack:** Ruby service objects in `lib/cohorts/`, following existing patterns in `lib/submissions/` - -#### **Admin Portal Vue Application** -**Responsibility:** Cohort creation, management, verification, and analytics interface - -**Integration Points:** -- Embeds existing DocuSeal form builder for template creation -- Uses existing API endpoints for document operations -- Integrates with existing authentication system - -**Key Interfaces:** -- `CohortDashboard.vue` - Main admin dashboard -- `CohortWizard.vue` - Multi-step cohort creation -- `VerificationInterface.vue` - Document review/rejection -- `SponsorCoordinator.vue` - Sponsor management -- `AnalyticsView.vue` - Reporting and metrics -- `ExcelExport.vue` - FR23 data export - -**Dependencies:** -- **Existing Components:** `TemplateBuilder` (embedded), `SubmissionPreview` (embedded) -- **New Components:** `CohortList`, `EnrollmentTable`, `VerificationModal` - -**Technology Stack:** Vue 3 Composition API, Custom TailwindCSS, Axios for API calls - -#### **Student Portal Vue Application** -**Responsibility:** Student enrollment, document upload, and agreement completion interface - -**Integration Points:** -- Embeds existing DocuSeal signing form components -- Uses existing file upload infrastructure -- Integrates with existing authentication for student access - -**Key Interfaces:** -- `CohortWelcome.vue` - Portal entry and authentication -- `DocumentUpload.vue` - File upload interface -- `AgreementForm.vue` - Embedded DocuSeal form builder -- `StatusDashboard.vue` - Progress tracking -- `ResubmissionFlow.vue` - Rejection handling - -**Dependencies:** -- **Existing Components:** `SubmissionForm` (embedded), `FileDropzone` (embedded) -- **New Components:** `CohortAccess`, `DocumentChecklist`, `ProgressTracker` - -**Technology Stack:** Vue 3 Composition API, Custom TailwindCSS, Existing submission form components - -#### **Sponsor Portal Vue Application** -**Responsibility:** Multi-student review, bulk signing, and cohort finalization interface - -**Integration Points:** -- Embeds existing DocuSeal signature components -- Uses existing submission APIs for signing workflows -- Integrates with existing authentication for sponsor access - -**Key Interfaces:** -- `SponsorDashboard.vue` - Cohort overview and student list -- `StudentReview.vue` - Individual student document review -- `BulkSigning.vue` - Mass signature operations -- `CohortFinalization.vue` - Completion workflow - -**Dependencies:** -- **Existing Components:** `SignatureCapture` (embedded), `SubmissionViewer` (embedded) -- **New Components:** `StudentProgressList`, `BulkSignControls` - -**Technology Stack:** Vue 3 Composition API, Custom TailwindCSS, Existing signing components - -#### **State Management Engine** -**Responsibility:** Orchestrate complex workflow states across all three portals - -**Integration Points:** -- Hooks into existing submission state machine -- Manages cohort-level state transitions -- Enforces workflow rules (sponsor access only after student completion) - -**Key Interfaces:** -- `CohortStateMachine` - State transition logic -- `WorkflowEnforcer` - Business rule validation -- `EventLogger` - Audit trail generation - -**Dependencies:** -- **Existing Components:** `SubmissionState` (extended) -- **New Components:** `CohortStateTransitions`, `EnrollmentValidator` - -**Technology Stack:** Ruby state machine pattern, ActiveRecord callbacks - -### Component Interaction Diagram - -```mermaid -graph TB - subgraph "Existing DocuSeal System" - Auth[Devise Authentication] - API[RESTful API] - Sub[Submission Engine] - Temp[Template Builder] - Store[Active Storage] - Email[Email System] - Webhook[Webhook Delivery] - end - - subgraph "New Cohort Management Layer" - AdminPortal[Admin Portal Vue] - StudentPortal[Student Portal Vue] - SponsorPortal[Sponsor Portal Vue] - CohortService[Cohort Management Services] - StateEngine[State Management Engine] - end - - subgraph "New Data Models" - Cohort[Cohort Model] - Enrollment[CohortEnrollment Model] - Institution[Institution Model] - Sponsor[Sponsor Model] - Verification[DocumentVerification Model] - end - - %% Integration Flows - AdminPortal -->|Uses| API - AdminPortal -->|Embeds| Temp - StudentPortal -->|Uses| API - StudentPortal -->|Embeds| Sub - SponsorPortal -->|Uses| API - SponsorPortal -->|Embeds| Sub - - CohortService -->|Uses| Sub - CohortService -->|Uses| Temp - CohortService -->|Uses| Email - CohortService -->|Uses| Webhook - - StateEngine -->|Extends| Sub - StateEngine -->|Manages| Cohort - StateEngine -->|Manages| Enrollment - - AdminPortal -->|Calls| CohortService - StudentPortal -->|Calls| CohortService - SponsorPortal -->|Calls| CohortService - - CohortService -->|Stores| Cohort - CohortService -->|Stores| Enrollment - CohortService -->|Stores| Institution - CohortService -->|Stores| Sponsor - CohortService -->|Stores| Verification - - Enrollment -->|Links| Sub - Cohort -->|Uses| Temp - Verification -->|Links| Sub - - Auth -->|Validates| AdminPortal - Auth -->|Validates| StudentPortal - Auth -->|Validates| SponsorPortal -``` - -### Component Props and Events Documentation - -#### **Admin Portal Components** - -**CohortWizard.vue** -```typescript -// Props -interface Props { - institutionId: number - availableTemplates: Template[] // Existing DocuSeal templates - programTypes: ['learnership', 'internship', 'candidacy'] -} - -// Events -interface Emits { - (e: 'created', cohort: Cohort): void - (e: 'cancelled'): void - (e: 'error', message: string): void -} - -// State -interface State { - step: 1 | 2 | 3 | 4 - formData: { - name: string - programType: string - sponsorEmail: string - studentCount: number - mainTemplateId: number - supportingTemplateIds: number[] - startDate: string - endDate: string - } -} -``` - -**VerificationInterface.vue** -```typescript -// Props -interface Props { - cohortId: number - enrollments: Enrollment[] - verificationHistory: Verification[] -} - -// Events -interface Emits { - (e: 'verified', enrollmentId: number, action: 'approved' | 'rejected', reason?: string): void - (e: 'bulkVerify', enrollmentIds: number[], action: 'approved' | 'rejected'): void -} -``` - -#### **Student Portal Components** - -**DocumentUpload.vue** -```typescript -// Props -interface Props { - requiredDocuments: string[] // ['matric', 'id', 'disability', 'qualifications', 'certificates'] - maxFileSize: number // 10MB - allowedFormats: string[] // ['pdf', 'jpg', 'png'] -} - -// Events -interface Emits { - (e: 'uploaded', documents: UploadedDocument[]): void - (e: 'removed', documentId: number): void - (e: 'error', errors: string[]): void -} - -// State -interface State { - uploadedFiles: File[] - uploadProgress: Record - validationErrors: string[] -} -``` - -**AgreementForm.vue** -```typescript -// Props -interface Props { - templateId: number - submissionId?: number // For existing submission - readOnly?: boolean -} - -// Events -interface Emits { - (e: 'completed', submission: Submission): void - (e: 'saved', submission: Submission): void - (e: 'error', error: string): void -} -``` - -#### **Sponsor Portal Components** - -**BulkSigning.vue** -```typescript -// Props -interface Props { - cohortId: number - studentCount: number - completedCount: number - signatureRequired: boolean - initialsRequired: boolean -} - -// Events -interface Emits { - (e: 'bulkSigned', signatureData: SignatureData): void - (e: 'individualSign', studentId: number, signatureData: SignatureData): void - (e: 'error', error: string): void -} - -// State -interface State { - signatureCanvas: HTMLCanvasElement | null - initialsCanvas: HTMLCanvasElement | null - selectedStudents: number[] - isDrawing: boolean -} -``` - -#### **Shared Components** - -**PortalNavigation.vue** -```typescript -// Props -interface PortalNavigationProps { - portal: 'admin' | 'student' | 'sponsor' - user?: { - name: string - email: string - role?: string - } - cohortName?: string -} - -// Events -interface PortalNavigationEvents { - (e: 'navigate', view: string): void - (e: 'logout'): void - (e: 'switchRole'): void // For admin users with multiple roles -} -``` - -**RoleSwitcher.vue** -```typescript -// Props -interface RoleSwitcherProps { - availableRoles: Array<{ - role: string - portal: 'admin' | 'student' | 'sponsor' - label: string - }> - currentRole: string -} - -// Events -interface RoleSwitcherEvents { - (e: 'roleChange', newRole: { role: string, portal: string }): void -} -``` - -**PortalNotifications.vue** -```typescript -// Props -interface PortalNotificationsProps { - notifications: Array<{ - id: number - type: 'success' | 'error' | 'warning' | 'info' - message: string - timestamp: string - read: boolean - }> - autoDismiss?: boolean - dismissTime?: number // milliseconds -} - -// Events -interface PortalNotificationsEvents { - (e: 'dismiss', notificationId: number): void - (e: 'markRead', notificationId: number): void -} -``` - -### UI Mockups and Wireframes Reference - -#### **Admin Portal Wireframes** - -**Dashboard View:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ FloDoc Cohort Management - Institution Name │ -├─────────────────────────────────────────────────────────────┤ -│ [Create Cohort] [Export Data] [Settings] [Logout] │ -├─────────────────────────────────────────────────────────────┤ -│ Overview Statistics │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Active │ │ Completed │ │ Total │ │ -│ │ Cohorts: 5 │ │ Cohorts: 12 │ │ Students: 250│ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -├─────────────────────────────────────────────────────────────┤ -│ Recent Cohorts │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ Q1 2025 Learnership │ Active │ 32/50 Complete │ [View]│ -│ │ Q4 2024 Internship │ Draft │ 0/20 Complete │ [View]│ -│ │ Q3 2024 Candidacy │ Complete │ 45/45 Done │ [View]│ -│ └──────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Cohort Creation Wizard:** -``` -Step 1: Basic Information -┌─────────────────────────────────────────────────────────────┐ -│ Cohort Name: [Q1 2025 Learnership_______________] │ -│ Program Type: [Learnership ▼] [Internship] [Candidacy] │ -│ Student Count: [50___] Sponsor Email: [sponsor@company.com]│ -└─────────────────────────────────────────────────────────────┘ - -Step 2: Templates -┌─────────────────────────────────────────────────────────────┐ -│ Main Agreement Template: [Select Template ▼] │ -│ Supporting Templates: │ -│ [✓] Code of Conduct [✓] Privacy Policy [ ] Other │ -└─────────────────────────────────────────────────────────────┘ - -Step 3: Timeline -┌─────────────────────────────────────────────────────────────┐ -│ Start Date: [2025-02-01] End Date: [2025-07-31] │ -└─────────────────────────────────────────────────────────────┘ - -Step 4: Review & Create -┌─────────────────────────────────────────────────────────────┐ -│ Summary: │ -│ • 50 students for Learnership program │ -│ • Main agreement: Learnership Agreement │ -│ • Supporting docs: Code of Conduct, Privacy Policy │ -│ • Timeline: Feb 1 - Jul 31, 2025 │ -│ │ -│ [Create Cohort] [Back] [Cancel] │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Document Verification Interface:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ Verification Queue - Q1 2025 Learnership │ -│ [Filter: Pending] [Sort: Date] [Bulk Actions] │ -├─────────────────────────────────────────────────────────────┤ -│ Student: John Doe (john@example.com) │ -│ Documents: │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ Matric Certificate: [Preview] [Approve] [Reject] │ │ -│ │ ID Document: [Preview] [Approve] [Reject] │ │ -│ │ Disability Doc: [Preview] [Approve] [Reject] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ Rejection Reason: [____________________________________] │ -│ [Submit Verification] │ -└─────────────────────────────────────────────────────────────┘ -``` - -#### **Student Portal Wireframes** - -**Welcome Screen:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ Welcome to Q1 2025 Learnership Program │ -│ Institution: ABC Training Academy │ -├─────────────────────────────────────────────────────────────┤ -│ Your Enrollment Process: │ -│ 1. Upload Required Documents │ -│ 2. Sign Program Agreement │ -│ 3. Sign Supporting Documents │ -│ 4. Wait for Admin Verification │ -│ 5. Sponsor Review & Signing │ -├─────────────────────────────────────────────────────────────┤ -│ Required Documents: │ -│ • Matric Certificate │ -│ • ID Document │ -│ • Disability Documentation (if applicable) │ -│ • Tertiary Qualifications │ -│ • International Certificates (if applicable) │ -│ │ -│ [Start Enrollment] │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Document Upload:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ Upload Required Documents │ -├─────────────────────────────────────────────────────────────┤ -│ Matric Certificate: [Drag files here or click to browse] │ -│ [Uploaded: matric.pdf ✓] │ -│ ID Document: [Drag files here or click to browse] │ -│ [Uploaded: id.pdf ✓] │ -│ Disability Doc: [Drag files here or click to browse] │ -│ [No file selected] │ -│ Qualifications: [Drag files here or click to browse] │ -│ [Uploaded: degree.pdf ✓] │ -│ Certificates: [Drag files here or click to browse] │ -│ [Uploaded: cert.pdf ✓] │ -│ │ -│ [Continue to Agreement] [Save Progress] │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Status Dashboard:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ Your Enrollment Status: Complete ✓ │ -│ Last Updated: 2025-01-02 14:30 │ -├─────────────────────────────────────────────────────────────┤ -│ Progress: ████████████████████░░░░░░░░ 75% │ -│ │ -│ Documents: │ -│ ✓ Matric Certificate - Uploaded & Signed │ -│ ✓ ID Document - Uploaded & Signed │ -│ ✓ Disability Doc - Uploaded & Verified │ -│ ✓ Qualifications - Uploaded & Signed │ -│ ✓ Certificates - Uploaded & Signed │ -│ │ -│ Next Step: Waiting for Sponsor Review │ -│ Estimated Time: 2-3 business days │ -└─────────────────────────────────────────────────────────────┘ -``` - -#### **Sponsor Portal Wireframes** - -**Cohort Overview:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ Sponsor Portal - Q1 2025 Learnership │ -│ Institution: ABC Training Academy │ -├─────────────────────────────────────────────────────────────┤ -│ Cohort Summary: │ -│ • Total Students: 50 │ -│ • Ready for Signing: 50 │ -│ • Already Signed: 0 │ -│ • Status: All Complete ✓ │ -├─────────────────────────────────────────────────────────────┤ -│ Student List │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ John Doe │ john@example.com │ Ready │ [Review] │ │ -│ │ Jane Smith │ jane@example.com │ Ready │ [Review] │ │ -│ │ ... (48 more)│ │ │ │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ [Bulk Sign All] [Sign Selected] [Export Summary] │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Individual Student Review:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ John Doe - Document Review │ -│ [← Back to Overview] │ -├─────────────────────────────────────────────────────────────┤ -│ Student Information: │ -│ Age: 23 | Race: Black | City: Johannesburg | Gender: Male │ -│ Disability: None │ -├─────────────────────────────────────────────────────────────┤ -│ Documents for Review: │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ Main Agreement: [View PDF] [Sign] │ │ -│ │ Code of Conduct: [View PDF] [Sign] │ │ -│ │ Privacy Policy: [View PDF] [Sign] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ Your Signature: [Canvas Area - Draw Here] │ -│ Your Initials: [Canvas Area - Draw Here] │ -│ │ -│ [Sign This Student] [Skip for Now] │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Bulk Signing Interface:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ Bulk Signing - Q1 2025 Learnership │ -│ 50 students ready for signing │ -├─────────────────────────────────────────────────────────────┤ -│ Signature Application: │ -│ Apply to: [All 50 Students ▼] [Selected Students] │ -│ │ -│ Your Signature: │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ [Canvas Area - Draw Your Signature] │ │ -│ │ [Clear] [Apply to All] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ Your Initials: │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ [Canvas Area - Draw Your Initials] │ │ -│ │ [Clear] [Apply to All] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ [Sign All Documents] [Cancel] │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## API Design and Integration - -### API Integration Strategy - -**API Integration Strategy:** Extend existing DocuSeal API v1 with new cohort-specific endpoints under `/api/v1/cohorts/*`. All endpoints follow existing RESTful patterns, authentication mechanisms, and response formats. - -**Authentication:** Reuse existing Devise + JWT authentication. No changes to auth flow required. New endpoints will require the same bearer token authentication as existing endpoints. - -**Versioning:** No new API version required. New endpoints extend v1 following existing patterns. All new endpoints return consistent JSON response formats matching existing endpoints. - -### New API Endpoints - -#### **Cohort Management Endpoints** - -##### **Create Cohort** -- **Method:** `POST` -- **Endpoint:** `/api/v1/cohorts` -- **Purpose:** Create new cohort with templates and configuration -- **Integration:** Uses existing Template APIs for template management - -**Request:** -```json -{ - "cohort": { - "name": "Q1 2025 Learnership", - "program_type": "learnership", - "sponsor_email": "sponsor@company.com", - "student_count": 50, - "main_template_id": 123, - "supporting_template_ids": [124, 125], - "start_date": "2025-02-01", - "end_date": "2025-07-31" - } -} -``` - -**Response:** -```json -{ - "id": 1, - "name": "Q1 2025 Learnership", - "state": "draft", - "created_at": "2025-01-02T10:00:00Z", - "links": { - "self": "/api/v1/cohorts/1", - "enrollments": "/api/v1/cohorts/1/enrollments" - } -} -``` - -##### **List Cohorts** -- **Method:** `GET` -- **Endpoint:** `/api/v1/cohorts` -- **Purpose:** Get paginated list of cohorts for current institution -- **Integration:** Filters by current user's institution - -**Response:** -```json -{ - "data": [ - { - "id": 1, - "name": "Q1 2025 Learnership", - "program_type": "learnership", - "state": "active", - "completion_percentage": 65, - "student_count": 50, - "completed_students": 32 - } - ], - "meta": { - "page": 1, - "per_page": 20, - "total": 1 - } -} -``` - -##### **Get Cohort Details** -- **Method:** `GET` -- **Endpoint:** `/api/v1/cohorts/:id` -- **Purpose:** Get detailed cohort information with enrollment status -- **Integration:** Aggregates data from existing Submission APIs - -**Response:** -```json -{ - "id": 1, - "name": "Q1 2025 Learnership", - "program_type": "learnership", - "state": "active", - "sponsor_email": "sponsor@company.com", - "admin_signed_at": "2025-01-02T10:30:00Z", - "templates": { - "main": { "id": 123, "name": "Learnership Agreement" }, - "supporting": [{ "id": 124, "name": "Code of Conduct" }] - }, - "enrollments": { - "waiting": 5, - "in_progress": 13, - "complete": 32 - } -} -``` - -##### **Invite Students** -- **Method:** `POST` -- **Endpoint:** `/api/v1/cohorts/:id/invitations` -- **Purpose:** Generate invite links or send email invitations -- **Integration:** Uses existing email system and user creation - -**Request:** -```json -{ - "students": [ - { "email": "student1@example.com", "first_name": "John", "last_name": "Doe" }, - { "email": "student2@example.com", "first_name": "Jane", "last_name": "Smith" } - ], - "send_email": true -} -``` - -**Response:** -```json -{ - "invitations_sent": 2, - "invite_links": [ - { "email": "student1@example.com", "link": "https://flo.doc/invite/abc123" }, - { "email": "student2@example.com", "link": "https://flo.doc/invite/def456" } - ] -} -``` - -##### **Export Cohort Data (FR23)** -- **Method:** `GET` -- **Endpoint:** `/api/v1/cohorts/:id/export` -- **Purpose:** Export cohort data to Excel format -- **Integration:** Uses existing rubyXL gem for Excel generation - -**Query Parameters:** -- `format=xlsx` -- `include=student_demographics,program_details,sponsor_info` - -**Response:** Excel file download with columns: cohort_name, student_name, student_surname, student_age, student_race, student_city, program_type, sponsor_company_name, disability_status, gender - -### Web Portal Routes - -#### **Admin Portal Routes** - -| Route | Method | Purpose | Authentication | Component | -|-------|--------|---------|----------------|-----------| -| `/cohorts/admin` | GET | Cohort dashboard | Devise + Role | `AdminPortal.vue` | -| `/cohorts/admin/new` | GET | Create cohort wizard | Devise + Role | `CohortWizard.vue` | -| `/cohorts/admin/:id` | GET | Cohort details | Devise + Role | `CohortDashboard.vue` | -| `/cohorts/admin/:id/verify` | GET | Document verification | Devise + Role | `VerificationInterface.vue` | -| `/cohorts/admin/:id/sponsors` | GET | Sponsor management | Devise + Role | `SponsorCoordinator.vue` | -| `/cohorts/admin/:id/analytics` | GET | Analytics view | Devise + Role | `AnalyticsView.vue` | -| `/cohorts/admin/:id/export` | GET | Excel export (FR23) | Devise + Role | `ExcelExport.vue` | -| `/cohorts/admin/:id/invite` | POST | Student invitations | Devise + Role | API call | - -#### **Student Portal Routes** - -| Route | Method | Purpose | Authentication | Component | -|-------|--------|---------|----------------|-----------| -| `/cohorts/student/:token` | GET | Portal entry (token) | Token-based | `StudentPortal.vue` | -| `/cohorts/student/:token/welcome` | GET | Welcome screen | Token-based | `CohortWelcome.vue` | -| `/cohorts/student/:token/upload` | GET | Document upload | Token-based | `DocumentUpload.vue` | -| `/cohorts/student/:token/agreement` | GET | Main agreement | Token-based | `AgreementForm.vue` | -| `/cohorts/student/:token/supporting` | GET | Supporting docs | Token-based | `AgreementForm.vue` | -| `/cohorts/student/:token/status` | GET | Progress dashboard | Token-based | `StatusDashboard.vue` | -| `/cohorts/student/:token/resubmit` | GET | Re-submission flow | Token-based | `ResubmissionFlow.vue` | - -#### **Sponsor Portal Routes** - -| Route | Method | Purpose | Authentication | Component | -|-------|--------|---------|----------------|-----------| -| `/cohorts/sponsor/:token` | GET | Sponsor dashboard | Token-based | `SponsorPortal.vue` | -| `/cohorts/sponsor/:token/overview` | GET | Cohort overview | Token-based | `SponsorDashboard.vue` | -| `/cohorts/sponsor/:token/student/:student_id` | GET | Student review | Token-based | `StudentReview.vue` | -| `/cohorts/sponsor/:token/bulk-sign` | POST | Bulk signing | Token-based | `BulkSigning.vue` | -| `/cohorts/sponsor/:token/finalize` | POST | Cohort finalization | Token-based | `CohortFinalization.vue` | - -#### **Enrollment Management Endpoints** - -##### **List Enrollments** -- **Method:** `GET` -- **Endpoint:** `/api/v1/cohorts/:id/enrollments` -- **Purpose:** Get all student enrollments with status -- **Integration:** Aggregates from CohortEnrollment + existing User/Submission data - -**Response:** -```json -{ - "data": [ - { - "id": 1, - "student": { "name": "John Doe", "email": "john@example.com" }, - "state": "complete", - "verification_state": "verified", - "documents": { "uploaded": 5, "signed": 3 }, - "created_at": "2025-01-01T10:00:00Z" - } - ] -} -``` - -##### **Verify Document** -- **Method:** `POST` -- **Endpoint:** `/api/v1/enrollments/:id/verify` -- **Purpose:** Admin document verification (approve/reject) -- **Integration:** Creates DocumentVerification records - -**Request:** -```json -{ - "action": "reject", - "document_type": "matric_certificate", - "reason": "Certificate is not certified by SAQA" -} -``` - -#### **Sponsor Endpoints** - -##### **Get Sponsor Cohort Overview** -- **Method:** `GET` -- **Endpoint:** `/api/v1/sponsors/cohorts/:token` -- **Purpose:** Sponsor access to cohort overview (token-based auth) -- **Integration:** Validates token, checks all students complete - -**Response:** -```json -{ - "cohort": { "name": "Q1 2025 Learnership", "student_count": 50 }, - "students": [ - { "id": 1, "name": "John Doe", "state": "complete", "signed": true } - ], - "can_sign": true, - "bulk_sign_available": true -} -``` - -##### **Bulk Sign** - -**Request:** -```json -{ - "signature": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", - "initials": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", - "sign_all": true, - "timestamp": "2025-01-02T15:30:00Z" -} -``` - -**Success Response (200):** -```json -{ - "signed_count": 50, - "failed_count": 0, - "signatures_applied": [ - { - "enrollment_id": 1, - "submission_id": 100, - "status": "signed", - "signed_at": "2025-01-02T15:30:00Z" - } - ], - "cohort_finalized": true, - "next_step": "Admin can now finalize cohort and download documents" -} -``` - -**Error Responses:** -```json -// 422 Validation Error -{ - "error": { - "code": "VALIDATION_ERROR", - "message": "Signature data is invalid or corrupted", - "timestamp": "2025-01-02T15:30:00Z" - } -} - -// 403 Forbidden -{ - "error": { - "code": "STATE_ERROR", - "message": "Cannot sign - some students are not ready", - "details": { - "ready": 32, - "total": 50, - "pending": 18 - } - } -} -``` - -### Complete API Response Schemas - -#### **Cohort Endpoints** - -**POST /api/v1/cohorts - Request:** -```json -{ - "cohort": { - "name": "Q1 2025 Learnership", - "program_type": "learnership", - "sponsor_email": "sponsor@company.com", - "student_count": 50, - "main_template_id": 123, - "supporting_template_ids": [124, 125], - "start_date": "2025-02-01", - "end_date": "2025-07-31" - } -} -``` - -**POST /api/v1/cohorts - Success Response (201):** -```json -{ - "id": 1, - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "name": "Q1 2025 Learnership", - "program_type": "learnership", - "state": "draft", - "sponsor_email": "sponsor@company.com", - "student_count": 50, - "main_template_id": 123, - "supporting_template_ids": [124, 125], - "start_date": "2025-02-01", - "end_date": "2025-07-31", - "admin_signed_at": null, - "created_at": "2025-01-02T10:00:00Z", - "updated_at": "2025-01-02T10:00:00Z", - "links": { - "self": "/api/v1/cohorts/1", - "enrollments": "/api/v1/cohorts/1/enrollments", - "invitations": "/api/v1/cohorts/1/invitations" - } -} -``` - -**POST /api/v1/cohorts - Error Responses:** -```json -// 422 Validation Error -{ - "errors": { - "name": ["can't be blank"], - "sponsor_email": ["is invalid"], - "main_template_id": ["must exist"] - } -} - -// 403 Forbidden (wrong institution) -{ - "error": { - "code": "AUTHORIZATION_ERROR", - "message": "Access denied" - } -} -``` - -**GET /api/v1/cohorts/:id - Success Response (200):** -```json -{ - "id": 1, - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "name": "Q1 2025 Learnership", - "program_type": "learnership", - "state": "active", - "sponsor_email": "sponsor@company.com", - "student_count": 50, - "admin_signed_at": "2025-01-02T10:30:00Z", - "created_at": "2025-01-02T10:00:00Z", - "updated_at": "2025-01-02T10:30:00Z", - "templates": { - "main": { - "id": 123, - "name": "Learnership Agreement", - "uuid": "abc123..." - }, - "supporting": [ - { - "id": 124, - "name": "Code of Conduct", - "uuid": "def456..." - } - ] - }, - "enrollment_summary": { - "total": 50, - "waiting": 5, - "in_progress": 13, - "complete": 32, - "rejected": 0 - }, - "completion_percentage": 64, - "links": { - "self": "/api/v1/cohorts/1", - "enrollments": "/api/v1/cohorts/1/enrollments", - "export": "/api/v1/cohorts/1/export" - } -} -``` - -**GET /api/v1/cohorts/:id/enrollments - Success Response (200):** -```json -{ - "data": [ - { - "id": 1, - "uuid": "550e8400-e29b-41d4-a716-446655440001", - "student": { - "id": 100, - "name": "John Doe", - "email": "john@example.com", - "phone": "+27123456789" - }, - "state": "complete", - "verification_state": "verified", - "rejection_reason": null, - "student_data": { - "age": 23, - "race": "Black", - "city": "Johannesburg", - "gender": "Male", - "disability": "None" - }, - "documents": { - "uploaded": 5, - "signed": 3, - "rejected": 0 - }, - "created_at": "2025-01-01T10:00:00Z", - "updated_at": "2025-01-02T14:30:00Z", - "links": { - "self": "/api/v1/enrollments/1", - "verify": "/api/v1/enrollments/1/verify" - } - } - ], - "meta": { - "page": 1, - "per_page": 20, - "total": 50, - "filters": { - "state": ["complete"], - "verification_state": ["verified"] - } - } -} -``` - -**POST /api/v1/cohorts/:id/invitations - Request:** -```json -{ - "students": [ - { - "email": "student1@example.com", - "first_name": "John", - "last_name": "Doe", - "phone": "+27123456789", - "age": 23, - "race": "Black", - "city": "Johannesburg", - "gender": "Male", - "disability": "None" - } - ], - "send_email": true, - "message": "Welcome to our Q1 2025 Learnership program!" -} -``` - -**POST /api/v1/cohorts/:id/invitations - Success Response (201):** -```json -{ - "invitations_sent": 1, - "invite_links": [ - { - "email": "student1@example.com", - "token": "abc123def456", - "link": "https://flo.doc/cohorts/student/abc123def456", - "expires_at": "2025-02-01T10:00:00Z" - } - ], - "errors": [] -} -``` - -**GET /api/v1/cohorts/:id/export - Query Parameters:** -- `format=xlsx` (required) -- `include=student_demographics,program_details,sponsor_info` (optional) - -**GET /api/v1/cohorts/:id/export - Response:** -- Returns Excel file (.xlsx) as binary download -- **Headers:** - ``` - Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - Content-Disposition: attachment; filename="cohort_1_export_20250102.xlsx" - ``` - -**Excel Columns:** -``` -cohort_name | student_name | student_surname | student_age | student_race | student_city | program_type | sponsor_company_name | disability_status | gender -``` - -#### **Enrollment Endpoints** - -**POST /api/v1/enrollments/:id/verify - Request:** -```json -{ - "action": "reject", - "document_type": "matric_certificate", - "reason": "Certificate is not certified by SAQA. Please provide SAQA verification letter.", - "metadata": { - "reviewed_by": "admin@institution.com", - "review_notes": "Checked against SAQA database" - } -} -``` - -**POST /api/v1/enrollments/:id/verify - Success Response (200):** -```json -{ - "id": 1, - "enrollment_id": 1, - "action": "rejected", - "document_type": "matric_certificate", - "reason": "Certificate is not certified by SAQA. Please provide SAQA verification letter.", - "admin_id": 50, - "created_at": "2025-01-02T15:00:00Z", - "metadata": { - "reviewed_by": "admin@institution.com", - "review_notes": "Checked against SAQA database" - } -} -``` - -**POST /api/v1/enrollments/:id/verify - Error Responses:** -```json -// 422 Invalid State Transition -{ - "error": { - "code": "STATE_ERROR", - "message": "Cannot reject enrollment that is already complete" - } -} - -// 404 Not Found -{ - "error": { - "code": "NOT_FOUND", - "message": "Enrollment not found" - } -} -``` - -#### **Sponsor Endpoints** - -**GET /api/v1/sponsors/cohorts/:token - Success Response (200):** -```json -{ - "cohort": { - "id": 1, - "name": "Q1 2025 Learnership", - "program_type": "learnership", - "student_count": 50, - "sponsor_email": "sponsor@company.com" - }, - "students": [ - { - "id": 1, - "name": "John Doe", - "email": "john@example.com", - "state": "complete", - "verification_state": "verified", - "signed": true, - "signed_at": "2025-01-02T10:00:00Z", - "documents": { - "main_agreement": { - "id": 100, - "status": "signed", - "preview_url": "/api/v1/submissions/100/preview" - }, - "supporting_docs": [ - { - "id": 101, - "name": "Code of Conduct", - "status": "signed" - } - ] - } - } - ], - "summary": { - "total": 50, - "completed": 32, - "pending": 18, - "signed": 32 - }, - "can_sign": true, - "bulk_sign_available": true, - "token_expires_at": "2025-01-16T23:59:59Z" -} -``` - -**GET /api/v1/sponsors/cohorts/:token - Error Responses:** -```json -// 403 Forbidden (students not complete) -{ - "error": { - "code": "STATE_ERROR", - "message": "All students must complete their submissions before sponsor access", - "details": { - "completed": 32, - "total": 50, - "remaining": 18 - } - } -} - -// 401 Unauthorized (invalid/expired token) -{ - "error": { - "code": "AUTHENTICATION_ERROR", - "message": "Invalid or expired sponsor token" - } -} -``` - -##### **Bulk Sign** -- **Method:** `POST` -- **Endpoint:** `/api/v1/sponsors/cohorts/:token/bulk-sign` -- **Purpose:** Sign all student agreements at once -- **Integration:** Uses existing submission signing APIs - -**Request:** -```json -{ - "signature": "data:image/png;base64,...", - "initials": "data:image/png;base64,..." -} -``` - ---- - -## Source Tree - -### Existing Project Structure - -``` -floDoc-v3/ -├── app/ -│ ├── controllers/ -│ │ ├── api/ # RESTful API controllers -│ │ │ ├── api_base_controller.rb -│ │ │ ├── submissions_controller.rb -│ │ │ ├── templates_controller.rb -│ │ │ └── [15+ existing controllers] -│ │ ├── [30+ existing controllers] # Dashboard, settings, etc. -│ ├── models/ -│ │ ├── account.rb # Multi-tenancy root -│ │ ├── user.rb # Devise auth + 2FA -│ │ ├── template.rb # Document templates -│ │ ├── submission.rb # Document workflows -│ │ ├── submitter.rb # Signers/participants -│ │ ├── account_access.rb # User permissions -│ │ └── [15+ supporting models] -│ ├── jobs/ -│ │ ├── process_submitter_completion_job.rb -│ │ ├── send_submission_completed_webhook_request_job.rb -│ │ └── [15+ existing jobs] -│ ├── mailers/ -│ │ ├── application_mailer.rb -│ │ ├── submitter_mailer.rb -│ │ └── user_mailer.rb -│ ├── javascript/ -│ │ ├── application.js # Vue 3 entry point -│ │ ├── template_builder/ # PDF form builder (15+ Vue components) -│ │ ├── submission_form/ # Signing interface (10+ Vue components) -│ │ └── elements/ # Web Components (40+ custom elements) -│ └── views/ -│ ├── mailers/ # Email templates -│ └── shared/ # Common partials -├── lib/ -│ ├── submissions/ # Core business logic -│ │ ├── generate_result_attachments.rb -│ │ ├── generate_combined_attachment.rb -│ │ └── [10+ submission modules] -│ ├── submitters/ # Submitter logic -│ ├── templates/ # Template processing -│ ├── pdf_utils.rb # HexaPDF wrapper -│ ├── docuseal.rb # Global config -│ ├── ability.rb # CanCanCan rules -│ └── send_webhook_request.rb # Webhook delivery -├── config/ -│ ├── routes.rb # All routes (200+ lines) -│ ├── database.yml # DB config -│ ├── storage.yml # Active Storage config -│ ├── sidekiq.yml # Background job config -│ └── shakapacker.yml # Webpack config -├── db/ -│ ├── migrate/ # Existing migrations -│ └── schema.rb # Current schema -└── docs/ - ├── prd.md # Product requirements - └── architecture.md # This document -``` - -### New File Organization - -``` -floDoc-v3/ -├── app/ -│ ├── controllers/ -│ │ ├── api/ -│ │ │ ├── v1/ -│ │ │ │ ├── cohorts_controller.rb # NEW: Cohort API endpoints -│ │ │ │ ├── enrollments_controller.rb # NEW: Enrollment API endpoints -│ │ │ │ └── sponsors_controller.rb # NEW: Sponsor API endpoints -│ │ ├── cohorts/ # NEW: Web controllers -│ │ │ ├── admin_controller.rb # Admin portal web endpoints -│ │ │ ├── student_controller.rb # Student portal web endpoints -│ │ │ └── sponsor_controller.rb # Sponsor portal web endpoints -│ ├── models/ -│ │ ├── cohort.rb # NEW: Cohort model -│ │ ├── cohort_enrollment.rb # NEW: Enrollment model -│ │ ├── institution.rb # NEW: Institution model -│ │ ├── sponsor.rb # NEW: Sponsor model -│ │ └── document_verification.rb # NEW: Verification model -│ ├── jobs/ -│ │ ├── cohort_reminder_job.rb # NEW: Cohort reminders -│ │ ├── cohort_completion_job.rb # NEW: Workflow completion -│ │ └── excel_export_job.rb # NEW: FR23 Excel export -│ ├── mailers/ -│ │ ├── cohort_mailer.rb # NEW: Cohort notifications -│ │ └── sponsor_mailer.rb # NEW: Sponsor notifications -│ ├── javascript/ -│ │ ├── cohorts/ # NEW: Cohort management -│ │ │ ├── admin/ # Admin portal Vue app -│ │ │ │ ├── AdminPortal.vue -│ │ │ │ ├── CohortDashboard.vue -│ │ │ │ ├── CohortWizard.vue -│ │ │ │ ├── VerificationInterface.vue -│ │ │ │ ├── SponsorCoordinator.vue -│ │ │ │ ├── AnalyticsView.vue -│ │ │ │ └── ExcelExport.vue -│ │ │ ├── student/ # Student portal Vue app -│ │ │ │ ├── StudentPortal.vue -│ │ │ │ ├── CohortWelcome.vue -│ │ │ │ ├── DocumentUpload.vue -│ │ │ │ ├── AgreementForm.vue -│ │ │ │ ├── StatusDashboard.vue -│ │ │ │ └── ResubmissionFlow.vue -│ │ │ └── sponsor/ # Sponsor portal Vue app -│ │ │ ├── SponsorPortal.vue -│ │ │ ├── SponsorDashboard.vue -│ │ │ ├── StudentReview.vue -│ │ │ ├── BulkSigning.vue -│ │ │ └── CohortFinalization.vue -│ │ └── shared/ # NEW: Shared portal components -│ │ ├── PortalNavigation.vue -│ │ ├── RoleSwitcher.vue -│ │ └── PortalNotifications.vue -│ └── views/ -│ ├── cohorts/ -│ │ ├── admin/ -│ │ │ ├── index.html.erb -│ │ │ └── show.html.erb -│ │ ├── student/ -│ │ │ ├── index.html.erb -│ │ │ └── show.html.erb -│ │ └── sponsor/ -│ │ ├── index.html.erb -│ │ └── show.html.erb -│ └── mailers/ -│ ├── cohort_mailer/ -│ │ ├── cohort_created.html.erb -│ │ ├── student_invite.html.erb -│ │ └── sponsor_access.html.erb -│ └── sponsor_mailer/ -│ └── cohort_ready.html.erb -├── lib/ -│ ├── cohorts/ # NEW: Cohort business logic -│ │ ├── cohort_workflow_service.rb -│ │ ├── enrollment_service.rb -│ │ ├── verification_service.rb -│ │ ├── sponsor_service.rb -│ │ ├── cohort_state_engine.rb -│ │ ├── enrollment_validator.rb -│ │ ├── sponsor_access_manager.rb -│ │ └── excel_export_service.rb -│ └── templates/ -│ └── cohort_template_processor.rb # NEW: Cohort template extensions -├── db/ -│ ├── migrate/ -│ │ ├── 20250102000001_create_institutions.rb -│ │ ├── 20250102000002_create_cohorts.rb -│ │ ├── 20250102000003_create_cohort_enrollments.rb -│ │ ├── 20250102000004_create_sponsors.rb -│ │ └── 20250102000005_create_document_verifications.rb -│ └── schema.rb # UPDATED: New tables added -├── config/ -│ └── routes.rb # UPDATED: New cohort routes -└── docs/ - ├── architecture.md # This document - └── cohort-workflows.md # NEW: Workflow documentation -``` - -### Integration Guidelines - -**File Naming:** -- **Models:** `cohort.rb`, `cohort_enrollment.rb` (snake_case, singular) -- **Controllers:** `cohorts_controller.rb`, `admin_controller.rb` (plural for resources) -- **Vue Components:** `CohortDashboard.vue`, `StudentPortal.vue` (PascalCase) -- **Services:** `cohort_workflow_service.rb` (snake_case, descriptive) -- **Jobs:** `cohort_reminder_job.rb` (snake_case, _job suffix) - -**Folder Organization:** -- **API Controllers:** `app/controllers/api/v1/cohorts/` (versioned, resource-based) -- **Web Controllers:** `app/controllers/cohorts/` (portal-specific) -- **Vue Apps:** `app/javascript/cohorts/{admin,student,sponsor}/` (portal separation) -- **Services:** `lib/cohorts/` (business logic separation) - -**Import/Export Patterns:** -- **Ruby:** Follow existing patterns (service objects, concerns, modules) -- **Vue:** Use ES6 imports, Composition API, existing API client patterns -- **API:** Consistent JSON response format matching existing endpoints - ---- - -## Infrastructure and Deployment Integration - -### Existing Infrastructure - -**Current Deployment:** Docker-based with Dockerfile and docker-compose.yml -**Infrastructure Tools:** Docker, Sidekiq, Puma, Redis, PostgreSQL/MySQL -**Environments:** Development (SQLite), Production (PostgreSQL/MySQL) - -### Enhancement Deployment Strategy - -**Deployment Approach:** Incremental feature addition to existing DocuSeal deployment -- **Zero downtime:** Database migrations are additive only -- **Feature flags:** Can disable cohort features if issues arise -- **Rolling deployment:** Deploy new code alongside existing functionality - -**Infrastructure Changes:** None required -- ✅ No new services needed -- ✅ No infrastructure configuration changes -- ✅ Existing Docker setup sufficient -- ✅ Redis already configured for Sidekiq - -**Pipeline Integration:** -- ✅ Existing CI/CD handles new Ruby code -- ✅ Shakapacker bundles new Vue components automatically -- ✅ Existing test suite extends with new tests -- ✅ No changes to build process - -### Rollback Strategy - -**Rollback Method:** Standard git revert + database migration rollback -- **Code rollback:** `git revert ` - Reverts to previous state -- **Database rollback:** `bin/rails db:rollback STEP=5` - Rolls back last 5 migrations -- **Asset rollback:** Previous assets remain cached in CDN - -**Risk Mitigation:** -1. **Database backups:** Before migrations run in production -2. **Feature flags:** Can disable cohort routes if needed -3. **Gradual rollout:** Deploy to staging first, then production -4. **Monitoring:** Watch error rates and performance metrics - -**Monitoring:** -- Extend existing Rails logging with cohort events -- Add cohort-specific metrics to existing monitoring -- Use existing Sidekiq monitoring for new jobs -- Track API response times for new endpoints - -### Resource Sizing Recommendations - -**Development Environment:** -- **CPU:** 2 cores minimum -- **RAM:** 4GB minimum (8GB recommended) -- **Storage:** 10GB free space -- **Database:** SQLite (file-based, no additional resources) - -**Production Environment (Small Scale: 1-5 institutions, <1000 students):** -- **Application Server:** 4 cores, 8GB RAM, 50GB SSD -- **Database:** PostgreSQL 14+, 2GB RAM, 1 CPU core -- **Redis:** 1GB RAM for Sidekiq -- **Concurrent Users:** 50-100 -- **Background Workers:** 2 workers (1 core, 1GB RAM each) - -**Production Environment (Medium Scale: 5-20 institutions, <5000 students):** -- **Application Server:** 8 cores, 16GB RAM, 100GB SSD -- **Database:** PostgreSQL 14+, 4GB RAM, 2 CPU cores -- **Redis:** 2GB RAM -- **Concurrent Users:** 200-400 -- **Background Workers:** 4 workers (2 cores, 2GB RAM each) - -**Production Environment (Large Scale: 20+ institutions, 5000+ students):** -- **Application Server:** 16 cores, 32GB RAM, 200GB SSD -- **Database:** PostgreSQL 14+, 16GB RAM, 4 CPU cores (consider read replicas) -- **Redis:** 4GB RAM -- **Concurrent Users:** 500+ -- **Background Workers:** 8+ workers (2 cores, 4GB RAM each) - -**Performance Targets:** -- **Dashboard load:** < 2 seconds -- **Cohort list (50 cohorts):** < 1 second -- **Student list (100 students):** < 1.5 seconds -- **Excel export (100 students):** < 5 seconds -- **Document preview:** < 2 seconds -- **Bulk signing (50 students):** < 60 seconds - ---- - -## Coding Standards - -### Existing Standards Compliance - -**Code Style:** Follow existing RuboCop configuration (frozen_string_literal, line length, etc.) -**Linting Rules:** RuboCop for Ruby, ESLint for Vue/JavaScript -**Testing Patterns:** RSpec with FactoryBot, existing test helpers -**Documentation Style:** Inline comments for complex logic, model annotations - -### Enhancement-Specific Standards - -**New Patterns for Cohort Management:** -- **State Management:** Use state machine pattern for cohort/enrollment states -- **Service Objects:** All complex business logic in `lib/cohorts/` -- **Vue Composition API:** All new Vue components use ` + + + +``` + +#### View Components +```vue + + + + + + + +``` + +--- + +### 2. Composition API Standards + +```vue + + + + + +``` + +--- + +### 3. Props & Events + +```vue + + + + + + + +``` + +--- + +### 4. Pinia Stores + +```javascript +// ✅ Correct - Pinia store structure +// app/javascript/tp/stores/cohortStore.js +import { defineStore } from 'pinia' +import { CohortsAPI } from '@/tp/api/cohorts' + +export const useCohortStore = defineStore('cohort', { + state: () => ({ + cohorts: [], + currentCohort: null, + loading: false, + error: null + }), + + getters: { + activeCohorts: (state) => { + return state.cohorts.filter(c => c.status === 'active') + }, + + completedCohorts: (state) => { + return state.cohorts.filter(c => c.status === 'completed') + }, + + getCohortById: (state) => { + return (id) => state.cohorts.find(c => c.id === id) + } + }, + + actions: { + async fetchCohorts() { + this.loading = true + this.error = null + try { + this.cohorts = await CohortsAPI.getAll() + } catch (err) { + this.error = err.message + throw err + } finally { + this.loading = false + } + }, + + async fetchCohort(id) { + this.loading = true + try { + this.currentCohort = await CohortsAPI.get(id) + } finally { + this.loading = false + } + }, + + async createCohort(data) { + const cohort = await CohortsAPI.create(data) + this.cohorts.unshift(cohort) + return cohort + }, + + async updateCohort(id, data) { + const cohort = await CohortsAPI.update(id, data) + const index = this.cohorts.findIndex(c => c.id === id) + if (index !== -1) { + this.cohorts[index] = cohort + } + return cohort + }, + + async startSigning(id) { + const cohort = await CohortsAPI.startSigning(id) + const index = this.cohorts.findIndex(c => c.id === id) + if (index !== -1) { + this.cohorts[index] = cohort + } + return cohort + }, + + // Optimistic update + async deleteCohort(id) { + const index = this.cohorts.findIndex(c => c.id === id) + if (index === -1) return + + const deleted = this.cohorts[index] + this.cohorts.splice(index, 1) + + try { + await CohortsAPI.delete(id) + } catch (err) { + // Rollback on error + this.cohorts.splice(index, 0, deleted) + throw err + } + } + } +}) +``` --- + +### 5. API Client Standards + +```javascript +// ✅ Correct - API client with error handling +// app/javascript/tp/api/cohorts.js +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api/v1', + headers: { + 'Content-Type': 'application/json' + } +}) + +// Request interceptor for auth +api.interceptors.request.use((config) => { + const token = localStorage.getItem('auth_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// Response interceptor for error handling +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Handle unauthorized + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +export const CohortsAPI = { + async getAll() { + const response = await api.get('/cohorts') + return response.data + }, + + async get(id) { + const response = await api.get(`/cohorts/${id}`) + return response.data + }, + + async create(data) { + const response = await api.post('/cohorts', data) + return response.data + }, + + async update(id, data) { + const response = await api.patch(`/cohorts/${id}`, data) + return response.data + }, + + async startSigning(id) { + const response = await api.post(`/cohorts/${id}/start_signing`) + return response.data + }, + + async delete(id) { + await api.delete(`/cohorts/${id}`) + } +} +``` + +--- + +### 6. Template Standards + +```vue + + + + + +``` + +--- + +### 7. Conditional Rendering + +```vue + + + + +``` + +--- + +### 8. Styling Standards + +```vue + + + + + + +``` + +--- + +## 🧪 Testing Standards + +### 1. RSpec (Ruby) + +#### Model Specs +```ruby +# spec/models/cohort_spec.rb +require 'rails_helper' + +RSpec.describe Cohort, type: :model do + # Factory usage + let(:institution) { create(:institution) } + let(:template) { create(:template) } + let(:cohort) { build(:cohort, institution: institution, template: template) } + + describe 'validations' do + it { should validate_presence_of(:name) } + it { should validate_presence_of(:program_type) } + it { should validate_inclusion_of(:status).in_array(%w[draft active completed]) } + + it 'validates sponsor email format' do + cohort.sponsor_email = 'invalid' + expect(cohort).not_to be_valid + expect(cohort.errors[:sponsor_email]).to include('must be a valid email') + end + end + + describe 'associations' do + it { should belong_to(:institution) } + it { should belong_to(:template) } + it { should have_many(:cohort_enrollments) } + end + + describe 'scopes' do + let!(:active_cohort) { create(:cohort, status: 'active') } + let!(:draft_cohort) { create(:cohort, status: 'draft') } + + it '.active returns only active cohorts' do + expect(Cohort.active).to include(active_cohort) + expect(Cohort.active).not_to include(draft_cohort) + end + end + + describe 'instance methods' do + describe '#ready_for_sponsor?' do + it 'returns true when all conditions met' do + cohort = create(:cohort, + tp_signed_at: Time.current, + students_completed_at: Time.current, + status: 'active' + ) + create(:cohort_enrollment, cohort: cohort, role: 'student') + + expect(cohort.ready_for_sponsor?).to be true + end + + it 'returns false when students not completed' do + cohort = create(:cohort, tp_signed_at: Time.current) + expect(cohort.ready_for_sponsor?).to be false + end + end + end + + describe 'callbacks' do + it 'sends activation email when status changes to active' do + cohort = create(:cohort, status: 'draft') + expect(CohortMailer).to receive(:activated).with(cohort).and_call_original + + cohort.update!(status: 'active') + end + end +end +``` + +#### Controller Specs +```ruby +# spec/controllers/tp/cohorts_controller_spec.rb +require 'rails_helper' + +RSpec.describe tp::CohortsController, type: :controller do + let(:user) { create(:user, :tp_admin) } + let(:institution) { user.institution } + + before do + sign_in user + end + + describe 'GET #index' do + let!(:cohort) { create(:cohort, institution: institution) } + + it 'returns http success' do + get :index + expect(response).to have_http_status(:ok) + end + + it 'assigns cohorts' do + get :index + expect(assigns(:cohorts)).to include(cohort) + end + end + + describe 'POST #create' do + let(:template) { create(:template, account: user.account) } + + context 'with valid params' do + let(:valid_params) do + { + name: 'New Cohort', + program_type: 'learnership', + sponsor_email: 'sponsor@example.com', + template_id: template.id + } + end + + it 'creates a cohort' do + expect { + post :create, params: { cohort: valid_params } + }.to change(Cohort, :count).by(1) + end + + it 'redirects to cohort show' do + post :create, params: { cohort: valid_params } + expect(response).to redirect_to(tp_cohort_path(assigns(:cohort))) + end + end + + context 'with invalid params' do + it 'renders new template' do + post :create, params: { cohort: { name: '' } } + expect(response).to render_template(:new) + end + end + end +end +``` + +#### Request Specs (API) +```ruby +# spec/requests/api/v1/cohorts_spec.rb +require 'rails_helper' + +RSpec.describe 'API v1 Cohorts', type: :request do + let(:user) { create(:user, :tp_admin) } + let(:headers) { { 'Authorization' => "Bearer #{user.generate_jwt}" } } + + describe 'GET /api/v1/cohorts' do + let!(:cohort) { create(:cohort, institution: user.institution) } + + it 'returns cohorts' do + get '/api/v1/cohorts', headers: headers + + expect(response).to have_http_status(:ok) + expect(json_response.size).to eq(1) + expect(json_response.first['name']).to eq(cohort.name) + end + end + + describe 'POST /api/v1/cohorts' do + let(:template) { create(:template, account: user.account) } + + it 'creates a cohort' do + params = { + name: 'API Cohort', + program_type: 'internship', + sponsor_email: 'api@example.com', + template_id: template.id + } + + expect { + post '/api/v1/cohorts', headers: headers, params: params + }.to change(Cohort, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json_response['name']).to eq('API Cohort') + end + end + + def json_response + JSON.parse(response.body) + end +end +``` + +--- + +### 2. Vue Component Specs + +```javascript +// spec/javascript/tp/components/CohortCard.spec.js +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import CohortCard from '@/tp/components/CohortCard.vue' + +describe('CohortCard', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + const createWrapper = (props = {}) => { + return mount(CohortCard, { + props: { + cohort: { + id: 1, + name: 'Test Cohort', + status: 'active', + ...props + }, + ...props + } + }) + } + + it('renders cohort name', () => { + const wrapper = createWrapper() + expect(wrapper.text()).toContain('Test Cohort') + }) + + it('emits select event on click', async () => { + const wrapper = createWrapper() + await wrapper.trigger('click') + + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')[0]).toEqual([1]) + }) + + it('displays correct status badge', () => { + const wrapper = createWrapper({ status: 'active' }) + const badge = wrapper.find('.badge') + + expect(badge.text()).toBe('active') + expect(badge.classes()).toContain('bg-green-100') + }) + + it('handles missing cohort gracefully', () => { + const wrapper = mount(CohortCard, { + props: { cohort: null } + }) + + expect(wrapper.text()).toContain('No cohort data') + }) +}) +``` + +--- + +### 3. Store Specs + +```javascript +// spec/javascript/tp/stores/cohortStore.spec.js +import { createPinia, setActivePinia } from 'pinia' +import { useCohortStore } from '@/tp/stores/cohortStore' +import { CohortsAPI } from '@/tp/api/cohorts' + +// Mock API +vi.mock('@/tp/api/cohorts') + +describe('CohortStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('actions', () => { + describe('fetchCohorts', () => { + it('loads cohorts successfully', async () => { + const mockCohorts = [ + { id: 1, name: 'Cohort 1' }, + { id: 2, name: 'Cohort 2' } + ] + + CohortsAPI.getAll.mockResolvedValue(mockCohorts) + + const store = useCohortStore() + await store.fetchCohorts() + + expect(store.cohorts).toEqual(mockCohorts) + expect(store.loading).toBe(false) + }) + + it('handles errors', async () => { + CohortsAPI.getAll.mockRejectedValue(new Error('API Error')) + + const store = useCohortStore() + await store.fetchCohorts() + + expect(store.error).toBe('API Error') + expect(store.loading).toBe(false) + }) + }) + + describe('createCohort', () => { + it('adds cohort to list', async () => { + const newCohort = { id: 3, name: 'New Cohort' } + CohortsAPI.create.mockResolvedValue(newCohort) + + const store = useCohortStore() + store.cohorts = [{ id: 1, name: 'Existing' }] + + const result = await store.createCohort({ name: 'New Cohort' }) + + expect(result).toEqual(newCohort) + expect(store.cohorts).toHaveLength(2) + expect(store.cohorts[0].id).toBe(3) // Added to beginning + }) + }) + }) + + describe('getters', () => { + it('filters active cohorts', () => { + const store = useCohortStore() + store.cohorts = [ + { id: 1, status: 'active' }, + { id: 2, status: 'draft' }, + { id: 3, status: 'active' } + ] + + expect(store.activeCohorts).toHaveLength(2) + expect(store.activeCohorts.every(c => c.status === 'active')).toBe(true) + }) + }) +}) +``` + +--- + +### 4. System/Integration Specs + +```ruby +# spec/system/tp_cohort_workflow_spec.rb +require 'rails_helper' + +RSpec.describe 'TP Cohort Workflow', type: :system do + let(:user) { create(:user, :tp_admin) } + let(:template) { create(:template, account: user.account) } + + before do + sign_in user + visit tp_root_path + end + + scenario 'TP admin creates a cohort' do + click_link 'Cohorts' + click_link 'New Cohort' + + fill_in 'Name', with: '2026 Q1 Learnership' + select 'Learnership', from: 'Program Type' + fill_in 'Sponsor Email', with: 'sponsor@example.com' + select template.name, from: 'Template' + + click_button 'Create Cohort' + + expect(page).to have_content('Cohort created') + expect(page).to have_content('2026 Q1 Learnership') + end + + scenario 'TP admin manages students' do + cohort = create(:cohort, institution: user.institution) + visit tp_cohort_path(cohort) + + click_link 'Manage Students' + + fill_in 'Email', with: 'student@example.com' + fill_in 'Name', with: 'John' + fill_in 'Surname', with: 'Doe' + click_button 'Add Student' + + expect(page).to have_content('student@example.com') + expect(page).to have_content('John Doe') + end + + scenario 'Complete cohort workflow' do + cohort = create(:cohort, institution: user.institution) + create_list(:cohort_enrollment, 3, cohort: cohort, status: 'complete') + + visit tp_cohort_path(cohort) + + click_button 'Start Signing Phase' + expect(page).to have_content('Signing phase started') + + click_button 'Finalize Cohort' + expect(page).to have_content('Cohort finalized') + expect(cohort.reload.status).to eq('completed') + end +end +``` + +--- + +### 5. Test Data (Factories) + +```ruby +# spec/factories/institutions.rb +FactoryBot.define do + factory :institution do + name { "TechPro Training Academy" } + email { "admin@techpro.co.za" } + contact_person { "Jane Smith" } + phone { "+27 11 123 4567" } + settings { {} } + end +end + +# spec/factories/cohorts.rb +FactoryBot.define do + factory :cohort do + association :institution + association :template + + name { "2026 Q1 Learnership" } + program_type { "learnership" } + sponsor_email { "sponsor@example.com" } + required_student_uploads { ["id_copy", "matric"] } + status { "draft" } + + trait :active do + status { "active" } + end + + trait :completed do + status { "completed" } + tp_signed_at { Time.current } + students_completed_at { Time.current } + sponsor_completed_at { Time.current } + finalized_at { Time.current } + end + end +end + +# spec/factories/cohort_enrollments.rb +FactoryBot.define do + factory :cohort_enrollment do + association :cohort + association :submission + + student_email { "student@example.com" } + student_name { "John" } + student_surname { "Doe" } + status { "waiting" } + role { "student" } + + trait :completed do + status { "complete" } + completed_at { Time.current } + end + end +end +``` + +--- + +## 📋 General Standards + +### 1. Git Commit Messages + +``` +# ✅ Correct +git commit -m "Add Story 1.1: Database Schema Extension" + +git commit -m "Fix: Handle nil values in cohort status check" + +git commit -m "Refactor: Extract cohort workflow to service object" + +# ❌ Wrong +git commit -m "fixed stuff" +git commit -m "wip" +git commit -m "changes" +``` + +### 2. File Organization + +``` +# ✅ Correct - Logical grouping +app/models/ + institution.rb + cohort.rb + cohort_enrollment.rb + concerns/ + soft_deletable.rb + feature_flag_check.rb + +# ❌ Wrong - Flat or mixed +app/models/ + institution.rb + cohort.rb + cohort_enrollment.rb + soft_deletable.rb # Should be in concerns/ + user.rb # Should be grouped with other existing models +``` + +### 3. Comments & Documentation + +```ruby +# ✅ Correct - Clear, concise comments +class Cohort < ApplicationRecord + # Workflow states: + # - draft: Being configured by TP + # - active: Students can enroll + # - completed: All phases done + validates :status, inclusion: { in: %w[draft active completed] } + + # Returns cohorts ready for sponsor signing + def self.ready_for_sponsor + where.not(tp_signed_at: nil) + .where.not(students_completed_at: nil) + end +end + +# ❌ Wrong - Obvious or outdated comments +class Cohort < ApplicationRecord + # This is a model + # TODO: Update this + validates :status, inclusion: { in: %w[draft active completed] } +end +``` + +--- + +## 🎯 Quality Checklist + +### Ruby Code +- [ ] Models inherit from `ApplicationRecord` +- [ ] All associations have explicit class names if needed +- [ ] Validations are present and specific +- [ ] Scopes use lambdas +- [ ] Callbacks are in private methods +- [ ] Strong parameters are used in controllers +- [ ] Before actions are ordered correctly +- [ ] Service objects for complex logic + +### Vue Code +- [ ] Components use PascalCase +- [ ] Composition API with ` + + +``` + +**Pinia Store Pattern**: +```javascript +// app/javascript/tp/stores/cohortStore.js +import { defineStore } from 'pinia' +import { CohortsAPI } from '@/tp/api/cohorts' + +export const useCohortStore = defineStore('cohort', { + state: () => ({ + cohorts: [], + currentCohort: null, + loading: false, + error: null + }), + + actions: { + async fetchCohorts() { + this.loading = true + this.error = null + try { + this.cohorts = await CohortsAPI.getAll() + } catch (err) { + this.error = err.message + } finally { + this.loading = false + } + }, + + async createCohort(data) { + const cohort = await CohortsAPI.create(data) + this.cohorts.unshift(cohort) + return cohort + }, + + async fetchCohort(id) { + this.currentCohort = await CohortsAPI.get(id) + } + }, + + getters: { + activeCohorts: (state) => state.cohorts.filter(c => c.status === 'active'), + completedCohorts: (state) => state.cohorts.filter(c => c.status === 'completed') + } +}) +``` + +**API Client Pattern**: +```javascript +// app/javascript/tp/api/cohorts.js +import axios from 'axios' + +export const CohortsAPI = { + async getAll() { + const response = await axios.get('/api/v1/cohorts') + return response.data + }, + + async get(id) { + const response = await axios.get(`/api/v1/cohorts/${id}`) + return response.data + }, + + async create(data) { + const response = await axios.post('/api/v1/cohorts', data) + return response.data + }, + + async update(id, data) { + const response = await axios.patch(`/api/v1/cohorts/${id}`, data) + return response.data + }, + + async startSigning(id) { + const response = await axios.post(`/api/v1/cohorts/${id}/start_signing`) + return response.data + } +} +``` + +--- + +### `app/controllers/api/` - API Layer + +``` +app/controllers/api/ +├── v1/ +│ ├── base_controller.rb # API authentication & versioning +│ ├── cohorts_controller.rb # Cohort CRUD +│ ├── enrollments_controller.rb # Enrollment management +│ ├── students_controller.rb # Student portal API +│ ├── sponsors_controller.rb # Sponsor portal API +│ ├── webhooks_controller.rb # Webhook endpoints +│ └── uploads_controller.rb # File uploads +│ +└── v2/ # Future version + └── base_controller.rb +``` + +**API Base Controller**: +```ruby +# app/controllers/api/v1/base_controller.rb +class Api::V1::BaseController < ActionController::API + before_action :authenticate_api! + rescue_from StandardError, with: :handle_error + + private + + def authenticate_api! + token = request.headers['Authorization']&.split(' ')&.last + @current_user = User.find_by(jwt_token: token) + render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user + end + + def current_institution + @current_user.institution + end + + def handle_error(exception) + render json: { error: exception.message }, status: :internal_server_error + end +end +``` + +--- + +### `db/migrate/` - Database Migrations + +``` +db/migrate/ +├── 20260114000001_create_flo_doc_tables.rb # Story 1.1 +├── 20260114000002_create_feature_flags.rb # Story 1.2 +├── 20260114000003_add_flo_doc_indexes.rb # Performance +└── # Existing DocuSeal migrations +``` + +**Migration Naming Convention**: +- Timestamp format: `YYYYMMDDHHMMSS` +- Descriptive name: `create_[table]_tables` or `add_[field]_to_[table]` +- Group related changes + +**Example Migration**: +```ruby +# db/migrate/20260114000001_create_flo_doc_tables.rb +class CreateFloDocTables < ActiveRecord::Migration[7.0] + def change + create_table :institutions do |t| + t.string :name, null: false + t.string :email, null: false + t.string :contact_person + t.string :phone + t.jsonb :settings, default: {} + t.timestamps + t.datetime :deleted_at + end + + add_index :institutions, :name, unique: true + add_index :institutions, :email, unique: true + + # ... more tables + end +end +``` + +--- + +### `spec/` - Test Suite + +``` +spec/ +├── models/ +│ ├── institution_spec.rb +│ ├── cohort_spec.rb +│ └── cohort_enrollment_spec.rb +│ +├── controllers/ +│ ├── tp/ +│ │ ├── cohorts_controller_spec.rb +│ │ └── dashboard_controller_spec.rb +│ ├── student/ +│ │ └── enrollment_controller_spec.rb +│ └── api/ +│ └── v1/ +│ ├── cohorts_controller_spec.rb +│ └── webhooks_controller_spec.rb +│ +├── requests/ +│ └── api/ +│ └── v1/ +│ ├── cohorts_spec.rb +│ └── webhooks_spec.rb +│ +├── system/ +│ ├── tp_portal_spec.rb +│ ├── student_portal_spec.rb +│ └── sponsor_portal_spec.rb +│ +├── migrations/ +│ └── create_flo_doc_tables_spec.rb +│ +├── javascript/ +│ ├── tp/ +│ │ ├── views/ +│ │ │ └── CohortList.spec.js +│ │ └── stores/ +│ │ └── cohortStore.spec.js +│ └── student/ +│ └── stores/ +│ └── enrollmentStore.spec.js +│ +├── factories/ +│ ├── institutions.rb +│ ├── cohorts.rb +│ └── cohort_enrollments.rb +│ +└── support/ + ├── database_cleaner.rb + └── api_helpers.rb +``` + +**Model Spec Example**: +```ruby +# spec/models/cohort_spec.rb +require 'rails_helper' + +RSpec.describe Cohort, type: :model do + describe 'validations' do + it { should validate_presence_of(:name) } + it { should validate_presence_of(:program_type) } + it { should validate_inclusion_of(:status).in_array(%w[draft active completed]) } + end + + describe 'associations' do + it { should belong_to(:institution) } + it { should belong_to(:template) } + it { should have_many(:cohort_enrollments) } + end + + describe '#active?' do + it 'returns true when status is active' do + cohort = build(:cohort, status: 'active') + expect(cohort.active?).to be true + end + end +end +``` + +**Vue Component Spec Example**: +```javascript +// spec/javascript/tp/views/CohortList.spec.js +import { mount, flushPromises } from '@vue/test-utils' +import CohortList from '@/tp/views/CohortList.vue' +import { createPinia, setActivePinia } from 'pinia' +import { useCohortStore } from '@/tp/stores/cohortStore' + +describe('CohortList', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('displays loading state', () => { + const wrapper = mount(CohortList) + expect(wrapper.text()).toContain('Loading') + }) + + it('displays cohorts after loading', async () => { + const wrapper = mount(CohortList) + const store = useCohortStore() + store.cohorts = [{ id: 1, name: 'Test Cohort' }] + + await flushPromises() + expect(wrapper.text()).toContain('Test Cohort') + }) +}) +``` + +--- + +### `lib/` - Utility Modules + +``` +lib/ +├── # Business Logic Helpers +├── submissions.rb # Submission workflows (existing) +├── submitters.rb # Submitter logic (existing) +├── cohorts.rb # Cohort workflows (NEW) +├── enrollments.rb # Enrollment logic (NEW) +│ +├── # PDF Processing +├── pdf_utils.rb # PDF utilities +├── pdfium.rb # PDF rendering +│ +├── # Webhooks +├── send_webhook_request.rb # Webhook delivery +├── webhook_events.rb # Event definitions +│ +├── # Token Management +├── token_generator.rb # Secure token generation +├── token_verifier.rb # Token validation +│ +└── # Utilities + ├── load_active_storage_configs.rb + └── feature_flag_loader.rb +``` + +**Utility Module Example**: +```ruby +# lib/cohorts.rb +module Cohorts + module Workflow + def self.advance_to_active(cohort) + return false unless cohort.draft? + + cohort.update!(status: 'active') + CohortMailer.activated(cohort).deliver_later + true + end + + def self.ready_for_sponsor?(cohort) + cohort.students_completed_at.present? && + cohort.tp_signed_at.present? && + cohort.cohort_enrollments.students.any? + end + end +end +``` + +--- + +### `config/` - Configuration + +``` +config/ +├── application.rb # Rails config +├── database.yml # Database config +├── routes.rb # All routes +├── storage.yml # Active Storage +├── sidekiq.yml # Sidekiq config +├── shakapacker.yml # Webpack config +│ +├── # Initializers +├── devise.rb # Devise config +├── cors.rb # CORS settings +├── active_storage.rb # Storage config +│ +└── # Environments + ├── development.rb + ├── test.rb + └── production.rb +``` + +**Routes Configuration**: +```ruby +# config/routes.rb +Rails.application.routes.draw do + # Existing DocuSeal routes + resources :templates + resources :submissions + + # TP Portal (authenticated) + namespace :tp do + root 'dashboard#index' + resources :cohorts do + member do + post :start_signing + post :finalize + end + resources :enrollments, only: [:index, :show] + end + resources :students, only: [:index, :show] + resources :sponsors, only: [:index, :show] + end + + # Student Portal (ad-hoc tokens) + scope module: :student do + get '/enroll/:token', to: 'enrollment#show', as: :student_enroll + post '/enroll/:token/submit', to: 'enrollment#submit' + get '/status/:token', to: 'enrollment#status' + end + + # Sponsor Portal (ad-hoc tokens) + scope module: :sponsor do + get '/sponsor/:token', to: 'dashboard#show', as: :sponsor_dashboard + post '/sponsor/:token/sign', to: 'signing#bulk_sign' + end + + # API v1 + namespace :api do + namespace :v1 do + resources :cohorts do + member do + post :start_signing + end + end + resources :enrollments + resources :students, only: [:show, :update] + resources :sponsors, only: [:show] + resources :webhooks, only: [:create] + end + end + + # Devise (existing) + devise_for :users +end +``` + +--- + +## 🎯 File Naming Conventions + +### Models +- **Singular**: `cohort.rb`, not `cohorts.rb` +- **Snake case**: `cohort_enrollment.rb` +- **Table name**: Plural (Rails convention) + +### Controllers +- **Plural**: `cohorts_controller.rb` +- **Namespaced**: `tp/cohorts_controller.rb` +- **API versioned**: `api/v1/cohorts_controller.rb` + +### Views +- **Controller-based**: `app/views/tp/cohorts/` +- **Template names**: `index.html.erb`, `show.html.erb`, `_form.html.erb` + +### JavaScript +- **Components**: PascalCase (`CohortCard.vue`) +- **Stores**: camelCase (`cohortStore.js`) +- **API**: PascalCase (`CohortsAPI`) +- **Views**: PascalCase (`CohortList.vue`) + +### Tests +- **Models**: `model_name_spec.rb` +- **Controllers**: `controller_name_spec.rb` +- **Requests**: `request_name_spec.rb` +- **System**: `feature_name_spec.rb` +- **JavaScript**: `ComponentName.spec.js` + +--- + +## 🔧 Configuration Files + +### Gemfile +```ruby +# Core +gem 'rails', '~> 7.0' + +# Database +gem 'pg', '~> 1.4' + +# Authentication +gem 'devise', '~> 4.8' +gem 'devise-two-factor' +gem 'cancancan', '~> 3.0' + +# Background Jobs +gem 'sidekiq', '~> 7.0' + +# PDF +gem 'hexapdf', '~> 0.15' + +# API +gem 'jbuilder' + +# Security +gem 'rack-attack' +``` + +### package.json +```json +{ + "name": "flo-doc", + "dependencies": { + "vue": "^3.3.0", + "pinia": "^2.1.0", + "axios": "^1.6.0", + "tailwindcss": "^3.4.17", + "daisyui": "^3.9.4" + }, + "devDependencies": { + "@vue/test-utils": "^2.4.0", + "vitest": "^1.0.0" + } +} +``` + +### docker-compose.yml +```yaml +version: '3.8' +services: + app: + build: . + ports: + - "3000:3000" + depends_on: + - db + - redis + - minio + environment: + DATABASE_URL: postgresql://postgres:password@db:5432/flo_doc + REDIS_URL: redis://redis:6379 + + db: + image: postgres:14 + environment: + POSTGRES_PASSWORD: password + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + + minio: + image: minio/minio + command: server /data + ports: + - "9000:9000" + + mailhog: + image: mailhog/mailhog + ports: + - "1025:1025" + - "8025:8025" + +volumes: + postgres_data: +``` + +--- + +## 📊 Source Tree Reference + +For complete file tree with explanations, see: `docs/architecture/source-tree.md` + +--- + +## 🎯 Development Workflow + +### Adding a New Model +1. Create migration: `rails g migration CreateTableName` +2. Create model: `rails g model TableName` +3. Add associations & validations +4. Write model specs +5. Run migration: `rails db:migrate` + +### Adding a New Controller +1. Create controller: `rails g controller Namespace/Name` +2. Add routes +3. Add authentication/authorization +4. Write controller specs +5. Test manually + +### Adding a New Vue Component +1. Create component file in appropriate portal folder +2. Add to view or register globally +3. Write component spec +4. Test in browser + +### Running Tests +```bash +# Ruby tests +bundle exec rspec spec/models/cohort_spec.rb + +# JavaScript tests +yarn test spec/javascript/tp/views/CohortList.spec.js + +# All tests +bundle exec rspec +yarn test +``` + +--- + +## 🔍 Key Principles + +1. **Convention Over Configuration**: Follow Rails and Vue conventions +2. **Separation of Concerns**: Keep models, controllers, views separate +3. **DRY**: Reuse code via concerns, mixins, and components +4. **Testability**: Design for easy testing +5. **Maintainability**: Clear structure, good naming, documentation + +--- + +## 📚 Related Documents + +- **Tech Stack**: `docs/architecture/tech-stack.md` +- **Data Models**: `docs/architecture/data-models.md` +- **Coding Standards**: `docs/architecture/coding-standards.md` +- **Source Tree**: `docs/architecture/source-tree.md` + +--- + +**Document Status**: ✅ Complete +**Ready for**: Implementation \ No newline at end of file diff --git a/docs/architecture/rollback-strategy.md b/docs/architecture/rollback-strategy.md deleted file mode 100644 index 62ed64db..00000000 --- a/docs/architecture/rollback-strategy.md +++ /dev/null @@ -1,152 +0,0 @@ -# Rollback Strategy for Institution Management - -## Overview - -This document outlines the rollback procedure for the institution management migrations (20250103000001-20250103000006). - -## Migration Sequence - -1. `20250103000001` - Add institution_id to account_access (nullable) -2. `20250103000002` - Create institutions table -3. `20250103000003` - Create cohort_admin_invitations table -4. `20250103000004` - Update account_access roles -5. `20250103000005` - Backfill institution data (makes institution_id non-nullable) -6. `20250103000006` - Create security_events table - -## Rollback Procedure - -### Step 1: Test Rollback on Staging/Development - -```bash -# Rollback last migration -bin/rails db:rollback STEP=1 - -# Rollback all institution migrations -bin/rails db:rollback STEP=6 -``` - -### Step 2: Production Rollback (If Needed) - -**⚠️ CRITICAL: Always backup database before rollback** - -```bash -# 1. Create database backup -pg_dump -Fc docuseal_production > docuseal_backup_$(date +%Y%m%d_%H%M%S).dump - -# 2. Check current migration status -bin/rails db:migrate:status - -# 3. Rollback in reverse order -bin/rails db:rollback STEP=1 # Rollback security_events -bin/rails db:rollback STEP=1 # Rollback backfill -bin/rails db:rollback STEP=1 # Rollback account_access roles -bin/rails db:rollback STEP=1 # Rollback cohort_admin_invitations -bin/rails db:rollback STEP=1 # Rollback institutions -bin/rails db:rollback STEP=1 # Rollback institution_id addition - -# 4. Verify rollback -bin/rails db:migrate:status -``` - -### Step 3: Data Safety Verification - -**After rollback, verify:** - -1. **Existing data intact:** - ```sql - SELECT COUNT(*) FROM users; - SELECT COUNT(*) FROM accounts; - SELECT COUNT(*) FROM account_accesses; - SELECT COUNT(*) FROM templates; - SELECT COUNT(*) FROM submissions; - ``` - -2. **No orphaned records:** - ```sql - -- Check for orphaned records - SELECT * FROM account_accesses WHERE account_id NOT IN (SELECT id FROM accounts); - SELECT * FROM users WHERE account_id NOT IN (SELECT id FROM accounts); - ``` - -3. **Existing functionality works:** - - User login - - Template creation - - Submission workflows - - API access - -## Rollback Risks and Mitigations - -### Risk 1: Data Loss -**Impact:** High -**Mitigation:** -- Always backup before rollback -- Test rollback on staging first -- Verify data integrity after rollback - -### Risk 2: Downtime -**Impact:** Medium -**Mitigation:** -- Schedule rollback during maintenance window -- Have rollback plan ready -- Test procedure beforehand - -### Risk 3: Application Errors -**Impact:** High -**Mitigation:** -- Keep application version compatible with database schema -- Have emergency rollback to previous app version ready -- Monitor error logs during rollback - -## Emergency Rollback - -If critical issues arise during deployment: - -1. **Immediate rollback:** - ```bash - git revert HEAD - bin/rails db:rollback STEP=6 - ``` - -2. **Restore from backup if needed:** - ```bash - pg_restore -d docuseal_production docuseal_backup_YYYYMMDD_HHMMSS.dump - ``` - -3. **Notify stakeholders** of rollback and reason - -## Post-Rollback Verification - -After rollback, verify these critical paths: - -- [ ] User authentication works -- [ ] Existing templates accessible -- [ ] Submissions can be created -- [ ] API endpoints return correct data -- [ ] No database constraint violations -- [ ] Email notifications work -- [ ] Webhook delivery works - -## Rollback Decision Matrix - -**Rollback if:** -- Data corruption detected -- Critical security vulnerabilities found -- Major performance degradation (>50%) -- Application crashes on startup -- Cannot fix issues within 2 hours - -**Do NOT rollback if:** -- Minor bugs that can be hotfixed -- Performance issues within acceptable range (<10%) -- UI/UX issues only -- Non-critical feature failures - -## Contact Information - -**Emergency Contacts:** -- Database Administrator: [To be filled] -- DevOps Engineer: [To be filled] -- Security Team: [To be filled] - -**Rollback Window:** [To be scheduled] -**Estimated Downtime:** 15-30 minutes \ No newline at end of file diff --git a/docs/architecture/security-integration.md b/docs/architecture/security-integration.md deleted file mode 100644 index 84ff1af5..00000000 --- a/docs/architecture/security-integration.md +++ /dev/null @@ -1,44 +0,0 @@ -# Security Integration - -## Existing Security Measures - -**Authentication:** Devise with database_authenticatable, 2FA support, JWT tokens -**Authorization:** Cancancan with `Ability` class, role-based via `AccountAccess` -**Data Protection:** Encrypted fields, secure file storage, CSRF protection -**Security Tools:** Devise security extensions, input validation, secure headers - -## Enhancement Security Requirements - -**New Security Measures:** -- **Token-based Sponsor Access:** Unique tokens for sponsor portal (not JWT) -- **Institution Isolation:** Ensure strict data separation between institutions -- **Role Validation:** Portal-specific role checks at controller level -- **Document Access Control:** Verify enrollment ownership before document access -- **Bulk Operation Limits:** Rate limiting for sponsor bulk signing - -**Integration Points:** -- **Authentication:** Extend existing Devise setup with cohort-specific roles -- **Authorization:** Add cohort permissions to existing Cancancan abilities -- **Data Protection:** Apply existing encryption to new sensitive fields -- **Session Management:** Use existing session handling for portal access - -**Compliance Requirements:** -- **South African Regulations:** Electronic signature compliance (existing HexaPDF signatures) -- **Data Privacy:** POPIA compliance for student personal data (existing GDPR patterns) -- **Audit Trail:** Document verification actions logged (extends existing audit capabilities) - -## Security Testing - -**Existing Security Tests:** Devise security tests, API authentication tests -**New Security Test Requirements:** -- **Portal Access Control:** Test role-based portal access -- **Institution Isolation:** Test cross-institution data access prevention -- **Token Security:** Test sponsor token generation, expiration, reuse prevention -- **Bulk Operation Security:** Test rate limiting and abuse prevention - -**Penetration Testing:** -- **Scope:** New cohort endpoints and portal authentication -- **Focus:** Token-based sponsor access, institution isolation, bulk operations -- **Tools:** Existing security scanning tools, OWASP ZAP for API testing - ---- diff --git a/docs/architecture/security.md b/docs/architecture/security.md new file mode 100644 index 00000000..b427e2e6 --- /dev/null +++ b/docs/architecture/security.md @@ -0,0 +1,1112 @@ +# Security Architecture - FloDoc + +**Document**: Security Design & Implementation +**Version**: 1.0 +**Last Updated**: 2026-01-14 +**Compliance**: POPIA, OWASP Top 10 + +--- + +## 🛡️ Security Overview + +FloDoc implements defense-in-depth security across all layers. The system handles sensitive student PII and must comply with South African POPIA (Protection of Personal Information Act). + +**Security Principles**: +1. **Least Privilege**: Users only access what they need +2. **Defense in Depth**: Multiple security layers +3. **Zero Trust**: Verify everything, trust nothing +4. **Audit Everything**: Complete audit trail +5. **Secure by Default**: Safe defaults, opt-in for risky features + +--- + +## 🔐 Authentication + +### 1. TP Portal (Devise + JWT) + +**Authentication Flow**: +``` +User Login (Email/Password) + ↓ +Devise Validates + ↓ +2FA Check (if enabled) + ↓ +JWT Token Generated + ↓ +Token Stored (HTTP-only cookie or localStorage) + ↓ +Subsequent Requests Include Token +``` + +**Implementation**: +```ruby +# app/models/user.rb +class User < ApplicationRecord + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :validatable, + :two_factor_authenticatable, + :otp_secret_encryption_key => ENV['OTP_SECRET_KEY'] + + # JWT token generation + def generate_jwt + payload = { + user_id: id, + exp: 7.days.from_now.to_i, + role: role + } + JWT.encode(payload, ENV['JWT_SECRET_KEY'], 'HS256') + end + + def valid_otp?(code) + return true unless otp_required_for_login? + super + end +end +``` + +**Token Storage**: +```javascript +// Frontend - Secure storage +// Option 1: HTTP-only cookie (more secure) +// Option 2: Memory (less persistent but secure) + +// Recommended: Memory storage with refresh +const authStore = defineStore('auth', { + state: () => ({ + token: null, + user: null + }), + + actions: { + async login(email, password) { + const response = await axios.post('/api/v1/auth/token', { email, password }) + this.token = response.data.token + // Store in memory only + axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}` + }, + + async refresh() { + if (this.isTokenExpiringSoon) { + const response = await axios.post('/api/v1/auth/refresh') + this.token = response.data.token + } + } + }, + + getters: { + isTokenExpiringSoon: (state) => { + if (!state.token) return false + const decoded = jwtDecode(state.token) + const expiry = decoded.exp * 1000 + return expiry - Date.now() < 5 * 60 * 1000 // 5 minutes + } + } +}) +``` + +**Session Management**: +- Token expiry: 7 days +- Auto-refresh: 5 minutes before expiry +- Logout: Token invalidation on server +- Concurrent session limit: 3 sessions per user + +--- + +### 2. Student Portal (Ad-hoc Token Authentication) + +**Authentication Flow**: +``` +TP Creates Enrollment + ↓ +Generate Unique Token (32 bytes) + ↓ +Send Email with Link: /enroll/:token + ↓ +Student Clicks Link + ↓ +Token Validated (1-time use, expires in 7 days) + ↓ +Session Established (no account needed) +``` + +**Token Generation**: +```ruby +# app/models/cohort_enrollment.rb +class CohortEnrollment < ApplicationRecord + before_create :generate_secure_token + + def generate_secure_token + self.token = SecureRandom.urlsafe_base64(32) + self.token_expires_at = 7.days.from_now + end + + def valid_token?(provided_token) + return false if token_used? + return false if token_expires_at < Time.current + provided_token == token + end + + def mark_token_used! + update!(token_used: true, token_used_at: Time.current) + end +end +``` + +**Token Security**: +- **Length**: 256-bit (32 bytes URL-safe base64) +- **Entropy**: High (SecureRandom) +- **Expiry**: 7 days from creation +- **One-time use**: Marked as used after first access +- **Revocation**: Can be regenerated by TP admin + +**Email Template**: +```erb + +

You've been invited to enroll in <%= @cohort.name %>

+ +

+ <%= link_to 'Click here to enroll', + student_enroll_url(token: @enrollment.token) %> +

+ +

+ This link expires in 7 days and can only be used once.
+ If you didn't request this, please ignore this email. +

+``` + +--- + +### 3. Sponsor Portal (Single Email Token) + +**Authentication Flow**: +``` +Cohort Reaches Sponsor Phase + ↓ +Generate Single Token for Bulk Signing + ↓ +Send Email to Sponsor + ↓ +Sponsor Clicks Link: /sponsor/:token + ↓ +Token Validated (expires in 3 days) + ↓ +Bulk Signing Interface +``` + +**Special Characteristics**: +- **Single Email Rule**: One email per cohort, not per student +- **Bulk Operations**: Sign once for all students +- **Short Expiry**: 3 days (more sensitive) +- **Read-Only Preview**: Can view before signing + +**Implementation**: +```ruby +# app/models/cohort.rb +class Cohort < ApplicationRecord + def generate_sponsor_token + self.sponsor_token = SecureRandom.urlsafe_base64(32) + self.sponsor_token_expires_at = 3.days.from_now + save! + end + + def sponsor_token_valid? + return false if sponsor_token.blank? + sponsor_token_expires_at > Time.current + end + + def send_sponsor_invitation + generate_sponsor_token + CohortMailer.sponsor_invitation(self).deliver_later + end +end +``` + +--- + +## 🔑 Authorization + +### 1. Role-Based Access Control (RBAC) + +**Roles**: +- `tp_admin`: Full access to TP portal +- `tp_user`: Limited access (view only) +- `student`: Ad-hoc access to own enrollment +- `sponsor`: Ad-hoc access to cohort signing + +**Ability Class**: +```ruby +# app/models/ability.rb +class Ability + include CanCanCan + + def initialize(user) + return unless user.present? + + if user.tp_admin? + can :manage, :all + cannot :manage, User unless user.super_admin? + elsif user.tp_user? + can :read, Cohort, institution_id: user.institution_id + can :manage, CohortEnrollment, cohort: { institution_id: user.institution_id } + end + end +end +``` + +**Controller Authorization**: +```ruby +# app/controllers/tp/cohorts_controller.rb +class tp::CohortsController < ApplicationController + before_action :authenticate_user! + load_and_authorize_resource + + def index + @cohorts = current_institution.cohorts + end + + def create + @cohort = current_institution.cohorts.new(cohort_params) + if @cohort.save + redirect_to tp_cohort_path(@cohort) + else + render :new + end + end +end +``` + +--- + +### 2. Ad-hoc Token Authorization + +**Token-Based CanCanCan**: +```ruby +# app/controllers/student/enrollment_controller.rb +class Student::EnrollmentController < ApplicationController + skip_before_action :authenticate_user! + before_action :validate_token + + def show + # @enrollment set by before_action + end + + private + + def validate_token + @enrollment = CohortEnrollment.find_by(token: params[:token]) + + unless @enrollment&.valid_token?(params[:token]) + redirect_to root_path, alert: 'Invalid or expired link' + return + end + + # Auto-mark as used after first access + @enrollment.mark_token_used! unless @enrollment.token_used? + end +end +``` + +**Authorization Matrix**: + +| Action | TP Admin | TP User | Student | Sponsor | +|--------|----------|---------|---------|---------| +| Create Cohort | ✅ | ❌ | ❌ | ❌ | +| View Cohorts | ✅ | ✅ | ❌ | ❌ | +| Manage Enrollments | ✅ | ✅ | ❌ | ❌ | +| Enroll Self | ❌ | ❌ | ✅ (token) | ❌ | +| Upload Documents | ❌ | ❌ | ✅ (token) | ❌ | +| View Cohort (Sponsor) | ❌ | ❌ | ❌ | ✅ (token) | +| Bulk Sign | ❌ | ❌ | ❌ | ✅ (token) | +| Finalize Cohort | ✅ | ❌ | ❌ | ❌ | + +--- + +## 🔒 Data Security + +### 1. Encryption at Rest + +**Database Encryption**: +```ruby +# app/models/cohort_enrollment.rb +class CohortEnrollment < ApplicationRecord + # Encrypt sensitive fields + encrypts :student_email + encrypts :student_name + encrypts :student_surname + encrypts :student_id + + # Rails 7+ built-in encryption + # Requires: ENCRYPTION_KEY in environment +end +``` + +**Configuration**: +```ruby +# config/initializers/active_record_encryption.rb +Rails.application.config.active_record.encryption.primary_key = ENV['ENCRYPTION_PRIMARY_KEY'] +Rails.application.config.active_record.encryption.deterministic_key = ENV['ENCRYPTION_DETERMINISTIC_KEY'] +Rails.application.config.active_record.encryption.key_derivation_salt = ENV['ENCRYPTION_SALT'] +``` + +**Key Management**: +- Use environment variables (never commit keys) +- Rotate keys periodically +- Backup keys securely +- Use AWS KMS or similar for production + +--- + +### 2. File Upload Security + +**Active Storage Configuration**: +```ruby +# config/storage.yml +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +minio: + service: S3 + access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %> + secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %> + region: us-east-1 + endpoint: <%= ENV['AWS_ENDPOINT_URL'] %> + bucket: <%= ENV['AWS_BUCKET_NAME'] %> +``` + +**Upload Validation**: +```ruby +# app/models/cohort_enrollment.rb +class CohortEnrollment < ApplicationRecord + has_many_attached :documents + + validate :documents_are_valid + + private + + def documents_are_valid + documents.each do |doc| + # Size limit (5MB) + if doc.byte_size > 5.megabytes + errors.add(:documents, 'must be less than 5MB') + end + + # Type validation + allowed_types = ['application/pdf', 'image/jpeg', 'image/png'] + unless allowed_types.include?(doc.content_type) + errors.add(:documents, 'must be PDF, JPG, or PNG') + end + + # Virus scan (if ClamAV available) + if defined?(ClamAV) + clam = ClamAV.instance + scan = clam.scanfile(doc.path) + if scan.is_a?(ClamAV::VirusResponse) + errors.add(:documents, 'contains virus') + end + end + end + end +end +``` + +**Content Security Policy**: +```ruby +# config/initializers/content_security_policy.rb +Rails.application.config.content_security_policy do |policy| + policy.default_src :self + policy.font_src :self, :https, :data + policy.img_src :self, :https, :data + policy.object_src :none + policy.script_src :self, :https + policy.style_src :self, :https, :unsafe_inline + policy.connect_src :self, :https, 'http://localhost:3000' if Rails.env.development? +end +``` + +--- + +### 3. Input Validation & Sanitization + +**Model Validations**: +```ruby +class Cohort < ApplicationRecord + validates :name, presence: true, length: { maximum: 100 } + validates :sponsor_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :program_type, inclusion: { in: %w[learnership internship candidacy] } + + # Sanitize inputs + before_validation :strip_whitespace + + private + + def strip_whitespace + self.name = name&.strip + self.sponsor_email = sponsor_email&.strip&.downcase + end +end +``` + +**Controller Parameter Sanitization**: +```ruby +class Api::V1::CohortsController < Api::V1::BaseController + def create + # Strong parameters prevent mass assignment + @cohort = current_institution.cohorts.new(cohort_params) + # ... + end + + private + + def cohort_params + params.require(:cohort).permit( + :name, + :program_type, + :sponsor_email, + :template_id, + required_student_uploads: [], + cohort_metadata: [:start_date, :duration_months, :stipend_amount] + ) + end +end +``` + +**SQL Injection Prevention**: +```ruby +# ✅ Safe - Uses ActiveRecord +Cohort.where("name ILIKE ?", "%#{sanitize_sql_like(search)}%") + +# ❌ Dangerous - Raw SQL with interpolation +Cohort.where("name ILIKE '%#{search}%'") +``` + +**XSS Prevention**: +```ruby +# Rails automatically escapes in views +<%= @cohort.name %> # Safe + +# Mark as safe only if trusted +<%= raw @trusted_html %> # Only if you control the content +``` + +--- + +## 🛡️ Web Security + +### 1. CSRF Protection + +**Rails Default**: +```ruby +# config/initializers/csrf_protection.rb +Rails.application.config.action_controller.csrf_protection_enabled = true +``` + +**API Controllers**: +```ruby +class Api::V1::BaseController < ActionController::API + # APIs typically skip CSRF (use token auth instead) + skip_before_action :verify_authenticity_token +end +``` + +**Web Controllers**: +```ruby +class ApplicationController < ActionController::Base + protect_from_forgery with: :exception +end +``` + +--- + +### 2. CORS Configuration + +```ruby +# config/initializers/cors.rb +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins 'http://localhost:3000' if Rails.env.development? + origins 'https://flodoc.com' if Rails.env.production? + + resource '/api/v1/*', + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head], + credentials: false, + expose: ['Authorization'] + end +end +``` + +--- + +### 3. Security Headers + +```ruby +# config/initializers/secure_headers.rb +Rails.application.config.middleware.use SecureHeaders::Middleware + +SecureHeaders::Configuration.configure do |config| + config.csp = { + default_src: %w['self'], + script_src: %w['self' 'unsafe-inline'], + style_src: %w['self' 'unsafe-inline'], + img_src: %w['self' data:], + font_src: %w['self' https: data:], + connect_src: %w['self'], + object_src: %w['none'], + base_uri: %w['self'], + form_action: %w['self'] + } + + config.hsts = "max-age=#{1.year.to_i}; includeSubDomains; preload" + config.x_frame_options = "DENY" + config.x_content_type_options = "nosniff" + config.x_xss_protection = "1; mode=block" + config.referrer_policy = "strict-origin-when-cross-origin" +end +``` + +--- + +## 📝 Audit & Logging + +### 1. Audit Trail + +**Audit Model**: +```ruby +# app/models/audit_log.rb +class AuditLog < ApplicationRecord + belongs_to :user, optional: true + belongs_to :auditable, polymorphic: true + + enum action: { + cohort_created: 0, + cohort_activated: 1, + enrollment_created: 2, + enrollment_completed: 3, + sponsor_signed: 4, + cohort_finalized: 5, + document_uploaded: 6, + token_generated: 7, + login: 8, + logout: 9 + } + + validates :action, :auditable, presence: true +end +``` + +**Audit Logger**: +```ruby +# app/controllers/concerns/auditable.rb +module Auditable + extend ActiveSupport::Concern + + included do + after_action :log_action, if: :auditable_action? + end + + private + + def log_action + AuditLog.create!( + user: current_user, + auditable: @auditable, + action: action_name, + ip_address: request.remote_ip, + user_agent: request.user_agent, + metadata: { params: params.except(:password, :token) } + ) + end + + def auditable_action? + %w[create update destroy].include?(action_name) + end +end + +# Usage in controllers +class tp::CohortsController < ApplicationController + include Auditable + + def create + @cohort = current_institution.cohorts.new(cohort_params) + @auditable = @cohort + # ... + end +end +``` + +**Audit Queries**: +```ruby +# Find all actions by a user +AuditLog.where(user: user).order(created_at: :desc) + +# Find all actions on a cohort +AuditLog.where(auditable: cohort) + +# Find suspicious activity +AuditLog.where('created_at > ?', 1.hour.ago) + .where(action: :login) + .group(:ip_address) + .count + .select { |_, count| count > 10 } +``` + +--- + +### 2. Application Logging + +```ruby +# config/environments/production.rb +config.log_level = :info +config.logger = ActiveSupport::Logger.new(STDOUT) + +# Log format +config.log_tags = [:request_id] + +# Filter sensitive parameters +config.filter_parameters += [:password, :token, :secret, :key] +``` + +**Custom Logging**: +```ruby +class CohortWorkflowService + def initialize(cohort, user) + @cohort = cohort + @user = user + @logger = Rails.logger + end + + def advance_to_active + @logger.info( + "[CohortWorkflow] User #{@user.id} advancing cohort #{@cohort.id} to active" + ) + + if @cohort.update(status: 'active') + @logger.info("[CohortWorkflow] Success: cohort #{@cohort.id}") + true + else + @logger.error( + "[CohortWorkflow] Failed: cohort #{@cohort.id}, errors: #{@cohort.errors.full_messages}" + ) + false + end + end +end +``` + +--- + +## 🚨 Incident Response + +### 1. Security Incident Detection + +**Suspicious Activity Alerts**: +```ruby +# app/services/security_monitor.rb +class SecurityMonitor + def self.check_suspicious_activity + # Multiple failed logins + failed_logins = AuditLog.where( + action: :login_failed, + created_at: 1.hour.ago..Time.current + ).group(:ip_address).count + + failed_logins.each do |ip, count| + if count > 5 + SecurityMailer.suspicious_activity(ip, count).deliver_later + end + end + + # Multiple token access from different IPs + token_usage = AuditLog.where( + action: :token_access, + created_at: 24.hours.ago..Time.current + ).group(:token_id, :ip_address).count + + # Alert if same token used from > 2 IPs + end +end +``` + +**Scheduled Monitoring**: +```ruby +# config/schedule.rb (using whenever gem) +every 30.minutes do + runner "SecurityMonitor.check_suspicious_activity" +end +``` + +--- + +### 2. Incident Response Plan + +**Severity Levels**: + +**Critical** (Immediate action): +- Unauthorized database access +- Token leak +- PII exposure +- Ransomware detected + +**High** (2 hours): +- Multiple failed login attempts +- Suspicious API usage +- File upload anomalies + +**Medium** (24 hours): +- Unusual traffic patterns +- Failed webhook deliveries +- Performance degradation + +**Response Steps**: +1. **Contain**: Disable affected accounts/tokens +2. **Investigate**: Review audit logs +3. **Remediate**: Patch vulnerability +4. **Notify**: Inform affected parties (POPIA requirement) +5. **Document**: Create incident report +6. **Prevent**: Implement additional controls + +--- + +## 🔒 POPIA Compliance + +### 1. Data Minimization + +**Principle**: Only collect what's necessary + +**Implementation**: +```ruby +# app/models/cohort_enrollment.rb +class CohortEnrollment < ApplicationRecord + # Only collect required fields + validates :student_email, presence: true + validates :student_name, presence: true, if: :required_for_program? + + def required_for_program? + cohort.program_type == 'learnership' + end +end +``` + +### 2. Purpose Limitation + +**Principle**: Use data only for specified purposes + +**Implementation**: +```ruby +# Clear purpose statements in UI +# app/views/student/enrollment/show.html.erb +

Document Upload

+

+ Your documents will be used for: +

+

+``` + +### 3. Data Retention + +**Principle**: Don't keep data longer than necessary + +**Implementation**: +```ruby +# app/models/cohort.rb +class Cohort < ApplicationRecord + # Auto-archive after 2 years + scope :for_archival, -> { where('created_at < ?', 2.years.ago) } + + def archive! + # Move to cold storage + # Anonymize PII + update!(status: 'archived') + end +end + +# Scheduled job +class ArchiveOldDataJob < ApplicationJob + def perform + Cohort.for_archival.find_each(&:archive!) + end +end +``` + +### 4. Right to Access & Deletion + +**Implementation**: +```ruby +# app/controllers/api/v1/user/data_controller.rb +class Api::V1::User::DataController < Api::V1::BaseController + # GET /api/v1/user/data/export + def export + data = { + profile: current_user, + enrollments: current_user.cohort_enrollments, + audit_logs: AuditLog.where(user: current_user) + } + send_data data.to_json, filename: "my-data-#{Date.current}.json" + end + + # DELETE /api/v1/user/data + def delete + # Anonymize instead of hard delete for audit + current_user.anonymize! + sign_out + head :no_content + end +end +``` + +--- + +## 🛡️ OWASP Top 10 Mitigation + +### A01:2021 - Broken Access Control +- ✅ Role-based authorization (CanCanCan) +- ✅ Institution isolation +- ✅ Token-based ad-hoc access +- ✅ Audit all access attempts + +### A02:2021 - Cryptographic Failures +- ✅ HTTPS enforced +- ✅ Password hashing (Devise bcrypt) +- ✅ JWT signing with secrets +- ✅ Database encryption + +### A03:2021 - Injection +- ✅ ActiveRecord parameterized queries +- ✅ Strong parameters +- ✅ Input validation +- ✅ SQL LIKE sanitization + +### A04:2021 - Insecure Design +- ✅ Threat modeling completed +- ✅ Security by default +- ✅ Defense in depth +- ✅ Least privilege principle + +### A05:2021 - Security Misconfiguration +- ✅ Secure headers +- ✅ CORS configuration +- ✅ No default credentials +- ✅ Environment-based configs + +### A06:2021 - Vulnerable and Outdated Components +- ✅ Regular dependency updates +- ✅ Security scanning (bundler-audit) +- ✅ Ruby/Rails version maintained +- ✅ Vue.js security updates + +### A07:2021 - Identification and Authentication Failures +- ✅ 2FA for TP users +- ✅ Token expiration +- ✅ Rate limiting +- ✅ Secure token generation + +### A08:2021 - Software and Data Integrity Failures +- ✅ Signed JWT tokens +- ✅ Webhook signature verification +- ✅ Code signing in CI/CD +- ✅ Dependency lock files + +### A09:2021 - Security Logging and Monitoring Failures +- ✅ Comprehensive audit logs +- ✅ Failed login tracking +- ✅ Suspicious activity alerts +- ✅ Log retention policy + +### A10:2021 - Server-Side Request Forgery (SSRF) +- ✅ Webhook URL validation +- ✅ No arbitrary URL fetching +- ✅ Internal network restrictions +- ✅ URL whitelist for webhooks + +--- + +## 🔍 Security Testing + +### 1. Static Analysis + +```bash +# Ruby security scanning +bundle exec brakeman +bundle exec bundler-audit + +# JavaScript security scanning +npm audit +yarn audit + +# Code quality +bundle exec rubocop --only security +``` + +### 2. Dynamic Testing + +```ruby +# spec/security/authentication_spec.rb +RSpec.describe 'Authentication Security', type: :request do + it 'rejects invalid JWT tokens' do + get '/api/v1/cohorts', headers: { 'Authorization' => 'Bearer invalid' } + expect(response).to have_http_status(:unauthorized) + end + + it 'prevents token reuse after logout' do + token = user.generate_jwt + user.invalidate_all_tokens! + get '/api/v1/cohorts', headers: { 'Authorization' => "Bearer #{token}" } + expect(response).to have_http_status(:unauthorized) + end +end +``` + +### 3. Penetration Testing Checklist + +**Authentication**: +- [ ] Brute force protection +- [ ] Session fixation prevention +- [ ] Token leakage protection +- [ ] 2FA bypass attempts + +**Authorization**: +- [ ] Horizontal privilege escalation +- [ ] Vertical privilege escalation +- [ ] IDOR (Insecure Direct Object Reference) +- [ ] Mass assignment + +**Input Validation**: +- [ ] SQL injection +- [ ] XSS (reflected, stored, DOM) +- [ ] Command injection +- [ ] File upload bypass + +**API Security**: +- [ ] Rate limiting bypass +- [ ] CORS misconfiguration +- [ ] Authentication bypass +- [ ] Information disclosure + +--- + +## 📊 Security Metrics + +**Track These Metrics**: +- Failed login attempts per day +- Token reuse attempts +- Unauthorized access attempts +- Security incidents per month +- Time to detect incidents +- Time to remediate incidents +- Dependency vulnerabilities +- Code coverage for security tests + +**Dashboard**: +```ruby +# Admin dashboard security widget +class SecurityDashboard + def self.metrics + { + failed_logins: AuditLog.where(action: :login_failed, created_at: 24.hours.ago..).count, + suspicious_ips: AuditLog.where('created_at > ?', 1.hour.ago).group(:ip_address).count.select { |_, c| c > 5 }, + tokens_issued: CohortEnrollment.where('created_at > ?', 24.hours.ago).count, + security_incidents: SecurityIncident.where('created_at > ?', 30.days.ago).count + } + end +end +``` + +--- + +## 🎯 Security Checklist + +### Application Security +- [ ] All inputs validated +- [ ] SQL injection prevented +- [ ] XSS prevented +- [ ] CSRF protected +- [ ] Authentication required +- [ ] Authorization enforced +- [ ] Sensitive data encrypted +- [ ] Secure headers configured +- [ ] CORS properly configured +- [ ] Rate limiting implemented + +### Data Security +- [ ] Database encryption enabled +- [ ] File uploads validated +- [ ] PII minimized +- [ ] Retention policy defined +- [ ] Backup encryption +- [ ] Access logging enabled + +### Authentication & Authorization +- [ ] 2FA for TP users +- [ ] Token expiration +- [ ] Secure token generation +- [ ] Session management +- [ ] Role-based access control +- [ ] Institution isolation + +### Monitoring & Logging +- [ ] Audit logs implemented +- [ ] Failed login tracking +- [ ] Suspicious activity alerts +- [ ] Incident response plan +- [ ] Log retention policy +- [ ] Security metrics tracked + +### Compliance +- [ ] POPIA compliance +- [ ] OWASP Top 10 addressed +- [ ] Security testing performed +- [ ] Documentation complete +- [ ] Incident response ready + +--- + +## 🔐 Environment Security + +### Development +```bash +# .env.example (never commit .env) +DATABASE_URL=postgresql://localhost/flo_doc +SECRET_KEY_BASE=dev_secret +JWT_SECRET_KEY=dev_jwt_secret +ENCRYPTION_PRIMARY_KEY=dev_encryption_key +``` + +### Production +```bash +# Use environment variables or secrets manager +DATABASE_URL=postgresql://... +SECRET_KEY_BASE=$(rails secret) +JWT_SECRET_KEY=$(openssl rand -base64 32) +ENCRYPTION_PRIMARY_KEY=$(openssl rand -base64 32) +``` + +**Never commit secrets**: +```gitignore +# .gitignore +.env +.env.production +config/secrets.yml +config/master.key +``` + +--- + +## 📚 Security Resources + +- **OWASP Top 10**: https://owasp.org/Top10/ +- **POPIA Guide**: https://www.justice.gov.za/legislation/acts/2013-013.pdf +- **Rails Security**: https://guides.rubyonrails.org/security.html +- **Vue.js Security**: https://vuejs.org/guide/best-practices/security.html + +--- + +**Document Status**: ✅ Complete +**Last Security Review**: 2026-01-14 +**Next Review**: After Phase 1 Implementation \ No newline at end of file diff --git a/docs/architecture/source-tree.md b/docs/architecture/source-tree.md deleted file mode 100644 index 8e17077c..00000000 --- a/docs/architecture/source-tree.md +++ /dev/null @@ -1,184 +0,0 @@ -# Source Tree - -## Existing Project Structure - -``` -floDoc-v3/ -├── app/ -│ ├── controllers/ -│ │ ├── api/ # RESTful API controllers -│ │ │ ├── api_base_controller.rb -│ │ │ ├── submissions_controller.rb -│ │ │ ├── templates_controller.rb -│ │ │ └── [15+ existing controllers] -│ │ ├── [30+ existing controllers] # Dashboard, settings, etc. -│ ├── models/ -│ │ ├── account.rb # Multi-tenancy root -│ │ ├── user.rb # Devise auth + 2FA -│ │ ├── template.rb # Document templates -│ │ ├── submission.rb # Document workflows -│ │ ├── submitter.rb # Signers/participants -│ │ ├── account_access.rb # User permissions -│ │ └── [15+ supporting models] -│ ├── jobs/ -│ │ ├── process_submitter_completion_job.rb -│ │ ├── send_submission_completed_webhook_request_job.rb -│ │ └── [15+ existing jobs] -│ ├── mailers/ -│ │ ├── application_mailer.rb -│ │ ├── submitter_mailer.rb -│ │ └── user_mailer.rb -│ ├── javascript/ -│ │ ├── application.js # Vue 3 entry point -│ │ ├── template_builder/ # PDF form builder (15+ Vue components) -│ │ ├── submission_form/ # Signing interface (10+ Vue components) -│ │ └── elements/ # Web Components (40+ custom elements) -│ └── views/ -│ ├── mailers/ # Email templates -│ └── shared/ # Common partials -├── lib/ -│ ├── submissions/ # Core business logic -│ │ ├── generate_result_attachments.rb -│ │ ├── generate_combined_attachment.rb -│ │ └── [10+ submission modules] -│ ├── submitters/ # Submitter logic -│ ├── templates/ # Template processing -│ ├── pdf_utils.rb # HexaPDF wrapper -│ ├── docuseal.rb # Global config -│ ├── ability.rb # CanCanCan rules -│ └── send_webhook_request.rb # Webhook delivery -├── config/ -│ ├── routes.rb # All routes (200+ lines) -│ ├── database.yml # DB config -│ ├── storage.yml # Active Storage config -│ ├── sidekiq.yml # Background job config -│ └── shakapacker.yml # Webpack config -├── db/ -│ ├── migrate/ # Existing migrations -│ └── schema.rb # Current schema -└── docs/ - ├── prd.md # Product requirements - └── architecture.md # This document -``` - -## New File Organization - -``` -floDoc-v3/ -├── app/ -│ ├── controllers/ -│ │ ├── api/ -│ │ │ ├── v1/ -│ │ │ │ ├── cohorts_controller.rb # NEW: Cohort API endpoints -│ │ │ │ ├── enrollments_controller.rb # NEW: Enrollment API endpoints -│ │ │ │ └── sponsors_controller.rb # NEW: Sponsor API endpoints -│ │ ├── cohorts/ # NEW: Web controllers -│ │ │ ├── admin_controller.rb # Admin portal web endpoints -│ │ │ ├── student_controller.rb # Student portal web endpoints -│ │ │ └── sponsor_controller.rb # Sponsor portal web endpoints -│ ├── models/ -│ │ ├── cohort.rb # NEW: Cohort model -│ │ ├── cohort_enrollment.rb # NEW: Enrollment model -│ │ ├── institution.rb # NEW: Institution model -│ │ ├── sponsor.rb # NEW: Sponsor model -│ │ └── document_verification.rb # NEW: Verification model -│ ├── jobs/ -│ │ ├── cohort_reminder_job.rb # NEW: Cohort reminders -│ │ ├── cohort_completion_job.rb # NEW: Workflow completion -│ │ └── excel_export_job.rb # NEW: FR23 Excel export -│ ├── mailers/ -│ │ ├── cohort_mailer.rb # NEW: Cohort notifications -│ │ └── sponsor_mailer.rb # NEW: Sponsor notifications -│ ├── javascript/ -│ │ ├── cohorts/ # NEW: Cohort management -│ │ │ ├── admin/ # Admin portal Vue app -│ │ │ │ ├── AdminPortal.vue -│ │ │ │ ├── CohortDashboard.vue -│ │ │ │ ├── CohortWizard.vue -│ │ │ │ ├── VerificationInterface.vue -│ │ │ │ ├── SponsorCoordinator.vue -│ │ │ │ ├── AnalyticsView.vue -│ │ │ │ └── ExcelExport.vue -│ │ │ ├── student/ # Student portal Vue app -│ │ │ │ ├── StudentPortal.vue -│ │ │ │ ├── CohortWelcome.vue -│ │ │ │ ├── DocumentUpload.vue -│ │ │ │ ├── AgreementForm.vue -│ │ │ │ ├── StatusDashboard.vue -│ │ │ │ └── ResubmissionFlow.vue -│ │ │ └── sponsor/ # Sponsor portal Vue app -│ │ │ ├── SponsorPortal.vue -│ │ │ ├── SponsorDashboard.vue -│ │ │ ├── StudentReview.vue -│ │ │ ├── BulkSigning.vue -│ │ │ └── CohortFinalization.vue -│ │ └── shared/ # NEW: Shared portal components -│ │ ├── PortalNavigation.vue -│ │ ├── RoleSwitcher.vue -│ │ └── PortalNotifications.vue -│ └── views/ -│ ├── cohorts/ -│ │ ├── admin/ -│ │ │ ├── index.html.erb -│ │ │ └── show.html.erb -│ │ ├── student/ -│ │ │ ├── index.html.erb -│ │ │ └── show.html.erb -│ │ └── sponsor/ -│ │ ├── index.html.erb -│ │ └── show.html.erb -│ └── mailers/ -│ ├── cohort_mailer/ -│ │ ├── cohort_created.html.erb -│ │ ├── student_invite.html.erb -│ │ └── sponsor_access.html.erb -│ └── sponsor_mailer/ -│ └── cohort_ready.html.erb -├── lib/ -│ ├── cohorts/ # NEW: Cohort business logic -│ │ ├── cohort_workflow_service.rb -│ │ ├── enrollment_service.rb -│ │ ├── verification_service.rb -│ │ ├── sponsor_service.rb -│ │ ├── cohort_state_engine.rb -│ │ ├── enrollment_validator.rb -│ │ ├── sponsor_access_manager.rb -│ │ └── excel_export_service.rb -│ └── templates/ -│ └── cohort_template_processor.rb # NEW: Cohort template extensions -├── db/ -│ ├── migrate/ -│ │ ├── 20250102000001_create_institutions.rb -│ │ ├── 20250102000002_create_cohorts.rb -│ │ ├── 20250102000003_create_cohort_enrollments.rb -│ │ ├── 20250102000004_create_sponsors.rb -│ │ └── 20250102000005_create_document_verifications.rb -│ └── schema.rb # UPDATED: New tables added -├── config/ -│ └── routes.rb # UPDATED: New cohort routes -└── docs/ - ├── architecture.md # This document - └── cohort-workflows.md # NEW: Workflow documentation -``` - -## Integration Guidelines - -**File Naming:** -- **Models:** `cohort.rb`, `cohort_enrollment.rb` (snake_case, singular) -- **Controllers:** `cohorts_controller.rb`, `admin_controller.rb` (plural for resources) -- **Vue Components:** `CohortDashboard.vue`, `StudentPortal.vue` (PascalCase) -- **Services:** `cohort_workflow_service.rb` (snake_case, descriptive) -- **Jobs:** `cohort_reminder_job.rb` (snake_case, _job suffix) - -**Folder Organization:** -- **API Controllers:** `app/controllers/api/v1/cohorts/` (versioned, resource-based) -- **Web Controllers:** `app/controllers/cohorts/` (portal-specific) -- **Vue Apps:** `app/javascript/cohorts/{admin,student,sponsor}/` (portal separation) -- **Services:** `lib/cohorts/` (business logic separation) - -**Import/Export Patterns:** -- **Ruby:** Follow existing patterns (service objects, concerns, modules) -- **Vue:** Use ES6 imports, Composition API, existing API client patterns -- **API:** Consistent JSON response format matching existing endpoints - ---- diff --git a/docs/architecture/table-of-contents.md b/docs/architecture/table-of-contents.md deleted file mode 100644 index 1e94d964..00000000 --- a/docs/architecture/table-of-contents.md +++ /dev/null @@ -1,16 +0,0 @@ -# Table of Contents -1. [Introduction](#introduction) -2. [Enhancement Scope and Integration Strategy](#enhancement-scope-and-integration-strategy) -3. [Tech Stack](#tech-stack) -4. [Data Models and Schema Changes](#data-models-and-schema-changes) -5. [Component Architecture](#component-architecture) -6. [API Design and Integration](#api-design-and-integration) -7. [Source Tree](#source-tree) -8. [Infrastructure and Deployment Integration](#infrastructure-and-deployment-integration) -9. [Coding Standards](#coding-standards) -10. [Testing Strategy](#testing-strategy) -11. [Security Integration](#security-integration) -12. [Checklist Results Report](#checklist-results-report) -13. [Next Steps](#next-steps) - ---- diff --git a/docs/architecture/tech-stack.md b/docs/architecture/tech-stack.md index f1be2ebc..9860f1c0 100644 --- a/docs/architecture/tech-stack.md +++ b/docs/architecture/tech-stack.md @@ -1,30 +1,348 @@ -# Tech Stack +# Tech Stack - FloDoc Architecture -## Existing Technology Stack +**Document**: Tech Stack Specification +**Version**: 1.0 +**Last Updated**: 2026-01-14 -| Category | Current Technology | Version | Usage in Enhancement | Notes | -|----------|-------------------|---------|---------------------|--------| -| **Backend Language** | Ruby | 3.4.2 | ✅ Core backend logic | Existing version maintained | -| **Web Framework** | Rails | 7.x | ✅ Controllers, Models, Views | Existing patterns followed | -| **Frontend Framework** | Vue.js | 3.3.2 | ✅ All three portals | Composition API for new components | -| **CSS Framework** | TailwindCSS | 3.4.17 | ✅ Custom portal styling | Replacing DaisyUI for portals | -| **UI Components** | DaisyUI | 3.9.4 | ⚠️ Legacy DocuSeal UI only | Not used in new portals | -| **Build Tool** | Shakapacker | 8.0 | ✅ Asset compilation | Existing configuration maintained | -| **Database** | PostgreSQL/MySQL/SQLite | Latest | ✅ New cohort tables | DATABASE_URL configuration | -| **Background Jobs** | Sidekiq | Latest | ✅ Email notifications, reminders | Existing queue system | -| **PDF Processing** | HexaPDF | Latest | ✅ Document generation/signing | Core DocuSeal capability | -| **PDF Rendering** | PDFium | Latest | ✅ Document preview | Existing rendering engine | -| **Authentication** | Devise | Latest | ✅ User auth + 2FA | Extended for new roles | -| **Authorization** | Cancancan | Latest | ✅ Role-based access | Extended for cohort permissions | -| **Storage** | Active Storage | Latest | ✅ Document storage | Existing multi-backend support | -| **Job Queue** | Redis | Latest | ✅ Sidekiq backend | Required dependency | -| **API Auth** | JWT | Latest | ✅ API token authentication | Existing mechanism | -| **Email** | SMTP | Latest | ✅ Notifications | Existing infrastructure | +--- + +## 🎯 Technology Overview + +FloDoc is a brownfield Rails 7 application enhanced with Vue.js 3 frontend. The stack is chosen for stability, developer productivity, and seamless integration with existing DocuSeal functionality. + +--- + +## 🔧 Backend Stack + +### Ruby on Rails +- **Version**: 7.x +- **Purpose**: Core application framework +- **Key Features**: + - MVC architecture + - Active Record ORM + - Action Mailer for emails + - Active Job for background processing + - Built-in security features + +### Database +- **Primary**: PostgreSQL 14+ +- **Alternative**: MySQL 8+ or SQLite (for development) +- **Configuration**: `DATABASE_URL` environment variable +- **Key Tables**: + - `institutions` - Single training institution + - `cohorts` - Training program cohorts + - `cohort_enrollments` - Student enrollments + - `templates` (existing) - Document templates + - `submissions` (existing) - Document workflows + - `submitters` (existing) - Signers/participants + +### Background Jobs +- **Framework**: Sidekiq +- **Backend**: Redis +- **Queues**: + - `default` - General tasks + - `mailers` - Email delivery + - `webhooks` - Webhook delivery + - `pdf` - PDF generation +- **Configuration**: `REDIS_URL` environment variable + +### Authentication +- **Gem**: Devise 4.x +- **Modules**: + - `database_authenticatable` - Password auth + - `registerable` - User registration + - `recoverable` - Password reset + - `rememberable` - Remember me + - `validatable` - Validations + - `omniauthable` - OAuth support + - `two_factor_authenticatable` - 2FA +- **API Auth**: JWT tokens (custom implementation) -## New Technology Additions +### Authorization +- **Gem**: Cancancan 3.x +- **Ability Class**: `app/models/ability.rb` +- **Roles**: TP (admin), Student, Sponsor +- **Access Control**: Role-based via `AccountAccess` model -**No new technologies required.** The enhancement leverages existing DocuSeal technology stack entirely. All new functionality will be implemented using current frameworks and libraries. +### PDF Processing +- **Generation**: HexaPDF 0.15+ + - PDF creation from templates + - Form field rendering + - Digital signatures + - Signature verification +- **Rendering**: PDFium + - PDF preview + - Document manipulation + - Multi-page handling -**Rationale:** Brownfield enhancement should minimize technology changes to reduce risk and maintain compatibility. The existing stack provides all necessary capabilities for the 3-portal cohort management system. +### Email Delivery +- **SMTP**: Standard Rails Action Mailer +- **Templates**: ERB in `app/views/mailers/` +- **Async**: Sidekiq `mailers` queue +- **Tracking**: `email_events` table + +### Webhooks +- **Delivery**: Custom `WebhookDeliveryJob` +- **Events**: submission.created, submission.completed, etc. +- **Retry**: Exponential backoff +- **Tracking**: `webhook_events` table + +--- + +## 🎨 Frontend Stack + +### Vue.js +- **Version**: 3.x with Composition API +- **Build Tool**: Shakapacker 8.x (Webpack wrapper) +- **Entry Point**: `app/javascript/application.js` +- **Key Libraries**: + - Vue Router (if needed for SPA sections) + - Pinia for state management + - Axios for HTTP requests + +### State Management +- **Framework**: Pinia 2.x +- **Stores**: + - `cohortStore` - Cohort management state + - `submissionStore` - Submission workflow state + - `authStore` - Authentication state + - `uiStore` - UI state (modals, notifications) + +### Styling +- **Framework**: TailwindCSS 3.4.17 +- **Components**: DaisyUI 3.9.4 +- **Customization**: `tailwind.config.js` +- **Design System**: Custom FloDoc branding (not DaisyUI defaults) + +### Build & Development +- **Tool**: Shakapacker 8.x +- **Node**: 18+ recommended +- **Yarn**: Package management +- **Hot Reload**: Via Shakapacker dev server + +### API Integration +- **HTTP Client**: Axios or Fetch API +- **Base URL**: `/api/v1/` +- **Auth**: Bearer tokens in headers +- **Response Format**: JSON --- + +## 🔒 Security Stack + +### Authentication +- **Web Sessions**: Devise + Rails session store +- **API Access**: JWT tokens +- **Ad-hoc Links**: Short-lived tokens with email verification +- **2FA**: Devise-two-factor for TP users + +### Authorization +- **Backend**: Cancancan abilities +- **Frontend**: Route guards + UI visibility checks +- **API**: Token-based scope validation + +### Data Protection +- **Encryption at Rest**: + - Sensitive fields (emails) encrypted if policy requires + - Database-level encryption available +- **Input Validation**: Rails strong parameters + model validations +- **XSS Prevention**: Vue template auto-escaping +- **SQL Injection**: ActiveRecord parameterized queries + +### Web Security +- **CSRF**: Rails built-in protection +- **CORS**: Configured for API endpoints +- **HTTPS**: Enforced in production +- **Security Headers**: Via Rails default + custom + +--- + +## 🧪 Testing Stack + +### Ruby Tests +- **Framework**: RSpec 3.x +- **Coverage**: SimpleCov +- **Types**: + - Model specs: `spec/models/` + - Request specs: `spec/requests/` + - System specs: `spec/system/` + - Migration specs: `spec/migrations/` + - Job specs: `spec/jobs/` + +### JavaScript/Vue Tests +- **Framework**: Vue Test Utils + Vitest/Jest +- **Coverage**: Component unit tests +- **Location**: `spec/javascript/` + +### E2E Tests +- **Framework**: Playwright or Cypress +- **Scope**: Critical user journeys +- **Scenarios**: 3-portal workflow + +--- + +## 🐳 Infrastructure (Local Docker MVP) + +### Docker Compose +- **Services**: + - `app` - Rails application + - `db` - PostgreSQL + - `redis` - Sidekiq backend + - `minio` - S3-compatible storage + - `mailhog` - Email testing +- **Configuration**: `docker-compose.yml` +- **Volumes**: Persistent data storage + +### Storage +- **Backend**: Active Storage +- **Local**: Minio (S3-compatible) +- **Configuration**: `config/storage.yml` +- **Environment**: `AWS_*` variables for Minio + +### Development Tools +- **Linting**: RuboCop (Ruby), ESLint (JS) +- **Formatting**: StandardRB, Prettier +- **Debugging**: Byebug, Pry +- **Console**: Rails console + +--- + +## 📦 Dependencies Summary + +### Gemfile (Backend) +```ruby +# Core +gem 'rails', '~> 7.0' +gem 'pg', '~> 1.4' # or 'mysql2', 'sqlite3' + +# Authentication & Authorization +gem 'devise', '~> 4.8' +gem 'devise-two-factor' +gem 'cancancan', '~> 3.0' +gem 'jwt' + +# Background Jobs +gem 'sidekiq', '~> 7.0' +gem 'redis', '~> 5.0' + +# PDF Processing +gem 'hexapdf', '~> 0.15' +# PDFium via system library + +# API +gem 'jbuilder', '~> 2.11' + +# Security +gem 'rack-attack' + +# File Uploads +gem 'activestorage' +``` + +### package.json (Frontend) +```json +{ + "dependencies": { + "vue": "^3.3.0", + "pinia": "^2.1.0", + "axios": "^1.6.0", + "tailwindcss": "^3.4.17", + "daisyui": "^3.9.4" + } +} +``` + +--- + +## 🔌 Environment Variables + +### Required +```bash +# Database +DATABASE_URL=postgresql://user:pass@localhost:5432/flo_doc + +# Secrets +SECRET_KEY_BASE=your_rails_secret + +# Redis (Sidekiq) +REDIS_URL=redis://localhost:6379 + +# Storage (Minio) +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin +AWS_REGION=us-east-1 +AWS_ENDPOINT_URL=http://localhost:9000 +AWS_BUCKET_NAME=flo-doc + +# Email (Development) +SMTP_ADDRESS=localhost +SMTP_PORT=1025 # MailHog +``` + +### Optional +```bash +# Feature Flags +FLODOC_MULTITENANT=false +FLODOC_PRO=false + +# Webhooks +WEBHOOK_SECRET=your_webhook_secret + +# Security +ENCRYPT_EMAILS=false +``` + +--- + +## 🎯 Technology Justifications + +### Why Rails 7? +- **Brownfield**: DocuSeal is already Rails +- **Convention**: Rapid development with established patterns +- **Security**: Built-in protections +- **Ecosystem**: Rich gem ecosystem + +### Why Vue 3 + Pinia? +- **Composition API**: Better TypeScript support +- **Performance**: Virtual DOM optimization +- **Ecosystem**: Strong community support +- **Integration**: Works well with Rails via Shakapacker + +### Why PostgreSQL? +- **JSONB**: Perfect for flexible metadata (cohorts, uploads) +- **Reliability**: Production-ready +- **Performance**: Excellent for relational data +- **Extensions**: Full-text search if needed + +### Why Docker Compose? +- **Consistency**: Same environment for all developers +- **Simplicity**: Single command setup +- **Isolation**: Services don't conflict +- **MVP**: No production infrastructure needed + +--- + +## 📊 Performance Targets + +| Metric | Baseline (DocuSeal) | FloDoc Target | Max Degradation | +|--------|---------------------|---------------|-----------------| +| Page Load | 1.0s | 1.2s | +20% | +| PDF Generation | 2.0s | 2.4s | +20% | +| DB Query (complex) | 100ms | 120ms | +20% | +| Sidekiq Job | 500ms | 600ms | +20% | + +**NFR1**: All performance metrics must stay within 20% of baseline + +--- + +## 🚀 Next Steps + +1. **Setup Local Environment** → Follow `docs/architecture/infrastructure.md` +2. **Review Data Models** → Study `docs/architecture/data-models.md` +3. **Read Coding Standards** → Follow `docs/architecture/coding-standards.md` +4. **Start Story 1.1** → Database schema extension + +--- + +**Document Status**: ✅ Complete +**Review Date**: After Phase 1 Implementation \ No newline at end of file diff --git a/docs/architecture/testing-strategy.md b/docs/architecture/testing-strategy.md index 038c8407..341df59f 100644 --- a/docs/architecture/testing-strategy.md +++ b/docs/architecture/testing-strategy.md @@ -1,59 +1,1355 @@ -# Testing Strategy +# Testing Strategy - FloDoc Architecture -## Integration with Existing Tests +**Document**: Comprehensive Testing Approach +**Version**: 1.0 +**Last Updated**: 2026-01-14 -**Existing Test Framework:** RSpec with FactoryBot, System specs with Capybara -**Test Organization:** `spec/models/`, `spec/requests/`, `spec/system/` -**Coverage Requirements:** Maintain existing coverage levels (aim for 80%+ on new code) +--- + +## 🎯 Testing Philosophy + +**Quality Gates**: Every story must pass all tests before deployment +**Test Pyramid**: Unit > Integration > E2E +**Coverage Target**: 80% minimum, 90% for critical paths +**CI/CD**: All tests run on every commit + +--- + +## 📊 Test Pyramid + +``` + ┌─────────────┐ + │ E2E │ 5-10% (Critical Paths Only) + │ System │ + └─────────────┘ + ▲ + ┌─────────────┐ + │ Integration │ 20-30% + │ Request │ + │ Component │ + └─────────────┘ + ▲ + ┌─────────────┐ + │ Unit │ 60-70% + │ Model │ + │ Component │ + │ Store/API │ + └─────────────┘ +``` + +--- + +## 🧪 Ruby/Rails Testing (RSpec) + +### 1. Model Tests (Unit) + +**Location**: `spec/models/` + +**Coverage**: +- Validations +- Associations +- Scopes +- Callbacks +- Instance methods +- Class methods + +**Example**: +```ruby +# spec/models/cohort_spec.rb +require 'rails_helper' + +RSpec.describe Cohort, type: :model do + describe 'validations' do + it { should validate_presence_of(:name) } + it { should validate_presence_of(:program_type) } + it { should validate_inclusion_of(:status).in_array(%w[draft active completed]) } + + it 'validates sponsor email format' do + cohort = build(:cohort, sponsor_email: 'invalid') + expect(cohort).not_to be_valid + expect(cohort.errors[:sponsor_email]).to include('must be a valid email') + end + end + + describe 'associations' do + it { should belong_to(:institution) } + it { should belong_to(:template) } + it { should have_many(:cohort_enrollments).dependent(:destroy) } + end + + describe 'scopes' do + let!(:active_cohort) { create(:cohort, status: 'active') } + let!(:draft_cohort) { create(:cohort, status: 'draft') } + + it '.active returns only active cohorts' do + expect(Cohort.active).to include(active_cohort) + expect(Cohort.active).not_to include(draft_cohort) + end + end + + describe '#ready_for_sponsor?' do + it 'returns true when all conditions met' do + cohort = create(:cohort, + tp_signed_at: Time.current, + students_completed_at: Time.current, + status: 'active' + ) + create(:cohort_enrollment, cohort: cohort, role: 'student') + + expect(cohort.ready_for_sponsor?).to be true + end + + it 'returns false when students not completed' do + cohort = create(:cohort, tp_signed_at: Time.current) + expect(cohort.ready_for_sponsor?).to be false + end + end + + describe 'callbacks' do + it 'sends activation email when status changes to active' do + cohort = create(:cohort, status: 'draft') + expect(CohortMailer).to receive(:activated).with(cohort).and_call_original + + cohort.update!(status: 'active') + end + end +end +``` + +**Run**: +```bash +bundle exec rspec spec/models/cohort_spec.rb +``` + +--- + +### 2. Controller Tests (Integration) + +**Location**: `spec/controllers/` + +**Coverage**: +- Authentication +- Authorization +- Request handling +- Response codes +- Redirects +- Flash messages + +**Example**: +```ruby +# spec/controllers/tp/cohorts_controller_spec.rb +require 'rails_helper' + +RSpec.describe tp::CohortsController, type: :controller do + let(:user) { create(:user, :tp_admin) } + let(:institution) { user.institution } + + before do + sign_in user + end + + describe 'GET #index' do + let!(:cohort) { create(:cohort, institution: institution) } + + it 'returns http success' do + get :index + expect(response).to have_http_status(:ok) + end + + it 'assigns cohorts' do + get :index + expect(assigns(:cohorts)).to include(cohort) + end + + it 'renders index template' do + get :index + expect(response).to render_template(:index) + end + end + + describe 'POST #create' do + let(:template) { create(:template, account: user.account) } + + context 'with valid params' do + let(:valid_params) do + { + name: 'New Cohort', + program_type: 'learnership', + sponsor_email: 'sponsor@example.com', + template_id: template.id + } + end + + it 'creates a cohort' do + expect { + post :create, params: { cohort: valid_params } + }.to change(Cohort, :count).by(1) + end + + it 'redirects to cohort show' do + post :create, params: { cohort: valid_params } + expect(response).to redirect_to(tp_cohort_path(assigns(:cohort))) + end + + it 'sets correct institution' do + post :create, params: { cohort: valid_params } + expect(assigns(:cohort).institution).to eq(institution) + end + end + + context 'with invalid params' do + it 'renders new template' do + post :create, params: { cohort: { name: '' } } + expect(response).to render_template(:new) + end + + it 'does not create cohort' do + expect { + post :create, params: { cohort: { name: '' } } + }.not_to change(Cohort, :count) + end + end + + context 'unauthorized user' do + before do + sign_out user + sign_in create(:user, :regular_user) + end + + it 'denies access' do + post :create, params: { cohort: valid_params } + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('not authorized') + end + end + end + + describe 'DELETE #destroy' do + let!(:cohort) { create(:cohort, institution: institution) } + + it 'deletes the cohort' do + expect { + delete :destroy, params: { id: cohort.id } + }.to change(Cohort, :count).by(-1) + end + + it 'redirects to index' do + delete :destroy, params: { id: cohort.id } + expect(response).to redirect_to(tp_cohorts_path) + end + end +end +``` + +**Run**: +```bash +bundle exec rspec spec/controllers/tp/cohorts_controller_spec.rb +``` + +--- + +### 3. Request/API Tests + +**Location**: `spec/requests/api/v1/` + +**Coverage**: +- Authentication +- Request/response format +- Status codes +- Error handling +- Rate limiting + +**Example**: +```ruby +# spec/requests/api/v1/cohorts_spec.rb +require 'rails_helper' + +RSpec.describe 'API v1 Cohorts', type: :request do + let(:user) { create(:user, :tp_admin) } + let(:headers) { { 'Authorization' => "Bearer #{user.generate_jwt}" } } + + describe 'GET /api/v1/cohorts' do + let!(:cohort) { create(:cohort, institution: user.institution) } + let!(:other_cohort) { create(:cohort) } # Different institution + + it 'returns cohorts for current institution only' do + get '/api/v1/cohorts', headers: headers + + expect(response).to have_http_status(:ok) + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(cohort.id) + expect(json_response.map { |c| c['id'] }).not_to include(other_cohort.id) + end + + it 'returns correct JSON structure' do + get '/api/v1/cohorts', headers: headers + + cohort_data = json_response.first + expect(cohort_data).to include('id', 'name', 'status', 'program_type') + expect(cohort_data['name']).to eq(cohort.name) + end + + context 'with status filter' do + let!(:active_cohort) { create(:cohort, institution: user.institution, status: 'active') } + + it 'filters by status' do + get '/api/v1/cohorts?status=active', headers: headers + + expect(json_response.size).to eq(1) + expect(json_response.first['status']).to eq('active') + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get '/api/v1/cohorts' + + expect(response).to have_http_status(:unauthorized) + expect(json_response['error']).to eq('Unauthorized') + end + end + end + + describe 'POST /api/v1/cohorts' do + let(:template) { create(:template, account: user.account) } + + context 'valid request' do + let(:params) do + { + name: 'API Cohort', + program_type: 'internship', + sponsor_email: 'api@example.com', + template_id: template.id + } + end + + it 'creates a cohort' do + expect { + post '/api/v1/cohorts', headers: headers, params: params + }.to change(Cohort, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json_response['name']).to eq('API Cohort') + end + end + + context 'invalid request' do + it 'returns validation errors' do + post '/api/v1/cohorts', headers: headers, params: { name: '' } + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response['errors']).to be_present + end + end + + context 'rate limiting' do + it 'throttles excessive requests' do + 101.times do + post '/api/v1/cohorts', headers: headers, params: valid_params + end + + expect(response).to have_http_status(:too_many_requests) + end + end + end + + describe 'POST /api/v1/cohorts/:id/start_signing' do + let(:cohort) { create(:cohort, institution: user.institution, status: 'draft') } + + it 'transitions cohort to active' do + post "/api/v1/cohorts/#{cohort.id}/start_signing", headers: headers + + expect(response).to have_http_status(:ok) + expect(json_response['status']).to eq('active') + expect(json_response['tp_signed_at']).not_to be_nil + end + + it 'sends activation email' do + expect { + post "/api/v1/cohorts/#{cohort.id}/start_signing", headers: headers + }.to have_enqueued_mail(CohortMailer, :activated) + end + end + + def json_response + JSON.parse(response.body) + end + + def valid_params + { + name: 'Test Cohort', + program_type: 'learnership', + sponsor_email: 'test@example.com', + template_id: template.id + } + end +end +``` + +**Run**: +```bash +bundle exec rspec spec/requests/api/v1/cohorts_spec.rb +``` + +--- + +### 4. System/Feature Tests + +**Location**: `spec/system/` + +**Coverage**: +- User workflows +- Browser interactions +- JavaScript functionality +- Multi-step processes + +**Example**: +```ruby +# spec/system/tp_cohort_workflow_spec.rb +require 'rails_helper' + +RSpec.describe 'TP Cohort Workflow', type: :system do + let(:user) { create(:user, :tp_admin) } + let(:template) { create(:template, account: user.account) } + + before do + sign_in user + visit tp_root_path + end + + scenario 'TP admin creates and activates a cohort' do + # Navigate to cohorts + click_link 'Cohorts' + expect(page).to have_current_path(tp_cohorts_path) + + # Create cohort + click_link 'New Cohort' + expect(page).to have_current_path(new_tp_cohort_path) + + fill_in 'Name', with: '2026 Q1 Learnership' + select 'Learnership', from: 'Program Type' + fill_in 'Sponsor Email', with: 'sponsor@example.com' + select template.name, from: 'Template' + + click_button 'Create Cohort' + + # Verify creation + expect(page).to have_content('Cohort created') + expect(page).to have_content('2026 Q1 Learnership') + expect(page).to have_content('draft') + + # Activate cohort + click_button 'Start Signing Phase' + expect(page).to have_content('Cohort is now active') + expect(page).to have_content('active') + + cohort = Cohort.last + expect(cohort.status).to eq('active') + expect(cohort.tp_signed_at).not_to be_nil + end + + scenario 'Bulk student enrollment' do + cohort = create(:cohort, institution: user.institution) + + visit tp_cohort_path(cohort) + click_link 'Manage Students' + + # Add multiple students + fill_in 'Email', with: 'student1@example.com' + fill_in 'Name', with: 'John' + fill_in 'Surname', with: 'Doe' + click_button 'Add Student' + + expect(page).to have_content('student1@example.com') + + # Add second student + fill_in 'Email', with: 'student2@example.com' + fill_in 'Name', with: 'Jane' + fill_in 'Surname', with: 'Smith' + click_button 'Add Student' + + expect(page).to have_content('student2@example.com') + expect(cohort.cohort_enrollments.count).to eq(2) + end + + scenario 'Complete end-to-end workflow' do + # Create cohort + cohort = create(:cohort, institution: user.institution) + create_list(:cohort_enrollment, 3, cohort: cohort, status: 'complete') + + visit tp_cohort_path(cohort) + + # Verify all students completed + expect(page).to have_content('Completed: 3') + + # Start signing phase + click_button 'Start Signing Phase' + expect(page).to have_content('Signing phase started') + + # Finalize + click_button 'Finalize Cohort' + expect(page).to have_content('Cohort finalized') + + cohort.reload + expect(cohort.status).to eq('completed') + expect(cohort.finalized_at).not_to be_nil + end +end +``` + +**Run**: +```bash +bundle exec rspec spec/system/tp_cohort_workflow_spec.rb +``` + +--- + +## 🎨 Vue.js Testing + +### 1. Component Unit Tests + +**Location**: `spec/javascript/tp/components/` + +**Framework**: Vue Test Utils + Vitest + +**Coverage**: +- Props validation +- Event emission +- Conditional rendering +- User interactions +- Computed properties + +**Example**: +```javascript +// spec/javascript/tp/components/CohortCard.spec.js +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import CohortCard from '@/tp/components/CohortCard.vue' + +describe('CohortCard', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + const createWrapper = (props = {}) => { + return mount(CohortCard, { + props: { + cohort: { + id: 1, + name: 'Test Cohort', + status: 'active', + student_count: 15, + completed_count: 10, + ...props + }, + ...props + } + }) + } + + it('renders cohort information', () => { + const wrapper = createWrapper() + expect(wrapper.text()).toContain('Test Cohort') + expect(wrapper.text()).toContain('15 students') + expect(wrapper.text()).toContain('10 completed') + }) + + it('displays correct status badge', () => { + const activeWrapper = createWrapper({ status: 'active' }) + expect(activeWrapper.find('.badge').classes()).toContain('bg-green-100') + + const draftWrapper = createWrapper({ status: 'draft' }) + expect(draftWrapper.find('.badge').classes()).toContain('bg-gray-100') + }) + + it('emits select event on click', async () => { + const wrapper = createWrapper() + await wrapper.trigger('click') + + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')[0]).toEqual([1]) + }) + + it('shows progress bar', () => { + const wrapper = createWrapper() + const progress = wrapper.find('.progress-bar') + expect(progress.exists()).toBe(true) + expect(progress.text()).toContain('66%') + }) + + it('handles missing data gracefully', () => { + const wrapper = mount(CohortCard, { + props: { cohort: null } + }) + expect(wrapper.text()).toContain('No cohort data') + }) +}) +``` + +**Run**: +```bash +yarn test spec/javascript/tp/components/CohortCard.spec.js +``` + +--- + +### 2. Store Tests (Pinia) + +**Location**: `spec/javascript/tp/stores/` + +**Coverage**: +- State management +- Actions (async operations) +- Getters (computed state) +- Error handling + +**Example**: +```javascript +// spec/javascript/tp/stores/cohortStore.spec.js +import { createPinia, setActivePinia } from 'pinia' +import { useCohortStore } from '@/tp/stores/cohortStore' +import { CohortsAPI } from '@/tp/api/cohorts' + +// Mock API +vi.mock('@/tp/api/cohorts') + +describe('CohortStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('actions', () => { + describe('fetchCohorts', () => { + it('loads cohorts successfully', async () => { + const mockCohorts = [ + { id: 1, name: 'Cohort 1', status: 'active' }, + { id: 2, name: 'Cohort 2', status: 'draft' } + ] + + CohortsAPI.getAll.mockResolvedValue(mockCohorts) + + const store = useCohortStore() + await store.fetchCohorts() + + expect(store.cohorts).toEqual(mockCohorts) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('handles API errors', async () => { + CohortsAPI.getAll.mockRejectedValue(new Error('Network error')) + + const store = useCohortStore() + await store.fetchCohorts() + + expect(store.error).toBe('Network error') + expect(store.loading).toBe(false) + expect(store.cohorts).toEqual([]) + }) + + it('sets loading state', async () => { + let resolvePromise + const promise = new Promise(resolve => { + resolvePromise = resolve + }) + + CohortsAPI.getAll.mockReturnValue(promise) + + const store = useCohortStore() + const fetchPromise = store.fetchCohorts() + + expect(store.loading).toBe(true) + + resolvePromise([{ id: 1, name: 'Test' }]) + await fetchPromise + + expect(store.loading).toBe(false) + }) + }) + + describe('createCohort', () => { + it('adds cohort to list', async () => { + const newCohort = { id: 3, name: 'New Cohort' } + CohortsAPI.create.mockResolvedValue(newCohort) + + const store = useCohortStore() + store.cohorts = [{ id: 1, name: 'Existing' }] + + const result = await store.createCohort({ name: 'New Cohort' }) + + expect(result).toEqual(newCohort) + expect(store.cohorts).toHaveLength(2) + expect(store.cohorts[0].id).toBe(3) // Added to beginning + }) -## New Testing Requirements + it('handles validation errors', async () => { + const error = { response: { data: { errors: { name: ["can't be blank"] } } } } + CohortsAPI.create.mockRejectedValue(error) -### **Unit Tests for New Components** + const store = useCohortStore() -**Framework:** RSpec + FactoryBot -**Location:** `spec/models/`, `spec/services/cohorts/`, `spec/lib/cohorts/` -**Coverage Target:** 80% minimum on new models and services -**Integration with Existing:** Use existing test helpers, match existing patterns + await expect( + store.createCohort({ name: '' }) + ).rejects.toThrow() -**Test Files:** -- `spec/models/cohort_spec.rb` -- `spec/models/cohort_enrollment_spec.rb` -- `spec/services/cohorts/cohort_workflow_service_spec.rb` -- `spec/lib/cohorts/state_engine_spec.rb` + expect(store.error).toBeDefined() + }) + }) -### **Integration Tests** + describe('startSigning', () => { + it('updates cohort status', async () => { + const updatedCohort = { id: 1, name: 'Test', status: 'active' } + CohortsAPI.startSigning.mockResolvedValue(updatedCohort) -**Scope:** End-to-end cohort workflow testing -**Existing System Verification:** Ensure no regression in existing DocuSeal features -**New Feature Testing:** Complete workflow from cohort creation to sponsor finalization + const store = useCohortStore() + store.cohorts = [{ id: 1, name: 'Test', status: 'draft' }] -**Test Scenarios:** -1. **Admin Flow:** Create cohort → Invite students → Verify documents → Finalize -2. **Student Flow:** Receive invite → Upload documents → Sign agreements → Track status -3. **Sponsor Flow:** Review cohort → Sign agreements → Bulk operations → Completion -4. **Integration:** Cohort features + existing DocuSeal features work together + await store.startSigning(1) -**Test Files:** -- `spec/requests/api/v1/cohorts_spec.rb` -- `spec/requests/api/v1/enrollments_spec.rb` -- `spec/system/cohort_workflows_spec.rb` -- `spec/system/portal_access_spec.rb` + expect(store.cohorts[0].status).toBe('active') + }) + }) + }) -### **Regression Testing** + describe('getters', () => { + it('filters active cohorts', () => { + const store = useCohortStore() + store.cohorts = [ + { id: 1, status: 'active' }, + { id: 2, status: 'draft' }, + { id: 3, status: 'active' } + ] -**Existing Feature Verification:** Run full existing test suite before merging -**Automated Regression Suite:** Include cohort tests in CI/CD pipeline -**Manual Testing Requirements:** -- ✅ Existing DocuSeal workflows (template creation, submission, signing) -- ✅ Authentication across all portals -- ✅ File upload and storage -- ✅ Email notifications -- ✅ Webhook delivery + expect(store.activeCohorts).toHaveLength(2) + expect(store.activeCohorts.every(c => c.status === 'active')).toBe(true) + }) -**Test Data Strategy:** -- Use existing factories extended for cohort scenarios -- Create realistic test cohorts (50+ students) -- Test with existing document types and templates -- Include edge cases (large cohorts, rejected documents, sponsor delays) + it('finds cohort by ID', () => { + const store = useCohortStore() + store.cohorts = [ + { id: 1, name: 'Cohort 1' }, + { id: 2, name: 'Cohort 2' } + ] + + const found = store.getCohortById(2) + expect(found).toEqual({ id: 2, name: 'Cohort 2' }) + }) + }) +}) +``` + +**Run**: +```bash +yarn test spec/javascript/tp/stores/cohortStore.spec.js +``` --- + +### 3. API Client Tests + +**Location**: `spec/javascript/tp/api/` + +**Coverage**: +- Request formatting +- Response handling +- Error handling +- Authentication headers + +**Example**: +```javascript +// spec/javascript/tp/api/cohorts.spec.js +import { CohortsAPI } from '@/tp/api/cohorts' +import axios from 'axios' + +// Mock axios +vi.mock('axios') + +describe('CohortsAPI', () => { + beforeEach(() => { + axios.create.mockReturnValue(axios) + }) + + describe('getAll', () => { + it('returns cohorts', async () => { + const mockResponse = { data: [{ id: 1, name: 'Test' }] } + axios.get.mockResolvedValue(mockResponse) + + const result = await CohortsAPI.getAll() + + expect(axios.get).toHaveBeenCalledWith('/api/v1/cohorts') + expect(result).toEqual([{ id: 1, name: 'Test' }]) + }) + + it('handles query parameters', async () => { + axios.get.mockResolvedValue({ data: [] }) + + await CohortsAPI.getAll({ status: 'active', page: 2 }) + + expect(axios.get).toHaveBeenCalledWith('/api/v1/cohorts', { + params: { status: 'active', page: 2 } + }) + }) + }) + + describe('create', () => { + it('posts data correctly', async () => { + const cohortData = { name: 'New Cohort', status: 'draft' } + const mockResponse = { data: { id: 1, ...cohortData } } + axios.post.mockResolvedValue(mockResponse) + + const result = await CohortsAPI.create(cohortData) + + expect(axios.post).toHaveBeenCalledWith('/api/v1/cohorts', cohortData) + expect(result).toEqual({ id: 1, ...cohortData }) + }) + }) + + describe('error handling', () => { + it('throws on 401', async () => { + axios.get.mockRejectedValue({ + response: { status: 401, data: { error: 'Unauthorized' } } + }) + + await expect(CohortsAPI.getAll()).rejects.toThrow() + }) + + it('throws on network error', async () => { + axios.get.mockRejectedValue(new Error('Network Error')) + + await expect(CohortsAPI.getAll()).rejects.toThrow('Network Error') + }) + }) +}) +``` + +--- + +### 4. View/Integration Tests + +**Location**: `spec/javascript/tp/views/` + +**Coverage**: +- Full component lifecycle +- Store integration +- API calls +- User flows + +**Example**: +```javascript +// spec/javascript/tp/views/CohortList.spec.js +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import CohortList from '@/tp/views/CohortList.vue' +import { useCohortStore } from '@/tp/stores/cohortStore' + +vi.mock('@/tp/stores/cohortStore') + +describe('CohortList', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('displays loading state', () => { + const wrapper = mount(CohortList) + expect(wrapper.text()).toContain('Loading') + }) + + it('displays cohorts after loading', async () => { + const mockCohorts = [ + { id: 1, name: 'Cohort 1', status: 'active' }, + { id: 2, name: 'Cohort 2', status: 'draft' } + ] + + const store = useCohortStore() + store.cohorts = mockCohorts + store.loading = false + + const wrapper = mount(CohortList) + await flushPromises() + + expect(wrapper.text()).toContain('Cohort 1') + expect(wrapper.text()).toContain('Cohort 2') + }) + + it('handles empty state', async () => { + const store = useCohortStore() + store.cohorts = [] + store.loading = false + + const wrapper = mount(CohortList) + await flushPromises() + + expect(wrapper.text()).toContain('No cohorts found') + }) + + it('handles errors', async () => { + const store = useCohortStore() + store.error = 'Failed to load cohorts' + store.loading = false + + const wrapper = mount(CohortList) + await flushPromises() + + expect(wrapper.text()).toContain('Error') + expect(wrapper.text()).toContain('Failed to load') + }) +}) +``` + +--- + +## 🔌 Integration Tests + +### 1. Request Flow Tests + +**Location**: `spec/integration/` + +**Coverage**: +- Full request/response cycle +- Database state changes +- Email delivery +- Background jobs + +**Example**: +```ruby +# spec/integration/cohort_workflow_spec.rb +require 'rails_helper' + +RSpec.describe 'Cohort Workflow Integration', type: :request do + let(:user) { create(:user, :tp_admin) } + let(:template) { create(:template, account: user.account) } + + it 'completes full cohort lifecycle' do + # 1. Create cohort + post '/api/v1/cohorts', + headers: { 'Authorization' => "Bearer #{user.generate_jwt}" }, + params: { + name: 'Full Workflow Test', + program_type: 'learnership', + sponsor_email: 'sponsor@example.com', + template_id: template.id + } + + expect(response).to have_http_status(:created) + cohort_id = json_response['id'] + + # 2. Add students + post "/api/v1/cohorts/#{cohort_id}/enrollments", + headers: { 'Authorization' => "Bearer #{user.generate_jwt}" }, + params: { + students: [ + { email: 'student1@example.com', name: 'John', surname: 'Doe' }, + { email: 'student2@example.com', name: 'Jane', surname: 'Smith' } + ] + } + + expect(response).to have_http_status(:created) + expect(json_response['created']).to eq(2) + + # 3. Student completes enrollment + enrollment = CohortEnrollment.find_by(student_email: 'student1@example.com') + patch "/api/v1/enrollments/#{enrollment.id}", + params: { + token: enrollment.token, + values: { full_name: 'John Doe' } + } + + expect(response).to have_http_status(:ok) + + # 4. Mark all as complete + CohortEnrollment.where(cohort_id: cohort_id).update_all(status: 'complete') + + # 5. Start signing phase + post "/api/v1/cohorts/#{cohort_id}/start_signing", + headers: { 'Authorization' => "Bearer #{user.generate_jwt}" } + + expect(response).to have_http_status(:ok) + expect(json_response['status']).to eq('active') + + # 6. Sponsor signs + cohort = Cohort.find(cohort_id) + post "/api/v1/sponsor/#{cohort.sponsor_token}/sign", + params: { signature: 'Sponsor Name', agree_to_terms: true } + + expect(response).to have_http_status(:ok) + expect(json_response['signed_count']).to eq(2) + + # 7. Finalize + post "/api/v1/cohorts/#{cohort_id}/finalize", + headers: { 'Authorization' => "Bearer #{user.generate_jwt}" } + + expect(response).to have_http_status(:ok) + expect(json_response['status']).to eq('completed') + + # 8. Verify final state + cohort.reload + expect(cohort.status).to eq('completed') + expect(cohort.finalized_at).not_to be_nil + end +end +``` + +--- + +## 🌐 End-to-End Tests + +**Location**: `spec/e2e/` or `spec/system/` + +**Framework**: Playwright or Cypress + +**Coverage**: +- Real browser automation +- Complete user journeys +- Cross-browser testing +- Visual regression (optional) + +**Example (Playwright)**: +```javascript +// spec/e2e/tp-cohort-workflow.spec.js +const { test, expect } = require('@playwright/test') + +test.describe('TP Cohort Workflow', () => { + test.beforeEach(async ({ page }) => { + // Login + await page.goto('http://localhost:3000/login') + await page.fill('input[name="email"]', 'admin@example.com') + await page.fill('input[name="password"]', 'password') + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/.*dashboard/) + }) + + test('complete cohort lifecycle', async ({ page }) => { + // Navigate to cohorts + await page.click('text=Cohorts') + await expect(page).toHaveURL(/.*cohorts/) + + // Create cohort + await page.click('text=New Cohort') + await page.fill('input[name="name"]', 'E2E Test Cohort') + await page.selectOption('select[name="program_type"]', 'learnership') + await page.fill('input[name="sponsor_email"]', 'sponsor@example.com') + await page.click('button[type="submit"]') + + // Verify creation + await expect(page.locator('text=E2E Test Cohort')).toBeVisible() + await expect(page.locator('text=draft')).toBeVisible() + + // Add students + await page.click('text=Manage Students') + await page.fill('input[name="email"]', 'student@example.com') + await page.fill('input[name="name"]', 'John') + await page.fill('input[name="surname"]', 'Doe') + await page.click('button:has-text("Add Student")') + + await expect(page.locator('text=student@example.com')).toBeVisible() + + // Activate cohort + await page.click('text=Start Signing Phase') + await expect(page.locator('text=active')).toBeVisible() + + // Verify in database + const cohort = await page.evaluate(() => { + return fetch('/api/v1/cohorts?status=active') + .then(r => r.json()) + .then(data => data.data.find(c => c.name === 'E2E Test Cohort')) + }) + + expect(cohort).toBeDefined() + expect(cohort.status).toBe('active') + }) +}) +``` + +--- + +## 🧪 Test Data Management + +### 1. Factories + +**Location**: `spec/factories/` + +**Example**: +```ruby +# spec/factories/cohorts.rb +FactoryBot.define do + factory :cohort do + association :institution + association :template + + name { "2026 Q1 Learnership" } + program_type { "learnership" } + sponsor_email { "sponsor@example.com" } + required_student_uploads { ["id_copy", "matric"] } + status { "draft" } + + trait :active do + status { "active" } + tp_signed_at { Time.current } + end + + trait :completed do + status { "completed" } + tp_signed_at { Time.current } + students_completed_at { Time.current } + sponsor_completed_at { Time.current } + finalized_at { Time.current } + end + + trait :with_students do + after(:create) do |cohort| + create_list(:cohort_enrollment, 3, cohort: cohort) + end + end + end +end +``` + +### 2. Fixtures (for static data) + +**Location**: `spec/fixtures/` + +**Example**: +```yaml +# spec/fixtures/institutions.yml +techpro: + name: "TechPro Training Academy" + email: "admin@techpro.co.za" + contact_person: "Jane Smith" +``` + +### 3. Database Cleaner + +**Location**: `spec/support/database_cleaner.rb` + +```ruby +RSpec.configure do |config| + config.before(:suite) do + DatabaseCleaner.clean_with(:truncation) + end + + config.before(:each) do + DatabaseCleaner.strategy = :transaction + end + + config.before(:each, type: :system) do + DatabaseCleaner.strategy = :truncation + end + + config.before(:each) do + DatabaseCleaner.start + end + + config.after(:each) do + DatabaseCleaner.clean + end +end +``` + +--- + +## 📊 Coverage & Quality + +### 1. SimpleCov (Ruby) + +**Configuration**: `.simplecov` + +```ruby +require 'simplecov' + +SimpleCov.start 'rails' do + minimum_coverage 80 + maximum_coverage_drop 5 + + add_filter 'spec/' + add_filter 'config/initializers/' + add_filter 'lib/tasks/' + + add_group 'Models', 'app/models' + add_group 'Controllers', 'app/controllers' + add_group 'Mailers', 'app/mailers' + add_group 'Jobs', 'app/jobs' + add_group 'Services', 'app/services' +end +``` + +**Run**: +```bash +bundle exec rspec --format documentation +open coverage/index.html +``` + +### 2. JavaScript Coverage + +**Configuration**: `vitest.config.js` + +```javascript +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + coverage: { + reporter: ['text', 'json', 'html'], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80 + }, + exclude: [ + 'node_modules/', + 'spec/', + '**/*.spec.js' + ] + } + } +}) +``` + +**Run**: +```bash +yarn test --coverage +open coverage/index.html +``` + +--- + +## 🔄 CI/CD Integration + +### GitHub Actions Workflow + +```yaml +# .github/workflows/test.yml +name: Test Suite + +on: [push, pull_request] + +jobs: + rspec: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2 + bundler-cache: true + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'yarn' + + - name: Install dependencies + run: | + bundle install + yarn install + + - name: Setup database + env: + DATABASE_URL: postgresql://postgres:password@localhost:5432/flo_doc_test + RAILS_ENV: test + run: | + bundle exec rails db:create + bundle exec rails db:schema:load + + - name: Run Ruby tests + env: + DATABASE_URL: postgresql://postgres:password@localhost:5432/flo_doc_test + RAILS_ENV: test + run: bundle exec rspec --format documentation + + - name: Run JavaScript tests + run: yarn test --coverage + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + files: ./coverage/coverage.xml, ./coverage/lcov.info +``` + +--- + +## 📋 Test Checklist + +### Before Committing +- [ ] All unit tests pass +- [ ] All integration tests pass +- [ ] Coverage meets minimum (80%) +- [ ] No regressions in existing tests +- [ ] New tests for new functionality +- [ ] System tests for critical paths + +### Before Merging +- [ ] All CI checks pass +- [ ] Code review completed +- [ ] QA review completed +- [ ] Performance tests pass (if applicable) +- [ ] Security tests pass (if applicable) + +--- + +## 🎯 Test Execution Commands + +```bash +# All Ruby tests +bundle exec rspec + +# Specific model +bundle exec rspec spec/models/cohort_spec.rb + +# Specific controller +bundle exec rspec spec/controllers/tp/cohorts_controller_spec.rb + +# API tests +bundle exec rspec spec/requests/api/v1/cohorts_spec.rb + +# System tests +bundle exec rspec spec/system/ + +# All JavaScript tests +yarn test + +# Specific component +yarn test spec/javascript/tp/components/CohortCard.spec.js + +# With coverage +bundle exec rspec --format documentation +yarn test --coverage + +# Watch mode (JavaScript) +yarn test --watch + +# Run only failing tests +bundle exec rspec --only-failures +``` + +--- + +## 📚 Related Documents + +- **Coding Standards**: `docs/architecture/coding-standards.md` +- **Data Models**: `docs/architecture/data-models.md` +- **API Design**: `docs/architecture/api-design.md` + +--- + +**Document Status**: ✅ Complete +**Test Coverage Target**: 80% minimum, 90% for critical paths +**Next Review**: After Phase 1 Implementation \ No newline at end of file diff --git a/docs/prd.md b/docs/prd.md index 71346533..5b582ee8 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -198,28920 +198,808 @@ The enhancement adds a cohort management layer on top of DocuSeal, creating thre - ✅ Vue.js 3 portals with TailwindCSS - ✅ Email notifications (via MailHog) - ✅ PDF generation and signing (HexaPDF) -- ✅ Excel export (rubyXL) -- ✅ Background jobs (Sidekiq) - -**Testing:** -- ✅ End-to-end workflow testing -- ✅ Mobile responsiveness testing -- ✅ Performance testing (50+ students) -- ✅ Security audit (with enhanced checklist) -- ✅ User acceptance testing --- -#### Out of Scope (Post-MVP - Deferred) - -**Production Infrastructure (Stories 8.1-8.4 - Deferred):** -- ❌ Production CI/CD pipeline -- ❌ Cloud infrastructure (AWS/GCP/Azure) -- ❌ Infrastructure as Code (Terraform) -- ❌ DNS/domain registration -- ❌ CDN/static asset hosting -- ❌ Production monitoring (Sentry, New Relic) -- ❌ Analytics and user tracking -- ❌ Blue-green deployment -- ❌ Production backup strategy - -**User Documentation & Operations (Stories 8.5-8.7 - Deferred):** -- ⚠️ **Story 8.5**: User Communication & Training Materials (blocking - must be created before dev) -- ❌ **Story 8.6**: In-app help system -- ❌ **Story 8.7**: Knowledge transfer plan & operations runbook -- ❌ Migration announcement emails -- ❌ User training materials -- ❌ FAQ and tutorials -- ❌ Support team training -- ❌ Incident response procedures - -**Future Enhancements:** -- ❌ Multi-institution support -- ❌ Advanced analytics dashboard -- ❌ Custom branding -- ❌ Additional portal features - ---- +## 2. Requirements -#### Production Path Forward +### 2.1 FUNCTIONAL REQUIREMENTS (FR) -**After Local Validation Success:** -1. Decision point: Proceed to production or iterate on MVP -2. If proceeding: Create Stories 8.1-8.4 (production infrastructure) -3. Implement Stories 8.5-8.7 (documentation & KT) -4. Deploy to production environment +#### FR1: Institution Management +**Priority**: P0 - Critical +**Description**: Single institution record per deployment +- Create institution with name, email, contact person, phone +- Settings stored in JSONB for flexibility +- No multi-tenancy (single institution model) -**Note:** Production deployment is **NOT** part of current scope. All production-related work is deferred pending successful local validation. +#### FR2: Cohort Creation +**Priority**: P0 - Critical +**Description**: 5-step cohort creation workflow +- Step 1: Cohort name +- Step 2: Program type (learnership/internship/candidacy) +- Step 3: Student emails (manual entry or bulk upload) +- Step 4: Sponsor email (single email for all cohort documents) +- Step 5: Upload documents + specify required student uploads + +#### FR3: Cohort Status Management +**Priority**: P0 - Critical +**Description**: State tracking through workflow phases +- Status: draft → active → completed +- Timestamp tracking: tp_signed_at, students_completed_at, sponsor_completed_at, finalized_at + +#### FR4: Student Enrollment +**Priority**: P0 - Critical +**Description**: Ad-hoc student enrollment without account creation +- Students access via email links +- Track enrollment status: waiting → in_progress → complete +- Required document uploads tracked per enrollment + +#### FR5: Sponsor Portal +**Priority**: P0 - Critical +**Description**: Single email for all cohort assignments +- 3-panel view: student list | document viewer | student info +- Individual or bulk completion +- No duplicate emails rule + +#### FR6: TP Signing Phase +**Priority**: P0 - Critical +**Description**: TP initiates signing before students/sponsor +- TP signs first student +- System auto-fills/signs remaining students +- Must complete before student enrollment begins + +#### FR7: TP Review Phase +**Priority**: P0 - Critical +**Description**: Final review after sponsor completion +- Review all completed documents +- Finalize 3-party agreements +- Generate audit trail + +#### FR8: Bulk Operations +**Priority**: P1 - High +**Description**: Reduce repetitive work +- Bulk student email upload +- Fill once, replicate for all students +- Bulk sponsor completion + +#### FR9: Email Management +**Priority**: P0 - Critical +**Description**: Smart email delivery rules +- Single email per sponsor per cohort +- Bulk invite emails to students +- Reminder emails with configurable intervals + +#### FR10: PDF Generation & Download +**Priority**: P0 - Critical +**Description**: Final document packaging +- Generate signed PDFs per student +- Bulk ZIP download: Cohort_Name/Student_Name/All_Docs.pdf +- Include Audit_Trail.pdf + +#### FR11: Dashboard & Analytics +**Priority**: P1 - High +**Description**: Real-time status visibility +- Cohort overview (total students, completed, pending) +- Student status tracking +- Sponsor completion status + +#### FR12: Document Templates +**Priority**: P0 - Critical +**Description**: Leverage existing DocuSeal templates +- Use existing form builder +- Map signatories (Learner, Sponsor, TP) +- Support multiple documents per cohort + +#### FR13: Ad-hoc Access Links +**Priority**: P0 - Critical +**Description**: Secure access without authentication +- Short-lived tokens +- Email verification +- Role-based access (student vs sponsor) + +#### FR14: Audit Trail +**Priority**: P1 - High +**Description**: Complete audit logging +- All actions logged with timestamps +- User identification +- Document versioning + +#### FR15: Data Retention +**Priority**: P2 - Medium +**Description**: POPIA compliance +- Configurable retention periods +- Soft delete implementation +- Data export capabilities + +#### FR16: Error Handling +**Priority**: P1 - High +**Description**: Graceful failure handling +- Validation errors with clear messages +- Transaction rollback on failures +- Retry mechanisms for background jobs + +#### FR17: Mobile Responsiveness +**Priority**: P1 - High +**Description**: All portals mobile-friendly +- Responsive design +- Touch-optimized interactions +- Cross-browser compatibility + +#### FR18: Real-time Updates +**Priority**: P2 - Medium +**Description**: Live status changes +- WebSocket or polling for updates +- Instant status reflection +- Notification badges + +#### FR19: Bulk Import Validation +**Priority**: P1 - High +**Description**: Validate bulk student uploads +- Email format validation +- Duplicate detection +- Error reporting + +#### FR20: Configurable Program Types +**Priority**: P2 - Medium +**Description**: Flexible program categorization +- learnership +- internship +- candidacy +- Extensible for future types + +#### FR21: Required Document Types +**Priority**: P1 - High +**Description**: Track required student uploads +- ID copy +- Matric certificate +- Tertiary qualifications +- Custom document types + +#### FR22: Reminder System +**Priority**: P2 - Medium +**Description**: Automated reminders +- Configurable intervals +- Email notifications +- Escalation rules + +#### FR23: Excel Export +**Priority**: P1 - High +**Description**: Export cohort data +- Student list with status +- Completion rates +- Metadata export + +#### FR24: Feature Flags +**Priority**: P2 - Medium +**Description**: Toggle FloDoc features +- Enable/disable FloDoc module +- Gradual rollout support +- Environment-based configuration + +--- + +### 2.2 NON-FUNCTIONAL REQUIREMENTS (NFR) + +#### NFR1: Performance +**Baseline**: DocuSeal performance metrics +**Target**: <20% degradation +**Metrics**: +- Page load: <1.2s +- PDF generation: <2.4s +- DB query (complex): <120ms +- Sidekiq job: <600ms + +#### NFR2: Security +**Standard**: Production-ready security +**Requirements**: +- CSRF protection +- XSS prevention via Vue auto-escaping +- SQL injection prevention via ActiveRecord +- Input validation on all endpoints +- Secure token generation for ad-hoc links +- HTTPS enforcement in production + +#### NFR3: Data Integrity +**Requirements**: +- Foreign key constraints on all relationships +- Unique constraints where applicable +- Transaction-based operations +- Atomic state transitions +- Audit trail for all critical operations + +#### NFR4: Scalability +**Target**: Support 100+ cohorts, 1000+ students +**Requirements**: +- Indexed database queries +- Efficient eager loading +- Background job processing +- Caching where appropriate + +#### NFR5: Reliability +**Requirements**: +- 99.9% uptime for MVP +- Graceful error handling +- Automatic retry for transient failures +- Data backup and recovery +- Transaction rollback on failures + +#### NFR6: Maintainability +**Requirements**: +- Clear code structure +- Comprehensive documentation +- Follow Rails conventions +- Consistent naming patterns +- Test coverage >80% + +#### NFR7: Testability +**Requirements**: +- Unit tests for all models +- Request specs for API endpoints +- System specs for critical workflows +- Vue component tests +- E2E tests for 3-portal workflow + +#### NFR8: Observability +**Requirements**: +- Structured logging +- Error tracking +- Performance monitoring +- Email delivery tracking +- Webhook delivery tracking + +#### NFR9: Accessibility +**Requirements**: +- WCAG 2.1 AA compliance +- Keyboard navigation +- Screen reader support +- Color contrast requirements + +#### NFR10: Internationalization +**Requirements**: +- Support for multiple languages (initially English) +- I18n-ready architecture +- Easy translation addition + +--- + +### 2.3 UI/UX REQUIREMENTS + +#### UI1: TP Portal (Admin) +**Design**: Custom FloDoc branding (not DaisyUI defaults) +**Features**: +- Cohort creation wizard (5 steps) +- Dashboard with cohort overview +- Student management list +- Sponsor management +- Document mapping interface +- Bulk operations panel +- Settings page + +#### UI2: Student Portal +**Design**: Simple, focused interface +**Features**: +- Document upload interface +- Form filling workflow +- Status indicator +- Mobile-optimized + +#### UI3: Sponsor Portal +**Design**: 3-panel layout +**Features**: +- Left panel: Student list with status +- Center panel: Document viewer +- Right panel: Student info +- Bulk completion controls + +#### UI4: Common UI Elements +**Requirements**: +- Consistent navigation +- Loading states +- Error states +- Success notifications +- Confirmation dialogs --- -#### Scope Acknowledgment +### 2.4 INTEGRATION REQUIREMENTS -**Current State:** Local Docker MVP ready for development -**Target State:** Working demo with 3-portal workflow -**Production Readiness:** Deferred to post-MVP phase +#### IR1: DocuSeal Integration +**Points**: +- Template model integration +- Submission model integration +- Submitter model integration +- PDF generation via HexaPDF +- Form builder reuse -This scope declaration addresses PO Validation Issue #1 (Production Deployment Strategy Undefined). +#### IR2: Authentication Integration +**Points**: +- Devise for TP users +- Ad-hoc tokens for students/sponsors +- 2FA for TP users (existing) ---- - -### 1.8 EXTENSIBILITY PATTERNS (Optional Enhancement) +#### IR3: Email Integration +**Points**: +- Action Mailer +- Sidekiq queue (mailers) +- Email event tracking +- Template reuse -**Status**: Draft - Reference Documentation -**Priority**: Medium (Post-MVP) -**Purpose**: Guide future development and customization +#### IR4: Storage Integration +**Points**: +- Active Storage +- S3/Minio for documents +- File attachment management -This section documents how to extend the FloDoc system for future enhancements. +#### IR5: Background Jobs +**Points**: +- Sidekiq integration +- Queue management +- Job prioritization +- Retry logic --- -#### 1.8.1 Adding New Portal Types - -**Current Pattern**: 3 portals (TP, Student, Sponsor) with ad-hoc token authentication - -**Extension Steps:** +### 2.5 SECURITY REQUIREMENTS -1. **Create Portal Controller** (app/controllers/flodoc/portals/): -```ruby -# app/controllers/flodoc/portals/new_portal_controller.rb -class Flodoc::Portals::NewPortalController < ApplicationController - before_action :authenticate_token! +#### SR1: Data Protection +- Encryption at rest for sensitive fields +- Secure token generation +- Token expiration +- Access control validation - def dashboard - # Uses token-based auth like Student/Sponsor portals - @data = NewPortalService.load_data(@token) - end -end -``` - -2. **Add Token Model** (if new token type needed): -```ruby -# app/models/flodoc/new_portal_token.rb -class Flodoc::NewPortalToken < ApplicationRecord - belongs_to :cohort - has_secure_token :token - validates :email, presence: true, uniqueness: { scope: :cohort_id } -end -``` +#### SR2: Input Validation +- All user inputs validated +- SQL injection prevention +- XSS prevention +- File upload validation -3. **Add Vue Portal** (app/javascript/new_portal/): -```typescript -// app/javascript/new_portal/application.js -import { createApp } from 'vue' -import NewPortalApp from './NewPortalApp.vue' +#### SR3: Authentication +- TP users: Devise + 2FA +- Students: Ad-hoc tokens with email verification +- Sponsors: Ad-hoc tokens with email verification -createApp(NewPortalApp).mount('#app') -``` +#### SR4: Authorization +- Role-based access control +- Institution isolation (single institution) +- Resource ownership checks -4. **Update Routes** (config/routes.rb): -```ruby -namespace :new_portal do - get 'dashboard', to: 'dashboard#index' - post 'submit', to: 'submissions#create' -end -``` +#### SR5: Audit Trail +- All critical actions logged +- User identification +- Timestamp recording +- Immutable logs --- -#### 1.8.2 Extending Cohort State Machine - -**Current States**: `draft` → `active` → `completed` → `finalized` +### 2.6 COMPLIANCE REQUIREMENTS -**Adding New State:** +#### CR1: POPIA (South African Data Protection) +- Data minimization +- Purpose limitation +- Storage limitation +- Data subject rights +- Breach notification -1. **Update State Enum** (app/models/flodoc/cohort.rb): -```ruby -class Flodoc::Cohort < ApplicationRecord - STATES = %w[draft active completed finalized under_review].freeze - enum status: STATES.index_with(&:to_s) -end -``` - -2. **Add State Transition Logic**: -```ruby -# app/models/flodoc/cohort.rb -def can_under_review? - completed? && all_sponsors_signed? -end - -def under_review! - update!(status: 'under_review') - Flodoc::CohortMailer.under_review_notification(self).deliver_later -end -``` - -3. **Update Portal UI** (app/javascript/tp_portal/views/CohortDetail.vue): -```vue - -``` +#### CR2: Email Communication +- CAN-SPAM compliance +- Unsubscribe mechanism +- Clear sender identification --- -#### 1.8.3 Adding New Document Types - -**Current**: PDF documents with form fields - -**Extension Pattern:** - -1. **Create Document Type Model**: -```ruby -# app/models/flodoc/document_type.rb -class Flodoc::DocumentType < ApplicationRecord - validates :name, presence: true - validates :handler, presence: true - - # handler values: 'pdf', 'docx', 'spreadsheet', 'custom' -end -``` - -2. **Register Handler**: -```ruby -# config/initializers/flodoc_document_types.rb -Flodoc::DocumentType.register_handler('spreadsheet', Flodoc::SpreadsheetHandler) -``` - -3. **Implement Handler**: -```ruby -# app/services/flodoc/handlers/spreadsheet_handler.rb -module Flodoc - module Handlers - class SpreadsheetHandler - def self.generate(cohort, data) - # Custom generation logic - end +## 3. User Interface Enhancement Goals - def self.validate(file) - # Custom validation logic - end - end - end -end -``` +### UI1: Custom Design System +**Goal**: Distinct FloDoc branding +**Implementation**: +- Custom color palette +- Typography system +- Component library +- Layout patterns + +### UI2: Wizard Interfaces +**Goal**: Simplify complex workflows +**Implementation**: +- Step indicators +- Progress tracking +- Validation feedback +- Save draft functionality + +### UI3: Dashboard Design +**Goal**: Real-time visibility +**Implementation**: +- Status cards +- Progress bars +- Action buttons +- Filterable lists + +### UI4: Mobile Optimization +**Goal**: Full mobile support +**Implementation**: +- Responsive breakpoints +- Touch-friendly controls +- Optimized layouts +- Performance tuning + +### UI5: Accessibility +**Goal**: WCAG 2.1 AA +**Implementation**: +- Semantic HTML +- ARIA labels +- Keyboard navigation +- Screen reader support + +### UI6: Loading States +**Goal**: Smooth UX +**Implementation**: +- Skeleton screens +- Spinners +- Progress indicators +- Optimistic updates + +### UI7: Error Handling +**Goal**: Clear feedback +**Implementation**: +- Inline validation +- Error banners +- Help text +- Recovery options + +### UI8: Confirmation Flows +**Goal**: Prevent mistakes +**Implementation**: +- Confirmation dialogs +- Undo functionality +- Warning messages +- Critical action guards + +### UI9: Bulk Operations UI +**Goal**: Efficient batch actions +**Implementation**: +- Select all/none +- Bulk action toolbar +- Progress tracking +- Error aggregation + +### UI10: Real-time Updates +**Goal**: Live status changes +**Implementation**: +- Status indicators +- Notification badges +- Auto-refresh +- WebSocket (optional) + +--- + +## 4. Technical Constraints and Integration + +### TC1: Brownfield Constraints +**Constraint**: Must integrate with existing DocuSeal +**Impact**: +- Cannot modify core DocuSeal models extensively +- Must use existing template/submission/submitter patterns +- New tables must reference existing tables via foreign keys + +### TC2: Single Institution Model +**Constraint**: Not multi-tenant +**Impact**: +- One institution record per deployment +- No account switching +- Simplified access control + +### TC3: Ad-hoc Access Pattern +**Constraint**: No account creation for students/sponsors +**Impact**: +- Token-based authentication +- Email verification required +- Short-lived access tokens +- No persistent sessions + +### TC4: Database Schema +**Constraint**: Must use PostgreSQL/MySQL/SQLite +**Impact**: +- JSONB fields for flexibility +- Proper indexing strategy +- Foreign key constraints +- Migration rollback support + +### TC5: Frontend Framework +**Constraint**: Vue.js 3 + Pinia +**Impact**: +- Composition API required +- Pinia stores for state +- Component-based architecture +- Shakapacker build system + +### TC6: Styling Framework +**Constraint**: TailwindCSS + DaisyUI +**Impact**: +- Utility-first CSS +- Component customization +- Design system compliance +- Custom branding required + +### TC7: Background Processing +**Constraint**: Sidekiq + Redis +**Impact**: +- Async job processing +- Queue management +- Retry logic +- Dead letter queue + +### TC8: PDF Processing +**Constraint**: HexaPDF +**Impact**: +- Digital signatures +- Form field rendering +- PDF manipulation +- License requirements + +### TC9: Email Delivery +**Constraint**: SMTP + Action Mailer +**Impact**: +- Template management +- Async delivery +- Event tracking +- Testing (MailHog) + +### TC10: Storage +**Constraint**: Active Storage +**Impact**: +- Multiple backend support +- File attachments +- Direct uploads +- CDN support (production) --- -#### 1.8.4 Extending the API - -**Current**: `/api/v1/flodoc/` namespace - -**Adding New Endpoint:** - -1. **Create API Controller**: -```ruby -# app/controllers/api/v1/flodoc/new_feature_controller.rb -class Api::V1::Flodoc::NewFeatureController < Api::V1::BaseController - def index - # Uses JWT authentication from base controller - render json: { data: 'example' } - end -end -``` - -2. **Add Route**: -```ruby -# config/routes.rb -namespace :api do - namespace :v1 do - namespace :flodoc do - get 'new_feature', to: 'new_feature#index' - end - end -end -``` - -3. **Update API Documentation**: -```markdown -#### GET /api/v1/flodoc/new_feature +## 5. Epic and Story Structure -**Authentication**: Bearer JWT token +### 5.1 EPIC OVERVIEW -**Response**: -```json -{ - "data": "example" -} -``` +**Epic 1: Core Models & Infrastructure** (Stories 1.1-1.2) +- Database schema extension +- ActiveRecord models +- Relationships and validations ---- +**Epic 2: API Layer** (Stories 2.1-2.3) +- RESTful endpoints +- Authentication +- Business logic -#### 1.8.5 Adding New Authentication Providers +**Epic 3: TP Portal - Admin Interface** (Stories 3.1-3.4) +- Cohort creation wizard +- Dashboard +- Student management +- Sponsor management -**Current**: Email-based ad-hoc tokens for students/sponsors +**Epic 4: Student Portal** (Stories 4.1-4.2) +- Enrollment interface +- Document upload +- Form filling -**Adding OAuth Provider:** +**Epic 5: Sponsor Portal** (Stories 5.1-5.2) +- 3-panel interface +- Bulk completion +- Document review -1. **Add OmniAuth Strategy** (Gemfile): -```ruby -gem 'omniauth-google-oauth2' -gem 'omniauth-saml' # For enterprise SSO -``` +**Epic 6: Workflow & Automation** (Stories 6.1-6.3) +- Email notifications +- State management +- Bulk operations -2. **Configure Provider** (config/initializers/omniauth.rb): -```ruby -Rails.application.config.middleware.use OmniAuth::Builder do - provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'] - provider :saml, - issuer: 'flodoc', - idp_sso_target_url: ENV['SAML_SSO_URL'] -end -``` +**Epic 7: PDF & Export** (Stories 7.1-7.2) +- PDF generation +- Bulk download +- Excel export -3. **Create Authentication Handler**: -```ruby -# app/services/flodoc/auth/oauth_handler.rb -module Flodoc - module Auth - class OAuthHandler - def self.authenticate(provider, auth_hash) - user = User.find_or_create_by(email: auth_hash.info.email) do |u| - u.password = SecureRandom.hex(16) - u.name = auth_hash.info.name - end - # Generate portal-specific token - Flodoc::PortalToken.create!(user: user, provider: provider) - end - end - end -end -``` +**Epic 8: Infrastructure & Deployment** (Stories 8.0-8.0.1) +- Local Docker setup +- Demo readiness --- -#### 1.8.6 Customizing UI Components - -**Current**: Vue 3 + TailwindCSS 3.4.17 + DaisyUI 3.9.4 - -**Customization Pattern:** +### 5.2 STORY COUNT BY EPIC -1. **Override Design Tokens** (app/javascript/design-system/tailwind.config.js): -```javascript -module.exports = { - theme: { - extend: { - colors: { - flodoc: { - primary: '#1e3a8a', // Custom blue - accent: '#f59e0b', // Custom amber - } - } - } - } -} -``` - -2. **Create Custom Component**: -```vue - - - - - - -``` - -3. **Register Globally**: -```javascript -// app/javascript/application.js -import FlodocCustomButton from './elements/FlodocCustomButton.vue' -app.component('FlodocCustomButton', FlodocCustomButton) -``` +| Epic | Stories | Status | +|------|---------|--------| +| 1. Core Models | 2 | Draft | +| 2. API Layer | 3 | Draft | +| 3. TP Portal | 4 | Draft | +| 4. Student Portal | 2 | Draft | +| 5. Sponsor Portal | 2 | Draft | +| 6. Workflow | 3 | Draft | +| 7. PDF & Export | 2 | Draft | +| 8. Infrastructure | 2 | ✅ Complete | +| **Total** | **20** | | --- -#### 1.8.7 Extending Background Jobs - -**Current**: Sidekiq queues for emails, webhooks, PDF generation +### 5.3 STORY PRIORITY DISTRIBUTION -**Adding New Job Type:** +**P0 - Critical (12 stories)**: Core functionality +**P1 - High (6 stories)**: Important features +**P2 - Medium (2 stories)**: Nice-to-have -1. **Create Job**: -```ruby -# app/jobs/flodoc/custom_analysis_job.rb -class Flodoc::CustomAnalysisJob < ApplicationJob - queue_as :analytics +--- - def perform(cohort_id) - cohort = Flodoc::Cohort.find(cohort_id) - # Custom analysis logic - Flodoc::AnalysisReport.generate(cohort) - end -end -``` +### 5.4 DEPENDENCY MAPPING -2. **Enqueue Job**: -```ruby -# In any service or controller -Flodoc::CustomAnalysisJob.perform_later(@cohort.id) ``` - -3. **Monitor in Sidekiq**: -```ruby -# config/sidekiq.yml -:queues: - - default - - mailers - - webhooks - - pdf - - analytics # New queue +1.1 → 1.2 → 2.1 → 2.2 → 2.3 → 3.1 → 3.2 → 3.3 → 3.4 + ↓ +4.1 → 4.2 → 5.1 → 5.2 → 6.1 → 6.2 → 6.3 → 7.1 → 7.2 ``` --- -#### 1.8.8 Adding Custom Validations - -**Current**: Standard Rails validations +### 5.5 TESTING STRATEGY -**Custom Validation Pattern:** - -1. **Create Validator**: -```ruby -# app/validators/flodoc/sponsor_email_validator.rb -class Flodoc::SponsorEmailValidator < ActiveModel::Validator - def validate(record) - unless record.email.end_with?('@company.com') - record.errors.add(:email, 'must be a company email') - end - end -end -``` - -2. **Use in Model**: -```ruby -# app/models/flodoc/submitter.rb -class Flodoc::Submitter < ApplicationRecord - validates_with Flodoc::SponsorEmailValidator, if: :sponsor? -end -``` +**Unit Tests**: All models, services, helpers +**Request Specs**: All API endpoints +**System Specs**: Critical workflows +**Vue Tests**: All components +**E2E Tests**: 3-portal workflow --- -#### 1.8.9 Database Extension Patterns +### 5.6 DOCUMENTATION REQUIREMENTS -**Adding New Tables:** - -1. **Migration**: -```ruby -# db/migrate/20260114120000_create_flodoc_custom_data.rb -class CreateFlodocCustomData < ActiveRecord::Migration[7.0] - def change - create_table :flodoc_custom_data do |t| - t.references :cohort, null: false, foreign_key: true - t.jsonb :data - t.timestamps - end - - add_index :flodoc_custom_data, [:cohort_id, :created_at] - end -end -``` +**Per Story**: +- Technical implementation notes +- Code examples +- Test requirements +- Rollback procedures +- Risk assessment -2. **Model**: -```ruby -# app/models/flodoc/custom_datum.rb -class Flodoc::CustomDatum < ApplicationRecord - belongs_to :cohort - validates :data, presence: true -end -``` +**Overall**: +- Architecture diagrams +- API documentation +- Deployment guide +- User manuals --- -#### 1.8.10 Event System Extension +## 6. Epic Details -**Current**: SubmissionEvents for audit trail +### 6.1 EPIC 1: Core Models & Infrastructure -**Adding Custom Events:** +**Objective**: Establish foundation with database schema and models -1. **Define Event Types**: -```ruby -# app/models/flodoc/event_type.rb -class Flodoc::EventType < ApplicationRecord - TYPES = %w[ - cohort_created - cohort_completed - submitter_signed - sponsor_invited - document_downloaded - custom_alert_sent # New event - ].freeze -end -``` +**Stories**: +- **Story 1.1**: Database Schema Extension +- **Story 1.2**: ActiveRecord Models & Relationships -2. **Track Custom Events**: -```ruby -# app/services/flodoc/event_tracker.rb -module Flodoc - class EventTracker - def self.track(cohort, event_type, user, metadata = {}) - Flodoc::SubmissionEvent.create!( - cohort: cohort, - event_type: event_type, - user: user, - metadata: metadata - ) - end - end -end -``` +**Key Deliverables**: +- 3 new tables (institutions, cohorts, cohort_enrollments) +- ActiveRecord models with validations +- Foreign key constraints +- Indexes for performance +- Migration strategy -3. **Query Events**: -```ruby -# In reports or analytics -Flodoc::SubmissionEvent - .where(cohort_id: cohort.id) - .where(event_type: 'custom_alert_sent') - .where('created_at > ?', 30.days.ago) - .count -``` +**Integration Points**: +- References to existing `templates` table +- References to existing `submissions` table +- Backward compatibility maintained --- -#### 1.8.11 Integration Checklist +### 6.2 EPIC 2: API Layer -When extending FloDoc, verify: +**Objective**: Build RESTful API for all portal operations -- ✅ **Security**: New endpoints use JWT/auth tokens -- ✅ **Multi-tenancy**: Check single-institution vs multi-institution -- ✅ **Database**: Proper foreign keys and indexes -- ✅ **Background Jobs**: Sidekiq queue exists -- ✅ **API Versioning**: Use `/api/v1/flodoc/` namespace -- ✅ **Vue Components**: Follow design system (FR28) -- ✅ **Testing**: RSpec coverage for new code -- ✅ **Rollback**: Migration can be reversed -- ✅ **Documentation**: Update this extensibility guide - ---- +**Stories**: +- **Story 2.1**: Cohort Management API +- **Story 2.2**: Enrollment & Student API +- **Story 2.3**: Sponsor & Bulk Operations API -**Note**: This is optional documentation for future development. All current stories (1.1-8.0.1) are complete and ready for implementation. +**Key Deliverables**: +- `/api/v1/flodoc/` namespace +- Authentication middleware +- Rate limiting +- Error handling +- API documentation --- -## 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 +### 6.3 EPIC 3: TP Portal - Admin Interface -**CR1: API Compatibility**: All new endpoints must follow existing DocuSeal API patterns and authentication mechanisms. No breaking changes to existing public APIs. +**Objective**: Complete admin portal for training providers -**CR2: Database Schema Compatibility**: New tables and relationships must not modify existing DocuSeal core schemas. Extensions should use foreign keys and new tables only. +**Stories**: +- **Story 3.1**: Cohort Creation Wizard +- **Story 3.2**: TP Dashboard & Analytics +- **Story 3.3**: Student Management Interface +- **Story 3.4**: Sponsor Management & Document Mapping -**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. +**Key Deliverables**: +- 5-step wizard component +- Dashboard with real-time stats +- Student list with filters +- Sponsor assignment UI +- Document signatory mapping --- -## 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 +### 6.4 EPIC 4: Student Portal -**Modified Existing Screens**: -- **Submission Form** - Rebranded for cohort context, simplified navigation +**Objective**: Student-facing interface for document completion -#### Sponsor Portal +**Stories**: +- **Story 4.1**: Student Enrollment & Access +- **Story 4.2**: Document Upload & Form Filling -**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 +**Key Deliverables**: +- Ad-hoc access flow +- Document upload interface +- Form filling workflow +- Mobile-optimized UI --- -## 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, ` -``` - -**Model Structure:** - -```ruby -# app/models/institution.rb -class Institution < ApplicationRecord - include SoftDeletable - - # Associations - has_many :cohorts, dependent: :destroy - - # Validations - validates :name, presence: true, length: { maximum: 255 } - validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :contact_person, length: { maximum: 255 }, allow_nil: true - validates :phone, length: { maximum: 50 }, allow_nil: true - - # Scopes - scope :active, -> { where(deleted_at: nil) } - - # Methods - def self.current - # For single-institution model, return first active institution - active.first - end -end - -# app/models/cohort.rb -class Cohort < ApplicationRecord - include SoftDeletable - include AASM # For state machine if needed - - # Associations - belongs_to :institution - belongs_to :template, class_name: 'Template' - has_many :cohort_enrollments, dependent: :destroy - has_many :submissions, through: :cohort_enrollments - - # Validations - validates :name, presence: true, length: { maximum: 255 } - validates :program_type, presence: true, inclusion: { in: %w[learnership internship candidacy] } - validates :sponsor_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :required_student_uploads, presence: true - - # Scopes - scope :active, -> { where(deleted_at: nil) } - scope :draft, -> { where(status: 'draft') } - scope :ready_for_sponsor, -> { where(status: 'ready_for_sponsor') } - scope :completed, -> { where(status: 'completed') } - - # State management - aasm column: :status do - state :draft, initial: true - state :tp_signing - state :student_enrollment - state :ready_for_sponsor - state :sponsor_review - state :tp_review - state :completed - - event :start_tp_signing do - transitions from: :draft, to: :tp_signing - end - - event :complete_tp_signing do - transitions from: :tp_signing, to: :student_enrollment - end - - event :all_students_complete do - transitions from: :student_enrollment, to: :ready_for_sponsor - end - - event :sponsor_starts_review do - transitions from: :ready_for_sponsor, to: :sponsor_review - end - - event :sponsor_completes do - transitions from: :sponsor_review, to: :tp_review - end - - event :finalize do - transitions from: :tp_review, to: :completed - end - end - - # Methods - def all_students_completed? - cohort_enrollments.where(role: 'student', status: 'complete').count == - cohort_enrollments.where(role: 'student').count - end - - def sponsor_access_ready? - all_students_completed? && status == 'ready_for_sponsor' - end - - def tp_can_sign? - status == 'draft' || status == 'tp_signing' - end -end - -# app/models/cohort_enrollment.rb -class CohortEnrollment < ApplicationRecord - include SoftDeletable - - # Associations - belongs_to :cohort - belongs_to :submission, class_name: 'Submission' - - # Validations - validates :student_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :status, presence: true, inclusion: { in: %w[waiting in_progress complete] } - validates :role, presence: true, inclusion: { in: %w[student sponsor] } - validates :submission_id, uniqueness: true - - # Scopes - scope :active, -> { where(deleted_at: nil) } - scope :students, -> { where(role: 'student') } - scope :sponsor, -> { where(role: 'sponsor') } - scope :completed, -> { where(status: 'complete') } - scope :waiting, -> { where(status: 'waiting') } - scope :in_progress, -> { where(status: 'in_progress') } - - # Methods - def complete! - update(status: 'complete', completed_at: Time.current) - end - - def mark_in_progress! - update(status: 'in_progress') - end - - def waiting? - status == 'waiting' - end - - def completed? - status == 'complete' - end -end -``` - -**Key Design Decisions:** - -1. **SoftDeletable Module**: Reuse existing pattern from DocuSeal -2. **State Machine**: AASM for cohort lifecycle management -3. **Associations**: Proper bidirectional relationships -4. **Scopes**: Common query patterns for performance -5. **Validation**: Consistent with existing DocuSeal models - -##### Acceptance Criteria - -**Functional:** -1. ✅ All three models created with correct class structure -2. ✅ All associations defined correctly -3. ✅ All validations implemented -4. ✅ All scopes defined -5. ✅ State machine logic correct (if used) -6. ✅ Model methods work as specified -7. ✅ FeatureFlag model created with enabled?, enable!, disable! methods -8. ✅ FeatureFlagCheck concern implemented -9. ✅ Default flags seeded (flodoc_cohorts, flodoc_portals) -10. ✅ All FloDoc routes protected by feature flags - -**Integration:** -1. ✅ IV1: Models don't break existing DocuSeal models -2. ✅ IV2: Associations work with existing tables (templates, submissions) -3. ✅ IV3: Query performance acceptable with 1000+ records -4. ✅ Feature flags integrate with existing authentication - -**Security:** -1. ✅ No mass assignment vulnerabilities -2. ✅ Proper attribute whitelisting -3. ✅ Email validation on all email fields -4. ✅ Feature flags can disable FloDoc instantly - -**Quality:** -1. ✅ Follow existing code style (RuboCop compliant) -2. ✅ All methods have YARD comments -3. ✅ Test coverage > 80% -4. ✅ No N+1 query issues -5. ✅ Feature flag tests included - -##### Integration Verification (IV1-3) - -**IV1: Existing Functionality Verification** -- Verify that `Template` model still works -- Verify that `Submission` model still works -- Verify that `Submitter` model still works -- Run existing model specs - -**IV2: Integration Point Verification** -- Verify that `cohort.template` returns correct Template record -- Verify that `cohort_enrollment.submission` returns correct Submission record -- Verify that `cohort.cohort_enrollments` returns correct records -- Verify that associations work bidirectionally - -**IV3: Performance Impact Verification** -- Verify that `Cohort.includes(:cohort_enrollments)` doesn't cause N+1 -- Verify that queries with 1000 cohorts perform in < 100ms -- Verify that state machine transitions are fast - -**IV4: Feature Flag Integration Verification** -- Verify that `FeatureFlag.enabled?(:flodoc_cohorts)` returns correct boolean -- Verify that `FeatureFlag.enable!('flodoc_cohorts')` sets flag to true -- Verify that `FeatureFlag.disable!('flodoc_cohorts')` sets flag to false -- Verify that `require_feature(:flodoc_cohorts)` blocks access when disabled -- Verify that `require_feature(:flodoc_cohorts)` allows access when enabled -- Verify that feature flag UI displays all flags correctly -- Verify that admin can toggle flags via UI - -##### Test Requirements - -**Model Specs:** -```ruby -# spec/models/cohort_spec.rb -describe Cohort do - describe 'associations' do - it { should belong_to(:institution) } - it { should belong_to(:template) } - it { should have_many(:cohort_enrollments) } - end - - describe 'validations' do - it { should validate_presence_of(:name) } - it { should validate_inclusion_of(:program_type).in_array(%w[learnership internship candidacy]) } - end - - describe 'state machine' do - it 'starts in draft state' do - cohort = build(:cohort) - expect(cohort.draft?).to be true - end - - it 'transitions from draft to tp_signing' do - cohort = create(:cohort, status: 'draft') - cohort.start_tp_signing! - expect(cohort.tp_signing?).to be true - end - end - - describe '#all_students_completed?' do - # Test logic - end -end -``` - -**Factory Definitions:** -```ruby -# spec/factories/cohorts.rb -FactoryBot.define do - factory :cohort do - institution - template - name { "Test Cohort 2026" } - program_type { "learnership" } - sponsor_email { "sponsor@example.com" } - required_student_uploads { ["id", "matric"] } - status { "draft" } - end -end -``` - -**Feature Flag Specs:** -```ruby -# spec/models/feature_flag_spec.rb -describe FeatureFlag do - describe 'validations' do - it { should validate_uniqueness_of(:name) } - end - - describe '.enabled?' do - it 'returns true when flag is enabled' do - FeatureFlag.create!(name: 'test_feature', enabled: true) - expect(FeatureFlag.enabled?(:test_feature)).to be true - end - - it 'returns false when flag is disabled' do - FeatureFlag.create!(name: 'test_feature', enabled: false) - expect(FeatureFlag.enabled?(:test_feature)).to be false - end - - it 'returns false when flag does not exist' do - expect(FeatureFlag.enabled?(:nonexistent)).to be false - end - end - - describe '.enable!' do - it 'creates and enables a flag' do - FeatureFlag.enable!(:new_feature) - flag = FeatureFlag.find_by(name: 'new_feature') - expect(flag.enabled).to be true - end - end - - describe '.disable!' do - it 'creates and disables a flag' do - FeatureFlag.disable!(:new_feature) - flag = FeatureFlag.find_by(name: 'new_feature') - expect(flag.enabled).to be false - end - end -end - -# spec/concerns/feature_flag_check_spec.rb -describe FeatureFlagCheck do - controller(ApplicationController) do - include FeatureFlagCheck - before_action :require_feature(:test_feature) - - def index - render json: { success: true } - end - end - - it 'allows access when feature is enabled' do - FeatureFlag.enable!(:test_feature) - get :index - expect(response).to have_http_status(:ok) - end - - it 'blocks access when feature is disabled' do - FeatureFlag.disable!(:test_feature) - get :index - expect(response).to have_http_status(:forbidden) - end -end -``` - -**Integration Specs:** -```ruby -# spec/requests/flodoc/cohorts_spec.rb -describe 'FloDoc Cohorts', type: :request do - before do - FeatureFlag.enable!(:flodoc_cohorts) - end - - it 'allows access when feature flag is enabled' do - get '/flodoc/cohorts' - expect(response).to have_http_status(:ok) - end - - it 'blocks access when feature flag is disabled' do - FeatureFlag.disable!(:flodoc_cohorts) - get '/flodoc/cohorts' - expect(response).to have_http_status(:forbidden) - end -end -``` - -##### Rollback Procedure - -**If models cause issues:** - -1. **Code Rollback**: Revert Git commit -2. **Database**: No rollback needed (models are code, not schema) -3. **Verify**: Application loads without errors -4. **Verify**: Existing functionality works - -**Note**: Models don't modify database, so rollback is code-only. - -##### Risk Assessment - -**Low Risk because:** -- Models are pure Ruby code additions -- No database modifications -- Follow existing patterns -- Easily reversible via Git - -**Mitigation**: -- Code review before merge -- Comprehensive test coverage -- Staging environment testing - -##### Success Metrics - -- **Feature Flag Accuracy**: 100% of flag checks return correct state -- **Toggle Success Rate**: 99.9% of enable/disable operations succeed -- **Access Control**: 0 unauthorized access when flag disabled -- **UI Responsiveness**: Feature flag UI loads in <500ms -- **Test Coverage**: 100% of feature flag code covered -- **Zero Breaking Changes**: All existing tests pass - -#### Story 1.3: Authorization Layer Extension - -**Status**: Draft -**Priority**: Critical -**Epic**: Phase 1 - Foundation -**Estimated Effort**: 2-3 days -**Risk Level**: Medium - -##### User Story - -**As a** system administrator, -**I want** the authorization system to support FloDoc roles and permissions, -**So that** users can only access appropriate cohort management functions. - -##### Background - -DocuSeal uses Cancancan for authorization. We need to: -- Extend `Ability` class to handle FloDoc models -- Define permissions for TP, Student, and Sponsor roles -- Support ad-hoc access patterns (students/sponsors without accounts) -- Maintain existing DocuSeal permissions - -##### Technical Implementation Notes - -**Ability Class Extension:** - -```ruby -# app/models/ability.rb -class Ability - include CanCan::Ability - - def initialize(user) - # Existing DocuSeal abilities (keep unchanged) - if user.nil? - # Guest access for ad-hoc users (students, sponsors) - define_ad_hoc_abilities - elsif user.admin? - # Admin gets everything - define_admin_abilities - elsif user.institution_admin? - # TP (institution admin) abilities - define_tp_abilities(user) - else - # Regular user abilities (existing DocuSeal) - define_standard_abilities(user) - end - end - - private - - def define_ad_hoc_abilities - # Ad-hoc access for students and sponsors via token - can :read, Cohort, -> { true } do |cohort, token| - # Verify token and check if user has access to this cohort - verify_ad_hoc_token(cohort, token) - end - - can :fill, Submission, -> { true } do |submission, token| - # Students can fill their own submissions - verify_ad_hoc_token(submission.cohort, token) && - submission.submitter.email == token[:email] - end - - can :sign, Submission, -> { true } do |submission, token| - # Students and sponsors can sign their assigned submissions - verify_ad_hoc_token(submission.cohort, token) && - submission.submitter.email == token[:email] - end - - can :review, Cohort, -> { true } do |cohort, token| - # Sponsors can review all students in their cohort - verify_ad_hoc_token(cohort, token) && - token[:role] == 'sponsor' && - cohort.sponsor_access_ready? - end - end - - def define_tp_abilities(user) - # TP can manage their institution's cohorts - can :manage, Cohort, institution_id: user.institution_id - can :manage, CohortEnrollment, cohort: { institution_id: user.institution_id } - - # TP can access all submissions for their cohorts - can :read, Submission, cohort: { institution_id: user.institution_id } - can :sign, Submission, cohort: { institution_id: user.institution_id } - - # TP can export data - can :export, Cohort, institution_id: user.institution_id - - # Existing DocuSeal abilities - define_standard_abilities(user) - end - - def define_admin_abilities - # Admin gets everything - can :manage, :all - end - - def define_standard_abilities(user) - # Existing DocuSeal abilities (unchanged) - can :manage, Template, account_id: user.account_id - can :manage, Submission, account_id: user.account_id - # ... existing logic - end - - def verify_ad_hoc_token(cohort, token) - # Verify signed token for ad-hoc access - return false unless token.is_a?(Hash) - return false unless token[:cohort_id] == cohort.id - return false unless token[:email].present? - - # Verify JWT signature - begin - decoded = JWT.decode( - token[:jwt], - Rails.application.secrets.secret_key_base, - true, - { algorithm: 'HS256' } - ) - decoded.first['cohort_id'] == cohort.id && - decoded.first['email'] == token[:email] - rescue JWT::DecodeError - false - end - end -end -``` - -**Ad-hoc Token Generation Service:** - -```ruby -# lib/services/ad_hoc_token_service.rb -class AdHocTokenService - def self.generate_student_token(cohort, student_email, student_name = nil) - payload = { - cohort_id: cohort.id, - email: student_email, - role: 'student', - name: student_name, - exp: 30.days.from_now.to_i - } - - JWT.encode(payload, Rails.application.secrets.secret_key_base, 'HS256') - end - - def self.generate_sponsor_token(cohort) - payload = { - cohort_id: cohort.id, - email: cohort.sponsor_email, - role: 'sponsor', - exp: 30.days.from_now.to_i - } - - JWT.encode(payload, Rails.application.secrets.secret_key_base, 'HS256') - end - - def self.decode_token(token) - JWT.decode(token, Rails.application.secrets.secret_key_base, true, { algorithm: 'HS256' }).first - rescue JWT::DecodeError - nil - end -end -``` - -**Controller Authorization Pattern:** - -```ruby -# app/controllers/cohorts/student_portal_controller.rb -class Cohorts::StudentPortalController < ApplicationController - skip_before_action :authenticate_user! - before_action :verify_ad_hoc_access - - def show - @cohort = Cohort.find(params[:id]) - authorize! :read, @cohort, ad_hoc_token - - @submission = @cohort.submissions.find_by(email: ad_hoc_token[:email]) - authorize! :fill, @submission, ad_hoc_token - end - - private - - def ad_hoc_token - @ad_hoc_token ||= AdHocTokenService.decode_token(params[:token]) - end - - def verify_ad_hoc_access - redirect_to root_path, alert: 'Invalid access token' unless ad_hoc_token - end -end -``` - -##### Acceptance Criteria - -**Functional:** -1. ✅ Ability class extended with FloDoc permissions -2. ✅ Ad-hoc access works for students (via token) -3. ✅ Ad-hoc access works for sponsors (via token) -4. ✅ TP permissions work correctly -5. ✅ Admin permissions work correctly -6. ✅ Existing DocuSeal permissions unchanged - -**Integration:** -1. ✅ IV1: Existing DocuSeal authorization still works -2. ✅ IV2: New abilities integrate with existing Ability class -3. ✅ IV3: Authorization checks don't add significant overhead - -**Security:** -1. ✅ JWT tokens signed with secret key -2. ✅ Tokens have expiration (30 days) -3. ✅ Token verification prevents tampering -4. ✅ Ad-hoc users can't access other cohorts -5. ✅ Sponsor can't access before all students complete - -**Quality:** -1. ✅ Follow existing authorization patterns -2. ✅ All abilities tested -3. ✅ Token service has comprehensive tests -4. ✅ No security bypasses - -##### Integration Verification (IV1-3) - -**IV1: Existing Functionality Verification** -- Verify that existing user authentication still works -- Verify that existing template permissions still works -- Verify that existing submission permissions still works -- Run existing ability specs - -**IV2: Integration Point Verification** -- Verify that new abilities don't conflict with existing ones -- Verify that `authorize!` calls work with ad-hoc tokens -- Verify that token generation and verification work end-to-end -- Verify that controllers can use new authorization patterns - -**IV3: Performance Impact Verification** -- Verify that ability checks execute in < 10ms -- Verify that JWT encoding/decoding is fast -- Verify that no N+1 queries in authorization logic - -##### Test Requirements - -**Ability Specs:** -```ruby -# spec/models/ability_spec.rb -describe Ability do - context 'as ad-hoc student' do - let(:cohort) { create(:cohort) } - let(:token) { AdHocTokenService.generate_student_token(cohort, 'student@example.com') } - let(:decoded_token) { AdHocTokenService.decode_token(token) } - let(:ability) { Ability.new(nil) } - - it 'can read their cohort' do - expect(ability).to be_able_to(:read, cohort, decoded_token) - end - - it 'cannot read other cohorts' do - other_cohort = create(:cohort) - expect(ability).not_to be_able_to(:read, other_cohort, decoded_token) - end - end - - context 'as TP' do - let(:user) { create(:user, :institution_admin) } - let(:ability) { Ability.new(user) } - - it 'can manage their institution cohorts' do - cohort = create(:cohort, institution: user.institution) - expect(ability).to be_able_to(:manage, cohort) - end - end -end -``` - -**Token Service Specs:** -```ruby -# spec/lib/services/ad_hoc_token_service_spec.rb -describe AdHocTokenService do - describe '.generate_student_token' do - it 'generates valid JWT token' do - cohort = create(:cohort) - token = described_class.generate_student_token(cohort, 'student@example.com') - - decoded = described_class.decode_token(token) - expect(decoded['email']).to eq('student@example.com') - expect(decoded['cohort_id']).to eq(cohort.id) - end - end - - describe '.decode_token' do - it 'returns nil for invalid token' do - expect(described_class.decode_token('invalid')).to be_nil - end - end -end -``` - -##### Rollback Procedure - -**If authorization causes issues:** - -1. **Code Rollback**: Revert Git commit -2. **Verify**: Existing DocuSeal authorization works -3. **Verify**: No security holes introduced - -**Note**: Authorization is code-only, no database changes. - -##### Risk Assessment - -**Medium Risk because:** -- Security-critical component -- Ad-hoc access is new pattern -- JWT token management adds complexity -- Potential for authorization bypasses - -**Mitigation**: -- Comprehensive security testing -- Code review by security-conscious developer -- Staging environment testing with various user scenarios -- Monitor authorization failures in production - -**Critical Security Checks**: -- Token signature verification -- Token expiration enforcement -- Cohort ID validation in tokens -- Role-based access control -- No privilege escalation paths - ---- - -### 6.2 Phase 2: Backend Business Logic - -This section provides detailed user stories for Phase 2 (Backend Business Logic) of the FloDoc enhancement. This phase implements the core business logic for cohort management and workflow orchestration. - -#### Story 2.1: Cohort Creation & Management - -**Status**: Draft -**Priority**: High -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 3-4 days -**Risk Level**: Low - -##### User Story - -**As a** TP (Training Provider) administrator, -**I want** to create and manage cohorts with all their configuration details, -**So that** I can organize students into training programs and prepare them for the signature workflow. - -##### Background - -Cohort creation is the entry point for the FloDoc workflow. TP administrators need to: -- Create a cohort by selecting an existing DocuSeal template -- Define program type (learnership/internship/candidacy) -- Specify required student documents (ID, matric, tertiary, etc.) -- Set sponsor email for the review phase -- Configure cohort metadata and settings - -The cohort acts as a container that orchestrates the entire 3-party signature workflow. - -##### Technical Implementation Notes - -**Cohort Creation Service:** - -```ruby -# app/services/cohort_management_service.rb -class CohortManagementService - def self.create_cohort(params, user) - institution = user.institution || Institution.current - - ActiveRecord.transaction do - cohort = Cohort.create!( - institution: institution, - template_id: params[:template_id], - name: params[:name], - program_type: params[:program_type], - sponsor_email: params[:sponsor_email], - required_student_uploads: params[:required_student_uploads] || [], - cohort_metadata: params[:cohort_metadata] || {}, - status: 'draft' - ) - - # Create cohort audit event - AuditLog.create!( - user: user, - action: 'cohort_created', - entity: cohort, - details: { name: cohort.name, sponsor: cohort.sponsor_email } - ) - - cohort - end - end - - def self.update_cohort(cohort, params, user) - # Only allow updates in draft or tp_signing states - raise 'Cannot update cohort in current state' unless cohort.draft? || cohort.tp_signing? - - cohort.update!(params) - - AuditLog.create!( - user: user, - action: 'cohort_updated', - entity: cohort, - details: params - ) - - cohort - end - - def self.delete_cohort(cohort, user) - # Soft delete only - preserve historical data - cohort.update!(deleted_at: Time.current) - - AuditLog.create!( - user: user, - action: 'cohort_deleted', - entity: cohort, - details: { cohort_id: cohort.id, name: cohort.name } - ) - - true - end -end -``` - -**Cohort Validation:** - -```ruby -# app/models/cohort.rb (extended validation) -validate :template_exists_and_accessible -validate :sponsor_email_different_from_tp -validate :required_uploads_reasonable - -def template_exists_and_accessible - errors.add(:template_id, 'must be a valid template') unless Template.exists?(template_id) -end - -def sponsor_email_different_from_tp - # Ensure sponsor is different from TP admin - return if institution.nil? - errors.add(:sponsor_email, 'cannot be same as institution email') if sponsor_email == institution.email -end - -def required_uploads_reasonable - return if required_student_uploads.blank? - errors.add(:required_student_uploads, 'cannot exceed 10 document types') if required_student_uploads.length > 10 -end -``` - -**Key Design Decisions:** - -1. **State-Aware Updates**: Only allow modifications in draft/tp_signing states -2. **Audit Trail**: All cohort operations logged for compliance -3. **Soft Delete**: Preserve historical data while removing from active views -4. **Validation Rules**: Enforce business rules (max 10 upload types, sponsor must differ from TP) -5. **Template Linking**: Cohorts must link to accessible templates - -##### Acceptance Criteria - -**Functional:** -1. ✅ TP can create cohort with template selection -2. ✅ TP can specify program type (learnership/internship/candidacy) -3. ✅ TP can define required student uploads (max 10 types) -4. ✅ TP can set sponsor email -5. ✅ TP can add cohort metadata -6. ✅ TP can edit cohort in draft state -7. ✅ TP can delete cohort (soft delete) -8. ✅ All operations logged in audit trail - -**Integration:** -1. ✅ IV1: Cohort creation validates template exists -2. ✅ IV2: Cohort links correctly to existing template -3. ✅ IV3: No performance degradation with 100+ cohorts - -**Security:** -1. ✅ Only TP admins can create/manage cohorts -2. ✅ TP can only manage their institution's cohorts -3. ✅ Sponsor email validation enforced -4. ✅ Audit logs cannot be tampered with - -**Quality:** -1. ✅ Follow existing service object patterns -2. ✅ Transactional safety for cohort creation -3. ✅ Proper error handling and user feedback -4. ✅ 80% test coverage for service layer - -##### Integration Verification (IV1-3) - -**IV1: Template Validation** -- Verify template selection UI shows only accessible templates -- Verify cohort creation fails with invalid template_id -- Verify cohort creation succeeds with valid template_id -- Verify template deletion is prevented if cohort exists - -**IV2: Template Mapping** -- Verify `cohort.template` returns correct Template record -- Verify cohort inherits template's field definitions -- Verify cohort can render template's form fields -- Verify template modification doesn't break existing cohorts - -**IV3: Performance** -- Verify cohort creation completes in < 200ms -- Verify cohort list query returns 100 records in < 200ms -- Verify audit log queries don't impact cohort performance -- Verify no N+1 queries in cohort index view - -##### Test Requirements - -**Service Specs:** -```ruby -# spec/services/cohort_management_service_spec.rb -describe CohortManagementService do - describe '.create_cohort' do - it 'creates cohort with valid params' do - params = { template_id: 1, name: 'Test', program_type: 'learnership', sponsor_email: 'sp@example.com' } - cohort = described_class.create_cohort(params, user) - expect(cohort).to be_persisted - expect(cohort.status).to eq('draft') - end - - it 'validates template exists' do - params = { template_id: 99999, name: 'Test' } - expect { described_class.create_cohort(params, user) }.to raise_error(ActiveRecord::RecordInvalid) - end - end -end -``` - -**Model Specs:** -- Verify sponsor email must differ from institution email -- Verify required uploads max 10 items -- Verify template accessibility validation - -##### Rollback Procedure - -**If cohort management causes issues:** -1. Revert service object code -2. Verify cohort creation page loads -3. Verify no orphaned cohorts exist -4. Check audit logs for any incomplete operations - -**Data Safety**: No destructive operations - all data preserved via soft delete. - -##### Risk Assessment - -**Low Risk because:** -- Service layer pattern is tested in existing codebase -- No complex business logic -- State machine already validated in Phase 1 -- Audit logging reduces operational risk - -**Mitigation:** -- Validation on all inputs before database write -- Use transactions for cohort creation -- Comprehensive audit trails -- State restrictions on destructive operations - ---- - -#### Story 2.2: TP Signing Phase Logic (High Risk - Prototype First) - -**Status**: Draft -**Priority**: Critical -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 4-5 days -**Risk Level**: High - -##### User Story - -**As a** TP administrator, -**I want** to sign the first student's document and have that signing replicated to all other students in the cohort, -**So that** I don't need to sign each student's document individually, saving time and eliminating duplicate sponsor emails. - -##### Background - -This is the core innovation of FloDoc. The workflow is: - -1. **TP Signing Phase**: TP signs ONE student's document (with their fields/signatures) -2. **Duplication**: System duplicates the **completed submission** (not empty template) to remaining students -3. **Auto-fill**: TP's fields and signatures are automatically populated across all student submissions -4. **Email Management**: Sponsor receives ONE email (when all students are ready), not duplicate emails per submission - -**Why Prototype First**: DocuSeal's native multi-submission only duplicates empty templates. This story requires custom duplication of pre-filled submissions, which is a high-risk architectural change. - -**Prototype Approach**: Build a minimal prototype that: -- Creates 2-3 test submissions -- Demonstrates field/signature copying from first to remaining -- Validates sponsor email delivery logic -- Then expand to full implementation - -##### Technical Implementation Notes - -**TP Signing Phase Orchestration:** - -```ruby -# app/services/tp_signing_service.rb -class TpSigningService - def self.initiate_tp_signing(cohort, tp_user) - raise 'Invalid state for TP signing' unless cohort.draft? - - ActiveRecord.transaction do - # Step 1: Mark cohort as in TP signing phase - cohort.update!(status: 'tp_signing') - - # Step 2: Track TP signing started timestamp - cohort.update!(tp_started_at: Time.current) - - # Step 3: Create first student submission for TP to sign - # This is the "seed" submission that will be duplicated - first_student_email = "tp_signature_sample@#{cohort.id}.local" - - first_submission = Submission.create!( - template: cohort.template, - account: tp_user.account, - name: "#{cohort.name} - TP Signature Template", - metadata: { floDoc_cohort_id: cohort.id, is_tp_template: true } - ) - - # Create submitters for first submission - submitter = Submitter.create!( - submission: first_submission, - email: first_student_email, - name: "TP Signature Copy Base", - role: 'student' - ) - - # Link to cohort enrollment - CohortEnrollment.create!( - cohort: cohort, - submission: first_submission, - student_email: first_student_email, - student_name: "TP Template", - role: 'student', - status: 'complete' - ) - - # Return the submission for TP to fill - { submission: first_submission, submitter: submitter } - end - end - - def self.complete_tp_signing(cohort, tp_template_submission) - raise 'TP template submission not completed' unless tp_template_submission.completed? - - ActiveRecord.transaction do - # Extract TP's filled fields from the template submission - tp_template_submitter = tp_template_submission.submitters.first - tp_template_values = tp_template_submitter.values - - # Get all pending student enrollments (for station 2) - # Note: Will be created in Story 2.3 - pending_enrollments = cohort.cohort_enrollments.where(role: 'student', status: 'waiting') - - # Create duplications for remaining students - # This is the CRITICAL step that copies pre-filled data - pending_enrollments.each do |enrollment| - # Duplicate the submission with TP's fields pre-filled - duplicated_submission = Submission.create!( - template: cohort.template, - account: cohort.institution.users.first.account, # TP's account - name: "#{enrollment.student_name}'s Document", - metadata: { - floDoc_cohort_id: cohort.id, - copied_from_template: tp_template_submission.id, - pre_filled_by_tp: true - } - ) - - # Create submitter with TP's pre-filled values - submitter = Submitter.create!( - submission: duplicated_submission, - email: enrollment.student_email, - name: enrollment.student_name, - role: 'student', - # CRITICAL: Copy all TP's filled values - values: tp_template_values.dup - ) - - # Update enrollment to point to new submission - enrollment.update!( - submission: duplicated_submission, - status: 'waiting' - ) - end - - # Mark TP signing as complete - cohort.update!( - status: 'student_enrollment', - tp_signed_at: Time.current - ) - - cohort - end - end -end -``` - -**Submission Duplication Logic:** - -```ruby -# lib/flo_doc/submission_duplicator.rb -module FloDoc - class SubmissionDuplicator - def self.duplicate_with_pre_filled_values(source_submission, new_submitter_email, new_submitter_name) - # This is the PROTOTYPE approach - # Step 1: Get the source submitter's values - source_submitter = source_submission.submitters.first - source_values = source_submitter.values - - # Step 2: Create new submission - new_submission = Submission.create!( - template: source_submission.template, - account: source_submission.account, - name: source_submission.name, - metadata: source_submission.metadata.merge(duplicated: true) - ) - - # Step 3: Create submitter with pre-filled values - new_submitter = Submitter.create!( - submission: new_submission, - email: new_submitter_email, - name: new_submitter_name, - role: 'student', - values: source_values.deep_dup - ) - - # Step 4: Copy field definitions but mark as pre-filled - source_submission.fields.each do |field| - Field.create!( - submission: new_submission, - field_type: field.field_type, - field_name: field.field_name, - field_label: field.field_label, - field_value: field.field_value, - required: field.required, - x: field.x, - y: field.y, - page: field.page, - # Mark as pre-filled (not editable by student) - pre_filled: true, - pre_filled_by: 'tp' - ) - end - - new_submission - end - end -end -``` - -**Sponsor Email Logic (CRITICAL - Prevent Duplicates):** - -```ruby -# app/services/sponsor_notification_service.rb -class SponsorNotificationService - def self.notify_sponsor(cohort) - # CRITICAL: Only send ONE email per cohort - # Check if sponsor already notified - return false if cohort.sponsor_email_sent_at.present? - - # Verify all students completed - return false unless cohort.all_students_completed? - - # Verify cohort is in correct state - return false unless cohort.ready_for_sponsor? - - # Generate single use token for sponsor - token = AdHocTokenService.generate_sponsor_token(cohort) - - # Send ONE email with link to review all students - FloDocMailer.sponsor_review_invitation(cohort, token).deliver_later - - # Mark as sent to prevent duplicates - cohort.update!(sponsor_email_sent_at: Time.current) - - true - end -end -``` - -**Key Design Decisions:** - -1. **TP Template Pattern**: Create a "seed" submission that TP signs first -2. **Deep Duplication**: Copy all field values, not just metadata -3. **Source Tracking**: Remember which submission was copied for audit purposes -4. **Single Email Guarantee**: Timestamp-based prevention of duplicate emails -5. **State Machine Integration**: Cohort state drives when each operation is allowed - -##### Acceptance Criteria - -**Functional:** -1. ✅ TP can initiate TP signing phase from draft state -2. ✅ System creates "seed" submission for TP to sign -3. ✅ TP's signatures and field values are captured -4. ✅ System duplicates submission for all pending students -5. ✅ TP's values auto-fill into all student submissions -6. ✅ Sponsor receives only ONE email when all students ready -7. ✅ Cohort state transitions correctly through workflow - -**Integration:** -1. ✅ IV1: Doesn't break existing submission/submitter logic -2. ✅ IV2: Submissions correctly linked to cohorments -3. ✅ IV3: Performance acceptable with 100 students per cohort - -**Security:** -1. ✅ Only TP can initiate TP signing phase -2. ✅ TP can only sign their institution's cohorts -3. ✅ Pre-filled submissions marked clearly -4. ✅ Audit trail captures all duplication operations - -**Quality:** -1. ✅ Transactional guarantees for multi-submission creation -2. ✅ Proper error handling for partial failures -3. ✅ Debug logging for debugging duplication issues -4. ✅ 85% test coverage - -##### Integration Verification (IV1-3) - -**IV1: Existing Functionality Verification** -- Verify existing submission creation still works -- Verify existing submitter creation still works -- Verify existing field rendering still works -- Run existing submission specs - -**IV2: Duplication Logic Verification** -- Verify TP template submission created correctly -- Verify duplicate submissions have correct field values -- Verify submitter emails are unique -- Verify no duplicate sponsor emails sent - -**IV3: Performance Impact Verification** -- Verify TP signing phase completes in < 1s for 100 students -- Verify duplication doesn't cause DB locks -- Verify sponsor email logic is fast -- Verify state transitions execute quickly - -##### Test Requirements - -**Service Specs:** -```ruby -# spec/services/tp_signing_service_spec.rb -describe TpSigningService do - describe '.initiate_tp_signing' do - it 'creates seed submission for TP' do - cohort = create(:cohort, status: 'draft') - result = described_class.initiate_tp_signing(cohort, tp_user) - - expect(result[:submission]).to be_persisted - expect(result[:submitter]).to be_persisted - expect(cohort.reload.tp_signing?).to be true - end - end - - describe '.complete_tp_signing' do - it 'duplicates submissions for all students' do - cohort = create(:cohort_with_students, student_count: 5) - tp_template = create_completed_tp_template(cohort) - - described_class.complete_tp_signing(cohort, tp_template) - - expect(cohort.cohort_enrollments.students.count).to eq(5) - end - end -end - -# spec/lib/flo_doc/submission_duplicator_spec.rb -describe FloDoc::SubmissionDuplicator do - it 'copies all field values from source' do - source = create_submission_with_values(field1: 'value1') - duplicate = described_class.duplicate_with_pre_filled_values(source, 'new@test.com', 'New Student') - - expect(duplicate.submitters.first.values).to eq(source.submitters.first.values) - end -end -``` - -**Feature Specs:** -- End-to-end TP signing workflow -- Verify sponsor email sent only once -- Verify state transitions - -##### Rollback Procedure - -**If TP signing logic fails:** -1. **Immediate**: Revert service object code -2. **Database**: Check for orphaned "seed" submissions, clean up if needed -3. **Verify**: Existing submission workflow still works -4. **Monitor**: Watch for duplicate sponsor emails - -**Critical**: If duplicated in production, inspect cohort.email_sent_at flags to manually prevent duplicates. - -##### Risk Assessment - -**High Risk because:** -- **Custom duplication logic** - DocuSeal doesn't support this natively -- **Complex transaction** - Creating many submissions atomically -- **Email duplication risk** - Business-critical to prevent -- **State machine complexity** - Multiple coordinated state transitions -- **Data integrity** - Ensuring copied values are accurate - -**Specific Risks:** -1. **DocuSeal API Limitations**: May not support pre-filled fields in certain field types -2. **Database Locks**: Creating 100+ submissions in one transaction could lock tables -3. **Email Duplication**: If sponsor email logic triggers twice, sponsor gets spammed -4. **Field Mapping Errors**: TP's fields might not map correctly to student fields -5. **State Desynchronization**: Cohort state vs. submission states could get out of sync - -**Prototype-First Mitigation:** -1. Build standalone prototype with 2-3 test submissions -2. Validate field copying works for all 12 field types -3. Test sponsor email delivery with mock emails -4. Confirm performance with 100 student cohort in staging -5. Only then integrate into main application - -**Additional Mitigation:** -- **Testing**: Comprehensive unit + integration + feature specs -- **Monitoring**: Log all duplication operations -- **Guards**: Pre-condition checks before every operation -- **Fallback**: Manual rollback procedures documented -- **Version Control**: Feature flag for gradual rollout - -**Critical Success Metrics:** -- Zero duplicate sponsor emails -- 100% correct field value copying -- TP signing phase < 5 seconds for 100 students -- All submissions properly linked to cohort - ---- - -#### Story 2.3: Student Enrollment Management - -**Status**: Draft -**Priority**: High -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 3-4 days -**Risk Level**: Medium - -##### User Story - -**As a** TP administrator, -**I want** to manage student enrollment in cohorts and bulk-create student submissions, -**So that** students can access their documents to complete after TP signs. - -##### Background - -After TP completes signing (Phase 2.2), the system needs to: -- Create student records in the cohort -- Set up individual submissions for each student -- Send email invites (using existing DocuSeal email system) -- Track student completion status - -The enrollment process uses the existing DocuSeal submission invitation mechanism but adapts it to FloDoc's workflow needs. - -**Key Requirements:** -- Bulk enrollment via CSV or manual entry -- Each student gets their own submission (pre-filled with TP's data) -- Email invites sent via DocuSeal's existing email system -- Student status tracked in cohort_enrollments - -##### Technical Implementation Notes - -**Student Enrollment Service:** - -```ruby -# app/services/student_enrollment_service.rb -class StudentEnrollmentService - def self.bulk_enroll(cohort, student_data, tp_user) - raise 'TP signing must be completed first' unless cohort.tp_signing_completed? - - ActiveRecord.transaction do - students = [] - - student_data.each do |data| - # Create cohort enrollment - enrollment = CohortEnrollment.create!( - cohort: cohort, - student_email: data[:email], - student_name: data[:name], - student_surname: data[:surname], - student_id: data[:student_id], - role: 'student', - status: 'waiting' - ) - - # IMPORTANT: We don't create the submission here - # The submission is created by TP Signing Service (Story 2.2) - # This enrollment will be updated with submission_id when TP signs - - students << enrollment - end - - # Log enrollment creation - AuditLog.create!( - user: tp_user, - action: 'bulk_student_enrollment', - entity: cohort, - details: { count: students.count, students: students.map(&:student_email) } - ) - - students - end - end - - def self.invite_students(cohort) - raise 'Cannot invite before TP signs' unless cohort.student_enrollment? - - # Get all waiting student enrollments - enrollments = cohort.cohort_enrollments.students.waiting - - # Send invites using existing DocuSeal mechanism - enrollments.each do |enrollment| - # Generate student token - token = AdHocTokenService.generate_student_token( - cohort, - enrollment.student_email, - enrollment.student_name - ) - - # Use existing DocuSeal email infrastructure - FloDocMailer.student_invitation( - enrollment.student_email, - cohort, - enrollment.submission, - token - ).deliver_later - - # Mark as invited - enrollment.update!(invited_at: Time.current) - end - - true - end -end -``` - -**Student Progress Tracking:** - -```ruby -# app/models/cohort_enrollment.rb (extended) -def mark_student_started! - update!(status: 'in_progress') if waiting? -end - -def mark_student_completed! - update!(status: 'complete', completed_at: Time.current) - - # Check if all students completed - cohort.reload - if cohort.all_students_completed? - cohort.update!(status: 'ready_for_sponsor') - cohort.update!(students_completed_at: Time.current) - - # Trigger sponsor notification (from Story 2.2) - SponsorNotificationService.notify_sponsor(cohort) - end -end -``` - -**Required Student Uploads Tracking:** - -```ruby -# Track which required uploads student has completed -# uploaded_documents structure: { 'id' => true, 'matric' => false, 'tertiary' => true } - -def mark_upload_completed(upload_type) - uploads = uploaded_documents || {} - uploads[upload_type] = true - update!(uploaded_documents: uploads) -end - -def all_uploads_complete? - required = cohort.required_student_uploads - return false if required.blank? - - required.all? { |type| uploaded_documents[type] == true } -end -``` - -**Key Design Decisions:** - -1. **Two-Step Process**: Enrollment first, then TP creates submissions -2. **Existing Email System**: Reuse DocuSeal's email delivery -3. **Status Tracking**: CohortEnrollment tracks all student states -4. **Upload Tracking**: Separate tracking for document uploads vs. form completion -5. **Bulk Operations**: Efficient handling of large student lists - -##### Acceptance Criteria - -**Functional:** -1. ✅ TP can bulk enroll students via CSV or manual entry -2. ✅ System validates student emails before creating enrollments -3. ✅ TP can send email invites to all enrolled students -4. ✅ Students can access their pre-filled documents via link -5. ✅ System tracks student completion (form fill + upload) -6. ✅ System auto-moves cohort to "ready_for_sponsor" when all complete -7. ✅ System triggers sponsor notification once - -**Integration:** -1. ✅ IV1: Works with existing DocuSeal email system -2. ✅ IV2: Enrollment correctly links to submissions created by TP -3. ✅ IV3: Performance handles 500+ student cohorts - -**Security:** -1. ✅ TP can only enroll students in their institution's cohorts -2. ✅ Student access tokens expire after configurable duration (default 30 days) -3. ✅ **Progress is saved independently of token expiration** -4. ✅ **Expired tokens can be renewed via email verification** -5. ✅ Upload tracking prevents unauthorized document completion -6. ✅ Email validation prevents malformed addresses - -**Quality:** -1. ✅ Bulk enrollment has progress feedback -2. ✅ Duplicate email prevention -3. ✅ Proper error messages for invalid data -4. ✅ 85% test coverage - -##### Integration Verification (IV1-3) - -**IV1: Email System Integration** -- Verify emails use existing DocuSeal SMTP configuration -- Verify email templates work with FloDoc variables -- Verify email tracking is recorded -- Verify bounces are handled - -**IV2: Submission Linking** -- Verify enrollments link to correct submissions after TP signs -- Verify submissions have pre-filled TP values -- Verify student access works through token-based auth - -**IV3: Performance** -- Verify bulk enrollment of 500 students < 10 seconds -- Verify email queuing doesn't block enrollment -- Verify student listing query is fast -- Verify completion tracking doesn't cause race conditions - -##### Test Requirements - -**Service Specs:** -```ruby -# spec/services/student_enrollment_service_spec.rb -describe '.bulk_enroll' do - it 'creates enrollment records' do - cohort = create(:cohort) - students = [{ email: 's1@test.com', name: 'Student 1' }] - - result = described_class.bulk_enroll(cohort, students, tp_user) - expect(result.count).to eq(1) - end - - it 'validates TP signing completed' do - cohort = create(:cohort, status: 'draft') - expect { described_class.bulk_enroll(cohort, [], tp_user) }.to raise_error - end -end - -describe '.invite_students' do - it 'sends emails to waiting students' - it 'marks students as invited' - it 'doesn\'t send duplicate invites' -end -``` - -**Email Specs:** -- Verify mailer renders correctly -- Verify token in email link -- Verify tracking pixels (if used) - -##### Rollback Procedure - -**If enrollment service fails:** -1. Revert student_enrollment_service.rb code -2. Verify cohort_enrollments table is intact -3. Check for orphaned records -4. Verify existing DocuSeal email still works - -**Data Safety**: Enrollments are soft-deletable if cleanup needed. - -##### Risk Assessment - -**Medium Risk because:** -- Interaction with existing email system could have side effects -- Bulk operations may cause database performance issues -- State transitions need to handle race conditions -- Student data validation needs to be robust - -**Mitigation:** -- Use transactions for bulk operations -- Validate all student data before creating records -- Implement email rate limiting -- Add database indexes for common queries -- Comprehensive test coverage - ---- - -#### Story 2.4: Sponsor Review Workflow - -**Status**: Draft -**Priority**: Medium -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 2-3 days -**Risk Level**: Low - -##### User Story - -**As a** Sponsor, -**I want** to review all student documents in my cohort and sign them in bulk, -**So that** I can complete the verification workflow efficiently. - -##### Background - -After all students complete their portion, the cohort enters "ready_for_sponsor" state. The sponsor: -- Receives ONE email invitation (Story 2.2 ensures this) -- Gets a portal view showing all students and their documents -- Can review each student's completed document -- Can sign/verify in bulk or individually -- System tracks sponsor completion - -**No account creation needed** - sponsor uses ad-hoc token-based access. - -##### Technical Implementation Notes - -**Sponsor Portal Logic:** - -```ruby -# app/services/sponsor_review_service.rb -class SponsorReviewService - def self.initiate_sponsor_access(cohort) - raise 'Not ready for sponsor' unless cohort.sponsor_access_ready? - - # Generate sponsor token (one-time use or time-limited) - token = AdHocTokenService.generate_sponsor_token(cohort) - - # Update cohort state - cohort.update!(status: 'sponsor_review', sponsor_started_at: Time.current) - - # Send sponsor access email (already covered in Story 2.2) - # This method is if you need to regenerate or resend - - token - end - - def self.get_sponsor_dashboard(cohort, sponsor_token) - # Verify token - decoded = AdHocTokenService.decode_token(sponsor_token) - return nil unless decoded - return nil unless decoded['role'] == 'sponsor' - return nil unless decoded['cohort_id'] == cohort.id - - # Get all student submissions for this cohort - student_enrollments = cohort.cohort_enrollments.students - - { - cohort: cohort, - student_count: student_enrollments.count, - completed_count: student_enrollments.completed.count, - students: student_enrollments.preload(:submission).map do |enrollment| - { - id: enrollment.id, - name: "#{enrollment.student_name} #{enrollment.student_surname}", - email: enrollment.student_email, - status: enrollment.status, - completed_at: enrollment.completed_at, - submission_id: enrollment.submission_id, - # Check if sponsor can review/verify this student - can_review: enrollment.complete? && enrollment.all_uploads_complete? - } - end, - sponsor_can_sign: cohort.all_students_completed? - } - end - - def self.verify_student(cohort, student_enrollment_id, sponsor_token, verify_data) - # Verify sponsor token - decoded = AdHocTokenService.decode_token(sponsor_token) - return false unless decoded - return false unless decoded['role'] == 'sponsor' - return false unless decoded['cohort_id'] == cohort.id - - # Get enrollment - enrollment = cohort.cohort_enrollments.find(student_enrollment_id) - - # Ensure student is complete - return false unless enrollment.complete? - - # Create sponsor verification record - # (Could be a new model or a field on cohort_enrollments) - enrollment.update!( - sponsor_verified_at: Time.current, - sponsor_verification_data: verify_data - ) - - # Update cohort sponsor completion if all students verified - if cohort.all_students_verified? - cohort.update!(sponsor_completed_at: Time.current) - cohort.update!(status: 'tp_review') - end - - true - end -end -``` - -**Token Renewal Mechanism:** - -```ruby -# app/services/ad_hoc_token_service.rb -class AdHocTokenService - # Generate token with configurable expiration (default 30 days) - def self.generate_sponsor_token(cohort, expires_in: nil) - expires_in ||= cohort.cohort_metadata&.dig('token_expiration') || 30.days - - payload = { - role: 'sponsor', - cohort_id: cohort.id, - exp: Time.current.to_i + expires_in.to_i - } - - JWT.encode(payload, Rails.application.secrets.secret_key_base, 'HS256') - end - - def self.generate_student_token(cohort, email, name, expires_in: nil) - expires_in ||= cohort.cohort_metadata&.dig('token_expiration') || 30.days - - payload = { - role: 'student', - cohort_id: cohort.id, - email: email, - name: name, - exp: Time.current.to_i + expires_in.to_i - } - - JWT.encode(payload, Rails.application.secrets.secret_key_base, 'HS256') - end - - # Decode and verify token - def self.decode_token(token) - decoded = JWT.decode( - token, - Rails.application.secrets.secret_key_base, - true, - { algorithm: 'HS256' } - ) - decoded.first - rescue JWT::ExpiredSignature - { expired: true } - rescue JWT::DecodeError - nil - end - - # Renew expired token (requires email verification) - def self.renew_token(cohort, email, role) - # Verify email matches cohort - if role == 'sponsor' && email == cohort.sponsor_email - generate_sponsor_token(cohort) - elsif role == 'student' - enrollment = cohort.cohort_enrollments.find_by(student_email: email) - generate_student_token(cohort, email, enrollment&.student_name) if enrollment - else - nil - end - end - - # Check if token is expired - def self.expired?(token) - decoded = decode_token(token) - decoded&.dig(:expired) == true - end -end -``` - -**Token Renewal Controller:** - -```ruby -# app/controllers/api/v1/token_renewal_controller.rb -class Api::V1::TokenRenewalController < Api::BaseController - skip_before_action :authenticate_user! - - def request_renewal - cohort_id = params[:cohort_id] - email = params[:email] - role = params[:role] # 'sponsor' or 'student' - - cohort = Cohort.find(cohort_id) - - # Verify email matches - if role == 'sponsor' && email == cohort.sponsor_email - new_token = AdHocTokenService.generate_sponsor_token(cohort) - send_renewal_email(cohort, email, new_token, 'sponsor') - render json: { message: 'New access link sent to email' } - - elsif role == 'student' - enrollment = cohort.cohort_enrollments.find_by(student_email: email) - if enrollment - new_token = AdHocTokenService.generate_student_token( - cohort, email, enrollment.student_name - ) - send_renewal_email(cohort, email, new_token, 'student') - render json: { message: 'New access link sent to email' } - else - render json: { error: 'Student not found in this cohort' }, status: :not_found - end - else - render json: { error: 'Email does not match cohort' }, status: :unauthorized - end - end - - private - - def send_renewal_email(cohort, email, token, role) - if role == 'sponsor' - FloDocMailer.sponsor_token_renewal(cohort, email, token).deliver_later - else - FloDocMailer.student_token_renewal(cohort, email, token).deliver_later - end - end -end -``` - -**Email Templates for Renewal:** - -```ruby -# app/mailers/flo_doc_mailer.rb -class FloDocMailer < ApplicationMailer - def sponsor_token_renewal(cohort, email, token) - @cohort = cohort - @token = token - @renewal_link = "#{ENV['APP_URL']}/sponsor/portal?token=#{token}" - - mail( - to: email, - subject: "New access link for #{cohort.name} - FloDoc" - ) - end - - def student_token_renewal(cohort, email, token) - @cohort = cohort - @token = token - @renewal_link = "#{ENV['APP_URL']}/student/portal?token=#{token}" - - mail( - to: email, - subject: "Continue your document for #{cohort.name} - FloDoc" - ) - end -end -``` - -**Progress Preservation Design:** - -```ruby -# Key Principle: Progress is ALWAYS saved to database, independent of token - -# In SponsorReviewService.verify_student -def self.verify_student(cohort, student_enrollment_id, sponsor_token, verify_data) - # ... token verification ... - - # CRITICAL: Save progress IMMEDIATELY - enrollment.update!( - sponsor_verified_at: Time.current, - sponsor_verification_data: verify_data - ) - - # Progress is now in database, independent of token - # Token is just the "key" to access it -end - -# In StudentPortalController.update -def update - enrollment = @cohort.cohort_enrollments.find_by(student_email: @token[:email]) - - # Save student values immediately - submitter = enrollment.submission.submitters.find_by(email: @token[:email]) - merged_values = submitter.values.merge(params[:values]) - submitter.update!(values: merged_values) - - # Progress saved to DB, token expiration doesn't affect it -end -``` - -**Cohort Configuration for Token Expiration:** - -```ruby -# When creating cohort, TP can set token expiration -class CohortManagementService - def self.create_cohort(params, user) - cohort = Cohort.create!( - name: params[:name], - sponsor_email: params[:sponsor_email], - cohort_metadata: { - token_expiration: params[:token_expiration] || 30.days, - # ... other metadata - } - ) - end -end - -# Example cohort_metadata: -{ - "token_expiration": 2592000, # 30 days in seconds - "webhook_urls": ["https://..."], - "required_uploads": ["id", "matric"] -} -``` - -**Key Design Decisions:** - -1. **Progress Persistence**: All data saved to database immediately, independent of token -2. **Configurable Expiration**: TP can set custom expiration per cohort -3. **Renewal via Email**: Expired tokens can be renewed without data loss -4. **Email Verification**: Renewal requires email verification for security -5. **Graceful Degradation**: Expired token shows "renew" UI instead of blocking - -**User Experience with Expired Token:** - -1. User clicks old email link -2. System detects expired token -3. UI shows: "Your access link has expired" -4. User enters email, clicks "Request New Link" -5. System verifies email, sends new link -6. User clicks new link, continues where they left off -7. All progress is preserved - -**Security Considerations:** - -- Token renewal requires email verification -- New token has full access to existing progress -- Old token becomes invalid once new one is issued -- Audit trail tracks all token renewals -- Rate limiting on renewal requests - -**Sponsor Portal - Expired Token Flow:** - -```javascript -// Frontend handles expired token -if (tokenExpired) { - showRenewalForm(); - // User enters email - // POST to /api/v1/token/renewal - // Show "check your email" message -} -``` - -**Student Portal - Expired Token Flow:** - -Same mechanism as sponsor portal. - -##### Key Principle - -**Tokens are session keys, not data storage. Progress is ALWAYS in the database.** - -**Sponsor Verification Model:** - -```ruby -# Optional: New table for sponsor verification details -# app/models/sponsor_verification.rb - -# Only needed if sponsor verification is complex -# For simple approval, can use cohort_enrollments.sponsor_verified_at - -class SponsorVerification < ApplicationRecord - belongs_to :cohort_enrollment - belongs_to :sponsor_verification, optional: true - - validates :status, inclusion: { in: %w[approved rejected needs_changes] } - validates :signature_data, presence: true - - # Store verification details: { date: Time.current, notes: "Approved", signature: "..." } -end -``` - -**Key Design Decisions:** - -1. **Ad-hoc Portal**: No account needed, token-based access -2. **3-Panel Dashboard**: Student list | Document | Verification (FR10, FR11) -3. **Bulk Ready**: Structure supports bulk verification if needed -4. **State-Driven**: Sponsor can only review when all students complete - -##### Acceptance Criteria - -**Functional:** -1. ✅ Sponsor receives single email invitation -2. ✅ Sponsor can access portal via token link -3. ✅ Sponsor sees all student submissions in list -4. ✅ Sponsor can view each student's completed document -5. ✅ Sponsor can verify/approve students individually or in bulk -6. ✅ System tracks sponsor verification per student -7. ✅ Cohort moves to TP review when sponsor completes - -**Integration:** -1. ✅ IV1: Works with existing DocuSeal document viewing -2. ✅ IV2: Sponsor tokens integrate with ad-hoc auth system -3. ✅ IV3: Performance with 100+ students - -**Security:** -1. ✅ Sponsor can only access their assigned cohort -2. ✅ Sponsor access tokens expire after configurable duration (default 30 days) -3. ✅ **Progress is saved independently of token expiration** -4. ✅ **Expired tokens can be renewed via email verification** -5. ✅ Cannot view other cohorts' data -6. ✅ Verification data is tamper-proof - -**Quality:** -1. ✅ Clear UI feedback during verification -2. ✅ Error handling for expired/invalid tokens -3. ✅ Audit trail of sponsor actions -4. ✅ 85% test coverage - -##### Integration Verification (IV1-3) - -**IV1: Document Viewing** -- Verify sponsor can view PDF documents -- Verify DocuSeal's existing rendering is used -- Verify navigation between students works smoothly - -**IV2: Token Integration** -- Verify token generation creates valid sponsor tokens -- Verify token verification prevents unauthorized access -- Verify token expiration works correctly - -**IV3: Performance** -- Verify sponsor dashboard loads in < 2 seconds for 100 students -- Verify document preview is fast -- Verify verification updates don't cause delays - -##### Test Requirements - -**Controller Specs:** -```ruby -# spec/controllers/sponsor_portal_controller_spec.rb -describe SponsorPortalController do - it 'redirects invalid token' do - get :show, params: { id: 1, token: 'invalid' } - expect(response).to redirect_to(root_path) - end - - it 'shows dashboard with token' do - cohort = create(:cohort_with_completed_students) - token = AdHocTokenService.generate_sponsor_token(cohort) - - get :show, params: { id: cohort.id, token: token } - expect(assigns(:students)).to eq(cohort.cohort_enrollments.students) - end -end -``` - -##### Rollback Procedure - -**Review logic fails:** -1. Revert sponsor review service code -2. Verify existing DocuSeal review still works -3. Check token service integrity - -**Data Safety**: All verification data stored with references. - -##### Risk Assessment - -**Low Risk because:** -- Uses existing document viewing infrastructure -- Ad-hoc auth already validated in Story 1.3 -- Simple state transitions -- No complex business logic - -**Mitigation:** -- Validate all token operations -- Clear error messages for sponsors -- Comprehensive logging - ---- - -#### Story 2.5: TP Review & Finalization - -**Status**: Draft -**Priority**: Medium -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 2-3 days -**Risk Level**: Low - -##### User Story - -**As a** TP administrator, -**I want** to review the sponsor-verified submissions and finalize the cohort, -**So that** the entire 3-party signature workflow is completed and documents are ready for archival. - -##### Background - -Final phase of the workflow: -1. Sponsor completes verification (Story 2.4) -2. Cohort enters TP review state -3. TP admin reviews sponsor's verification -4. TP can approve/reject the cohort -5. Cohort moves to completed state -6. All documents finalized and archived - -This is the final quality check in the workflow. - -##### Technical Implementation Notes - -**TP Review Service:** - -```ruby -# app/services/finalization_service.rb -class FinalizationService - def self.initiate_tp_review(cohort, tp_user) - raise 'Cohort not ready for TP review' unless cohort.sponsor_completed? - - cohort.update!(status: 'tp_review') - - AuditLog.create!( - user: tp_user, - action: 'tp_review_started', - entity: cohort - ) - end - - def self.get_tp_review_dashboard(cohort, tp_user) - # Verify user has access - return nil unless tp_user.can?(:review, cohort) - - student_enrollments = cohort.cohort_enrollments.students - - { - cohort: cohort, - sponsor_verification_count: student_enrollments.where.not(sponsor_verified_at: nil).count, - total_students: student_enrollments.count, - students: student_enrollments.map do |enrollment| - { - email: enrollment.student_email, - name: "#{enrollment.student_name} #{enrollment.student_surname}", - sponsor_verified_at: enrollment.sponsor_verified_at, - sponsor_verification_data: enrollment.sponsor_verification_data, - status: enrollment.status - } - end, - can_finalize: cohort.sponsor_completed? - } - end - - def self.finalize_cohort(cohort, tp_user, approval_data) - raise 'Not in TP review state' unless cohort.tp_review? - - ActiveRecord.transaction do - # Mark all submissions as finalized - cohort.cohort_enrollments.update_all(finalized_at: Time.current) - - # Update cohort final state - cohort.update!( - status: 'completed', - finalized_at: Time.current, - completion_metadata: approval_data.merge(completed_by: tp_user.email) - ) - - # Generate completion report - completion_report = generate_completion_report(cohort) - - # Archive all documents (using existing DocuSeal archiving) - ArchiveCohortDocumentsJob.perform_later(cohort.id) - - # Log completion - AuditLog.create!( - user: tp_user, - action: 'cohort_completed', - entity: cohort, - details: { student_count: cohort.cohort_enrollments.count } - ) - - { cohort: cohort, report: completion_report } - end - end - - private - - def self.generate_completion_report(cohort) - { - cohort_id: cohort.id, - cohort_name: cohort.name, - completion_date: Time.current, - student_count: cohort.cohort_enrollments.students.count, - sponsor_email: cohort.sponsor_email, - program_type: cohort.program_type, - metadata: cohort.completion_metadata - } - end -end -``` - -**Final Document Archiving:** - -```ruby -# app/jobs/archive_cohort_documents_job.rb -class ArchiveCohortDocumentsJob < ApplicationJob - queue_as :pdf - - def perform(cohort_id) - cohort = Cohort.find(cohort_id) - - # Use DocuSeal's existing document generation - cohort.cohort_enrollments.students.each do |enrollment| - submission = enrollment.submission - - # Mark submission as archived - submission.update!(archived_at: Time.current) - - # Generate final PDF if not already done - unless submission.completed_document.present? - DocumentGenerationJob.perform_now(submission) - end - end - - # Mark cohort as archived - cohort.update!(archived_at: Time.current) - end -end -``` - -**Key Design Decisions:** - -1. **State-Gated**: Each step requires specific state -2. **Audit Trail**: Complete lifecycle audit from creation to completion -3. **Final Report**: Simple JSON report for TP records -4. **Async Archiving**: Job-based archiving doesn't block web request -5. **Reversible**: Rollback still possible if issues found - -##### Acceptance Criteria - -**Functional:** -1. ✅ TP sees sponsor verification dashboard -2. ✅ TP can review all student verifications -3. ✅ TP can approve/reject individual students -4. ✅ TP can finalize entire cohort -5. ✅ System archives all documents -6. ✅ System generates completion report -7. ✅ Cohort marked as completed -8. ✅ Audit trail complete - -**Integration:** -1. ✅ IV1: Uses existing DocuSeal document archiving -2. ✅ IV2: Completes workflow state machine -3. ✅ IV3: Handles large cohorts without performance issues - -**Security:** -1. ✅ Only TP can access review dashboard -2. ✅ All actions logged with user attribution -3. ✅ Finalized documents cannot be modified -4. ✅ Completion report is read-only - -**Quality:** -1. ✅ Clear indication of verification status -2. ✅ Errors for incomplete verification attempts -3. ✅ Very fast finalization (< 3 seconds) -4. ✅ 80% test coverage - -##### Integration Verification (IV1-3) - -**IV1: DocuSeal Integration** -- Verify existing document archiving works -- Verify PDF generation completes -- Verify existing document storage is respected - -**IV2: State Machine** -- Verify state transitions: tp_review → completed -- Verify cohort status updates correctly -- Verify no orphaned records - -**IV3: Performance** -- Verify review dashboard loads quickly -- Verify finalization performs well with 500 students -- Verify archive job doesn't block - -##### Test Requirements - -**Service Specs:** -```ruby -# spec/services/finalization_service_spec.rb -describe '.finalize_cohort' do - it 'transitions cohort to completed' do - cohort = create(:cohort, status: 'tp_review') - result = described_class.finalize_cohort(cohort, tp_user, {}) - - expect(result[:cohort].status).to eq('completed') - expect(result[:cohort].finalized_at).to be_present - end -end -``` - -##### Rollback Procedure - -**If finalization fails:** -1. Revert finalization service -2. Revert cohort to tp_review state if completed prematurely -3. Ensure documents are not in corrupted state - -**Data Safety**: All operations are idempotent. - -##### Risk Assessment - -**Low Risk because:** -- Final state transitions only -- Uses existing document tools -- Simple approval workflow -- Well-defined error cases - -**Mitigation:** -- Pre-flight checks before finalization -- Idempotent operations -- Clear user feedback - ---- - -#### Story 2.6: Excel Export for Cohort Data - -**Status**: Draft -**Priority**: Medium -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 2-3 days -**Risk Level**: Low - -##### User Story - -**As a** TP administrator, -**I want** to export cohort enrollment data to Excel, -**So that** I can perform additional analysis or reporting outside the system. - -##### Background - -FR23 requires Excel export capability. This should include: -- Student enrollment information -- Document status (completed/incomplete) -- Verification status (TP, students, sponsor) -- Required upload status - -This allows TP admins to use Excel for additional reporting. - -##### Technical Implementation Notes - -**Excel Export Service:** - -```ruby -# app/services/excel_export_service.rb -require 'rubyXL' - -class ExcelExportService - def self.export_cohort_data(cohort) - workbook = RubyXL::Workbook.new - worksheet = workbook[0] - worksheet.sheet_name = "#{cohort.name} - Enrollment Report" - - # Headers - headers = [ - 'Student Name', - 'Student Surname', - 'Email', - 'Student ID', - 'Enrollment Status', - 'Form Completed', - 'Uploads Status', - 'Sponsor Verified At', - 'Completed At' - ] - - headers.each_with_index do |header, index| - worksheet.add_cell(0, index, header) - worksheet[0][index].change_font_bold(true) - end - - # Data rows - row = 1 - cohort.cohort_enrollments.students.each do |enrollment| - worksheet.add_cell(row, 0, enrollment.student_name) - worksheet.add_cell(row, 1, enrollment.student_surname) - worksheet.add_cell(row, 2, enrollment.student_email) - worksheet.add_cell(row, 3, enrollment.student_id || '') - worksheet.add_cell(row, 4, enrollment.status) - worksheet.add_cell(row, 5, enrollment.status == 'complete' ? 'Yes' : 'No') - worksheet.add_cell(row, 6, uploads_status_text(enrollment)) - worksheet.add_cell(row, 7, format_timestamp(enrollment.sponsor_verified_at)) - worksheet.add_cell(row, 8, format_timestamp(enrollment.completed_at)) - row += 1 - end - - # Add summary section - worksheet.add_cell(row + 1, 0, 'Summary') - worksheet.add_cell(row + 2, 0, 'Total Students') - worksheet.add_cell(row + 2, 1, cohort.cohort_enrollments.students.count) - worksheet.add_cell(row + 3, 0, 'Completed') - worksheet.add_cell(row + 3, 1, cohort.cohort_enrollments.completed.count) - worksheet.add_cell(row + 4, 0, 'Sponsor Verified') - worksheet.add_cell(row + 4, 1, cohort.cohort_enrollments.where.not(sponsor_verified_at: nil).count) - - # Generate file - temp_file = Tempfile.new(['cohort_export', '.xlsx']) - workbook.write(temp_file.path) - temp_file - end - - private - - def self.uploads_status_text(enrollment) - return 'N/A' if enrollment.uploaded_documents.blank? - - required = enrollment.cohort.required_student_uploads || [] - completed = enrollment.uploaded_documents.select { |_, v| v }.keys - - if required.empty? - 'No requirements' - elsif completed.size == required.size - 'All completed' - else - "#{completed.size}/#{required.size}" - end - end - - def self.format_timestamp(timestamp) - timestamp ? timestamp.strftime('%Y-%m-%d %H:%M') : '' - end -end -``` - -**Controller Integration:** - -```ruby -# app/controllers/reports_controller.rb -class ReportsController < ApplicationController - before_action :authenticate_user! - load_resource :cohort - - def export - authorize! :export, @cohort - - temp_file = ExcelExportService.export_cohort_data(@cohort) - - send_file( - temp_file.path, - filename: "cohort_#{@cohort.id}_export.xlsx", - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - disposition: 'attachment' - ) - - # Cleanup - temp_file.close - temp_file.unlink - end -end -``` - -**Key Design Decisions:** - -1. **RubyXL Gem**: Industry standard for Excel generation -2. **Simple Structure**: Single worksheet with clear headers -3. **Summary Section**: Key metrics at bottom of spreadsheet -4. **Inline Formatting**: Bold headers, proper date formatting -5. **Tempfile Pattern**: Stream file download without storing on server - -##### Acceptance Criteria - -**Functional:** -1. ✅ TP can export cohort to Excel (.xlsx format) -2. ✅ Export includes all student enrollment data -3. ✅ Export includes completion status -4. ✅ Export includes sponsor verification status -5. ✅ Export includes upload tracking -6. ✅ Export includes summary statistics -7. ✅ File downloads automatically - -**Integration:** -1. ✅ IV1: Works with existing authorization -2. ✅ IV2: Performance acceptable for 500 students -3. ✅ IV3: No memory leaks with large exports - -**Security:** -1. ✅ Only TP can export their institution's cohorts -2. ✅ Export contains no sensitive tokens/PII -3. ✅ File cleaned up after download - -**Quality:** -1. ✅ Clean Excel formatting -2. ✅ Handle special characters in names -3. ✅ Nice error message for empty cohorts -4. ✅ 80% test coverage - -##### Integration Verification (IV1-3) - -**IV1: Export Permissions** -- Verify unauthorized users cannot access export -- Verify TP cannot export other institution's cohorts - -**IV2: Data Completeness** -- Verify all students included in export -- Verify all required columns present -- Verify summary matches database count - -**IV3: Performance** -- Verify export of 500 students < 5 seconds -- Verify memory usage is reasonable -- Verify temp file is cleaned up - -##### Test Requirements - -**Service Specs:** -```ruby -# spec/services/excel_export_service_spec.rb -describe '.export_cohort_data' do - it 'generates valid Excel file' do - cohort = create_cohort_with_students - temp_file = described_class.export_cohort_data(cohort) - - expect(File.exist?(temp_file.path)).to be true - expect(temp_file.path).to end_with('.xlsx') - - # Verify file can be read - workbook = RubyXL::Parser.parse(temp_file.path) - expect(workbook[0].sheet_name).to include(cohort.name) - end -end -``` - -##### Rollback Procedure - -**If export fails:** -1. Revert excel_export_service.rb -2. Verify RubyXL gem installation -3. Check file permissions for temp directory - -**Data Safety**: Export does not modify data. - -##### Risk Assessment - -**Low Risk because:** -- Simple data extraction only -- No database modifications -- Easy to verify output -- RubyXL is mature library - -**Mitigation:** -- Test with various data sets -- Handle edge cases (empty data, special characters) -- Stream large exports to avoid memory issues - ---- - -#### Story 2.7: Audit Log & Compliance - -**Status**: Draft -**Priority**: High -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 2-3 days -**Risk Level**: Medium - -##### User Story - -**As a** TP administrator, -**I want** comprehensive audit logs of all cohort workflow activities, -**So that** we can demonstrate compliance and trace any issues. - -##### Background - -FloDoc handles sensitive training documents. Compliance requires: -- Immutable audit trail of all actions -- Who did what and when -- Document access tracking -- Workflow state changes -- Sponsor access tracking - -All audit logs must be tamper-proof and easily searchable. - -##### Technical Implementation Notes - -**Audit Log Model:** - -```ruby -# app/models/audit_log.rb -class AuditLog < ApplicationRecord - # Immutable audit trail - self.readonly = false # But we will prevent updates - - belongs_to :user, optional: true # nil for system actions - belongs_to :entity, polymorphic: true, optional: true - - validates :action, presence: true - validates :entity_type, presence: true - validates :entity_id, presence: true - - # Prevent any updates or deletes - def readonly? - persisted? # New records can be saved, existing cannot be modified - end - - # Scopes for reporting - scope :recent, -> { order(created_at: :desc) } - scope :for_cohort, ->(cohort_id) { where(entity_type: 'Cohort', entity_id: cohort_id) } - scope :for_type, ->(type) { where(entity_type: type) } - scope :by_action, ->(action) { where(action: action) } - scope :by_user, ->(user_id) { where(user_id: user_id) } - scope :between, ->(start_time, end_time) { where(created_at: start_time..end_time) } - - # JSON serialization for details - serialize :details, JSON - - # Search - def self.search(query) - return all if query.blank? - - where("details LIKE ?", "%#{query}%") - .or(where("action LIKE ?", "%query%")) - end -end -``` - -**Audit Log Creation Module:** - -```ruby -# lib/flo_doc/audit.rb -module FloDoc - module Audit - def self.log(user, action, entity, details = {}) - # Use create! to ensure immutability - # Skip if user is nil and action is not critical - return if user.nil? && !%w[cohort_completed sponsor_accessed].include?(action) - - AuditLog.create!( - user: user, - action: action, - entity_type: entity.class.name, - entity_id: entity.id, - details: details - ) - rescue => e - # Log error silently, don't block main operation - Rails.logger.error "Audit log failed: #{e.message}" - end - - # Convenience methods for common audit events - def self.cohort_created(user, cohort) - log( - user, - 'cohort_created', - cohort, - { - name: cohort.name, - template: cohort.template_id, - sponsor: cohort.sponsor_email, - program_type: cohort.program_type - } - ) - end - - def self.tp_signing_completed(user, cohort) - log( - user, - 'tp_signing_completed', - cohort, - { tp_email: user.email, timestamp: Time.current } - ) - end - - def self.student_invited(user, cohort, student_email) - log( - user, - 'student_invited', - cohort, - { student_email: student_email } - ) - end - - def self.student_completed(cohort, student_email) - log( - nil, - 'student_completed', - cohort, - { student_email: student_email, completed_at: Time.current } - ) - end - - def self.sponsor_accessed(cohort, sponsor_email) - log( - nil, - 'sponsor_accessed', - cohort, - { sponsor_email: sponsor_email } - ) - end - - def self.sponsor_verified(cohort, student_enrollment) - log( - nil, - 'sponsor_verified', - cohort, - { - student_email: student_enrollment.student_email, - verified_at: Time.current - } - ) - end - - def self.cohort_completed(user, cohort) - log( - user, - 'cohort_completed', - cohort, - { - completed_by: user.email, - student_count: cohort.cohort_enrollments.count, - completed_at: Time.current - } - ) - end - - def self.document_accessed(user, submission) - log( - user, - 'document_accessed', - submission, - { document_id: submission.id, accessed_by: user&.email } - ) - end - end -end -``` - -**Document Access Tracking:** - -```ruby -# app/controllers/document_access_controller.rb -class DocumentAccessController < ApplicationController - before_action :authenticate_user_or_verify_token - - def show - # Track document access - FloDoc::Audit.document_accessed(current_user, @submission) - - # Render document using existing DocuSeal view - render 'submissions/show' - end - - private - - def authenticate_user_or_verify_token - # Support both authenticated users and ad-hoc tokens - if params[:token] - @token = AdHocTokenService.decode_token(params[:token]) - @submission = Submission.find(params[:id]) - authorize! :read, @submission, @token - else - authenticate_user! - @submission = Submission.find(params[:id]) - authorize! :read, @submission - end - end -end -``` - -**Audit Report Generation:** - -```ruby -# app/services/audit_report_service.rb -class AuditReportService - def self.generate_cohort_report(cohort, start_date, end_date) - logs = AuditLog.for_cohort(cohort.id) - .between(start_date, end_date) - .recent - - { - cohort: { - id: cohort.id, - name: cohort.name, - created_at: cohort.created_at - }, - period: { - start: start_date, - end: end_date - }, - stats: { - total_events: logs.count, - unique_users: logs.distinct.count(:user_id), - events_by_type: logs.group(:action).count, - workflow_timeline: workflow_timeline(cohort, logs) - }, - events: logs.map do |log| - { - timestamp: log.created_at, - action: log.action, - user: log.user&.email || 'System', - entity: "#{log.entity_type}##{log.entity_id}", - details: log.details - } - end - } - end - - private - - def self.workflow_timeline(cohort, logs) - # Reconstruct key workflow milestones - { - created_at: cohort.created_at, - tp_signing_completed: logs.find { |l| l.action == 'tp_signing_completed' }&.created_at, - student_completions: logs.where(action: 'student_completed').count, - sponsor_access_received: logs.find { |l| l.action == 'sponsor_accessed' }&.created_at, - sponsor_verifications: logs.where(action: 'sponsor_verified').count, - cohort_completed: logs.find { |l| l.action == 'cohort_completed' }&.created_at - } - end -end -``` - -**Integrate Audit Calls:** - -Add audit logging to existing service objects: -```ruby -# In TpSigningService.complete_tp_signing -FloDoc::Audit.tp_signing_completed(user, cohort) - -# In StudentEnrollmentService.bulk_enroll -FloDoc::Audit.cohort_created(user, cohort) - -# In StudentEnrollmentService.invite_students -students.each do |student| - FloDoc::Audit.student_invited(user, cohort, student.student_email) -end - -# In CohortEnrollment.mark_student_completed! -FloDoc::Audit.student_completed(cohort, student_email) - -# In SponsorPortalController -FloDoc::Audit.sponsor_accessed(cohort, sponsor_email) - -# In SponsorReviewService.verify_student -FloDoc::Audit.sponsor_verified(cohort, enrollment) - -# In FinalizationService.finalize_cohort -FloDoc::Audit.cohort_completed(user, cohort) -``` - -**Key Design Decisions:** - -1. **Immutability**: Audit records cannot be updated or deleted -2. **Polymorphic**: Can audit any entity type -3. **Comprehensive**: Every workflow step logged -4. **Searchable**: Scopes and search for compliance reports -5. **User-Friendly**: Report generation for common compliance queries - -##### Acceptance Criteria - -**Functional:** -1. ✅ Every cohort action is logged -2. ✅ All logs are immutable (no updates/deletes) -3. ✅ System actions logged even without user -4. ✅ Document access tracking works -5. ✅ Audit reports can be generated -6. ✅ Reports show workflow timeline -7. ✅ Reports filter by date range -8. ✅ Reports show event counts and breakdowns - -**Integration:** -1. ✅ IV1: Audit calls work without blocking main operations -2. ✅ IV2: Logs integrate with existing models -3. ✅ IV3: Performance impact < 5% on operations - -**Security:** -1. ✅ Audit logs protected from tampering -2. ✅ Only admins can view audit reports -3. ✅ Logs don't contain sensitive data (passwords, tokens) -4. ✅ Immutable storage is enforced - -**Quality:** -1. ✅ Standardized log format -2. ✅ Comprehensive coverage (all actions) -3. ✅ Clear error handling if audit logging fails -4. ✅ 90% test coverage for audit module - -##### Integration Verification (IV1-3) - -**IV1: Audit Calls** -- Verify audit log created for cohort creation -- Verify log created for TP signing -- Verify log created for student invites -- Verify log created for sponsor access -- Verify log created for completion - -**IV2: Immutability** -- Verify cannot update existing logs -- Verify cannot delete logs -- Verify database constraints work - -**IV3: Report Performance** -- Verify report generation < 1 second for 1000 events -- Verify search works faster than 2 seconds -- Verify no memory bloat with large reports - -##### Test Requirements - -**Model Specs:** -```ruby -# spec/models/audit_log_spec.rb -describe AuditLog do - it 'prevents updates' do - log = create(:audit_log) - expect { log.update!(action: 'hacked') }.to raise_error(ActiveRecord::ReadOnlyRecord) - end - - it 'can be created' do - expect { create(:audit_log) }.not_to raise_error - end -end - -# spec/lib/flo_doc/audit_spec.rb -describe FloDoc::Audit do - describe '.log' do - it 'creates audit record' do - cohort = create(:cohort) - expect { - described_class.log(user, 'test_action', cohort, { extra: 'data' }) - }.to change(AuditLog, :count).by(1) - end - end -end -``` - -**Report Specs:** -```ruby -# spec/services/audit_report_service_spec.rb -describe '.generate_cohort_report' do - it 'includes all events in date range' do - cohort = create(:cohort) - create_list(:audit_log, 10, entity: cohort) - - report = described_class.generate_cohort_report( - cohort, - 1.day.ago, - Time.current - ) - - expect(report[:stats][:total_events]).to eq(10) - end -end -``` - -##### Rollback Procedure - -**If audit system fails:** -1. Revert audit logging wrapper -2. Existing audit logs remain intact -3. Manual cleanup of partial audit records if needed -4. Main application unaffected - -**Data Safety**: Audit logs are append-only, no risk to core data. - -##### Risk Assessment - -**Medium Risk because:** -- Every operation now has extra database write -- Immutability means mistakes are permanent -- Performance impact needs monitoring -- Storage requirements grow with usage - -**Mitigation:** -- Use async jobs for non-critical audit events -- Monitor audit table size -- Index database appropriately -- Batch critical audit calls -- Clear retention policy (optional) - -**Critical Success Metrics:** -- Zero performance degradation on core workflows -- 100% audit event coverage -- Audit report generation < 2 seconds for 1000 events -- Storage grows predictably - ---- - -#### Story 2.8: Cohort State Machine & Workflow Orchestration - -**Status**: Draft -**Priority**: High -**Epic**: Phase 2 - Backend Business Logic -**Estimated Effort**: 2-3 days -**Risk Level**: Medium - -##### User Story - -**As a** system, -**I want** to manage cohort state transitions and workflow enforcement, -**So that** the 3-party signature workflow follows the correct sequence and prevents invalid operations. - -##### Background - -The FloDoc workflow has strict state requirements: -1. **Draft** → TP signs first document -2. **TP Signing** → Student enrollment -3. **Student Enrollment** → Student completion -4. **Ready for Sponsor** → Sponsor review -5. **Sponsor Review** → TP verification -6. **TP Review** → Completed - -Cannot skip steps. Prevents chaos. - -This story ties together all Phase 2 logic with proper state enforcement. - -##### Technical Implementation Notes - -**Enhanced State Machine (Story 1.2 had basic version):** - -```ruby -# app/models/cohort.rb (enhanced) -class Cohort < ApplicationRecord - include AASM - include SoftDeletable - - has_many :cohort_enrollments - has_many :submissions, through: :cohort_enrollments - - # State machine with full validation - aasm column: :status, whiny_transitions: true do - state :draft, initial: true - state :tp_signing - state :student_enrollment - state :ready_for_sponsor - state :sponsor_review - state :tp_review - state :completed - - # Event: Start TP Signing (draft → tp_signing) - event :start_tp_signing do - transitions from: :draft, to: :tp_signing, guard: :can_start_tp_signing? - end - - # Event: Complete TP Signing (tp_signing → student_enrollment) - event :complete_tp_signing do - transitions from: :tp_signing, to: :student_enrollment, guard: :can_complete_tp_signing? - end - - # Event: All Students Complete (student_enrollment → ready_for_sponsor) - event :all_students_complete do - transitions from: :student_enrollment, to: :ready_for_sponsor, guard: :can_move_to_ready_for_sponsor? - end - - # Event: Sponsor Starts Review (ready_for_sponsor → sponsor_review) - event :sponsor_starts_review do - transitions from: :ready_for_sponsor, to: :sponsor_review, guard: :can_sponsor_review? - end - - # Event: Sponsor Completes Review (sponsor_review → tp_review) - event :sponsor_completes do - transitions from: :sponsor_review, to: :tp_review, guard: :can_sponsor_complete? - end - - # Event: Finalize (tp_review → completed) - event :finalize do - transitions from: :tp_review, to: :completed, guard: :can_finalize? - end - end - - # Guards for state transitions - def can_start_tp_signing? - errors.clear - errors.add(:status, 'must be draft') unless draft? - errors.add(:template, 'required') unless template_id.present? - errors.add(:sponsor_email, 'required') unless sponsor_email.present? - errors.empty? - end - - def can_complete_tp_signing? - errors.clear - errors.add(:status, 'must be tp_signing') unless tp_signing? - errors.add(:tp_template, 'must be completed') unless tp_template_completed? - errors.empty? - end - - def can_move_to_ready_for_sponsor? - errors.clear - errors.add(:status, 'must be student_enrollment') unless student_enrollment? - errors.add(:students, 'must be enrolled') unless students_enrolled? - errors.add(:students, 'must be all complete') unless all_students_completed? - errors.empty? - end - - def can_sponsor_review? - errors.clear - errors.add(:status, 'must be ready_for_sponsor') unless ready_for_sponsor? - errors.add(:sponsor, 'must be ready') unless sponsor_access_ready? - errors.empty? - end - - def can_sponsor_complete? - errors.clear - errors.add(:status, 'must be sponsor_review') unless sponsor_review? - errors.add(:students, 'all must be verified') unless all_students_verified? - errors.empty? - end - - def can_finalize? - errors.clear - errors.add(:status, 'must be tp_review') unless tp_review? - errors.add(:sponsor, 'must have completed') unless sponsor_completed? - errors.empty? - end - - # Helper methods for guards - def tp_template_completed? - cohort_enrollments.where(role: 'student', student_email: "tp_signature_sample@#{id}.local").completed.exists? - end - - def students_enrolled? - cohort_enrollments.where(role: 'student', student_email: "tp_signature_sample@#{id}.local").blank? && - cohort_enrollments.where(role: 'student').count > 0 - end - - def all_students_completed? - return false if cohort_enrollments.students.empty? - cohort_enrollments.students.where(status: 'complete').count == cohort_enrollments.students.count - end - - def sponsor_access_ready? - all_students_completed? && sponsor_email_sent_at.present? - end - - def all_students_verified? - return false if cohort_enrollments.students.empty? - cohort_enrollments.students.where.not(sponsor_verified_at: nil).count == cohort_enrollments.students.count - end - - # Validations - validate :workflow_order - - def workflow_order - return if draft? - - # Additional runtime checks - case status - when 'tp_signing' - errors.add(:base, 'TP signing requires template') unless template_id.present? - when 'student_enrollment' - errors.add(:base, 'TP must sign first') unless tp_signing_completed? - when 'ready_for_sponsor' - errors.add(:base, 'All students must complete') unless all_students_completed? - end - end -end -``` - -**Workflow Orchestrator:** - -```ruby -# app/services/workflow_orchestrator.rb -class WorkflowOrchestrator - # High-level orchestration that ties together all actions - - def self.start_cohort(cohort, tp_user) - cohort.start_tp_signing! - - # Create seed submission - result = TpSigningService.initiate_tp_signing(cohort, tp_user) - - FloDoc::Audit.cohort_started(cohort, tp_user) - - result - rescue AASM::InvalidTransition => e - { error: e.message, errors: cohort.errors.full_messages } - end - - def self.enroll_students(cohort, student_data, tp_user) - cohort.complete_tp_signing! if cohort.tp_signing? - - # This transitions from tp_signing → student_enrollment - # Error handling handled by AASM - - students = StudentEnrollmentService.bulk_enroll(cohort, student_data, tp_user) - - { - cohort: cohort, - students: students, - next_step: 'send_invites' - } - rescue AASM::InvalidTransition => e - { error: e.message, errors: cohort.errors.full_messages } - end - - def self.mark_student_complete(cohort_enrollment) - cohort_enrollment.mark_student_completed! - - # Check if this completes the cohort - if cohort_enrollment.cohort.all_students_completed? - cohort_enrollment.cohort.all_students_complete! - - # Send sponsor email - SponsorNotificationService.notify_sponsor(cohort_enrollment.cohort) - end - - true - rescue AASM::InvalidTransition => e - Rails.logger.error "Student completion failed: #{e.message}" - false - end - - def self.sponsor_wants_to_review(cohort, sponsor_email) - # Check state - unless cohort.ready_for_sponsor? - return { error: 'Cohort not ready for sponsor review' } - end - - # Transition state - cohort.sponsor_starts_review! - - # Generate token - token = AdHocTokenService.generate_sponsor_token(cohort) - - # Log access - FloDoc::Audit.sponsor_accessed(cohort, sponsor_email) - - { cohort: cohort, token: token } - rescue AASM::InvalidTransition => e - { error: e.message } - end - - def self.sponsor_verifies_student(cohort_enrollment, verify_data) - cohort_enrollment.update!( - sponsor_verified_at: Time.current, - sponsor_verification_data: verify_data - ) - - cohort = cohort_enrollment.cohort - - FloDoc::Audit.sponsor_verified(cohort, cohort_enrollment) - - # Check if all verified - if cohort.all_students_verified? - cohort.sponsor_completes! - end - - true - end - - def self.finalize_cohort(cohort, tp_user, approval_data) - # Transition to tp_review first if not already - cohort.update!(status: 'tp_review') if cohort.sponsor_review? - - # Check final conditions - unless cohort.can_finalize? - return { error: 'Cannot finalize yet', errors: cohort.errors.full_messages } - end - - # Finalize - FinalizationService.finalize_cohort(cohort, tp_user, approval_data) - cohort.finalize! - - FloDoc::Audit.cohort_completed(cohort, tp_user) - - { success: true, cohort: cohort } - rescue AASM::InvalidTransition => e - { error: e.message } - end -end -``` - -**State Validation Controller Filters:** - -```ruby -# app/controllers/cohorts_controller.rb -class CohortsController < ApplicationController - before_action :load_cohort - - # Example validation - def start_signing - unless @cohort.can_start_tp_signing? - return render json: { error: 'Cannot start signing', details: @cohort.errors }, status: :bad_request - end - - result = WorkflowOrchestrator.start_cohort(@cohort, current_user) - - if result.is_a?(Hash) && result[:error] - render json: result, status: :unprocessable_entity - else - render json: { cohort: @cohort, next_step: result } - end - end - - def enroll_students - if @cohort.draft? || @cohort.status.nil? - return render json: { error: 'Cannot enroll - TP must sign first' }, status: :bad_request - end - - # More validations... - end -end -``` - -**Background Job Integration:** - -```ruby -# app/jobs/workflow_validation_job.rb -# Periodic job to check for stuck cohorts -class WorkflowValidationJob < ApplicationJob - queue_as :default - - def perform - # Find cohorts stuck in one state too long - stuck_threshold = 1.day.ago - - stuck_cohorts = Cohort.where('updated_at < ?', stuck_threshold) - .where(status: %w[tp_signing student_enrollment sponsor_review tp_review]) - - stuck_cohorts.each do |cohort| - # Notify admin or extend token expiration - # Log warning - FloDoc::Audit.workflow_stuck(cohort, "Cohort stuck in #{cohort.status}") - end - end -end -``` - -##### Acceptance Criteria - -**Functional:** -1. ✅ State transitions work via AASM events -2. ✅ Guards prevent invalid transitions -3. ✅ All workflow steps enforced -4. ✅ Cohort cannot skip steps -5. ✅ Workflow validations provide clear errors -6. ✅ System prevents operations in wrong states -7. ✅ Background job detects stuck cohorts - -**Integration:** -1. ✅ IV1: State machine works with existing services -2. ✅ IV2: Audit logs all state changes -3. ✅ IV3: No impact on performance - -**Security:** -1. ✅ Guards prevent unauthorized bypassing -2. ✅ State changes logged -3. ✅ Cannot manually set arbitrary states - -**Quality:** -1. ✅ Clear error messages for failed transitions -2. ✅ State validation in errors -3. ✅ 85% test coverage for state machine - -##### Integration Verification (IV1-3) - -**IV1: State Transitions** -- Verify draft → tp_signing works -- Verify tp_signing → student_enrollment works -- Verify student_enrollment → ready_for_sponsor works -- Verify ready_for_sponsor → sponsor_review works -- Verify sponsor_review → tp_review works -- Verify tp_review → completed works - -**IV2: Guard Validation** -- Verify cannot skip tp_signing -- Verify cannot enroll before tp_signing -- Verify cannot sponsor review before students complete -- Verify all guards return proper error messages - -**IV3: Performance** -- Verify state transitions execute in < 50ms -- Verify AASM doesn't add significant overhead -- Verify guards are fast - -##### Test Requirements - -**State Machine Specs:** -```ruby -# spec/models/cohort/state_machine_spec.rb -describe Cohort, 'state machine' do - let(:cohort) { create(:cohort) } - - describe 'draft → tp_signing' do - it 'transitions when valid' do - cohort.start_tp_signing! - expect(cohort.tp_signing?).to be true - end - - it 'fails without template' do - cohort.update!(template_id: nil) - expect { cohort.start_tp_signing! }.to raise_error(AASM::InvalidTransition) - end - end - - describe 'full workflow' do - it 'can complete all phases' do - cohort = create(:cohort) - - cohort.start_tp_signing! - expect(cohort.tp_signing?).to be true - - cohort.complete_tp_signing! - expect(cohort.student_enrollment?).to be true - - # ... populate mock data - - cohort.all_students_complete! - expect(cohort.ready_for_sponsor?).to be true - - cohort.sponsor_starts_review! - expect(cohort.sponsor_review?).to be true - - cohort.sponsor_completes! - expect(cohort.tp_review?).to be true - - cohort.finalize! - expect(cohort.completed?).to be true - end - end -end -``` - -**Orchestrator Specs:** -```ruby -# spec/services/workflow_orchestrator_spec.rb -describe WorkflowOrchestrator do - it 'handles full cohort lifecycle' do - cohort = create(:cohort) - tp_user = create(:tp_user) - - result = described_class.start_cohort(cohort, tp_user) - expect(result[:submission]).to be_present - - # Add students - students = [{ email: 's1@test.com', name: 'S1' }] - result = described_class.enroll_students(cohort, students, tp_user) - expect(result[:students].count).to eq(1) - - # ... complete full workflow - end -end -``` - -##### Rollback Procedure - -**If state machine causes issues:** -1. Revert cohort model changes -2. Restore original state machine -3. Verify existing cohorts still accessible -4. Clean up any invalid state records - -**Data Safety**: State machine is code, not data migration. - -##### Risk Assessment - -**Medium Risk because:** -- State management is critical to workflow -- Guards could have logical errors -- AASM gem could have edge cases -- Existing workflow patterns need adaptation - -**Mitigation:** -- Comprehensive testing with all state combinations -- Staging environment full workflow tests -- Clear error messaging for operators -- Gradual rollout with monitoring -- Manual state override tools (for emergencies) - -**Critical Checks:** -- Guard methods return true/false -- Errors accumulate on cohort object -- Transition failures don't leave partial state -- Audit logs capture all transitions - ---- - -### 6.3 Phase 3: API Layer - -This section provides detailed user stories for Phase 3 (API Layer) of the FloDoc enhancement. This phase creates the RESTful API endpoints that expose the 3-portal cohort management system to external integrations and frontend clients. - -#### Story 3.1: RESTful Cohort Management API - -**Status**: Draft -**Priority**: Critical -**Epic**: Phase 3 - API Layer -**Estimated Effort**: 2-3 days -**Risk Level**: Low - -##### User Story - -**As a** TP administrator or external system integrator, -**I want** to create, read, update, and delete cohorts via REST API, -**So that** I can automate cohort management and integrate with other systems. - -##### Background - -The FloDoc system needs a complete REST API for cohort management. This API will be used by: - -1. **TP Portal** (Vue.js frontend) - All cohort operations -2. **External systems** - Programmatic cohort creation and management -3. **Automation scripts** - Bulk operations and integrations - -The API should follow JSON:API standards and include proper authentication/authorization. - -##### Technical Implementation Notes - -**API Endpoints:** - -```ruby -# config/routes.rb -namespace :api do - namespace :v1 do - resources :cohorts do - member do - post :start_signing - post :enroll_students - post :send_invites - get :status - get :export - end - end - - resources :cohort_enrollments do - member do - post :mark_completed - end - end - - # Sponsor portal endpoints (ad-hoc token based) - namespace :sponsor do - get ':token/dashboard', to: 'portal#dashboard' - get ':token/students', to: 'portal#students' - post ':token/verify/:student_id', to: 'portal#verify' - end - end -end -``` - -**API Controller:** - -```ruby -# app/controllers/api/v1/cohorts_controller.rb -class Api::V1::CohortsController < Api::BaseController - before_action :load_cohort, only: [:show, :update, :destroy, :start_signing, :enroll_students] - load_and_authorize_resource - - def index - @cohorts = current_user.institution.cohorts - .where(deleted_at: nil) - .order(created_at: :desc) - .page(params[:page]) - .per(params[:per_page] || 20) - - render json: { - data: @cohorts, - meta: { - page: @cohorts.current_page, - total: @cohorts.total_count, - per_page: @cohorts.limit_value - } - } - end - - def show - render json: { - data: @cohort, - included: { - enrollments: @cohort.cohort_enrollments.students, - template: @cohort.template - } - } - end - - def create - @cohort = CohortManagementService.create_cohort(cohort_params, current_user) - - render json: { - data: @cohort, - message: 'Cohort created successfully' - }, status: :created - end - - def update - @cohort = CohortManagementService.update_cohort(@cohort, cohort_params, current_user) - - render json: { - data: @cohort, - message: 'Cohort updated successfully' - } - end - - def destroy - CohortManagementService.delete_cohort(@cohort, current_user) - - render json: { - message: 'Cohort deleted successfully' - }, status: :no_content - end - - def start_signing - result = WorkflowOrchestrator.start_cohort(@cohort, current_user) - - if result.is_a?(Hash) && result[:error] - render json: { error: result[:error], errors: result[:errors] }, status: :unprocessable_entity - else - render json: { - data: result, - message: 'TP signing phase started' - } - end - end - - def enroll_students - students = params[:students] || [] - - result = WorkflowOrchestrator.enroll_students(@cohort, students, current_user) - - if result.is_a?(Hash) && result[:error] - render json: { error: result[:error], errors: result[:errors] }, status: :unprocessable_entity - else - render json: { - data: result, - message: 'Students enrolled successfully' - } - end - end - - def send_invites - success = StudentEnrollmentService.invite_students(@cohort) - - if success - render json: { message: 'Invitations sent successfully' } - else - render json: { error: 'Cannot send invitations at this time' }, status: :unprocessable_entity - end - end - - def status - render json: { - data: { - id: @cohort.id, - status: @cohort.status, - student_count: @cohort.cohort_enrollments.students.count, - completed_count: @cohort.cohort_enrollments.students.completed.count, - sponsor_ready: @cohort.ready_for_sponsor?, - can_finalized: @cohort.tp_review? - } - } - end - - def export - authorize! :export, @cohort - - temp_file = ExcelExportService.export_cohort_data(@cohort) - - send_file( - temp_file.path, - filename: "cohort_#{@cohort.id}_export.xlsx", - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - disposition: 'attachment' - ) - end - - private - - def cohort_params - params.require(:cohort).permit( - :template_id, - :name, - :program_type, - :sponsor_email, - required_student_uploads: [], - cohort_metadata: {} - ) - end - - def load_cohort - @cohort = Cohort.find(params[:id]) - end -end -``` - -**Sponsor Portal API (Ad-hoc):** - -```ruby -# app/controllers/api/v1/sponsor/portal_controller.rb -class Api::V1::Sponsor::PortalController < Api::BaseController - skip_before_action :authenticate_user! - before_action :verify_token - - def dashboard - data = SponsorReviewService.get_sponsor_dashboard(@cohort, params[:token]) - - if data - render json: { data: data } - else - render json: { error: 'Invalid or expired token' }, status: :unauthorized - end - end - - def students - enrollments = @cohort.cohort_enrollments.students.preload(:submission) - - render json: { - data: enrollments.map do |e| - { - id: e.id, - name: "#{e.student_name} #{e.student_surname}", - email: e.student_email, - status: e.status, - completed_at: e.completed_at, - can_review: e.complete? - } - end - } - end - - def verify - enrollment = @cohort.cohort_enrollments.students.find(params[:student_id]) - - success = WorkflowOrchestrator.sponsor_verifies_student( - enrollment, - params[:verification_data] || {} - ) - - if success - render json: { message: 'Student verified successfully' } - else - render json: { error: 'Verification failed' }, status: :unprocessable_entity - end - end - - private - - def verify_token - @token = AdHocTokenService.decode_token(params[:token]) - - if @token.nil? || @token['role'] != 'sponsor' - render json: { error: 'Invalid sponsor token' }, status: :unauthorized - return - end - - @cohort = Cohort.find(@token['cohort_id']) - end -end -``` - -**API Response Standards:** - -```ruby -# app/controllers/api/base_controller.rb -class Api::BaseController < ActionController::API - include CanCan::Ability - rescue_from ActiveRecord::RecordNotFound, with: :record_not_found - rescue_from ActiveRecord::RecordInvalid, with: :record_invalid - rescue_from CanCan::AccessDenied, with: :access_denied - - private - - def record_not_found - render json: { error: 'Resource not found' }, status: :not_found - end - - def record_invalid(exception) - render json: { - error: 'Validation failed', - errors: exception.record.errors.full_messages - }, status: :unprocessable_entity - end - - def access_denied - render json: { error: 'Access denied' }, status: :forbidden - end -end -``` - -**Key Design Decisions:** - -1. **JSON:API Compliance**: Structured responses with data, included, and meta sections -2. **Token-based Sponsor API**: Ad-hoc access without authentication -3. **Service Integration**: All endpoints use existing service objects -4. **Authorization**: Cancancan throughout, with special handling for ad-hoc tokens -5. **Error Handling**: Standardized error responses across all endpoints -6. **Pagination**: Built-in for collections - -##### Acceptance Criteria - -**Functional:** -1. ✅ GET /api/v1/cohorts - List cohorts with pagination -2. ✅ POST /api/v1/cohorts - Create cohort -3. ✅ GET /api/v1/cohorts/:id - Show cohort details -4. ✅ PUT /api/v1/cohorts/:id - Update cohort -5. ✅ DELETE /api/v1/cohorts/:id - Delete cohort (soft) -6. ✅ POST /api/v1/cohorts/:id/start_signing - Start TP signing phase -7. ✅ POST /api/v1/cohorts/:id/enroll_students - Bulk enroll students -8. ✅ POST /api/v1/cohorts/:id/send_invites - Send student invitations -9. ✅ GET /api/v1/cohorts/:id/status - Get cohort status -10. ✅ GET /api/v1/cohorts/:id/export - Export to Excel -11. ✅ GET /api/v1/sponsor/:token/dashboard - Sponsor dashboard -12. ✅ POST /api/v1/sponsor/:token/verify/:student_id - Sponsor verification - -**Integration:** -1. ✅ IV1: All endpoints use existing service objects -2. ✅ IV2: Authorization works through CancanCan -3. ✅ IV3: API response time < 500ms for standard queries - -**Security:** -1. ✅ Authenticated access for TP endpoints -2. ✅ Token validation for sponsor endpoints -3. ✅ Authorization checks on all operations -4. ✅ No sensitive data leaked in responses -5. ✅ Rate limiting (if applicable) - -**Quality:** -1. ✅ Consistent response format across all endpoints -2. ✅ Proper HTTP status codes -3. ✅ Standardized error messages -4. ✅ API documentation in code comments -5. ✅ 85% test coverage - -##### Integration Verification (IV1-3) - -**IV1: Service Object Integration** -- Verify all endpoints call appropriate service methods -- Verify service layer handles business logic -- Verify controllers are thin wrappers only - -**IV2: Authorization** -- Verify authenticated users can only access their institution's data -- Verify sponsor tokens are properly validated -- Verify unauthorized requests receive 403 responses - -**IV3: Performance** -- Verify index endpoint paginates correctly -- Verify show endpoint returns in < 200ms -- Verify bulk operations handle 1000 records - -##### Test Requirements - -**Request Specs:** -```ruby -# spec/requests/api/v1/cohorts_spec.rb -describe Api::V1::CohortsController do - describe 'POST /api/v1/cohorts' do - it 'creates cohort with valid params' do - post '/api/v1/cohorts', params: { cohort: valid_params }, headers: auth_headers - - expect(response).to have_http_status(:created) - expect(json_response['data']['name']).to eq('Test Cohort') - end - end - - describe 'POST /api/v1/cohorts/:id/start_signing' do - it 'starts TP signing phase' do - post "/api/v1/cohorts/#{cohort.id}/start_signing", headers: auth_headers - - expect(response).to have_http_status(:ok) - expect(cohort.reload.status).to eq('tp_signing') - end - end -end - -# spec/requests/api/v1/sponsor/portal_spec.rb -describe Api::V1::Sponsor::PortalController do - describe 'GET /api/v1/sponsor/:token/dashboard' do - it 'returns dashboard with valid token' do - token = AdHocTokenService.generate_sponsor_token(cohort) - get "/api/v1/sponsor/#{token}/dashboard" - - expect(response).to have_http_status(:ok) - expect(json_response['data']['cohort']['id']).to eq(cohort.id) - end - end -end -``` - -##### Rollback Procedure - -**If API causes issues:** -1. Remove API routes for affected endpoints -2. Revert controller changes -3. Verify existing DocuSeal routes still work -4. Check for broken frontend integrations - -**Data Safety**: API layer is read/write wrapper around existing services. - -##### Risk Assessment - -**Low Risk because:** -- Thin controllers calling tested service objects -- Existing authorization patterns (Cancancan) -- Standard REST conventions -- No custom business logic in controllers - -**Specific Risks:** -1. **Breaking Changes**: Changes to API contract affect consumers -2. **Security**: Token exposure could lead to unauthorized access -3. **Performance**: N+1 queries without proper eager loading - -**Mitigation:** -- Version API (v1) for backward compatibility -- Use standard response formats -- Document all endpoints thoroughly -- Comprehensive request spec coverage -- Monitor API usage and errors - ---- - -#### Story 3.2: Webhook Events for Workflow State Changes - -**Status**: Draft -**Priority**: Medium -**Epic**: Phase 3 - API Layer -**Estimated Effort**: 2-3 days -**Risk Level**: Low - -##### User Story - -**As a** TP administrator, -**I want** webhook notifications for all cohort workflow events, -**So that** external systems can react to state changes in real-time. - -##### Background - -FloDoc should emit webhooks for: -- Cohort created -- TP signing completed -- Student enrolled -- Student completed -- Sponsor accessed portal -- Sponsor verified student -- Cohort completed - -This allows external integrations (e.g., CRM, LMS, reporting systems) to stay in sync. - -##### Technical Implementation Notes - -**Webhook Event Model:** - -```ruby -# app/models/webhook_event.rb -class WebhookEvent < ApplicationRecord - belongs_to :cohort - - validates :event_type, presence: true - validates :payload, presence: true - - scope :pending, -> { where(status: 'pending') } - scope :failed, -> { where(status: 'failed') } - scope :delivered, -> { where(status: 'delivered') } - - def mark_delivered! - update!(status: 'delivered', delivered_at: Time.current) - end - - def mark_failed!(error) - update!(status: 'failed', error_message: error, failed_at: Time.current) - end -end -``` - -**Webhook Emitter Service:** - -```ruby -# app/services/webhook_emitter_service.rb -class WebhookEmitterService - def self.emit_cohort_created(cohort, user) - payload = { - event: 'cohort.created', - timestamp: Time.current, - data: { - cohort_id: cohort.id, - cohort_name: cohort.name, - template_id: cohort.template_id, - created_by: user.email, - sponsor_email: cohort.sponsor_email, - program_type: cohort.program_type - } - } - - create_and_queue_event(cohort, payload) - end - - def self.emit_tp_signing_completed(cohort, user) - payload = { - event: 'cohort.tp_signing_completed', - timestamp: Time.current, - data: { - cohort_id: cohort.id, - cohort_name: cohort.name, - tp_email: user.email, - tp_signed_at: cohort.tp_signed_at - } - } - - create_and_queue_event(cohort, payload) - end - - def self.emit_student_enrolled(cohort, enrollment) - payload = { - event: 'cohort.student_enrolled', - timestamp: Time.current, - data: { - cohort_id: cohort.id, - student_email: enrollment.student_email, - student_name: enrollment.student_name, - enrollment_id: enrollment.id - } - } - - create_and_queue_event(cohort, payload) - end - - def self.emit_student_completed(cohort, enrollment) - payload = { - event: 'cohort.student_completed', - timestamp: Time.current, - data: { - cohort_id: cohort.id, - student_email: enrollment.student_email, - student_name: enrollment.student_name, - enrollment_id: enrollment.id, - completed_at: enrollment.completed_at - } - } - - create_and_queue_event(cohort, payload) - end - - def self.emit_sponsor_accessed(cohort, sponsor_email) - payload = { - event: 'cohort.sponsor_accessed', - timestamp: Time.current, - data: { - cohort_id: cohort.id, - sponsor_email: sponsor_email, - accessed_at: Time.current - } - } - - create_and_queue_event(cohort, payload) - end - - def self.emit_sponsor_verified(cohort, enrollment) - payload = { - event: 'cohort.sponsor_verified', - timestamp: Time.current, - data: { - cohort_id: cohort.id, - student_email: enrollment.student_email, - verified_at: enrollment.sponsor_verified_at, - verification_data: enrollment.sponsor_verification_data - } - } - - create_and_queue_event(cohort, payload) - end - - def self.emit_cohort_completed(cohort, user) - payload = { - event: 'cohort.completed', - timestamp: Time.current, - data: { - cohort_id: cohort.id, - cohort_name: cohort.name, - completed_by: user.email, - completed_at: cohort.finalized_at, - student_count: cohort.cohort_enrollments.students.count - } - } - - create_and_queue_event(cohort, payload) - end - - private - - def self.create_and_queue_event(cohort, payload) - # Get cohort's webhook URL(s) - webhook_urls = cohort.cohort_metadata&.dig('webhook_urls') || [] - return if webhook_urls.empty? - - webhook_urls.each do |url| - event = WebhookEvent.create!( - cohort: cohort, - event_type: payload[:event], - payload: payload, - webhook_url: url, - status: 'pending' - ) - - # Queue for delivery - WebhookDeliveryJob.perform_later(event.id) - end - end -end -``` - -**Webhook Delivery Job:** - -```ruby -# app/jobs/webhook_delivery_job.rb -class WebhookDeliveryJob < ApplicationJob - queue_as :webhooks - retry_on StandardError, attempts: 5, wait: :exponentially_longer - - def perform(webhook_event_id) - event = WebhookEvent.find(webhook_event_id) - return unless event.pending? - - begin - # Make HTTP request - response = Faraday.post(event.webhook_url) do |req| - req.headers['Content-Type'] = 'application/json' - req.headers['X-FloDoc-Signature'] = generate_signature(event) - req.headers['X-FloDoc-Event'] = event.event_type - req.body = event.payload.to_json - end - - if response.success? - event.mark_delivered! - FloDoc::Audit.webhook_delivered(event) - else - raise "Webhook delivery failed: #{response.status}" - end - - rescue StandardError => e - event.mark_failed!(e.message) - FloDoc::Audit.webhook_failed(event, e.message) - raise # Trigger retry - end - end - - private - - def generate_signature(event) - payload = "#{event.id}:#{event.payload.to_json}:#{Rails.application.secrets.secret_key_base}" - Digest::SHA256.hexdigest(payload) - end -end -``` - -**Integration Points:** - -```ruby -# In CohortManagementService.create_cohort -after_create do |cohort| - WebhookEmitterService.emit_cohort_created(cohort, user) -end - -# In TpSigningService.complete_tp_signing -WebhookEmitterService.emit_tp_signing_completed(cohort, tp_user) - -# In StudentEnrollmentService.bulk_enroll -enrollments.each do |enrollment| - WebhookEmitterService.emit_student_enrolled(cohort, enrollment) -end - -# In WorkflowOrchestrator.mark_student_complete -WebhookEmitterService.emit_student_completed(cohort, enrollment) - -# In SponsorPortalController (after access) -WebhookEmitterService.emit_sponsor_accessed(cohort, sponsor_email) - -# In SponsorReviewService.verify_student -WebhookEmitterService.emit_sponsor_verified(cohort, enrollment) - -# In FinalizationService.finalize_cohort -WebhookEmitterService.emit_cohort_completed(cohort, user) -``` - -**Webhook Configuration:** - -Cohort metadata should include: -```json -{ - "webhook_urls": [ - "https://api.partner.com/floDoc/webhook", - "https://crm.example.com/webhooks/floDoc" - ], - "webhook_secret": "optional_custom_secret_for_verification" -} -``` - -**Key Design Decisions:** - -1. **Reliability**: Retry with exponential backoff -2. **Audit Trail**: All webhook events logged -3. **Signature**: HMAC signature for verification -4. **Async Delivery**: Background jobs prevent blocking -5. **Multiple URLs**: Support multiple endpoints per cohort -6. **Event Naming**: Consistent "entity.action" pattern - -##### Acceptance Criteria - -**Functional:** -1. ✅ Webhook events created for all 7 event types -2. ✅ Events queued for background delivery -3. ✅ Signature generation works correctly -4. ✅ Retry logic handles failures -5. ✅ Multiple webhook URLs supported -6. ✅ Event status tracking (pending/delivered/failed) -7. ✅ Payload includes all relevant data - -**Integration:** -1. ✅ IV1: Events emitted at correct workflow points -2. ✅ IV2: Delivery job executes successfully -3. ✅ IV3: No impact on main workflow performance - -**Security:** -1. ✅ Signatures prevent tampering -2. ✅ HTTPS URLs only (validation) -3. ✅ Secrets can be customized per cohort -4. ✅ Failed deliveries don't expose sensitive data - -**Quality:** -1. ✅ Clear event naming convention -2. ✅ Comprehensive audit logging -3. ✅ Error messages for debugging -4. ✅ 85% test coverage - -##### Integration Verification (IV1-3) - -**IV1: Event Emission** -- Verify all 7 event types are emitted -- Verify events are created with correct payload -- Verify events are queued for delivery - -**IV2: Delivery** -- Verify webhook signature generation -- Verify HTTP POST request format -- Verify retry on failure -- Verify status updates on success/failure - -**IV3: Performance** -- Verify event creation doesn't block workflow -- Verify queue performance under load -- Verify no N+1 queries in event creation - -##### Test Requirements - -**Service Specs:** -```ruby -# spec/services/webhook_emitter_service_spec.rb -describe WebhookEmitterService do - it 'creates event for cohort creation' do - cohort = create(:cohort) - user = create(:user) - - expect { - described_class.emit_cohort_created(cohort, user) - }.to change(WebhookEvent, :count).by(1) - - event = WebhookEvent.last - expect(event.event_type).to eq('cohort.created') - expect(event.payload[:event]).to eq('cohort.created') - end -end - -# spec/jobs/webhook_delivery_job_spec.rb -describe WebhookDeliveryJob do - it 'delivers webhook successfully' do - stub_request(:post, 'https://test.example.com/webhook') - .to_return(status: 200, body: 'OK') - - event = create(:webhook_event) - described_class.perform_now(event.id) - - expect(event.reload.status).to eq('delivered') - end -end -``` - -**Feature Specs:** -- Full workflow triggers all expected webhooks -- Webhook retries work correctly - -##### Rollback Procedure - -**If webhooks cause issues:** -1. Disable WebhookDeliveryJob -2. Remove event emission calls -3. Existing data remains intact -4. Manual cleanup of pending events if needed - -**Data Safety**: Webhook events are append-only log. - -##### Risk Assessment - -**Low Risk because:** -- Decoupled from main workflow via background jobs -- Failures don't affect core functionality -- Retry mechanism ensures eventual delivery - -**Specific Risks:** -1. **Delivery Failures**: Partner endpoints may be down -2. **Payload Size**: Large cohorts create large payloads -3. **Rate Limits**: Partners may throttle -4. **Data Privacy**: Information going external - -**Mitigation:** -- Exponential backoff retry -- Payload size limits -- Configurable webhook URLs -- Audit all deliveries -- Signature verification option - ---- - -#### Story 3.3: Student API (Ad-hoc Token-Based Access) - -**Status**: Draft -**Priority**: Medium -**Epic**: Phase 3 - API Layer -**Estimated Effort**: 3-4 days -**Risk Level**: Medium - -##### User Story - -**As a** student with a cohort link, -**I want** a simple token-based API to access and complete my documents, -**So that** I can fulfill my requirements without account creation. - -##### Background - -Students need to: -- Access their pre-filled document -- Fill in their personal fields -- Upload required documents -- Submit the final document -- All without creating an account - -This requires a secure token-based API that: -- Validates tokens against cohort and email -- Exposes only student's own document -- Tracks completion status - -##### Technical Implementation Notes - -**Student API Controller:** - -```ruby -# app/controllers/api/v1/students/portal_controller.rb -class Api::V1::Students::PortalController < Api::BaseController - skip_before_action :authenticate_user! - before_action :verify_student_token - - def show - # Return student's document data with pre-filled TP values - enrollment = @cohort.cohort_enrollments.find_by(student_email: @token[:email]) - - unless enrollment - return render json: { error: 'Student not found in cohort' }, status: :not_found - end - - submission = enrollment.submission - tp_fields = submission.submitters.where.not(email: @token[:email]).first&.values || {} - - render json: { - data: { - id: enrollment.id, - cohort_name: @cohort.name, - program_type: @cohort.program_type, - required_uploads: @cohort.required_student_uploads, - tp_pre_filled_values: tp_fields, - student_fields_needed: student_fields_needed(submission), - upload_status: enrollment.uploaded_documents, - status: enrollment.status, - completed_at: enrollment.completed_at, - tp_name: @cohort.institution&.name - } - } - end - - def update - enrollment = @cohort.cohort_enrollments.find_by(student_email: @token[:email]) - - unless enrollment - return render json: { error: 'Student not found' }, status: :not_found - end - - if enrollment.complete? - return render json: { error: 'Document already completed' }, status: :unprocessable_entity - end - - # Update student's values in the submitter - submitter = enrollment.submission.submitters.find_by(email: @token[:email]) - - # Merge new values with existing (keeping TP's pre-filled values) - new_values = params[:values] || {} - existing_values = submitter.values || {} - merged_values = existing_values.merge(new_values) - - submitter.update!(values: merged_values) - enrollment.mark_in_progress! - - render json: { - message: 'Progress saved', - status: 'in_progress' - } - end - - def upload_document - enrollment = @cohort.cohort_enrollments.find_by(student_email: @token[:email]) - - unless enrollment - return render json: { error: 'Student not found' }, status: :not_found - end - - document_type = params[:document_type] - - unless @cohort.required_student_uploads.include?(document_type) - return render json: { error: 'Invalid document type' }, status: :unprocessable_entity - end - - # Handle file upload via Active Storage - file = params[:file] - - unless file - return render json: { error: 'File required' }, status: :unprocessable_entity - end - - # Store file reference - uploads = enrollment.uploaded_documents || {} - uploads[document_type] = { - uploaded_at: Time.current, - filename: file.original_filename, - content_type: file.content_type - } - - enrollment.update!(uploaded_documents: uploads) - - render json: { - message: 'Document uploaded successfully', - document_type: document_type, - uploaded_documents: uploads - } - end - - def submit - enrollment = @cohort.cohort_enrollments.find_by(student_email: @token[:email]) - - unless enrollment - return render json: { error: 'Student not found' }, status: :not_found - end - - # Validation: Check all required fields filled - submitter = enrollment.submission.submitters.find_by(email: @token[:email]) - - if submitter.values.blank? - return render json: { error: 'Please fill in all required fields' }, status: :unprocessable_entity - end - - # Validation: Check all required uploads complete - required = @cohort.required_student_uploads || [] - completed = (enrollment.uploaded_documents || {}).keys - - unless required.all? { |type| completed.include?(type) } - missing = required - completed - return render json: { - error: 'Missing required uploads', - missing_uploads: missing - }, status: :unprocessable_entity - end - - # Mark as complete - enrollment.mark_student_completed! - - render json: { - message: 'Document submitted successfully', - status: 'complete', - completed_at: enrollment.completed_at - } - end - - def status - enrollment = @cohort.cohort_enrollments.find_by(student_email: @token[:email]) - - unless enrollment - return render json: { error: 'Student not found' }, status: :not_found - end - - required = @cohort.required_student_uploads || [] - uploaded = (enrollment.uploaded_documents || {}).keys - missing = required - uploaded - - render json: { - data: { - status: enrollment.status, - uploads_completed: missing.empty?, - missing_uploads: missing, - completed_at: enrollment.completed_at, - progress: calculate_progress(enrollment) - } - } - end - - private - - def verify_student_token - @token = AdHocTokenService.decode_token(params[:token]) - - if @token.nil? || @token[:role] != 'student' - render json: { error: 'Invalid or expired student token' }, status: :unauthorized - return - end - - @cohort = Cohort.find(@token[:cohort_id]) - end - - def student_fields_needed(submission) - # Get all fields that haven't been pre-filled by TP - # These are fields the student needs to fill - submission.submitters.where.not(email: @token[:email]).first&.values&.keys || [] - end - - def calculate_progress(enrollment) - required = @cohort.required_student_uploads || [] - completed = (enrollment.uploaded_documents || {}).keys - - { - required_fields: required.count, - fields_completed: completed.count, - percentage: required.empty? ? 100 : (completed.count.to_f / required.count * 100).round - } - end -end -``` - -**Integration with Existing Submission Flow:** - -```ruby -# app/services/student_enrollment_service.rb (enhanced) -def self.invite_students(cohort) - # ... existing code ... - enrollments.each do |enrollment| - token = AdHocTokenService.generate_student_token( - cohort, - enrollment.student_email, - enrollment.student_name - ) - - enrollment.update!(access_token: token) - - # Use existing FloDoc mailer with custom link - FloDocMailer.student_invitation( - enrollment.student_email, - cohort, - enrollment.submission, - token - ).deliver_later - end -end -``` - -**Student Email Template:** -``` -Subject: Your document is ready to complete - #{cohort.name} - -Hi #{student_name}, - -Your training document for #{cohort.name} is ready. The Training Provider has already filled in their part. - -🔗 [Access Your Document] - -Here's what you need to do: -1. Review the pre-filled information -2. Fill in your fields -3. Upload required documents (#{required_uploads.join(', ')}) -4. Submit for sponsor review - -Access link: https://yourapp.com/student/portal?token=#{token} -(Valid for 30 days) - -Questions? Contact #{cohort.institution&.name} -``` - -**Key Design Decisions:** - -1. **No Authentication Required**: Token is the authentication -2. **Pre-filled Values**: TP's data shows but is non-editable -3. **Upload Tracking**: Separate from form completion -4. **Status Tracking**: Real-time progress for students -5. **Clear Errors**: Helpful messages for missing requirements -6. **Minimal Surface**: Only essential operations - -##### Acceptance Criteria - -**Functional:** -1. ✅ GET /api/v1/students/:token/status - Check progress -2. ✅ GET /api/v1/students/:token - Show document with pre-filled data -3. ✅ PUT /api/v1/students/:token - Save student field values -4. ✅ POST /api/v1/students/:token/upload - Upload required documents -5. ✅ POST /api/v1/students/:token/submit - Submit final document -6. ✅ Students cannot edit TP's pre-filled fields -7. ✅ Progress tracking shows completion percentage - -**Integration:** -1. ✅ IV1: Works with existing DocuSeal document rendering -2. ✅ IV2: Student tokens integrate with ad-hoc auth system -3. ✅ IV3: No impact on existing submission workflow - -**Security:** -1. ✅ Students can only access their own enrollment -2. ✅ Tokens have 30-day expiration -3. ✅ Cannot access other students in cohort -4. ✅ CSRF protection (if needed) - -**Quality:** -1. ✅ Clear error messages -2. ✅ Nice UI data structure -3. ✅ Proper validation -4. ✅ 85% test coverage - -##### Integration Verification (IV1-3) - -**IV1: Token-Based Access** -- Verify token validation works -- Verify students can only access their own data -- Verify expired tokens rejected - -**IV2: Data Integrity** -- Verify TP's pre-filled values are preserved -- Verify student values stored correctly -- Verify uploads tracked properly - -**IV3: Workflow Integration** -- Verify completion triggers sponsor notification -- Verify state transitions work -- Verify audit logs created - -##### Test Requirements - -**Controller Specs:** -```ruby -# spec/requests/api/v1/students/portal_spec.rb -describe Api::V1::Students::PortalController do - let(:token) { AdHocTokenService.generate_student_token(cohort, 'student@test.com') } - - describe 'GET /api/v1/students/:token/status' do - it 'returns student status' do - get "/api/v1/students/#{token}/status" - - expect(response).to have_http_status(:ok) - expect(json_response['data']['status']).to eq('waiting') - end - end - - describe 'POST /api/v1/students/:token/submit' do - it 'completes student submission' do - post "/api/v1/students/#{token}/submit", params: { values: { name: 'Test' } } - - expect(response).to have_http_status(:ok) - expect(json_response['status']).to eq('complete') - end - end -end -``` - -##### Rollback Procedure - -**If Student API causes issues:** -1. Remove student portal routes -2. Disable student email invites -3. Students use original DocuSeal flow -4. Existing data remains intact - -**Data Safety**: Student API is read/write wrapper around existing data. - -##### Risk Assessment - -**Medium Risk because:** -- New ad-hoc access pattern -- Token security is critical -- Students are external/untrusted users -- File uploads require validation - -**Specific Risks:** -1. **Token Exposure**: Token in email could be forwarded -2. **Invalid Data**: Students could upload wrong file types -3. **Exploitation**: Malicious students could probe API -4. **Storage**: Uploads could fill disk space - -**Mitigation:** -- One-time use tokens (optional enhancement) -- File type validation and size limits -- Rate limiting on token endpoints -- Audit all token-based access -- Upload size quotas per cohort - ---- - -#### Story 3.4: API Documentation & Versioning - -**Status**: Draft -**Priority**: Medium -**Epic**: Phase 3 - API Layer -**Estimated Effort**: 1-2 days -**Risk Level**: Low - -##### User Story - -**As a** developer integrating with FloDoc, -**I want** comprehensive API documentation and stable versioning, -**So that** I can build reliable integrations without breaking changes. - -##### Background - -API documentation must include: -- Endpoint reference -- Request/response examples -- Authentication methods -- Error codes -- Rate limits -- Versioning strategy - -This will be the source of truth for both internal frontend and external integrations. - -##### Technical Implementation Notes - -**Documentation Framework:** - -Option 1: OpenAPI/Swagger Documentation - -```ruby -# Gemfile -gem 'rswag-api' -gem 'rswag-ui' # If serving docs from app - -# spec/swagger/api/v1/swagger.rb -require 'swagger_helper' - -RSpec.describe 'FloDoc API v1', type: :request, doc: true do - path '/cohorts' do - get 'List cohorts' do - tags 'Cohorts' - produces 'application/json' - security [ bearer_auth: [] ] - parameter name: :page, in: :query, type: :integer, required: false - parameter name: :per_page, in: :query, type: :integer, required: false - - response '200', 'success' do - schema type: :object, - properties: { - data: { - type: :array, - items: { '$ref' => '#/definitions/cohort' } - }, - meta: { '$ref' => '#/definitions/pagination' } - } - - let(:page) { 1 } - run_test! - end - - response '401', 'unauthorized' do - run_test! - end - end - - post 'Create cohort' do - tags 'Cohorts' - consumes 'application/json' - produces 'application/json' - security [ bearer_auth: [] ] - - parameter name: :cohort, in: :body, schema: { - type: :object, - required: [:name, :template_id, :sponsor_email, :program_type], - properties: { - name: { type: :string }, - template_id: { type: :integer }, - sponsor_email: { type: :string }, - program_type: { type: :string, enum: %w[learnership internship candidacy] }, - required_student_uploads: { type: :array, items: { type: :string } }, - cohort_metadata: { type: :object } - } - } - - response '201', 'created' do - schema type: :object, - properties: { - data: { '$ref' => '#/definitions/cohort' }, - message: { type: :string } - } - run_test! - end - - response '422', 'validation failed' do - schema type: :object, - properties: { - error: { type: :string }, - errors: { type: :array, items: { type: :string } } - } - run_test! - end - end - end - - path '/cohorts/{id}/start_signing' do - post 'Start TP signing phase' do - tags 'Cohorts' - produces 'application/json' - security [ bearer_auth: [] ] - parameter name: :id, in: :path, type: :integer - - response '200', 'success' do - schema type: :object, - properties: { - data: { type: :object }, - message: { type: :string } - } - run_test! - end - - response '422', 'invalid transition' do - run_test! - end - end - end - - path '/sponsor/{token}/dashboard' do - get 'Sponsor dashboard' do - tags 'Sponsor Portal' - produces 'application/json' - parameter name: :token, in: :path, type: :string, required: true - parameter name: :token, in: :query, type: :string, required: true # Alternative - - response '200', 'success' do - run_test! - end - - response '401', 'invalid token' do - run_test! - end - end - end - - path '/students/{token}/submit' do - post 'Submit student document' do - tags 'Student Portal' - produces 'application/json' - parameter name: :token, in: :path, type: :string - parameter name: :body, in: :body, schema: { - type: :object, - properties: { - values: { type: :object, description: 'Student field values' } - } - } - - response '200', 'submitted' do - run_test! - end - end - end - - # Definitions - Swagger::Docs.config.definitions = { - 'cohort' => { - type: :object, - properties: { - id: { type: :integer }, - name: { type: :string }, - template_id: { type: :integer }, - sponsor_email: { type: :string }, - program_type: { type: :string }, - status: { type: :string }, - required_student_uploads: { type: :array, items: { type: :string } }, - created_at: { type: :string, format: 'date-time' } - } - }, - 'pagination' => { - type: :object, - properties: { - page: { type: :integer }, - per_page: { type: :integer }, - total: { type: :integer } - } - } - } -end -``` - -Option 2: Static Documentation in Docs Folder - -``` -docs/ -└── api/ - ├── README.md - ├── authentication.md - ├── endpoints/ - │ ├── cohorts.md - │ ├── sponsor.md - │ └── student.md - ├── examples/ - │ ├── create_cohort.md - │ ├── full_workflow.md - │ └── errors.md - └── changelog.md -``` - -**Versioning Strategy:** - -```ruby -# config/routes.rb -namespace :api do - namespace :v1 do - # Current endpoints - resources :cohorts - end - - # For future versions - namespace :v2 do - # resources :cohorts - end -end - -# app/controllers/api/base_controller.rb -# Add version header to responses -def set_version_header - response.headers['X-FloDoc-API-Version'] = '1.0' -end -``` - -**Authentication Documentation:** - -```markdown -### Authentication - -#### TP Portal (Authenticated Users) - -Use Bearer token authentication: - -``` -Authorization: Bearer -``` - -Obtain token via: -``` -POST /api/v1/auth/login -{ - "email": "user@example.com", - "password": "secret" -} -``` - -#### Sponsor Portal (Ad-hoc Tokens) - -Sponsor tokens are generated automatically and sent via email: -``` -GET /api/v1/sponsor/{token}/dashboard -``` - -Token format: JWT with 30-day expiration - -#### Student Portal (Ad-hoc Tokens) - -Student tokens are delivered via email invitation: -``` -GET /api/v1/students/{token}/status -POST /api/v1/students/{token}/submit -``` - -Token format: JWT with 30-day expiration -``` - -**Error Response Format:** - -```json -{ - "error": "Validation failed", - "errors": [ - "Template must exist", - "Sponsor email can't be blank" - ] -} -``` - -**Complete API Contract Examples:** - ---- - -##### Endpoint: POST /api/v1/cohorts - -**Purpose:** Create a new cohort with sponsor and student requirements - -**Request Headers:** -``` -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... -Content-Type: application/json -X-Request-ID: uuid-for-tracing -``` - -**Request Body:** -```json -{ - "name": "Q1 2026 Learnership Program", - "template_id": 42, - "sponsor_email": "sponsor@company.co.za", - "program_type": "learnership", - "required_student_uploads": ["id_document", "matric_certificate", "cv"], - "cohort_metadata": { - "budget_code": "LDN-2026-001", - "start_date": "2026-02-01", - "end_date": "2026-07-31" - } -} -``` - -**Success Response (201 Created):** -```json -{ - "data": { - "id": 123, - "name": "Q1 2026 Learnership Program", - "template_id": 42, - "sponsor_email": "sponsor@company.co.za", - "program_type": "learnership", - "status": "draft", - "required_student_uploads": ["id_document", "matric_certificate", "cv"], - "created_at": "2026-01-14T10:30:00Z", - "updated_at": "2026-01-14T10:30:00Z" - }, - "message": "Cohort created successfully" -} -``` - -**Error Responses:** - -*400 Bad Request - Invalid JSON:* -```json -{ - "error": "Invalid JSON format", - "details": "Unexpected token '}' at position 45" -} -``` - -*401 Unauthorized - Missing/Invalid Token:* -```json -{ - "error": "Authentication required", - "message": "Bearer token missing or invalid" -} -``` - -*403 Forbidden - Insufficient Permissions:* -```json -{ - "error": "Access denied", - "message": "User does not have permission to create cohorts" -} -``` - -*404 Not Found - Template Doesn't Exist:* -```json -{ - "error": "Template not found", - "message": "Template with ID 42 does not exist" -} -``` - -*422 Unprocessable Entity - Validation Failed:* -```json -{ - "error": "Validation failed", - "errors": [ - "Name can't be blank", - "Sponsor email is invalid", - "Program type must be one of: learnership, internship, candidacy" - ], - "details": { - "sponsor_email": "must be a valid email address" - } -} -``` - ---- - -##### Endpoint: GET /api/v1/cohorts - -**Purpose:** List all cohorts with pagination and filtering - -**Request Headers:** -``` -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... -Accept: application/json -``` - -**Query Parameters:** -``` -GET /api/v1/cohorts?page=1&per_page=20&status=draft&program_type=learnership&search=Q1 -``` - -**Success Response (200 OK):** -```json -{ - "data": [ - { - "id": 123, - "name": "Q1 2026 Learnership Program", - "program_type": "learnership", - "status": "draft", - "sponsor_email": "sponsor@company.co.za", - "student_count": 15, - "completed_count": 0, - "created_at": "2026-01-14T10:30:00Z" - }, - { - "id": 122, - "name": "Q4 2025 Internship Program", - "program_type": "internship", - "status": "completed", - "sponsor_email": "hr@company.co.za", - "student_count": 25, - "completed_count": 25, - "created_at": "2025-10-01T09:00:00Z" - } - ], - "meta": { - "pagination": { - "current_page": 1, - "per_page": 20, - "total_pages": 3, - "total_entries": 45 - }, - "filters": { - "status": "draft", - "program_type": "learnership", - "search": "Q1" - } - } -} -``` - -**Error Responses:** - -*429 Too Many Requests - Rate Limit Exceeded:* -```json -{ - "error": "Rate limit exceeded", - "message": "100 requests per minute limit reached", - "retry_after": 45 -} -``` - ---- - -##### Endpoint: POST /api/v1/cohorts/{id}/start_signing - -**Purpose:** Transition cohort from draft to TP signing phase - -**Request Headers:** -``` -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... -Content-Type: application/json -``` - -**Request Body:** -```json -{ - "notify_tp": true, - "message": "Please review and sign the cohort agreement" -} -``` - -**Success Response (200 OK):** -```json -{ - "data": { - "id": 123, - "status": "tp_signing", - "tp_signed_at": "2026-01-14T11:00:00Z", - "next_state": "student_enrollment" - }, - "message": "Cohort moved to TP signing phase" -} -``` - -**Error Responses:** - -*422 Unprocessable Entity - Invalid State Transition:* -```json -{ - "error": "Invalid state transition", - "message": "Cannot transition from 'completed' to 'tp_signing'", - "allowed_transitions": ["draft -> tp_signing", "tp_signing -> student_enrollment"] -} -``` - -*409 Conflict - Already Signed:* -```json -{ - "error": "Already signed", - "message": "TP has already signed this cohort" -} -``` - ---- - -##### Endpoint: GET /api/v1/sponsor/{token}/dashboard - -**Purpose:** Sponsor dashboard view (ad-hoc authentication) - -**Request Headers:** -``` -Accept: application/json -``` - -**Path Parameters:** -- `token`: Ad-hoc JWT token sent via email - -**Success Response (200 OK):** -```json -{ - "data": { - "cohort": { - "id": 123, - "name": "Q1 2026 Learnership Program", - "program_type": "learnership", - "tp_signed_at": "2026-01-14T11:00:00Z" - }, - "students": [ - { - "email": "student1@example.com", - "status": "complete", - "completed_at": "2026-01-14T12:00:00Z", - "document_url": "/api/v1/submissions/456/document" - }, - { - "email": "student2@example.com", - "status": "in_progress", - "last_activity": "2026-01-14T12:30:00Z" - } - ], - "summary": { - "total_students": 15, - "completed": 12, - "pending": 3, - "completion_rate": "80%" - }, - "actions": { - "can_review": true, - "can_finalize": false, - "can_remind": true - } - }, - "token_expiry": "2026-02-13T10:30:00Z" -} -``` - -**Error Responses:** - -*401 Unauthorized - Invalid Token:* -```json -{ - "error": "Invalid token", - "message": "Sponsor token expired or invalid", - "renewal_url": "/api/v1/sponsor/renew?email=sponsor@company.co.za" -} -``` - -*403 Forbidden - Access Denied:* -```json -{ - "error": "Access denied", - "message": "Cohort not ready for sponsor review yet" -} -``` - ---- - -##### Endpoint: POST /api/v1/students/{token}/submit - -**Purpose:** Student submits their completed document - -**Request Headers:** -``` -Content-Type: application/json -``` - -**Request Body:** -```json -{ - "values": { - "full_name": "John Doe", - "id_number": "9001015000081", - "signature": "data:image/png;base64,iVBORw0KGgo...", - "declaration": true, - "upload_url": "https://storage.example.com/uploads/john_doe_cv.pdf" - } -} -``` - -**Success Response (200 OK):** -```json -{ - "data": { - "submission_id": 456, - "status": "completed", - "completed_at": "2026-01-14T13:00:00Z", - "document_url": "/api/v1/submissions/456/final-document" - }, - "message": "Document submitted successfully" -} -``` - -**Error Responses:** - -*400 Bad Request - Missing Required Fields:* -```json -{ - "error": "Missing required fields", - "errors": [ - "signature is required", - "id_number must be 13 digits" - ] -} -``` - -*422 Unprocessable Entity - Invalid Data:* -```json -{ - "error": "Validation failed", - "errors": [ - "ID number format invalid", - "Signature must be a valid image" - ], - "invalid_fields": { - "id_number": "Must be 13 digits without spaces", - "signature": "Image must be PNG or JPG, max 2MB" - } -} -``` - ---- - -##### Endpoint: POST /api/v1/webhooks - -**Purpose:** Webhook event delivery (internal system) - -**Request Headers:** -``` -Content-Type: application/json -X-Signature: sha256=... -X-Event-Type: submission.completed -``` - -**Request Body:** -```json -{ - "event": "submission.completed", - "timestamp": "2026-01-14T13:00:00Z", - "data": { - "submission_id": 456, - "cohort_id": 123, - "student_email": "student1@example.com", - "document_url": "https://api.example.com/submissions/456/document" - } -} -``` - -**Success Response (200 OK):** -```json -{ - "status": "received", - "message": "Event processed successfully" -} -``` - -**Error Responses:** - -*401 Unauthorized - Invalid Signature:* -```json -{ - "error": "Invalid signature", - "message": "Webhook signature verification failed" -} -``` - -*500 Internal Server Error:* -```json -{ - "error": "Webhook delivery failed", - "message": "Max retry attempts exceeded", - "retry_count": 3 -} -``` - ---- - -**Rate Limiting:** - -```ruby -# app/controllers/api/base_controller.rb -class Api::BaseController < ActionController::API - before_action :check_rate_limit - - private - - def check_rate_limit - key = "api:rate:#{request_ip}" - current = Redis.current.get(key).to_i - - if current >= 100 # 100 requests per minute - render json: { error: 'Rate limit exceeded' }, status: :too_many_requests - return - end - - Redis.current.incr(key) - Redis.current.expire(key, 60) - end - - def request_ip - request.remote_ip - end -end -``` - -**Key Design Decisions:** - -1. **OpenAPI Standard**: Using Swagger/OpenAPI for industry-standard docs -2. **Version Schema**: Version in URL path for clear routing -3. **Dual Authentication**: Support both JWT and ad-hoc tokens -4. **Error Standards**: Consistent error response format -5. **Rate Limiting**: Protect against abuse -6. **Change Log**: Document breaking changes for consumers - -##### Acceptance Criteria - -**Functional:** -1. ✅ OpenAPI/Swagger spec for all endpoints -2. ✅ Static documentation in /docs/api -3. ✅ Versioning strategy documented -4. ✅ Authentication methods documented (JWT + ad-hoc tokens) -5. ✅ Error response format documented with all error codes -6. ✅ Rate limiting implemented and documented (100 req/min) -7. ✅ Code examples provided for all 6 core endpoints -8. ✅ Change log maintained -9. ✅ Complete request/response examples for POST /cohorts -10. ✅ Complete request/response examples for GET /cohorts -11. ✅ Complete request/response examples for POST /cohorts/{id}/start_signing -12. ✅ Complete request/response examples for GET /sponsor/{token}/dashboard -13. ✅ Complete request/response examples for POST /students/{token}/submit -14. ✅ Complete request/response examples for POST /webhooks -15. ✅ All error scenarios documented (400, 401, 403, 404, 422, 429, 500) - -**Quality:** -1. ✅ Documentation is comprehensive -2. ✅ Examples are runnable/verifiable -3. ✅ Coverage of all 11+ endpoints -4. ✅ Clear migration path for API versions -5. ✅ All HTTP status codes documented -6. ✅ All request headers documented -7. ✅ All response fields explained -8. ✅ Ad-hoc token flow clearly documented - -**Integration:** -1. ✅ IV1: Documentation matches actual implementation -2. ✅ IV2: Examples work without modification -3. ✅ IV3: Static docs don't affect app performance -4. ✅ IV4: All documented endpoints exist in routes - -##### Integration Verification (IV1-4) - -**IV1: Documentation Accuracy** -- Verify all documented endpoints exist in routes -- Verify request/response schemas match actual implementation -- Verify examples work with real API calls -- Verify all error codes are correct - -**IV2: Readability** -- Verify docs are clear and understandable -- Verify examples are helpful and complete -- Verify structure is logical -- Verify ad-hoc token flow is explained clearly - -**IV3: Impact** -- Verify docs don't slow down app -- Verify generated docs are up-to-date -- Verify static docs load quickly - -**IV4: API Contract Completeness** -- Verify POST /cohorts examples match actual request/response -- Verify GET /cohorts pagination works as documented -- Verify state transition endpoints work as specified -- Verify sponsor/student token authentication works -- Verify webhook delivery matches documentation -- Verify all headers and status codes are correct - -##### Test Requirements - -**Documentation Tests:** -```ruby -# spec/api_documentation_spec.rb -describe 'API Documentation' do - it 'has swagger spec' do - expect(File.exist?('spec/swagger/api/v1/swagger.json')).to be true - end - - it 'all endpoints documented' do - swagger = JSON.parse(File.read('spec/swagger/api/v1/swagger.json')) - - documented_paths = swagger['paths'].keys - actual_paths = Rails.application.routes.routes.map(&:path).select { |p| p.start_with?('/api/v1/') } - - # Verify coverage - expect(documented_paths).to include(*actual_paths) - end -end -``` - -**Integration Test:** -```ruby -# spec/requests/api/documentation_examples_spec.rb -describe 'Documentation Examples' do - it 'example for creating cohort works' do - # Run the exact curl command from docs - end -end -``` - -##### Rollback Procedure - -**If documentation approach fails:** -1. Remove Swagger gem -2. Keep static docs manually -3. App is unaffected - -**Data Safety**: Documentation is independent of application data. - -##### Risk Assessment - -**Low Risk because:** -- Documentation is read-only -- No application logic involved -- Easy to update/correct - -**Mitigation:** -- Keep docs in repository (version controlled) -- Automate swagger generation from tests -- Review docs before releases - -##### Success Metrics - -- **Documentation Coverage**: 100% of endpoints documented with examples -- **Example Accuracy**: 100% of examples work without modification -- **Error Code Coverage**: All 7 error types documented (400, 401, 403, 404, 422, 429, 500) -- **Endpoint Coverage**: All 11+ API endpoints fully documented -- **Readability Score**: Documentation passes clarity review -- **Version Clarity**: Migration path between versions is clear - ---- - -### 6.4 Phase 4: Frontend - TP Portal - -This section provides detailed user stories for Phase 4 (Frontend - TP Portal) of the FloDoc enhancement. This phase builds the Vue.js based TP Portal interface for managing cohorts. - -#### Story 4.1: Cohort Management Dashboard - -**Status**: Draft -**Priority**: High -**Epic**: Phase 4 - Frontend - TP Portal -**Estimated Effort**: 3-4 days -**Risk Level**: Low - -##### User Story - -**As a** TP administrator, -**I want** a dashboard to view and manage all cohorts, -**So that** I can monitor the 3-party workflow at a glance. - -##### Background - -TP Portal needs a comprehensive dashboard showing: -- Cohort list with status and progress -- Quick actions (create, open, export) -- Filter and search capability -- High-level metrics (total cohorts, active, completed) -- Recent activity feed - -This is the main entry point for TP administrators. - -##### Technical Implementation Notes - -**Vue 3 Component Structure:** - -```vue - - - - - - -``` - -**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('", - student_emails: ["test@example.com"] - }, - headers: { 'Authorization' => "Bearer #{tp_token}" } - - expect(response).to have_http_status(:unprocessable_entity) - end - - it 'validates file uploads' do - # Test malicious file upload - post '/api/v1/student/documents', - params: { - file: fixture_file_upload('malicious.exe', 'application/x-msdownload') - }, - headers: { 'Authorization' => "Bearer #{student_token}" } - - expect(response).to have_http_status(:unprocessable_entity) - end - end - - describe 'Penetration Testing Scenarios' do - it 'prevents horizontal privilege escalation' do - student_a_token = generate_student_token(cohort, student_a) - student_b_document = create(:submission, cohort: cohort, student: student_b) - - get "/api/v1/student/documents/#{student_b_document.id}", - headers: { 'Authorization' => "Bearer #{student_a_token}" } - - expect(response).to have_http_status(:not_found) - end - - it 'prevents vertical privilege escalation' do - student_token = generate_student_token - - post '/api/v1/tp/cohorts', - params: { name: 'Hacked Cohort' }, - headers: { 'Authorization' => "Bearer #{student_token}" } - - expect(response).to have_http_status(:forbidden) - end - - it 'prevents token brute force attacks' do - 100.times do - get '/api/v1/tp/cohorts', - headers: { 'Authorization' => "Bearer #{invalid_token}" } - end - - expect(response).to have_http_status(:too_many_requests) - end - end - - describe 'Security Headers' do - it 'enforces HTTPS' do - get 'http://localhost:3000/api/v1/tp/cohorts', - headers: { 'Authorization' => "Bearer #{tp_token}" } - - expect([301, 302, 400]).to include(response.status) - end - - it 'has secure headers' do - get '/api/v1/tp/cohorts', - headers: { 'Authorization' => "Bearer #{tp_token}" } - - expect(response.headers['Content-Security-Policy']).to be_present - expect(response.headers['X-Frame-Options']).to eq('DENY') - expect(response.headers['Strict-Transport-Security']).to be_present - end - end - - describe 'Webhook Security' do - it 'verifies webhook signatures' do - payload = { event: 'submission.completed', id: 123 } - signature = generate_webhook_signature(payload) - - post '/api/v1/webhooks/docuseal', - params: payload, - headers: { 'X-DocuSeal-Signature' => signature } - - expect(response).to have_http_status(:success) - - # Invalid signature - post '/api/v1/webhooks/docuseal', - params: payload, - headers: { 'X-DocuSeal-Signature' => 'invalid' } - - expect(response).to have_http_status(:unauthorized) - end - - it 'prevents replay attacks' do - payload = { event: 'submission.completed', id: 123, timestamp: Time.now.to_i } - signature = generate_webhook_signature(payload) - - # First request - post '/api/v1/webhooks/docuseal', - params: payload, - headers: { 'X-DocuSeal-Signature' => signature } - - expect(response).to have_http_status(:success) - - # Replay request - post '/api/v1/webhooks/docuseal', - params: payload, - headers: { 'X-DocuSeal-Signature' => signature } - - expect(response).to have_http_status(:conflict) - end - end -end -``` - -**Integration Tests:** -- End-to-end security workflow tests -- Token lifecycle tests (creation, renewal, expiration) -- Role-based access control tests -- Data encryption verification -- Audit logging verification - -**E2E Tests:** -- Penetration simulation tests using OWASP ZAP -- Complete user journey security validation -- Rate limiting stress tests -- Webhook security validation - -##### Risk Assessment - -**High Risk because:** -- Document signing involves sensitive PII -- GDPR violations carry heavy fines -- Authentication bypass is critical -- Third-party integrations add attack surface -- Ad-hoc access pattern is unconventional - -**Specific Risks:** - -1. **Authentication Bypass**: JWT implementation flaws could allow unauthorized access - - **Mitigation**: Use battle-tested libraries, comprehensive testing, security audits - -2. **Authorization Escalation**: Improper ability definitions could allow privilege escalation - - **Mitigation**: Test all role combinations, use automated ability testing, principle of least privilege - -3. **Data Exposure**: Unencrypted data or improper access controls - - **Mitigation**: Encrypt at rest, validate all access, audit logs, GDPR compliance - -4. **Injection Attacks**: SQL, XSS, command injection through user inputs - - **Mitigation**: Parameterized queries, input sanitization, CSP headers, WAF - -5. **Token Compromise**: Token theft, replay attacks, brute force - - **Mitigation**: Short expiration, rate limiting, HTTPS only, secure storage - -6. **Third-Party Vulnerabilities**: DocuSeal API or webhook compromise - - **Mitigation**: Signature verification, response validation, fail-secure design - -7. **GDPR Violation**: Improper data handling, retention, deletion - - **Mitigation**: Automated retention policies, data export, anonymization, audit trails - -8. **Email Security**: Spoofing, phishing, token leakage - - **Mitigation**: SPF/DKIM, secure tokens, sanitization, no sensitive data in emails - -9. **Webhook Security**: Unverified webhook deliveries - - **Mitigation**: HMAC signatures, timestamp validation, replay protection - -10. **Rate Limiting**: DoS through resource exhaustion - - **Mitigation**: Rate limiting, request throttling, resource quotas - -**Mitigation Strategies:** -- **Defense in Depth**: Multiple layers of security controls -- **Zero Trust**: Verify every request, assume breach -- **Secure by Default**: Most secure settings out of the box -- **Fail Secure**: Deny by default, fail closed -- **Audit Everything**: Comprehensive logging and monitoring -- **Regular Audits**: Automated and manual security testing -- **Patch Management**: Rapid response to vulnerabilities -- **Incident Response**: Documented procedures for breaches -- **Security Training**: Developer awareness of common vulnerabilities -- **Code Review**: Security-focused peer review process - -##### Success Metrics - -**Authentication:** -- 100% of auth endpoints tested -- 0 authentication bypasses -- Token validation success rate: 100% -- Rate limiting effective: 0 brute force successes - -**Authorization:** -- 100% of authorization paths tested -- 0 privilege escalation vulnerabilities -- 0 horizontal access violations -- Ability coverage: 100% of actions - -**Data Protection:** -- 100% of sensitive fields encrypted -- 0 data breaches in testing -- GDPR compliance: 100% -- Audit log completeness: 100% - -**Input Validation:** -- 0 SQL injection vulnerabilities -- 0 XSS vulnerabilities -- 0 file upload bypasses -- 100% input sanitization - -**Web Security:** -- 100% secure headers present -- 0 CSRF vulnerabilities -- 0 CORS misconfigurations -- 0 open redirects - -**Third-Party:** -- 100% webhook signature verification -- 0 third-party integration vulnerabilities -- 100% response validation - -**Compliance:** -- OWASP Top 10: 0 critical/high findings -- Brakeman: 0 high-severity warnings -- ZAP Scan: 0 critical/high alerts -- Penetration Test: 0 critical findings - -**Monitoring:** -- Security alerts: 100% detection rate -- False positive rate: <5% -- Response time: <1 hour for critical issues -- Audit log integrity: 100% - ---- - -#### Story 7.5: User Acceptance Testing - -**Status**: Draft/Pending -**Priority**: High -**Epic**: Integration & Testing -**Estimated Effort**: 5 days -**Risk Level**: Medium - -##### User Story - -**As a** Product Owner, -**I want** to conduct comprehensive user acceptance testing with real stakeholders, -**So that** I can validate the system meets business requirements and user needs before production launch. - -##### Background - -User Acceptance Testing (UAT) is the final validation phase where real users test the complete system in a production-like environment. This story validates: - -**Stakeholder Testing:** -- **Training Provider (TP)**: Creates cohorts, manages students, reviews submissions -- **Students**: Upload documents, fill forms, sign documents -- **Sponsors**: Bulk sign documents, track progress - -**Workflow Validation:** -- Complete end-to-end cohort lifecycle -- All three portals working together -- Email notifications delivered correctly -- Document signing workflow complete -- Excel export functionality -- Token renewal and session management - -**Real-World Scenarios:** -- Large cohorts (50+ students) -- Multiple concurrent users -- Different document types -- Various form field types -- Edge cases and error handling - -**Business Process Validation:** -- TP creates cohort with 50 students -- TP signs first student's document -- System auto-fills TP signature to all students -- Students receive emails and upload documents -- Students fill forms and sign -- Sponsor receives ONE email for entire cohort -- Sponsor bulk signs all students -- TP reviews and finalizes cohort -- Excel export contains all data - -**UX/Usability Testing:** -- Intuitive navigation across portals -- Clear user instructions -- Mobile responsiveness -- Accessibility compliance -- Performance under real usage - -**Data Integrity:** -- No data loss during workflow -- Proper audit trail -- Correct document generation -- Accurate state management -- Proper error recovery - -##### Technical Implementation Notes - -**UAT Test Framework:** -```ruby -# spec/acceptance/uat_spec.rb -require 'rails_helper' -require 'capybara/rspec' -require 'selenium-webdriver' - -RSpec.describe 'User Acceptance Testing', type: :feature, uat: true do - let(:tp_user) { create(:user, role: 'tp') } - let(:cohort) { create(:cohort, :with_50_students) } - - describe 'TP Portal - Complete Workflow' do - it 'TP creates cohort and signs first document' do - # Login to TP Portal - visit '/tp/login' - fill_in 'Email', with: tp_user.email - fill_in 'Password', with: 'password' - click_button 'Login' - - # Create cohort - visit '/tp/cohorts/new' - fill_in 'Cohort Name', with: 'UAT Test Cohort' - fill_in 'Student Count', with: 50 - attach_file 'Template PDF', Rails.root.join('spec/fixtures/files/template.pdf') - click_button 'Create Cohort' - - expect(page).to have_content('Cohort created successfully') - expect(page).to have_content('50 students') - - # Navigate to first student - within('.student-list') do - first('.student-item').click - end - - # Sign first document - within('.signature-pad') do - page.execute_script("drawSignature()") - end - - click_button 'Sign Document' - - expect(page).to have_content('Document signed') - expect(page).to have_content('Auto-filled to 49 remaining students') - - # Verify auto-fill - cohort.reload - expect(cohort.submissions.where(sponsor_signed: true).count).to eq(50) - end - - it 'TP monitors cohort progress' do - # Setup: cohort with mixed statuses - cohort = create(:cohort, :with_mixed_statuses) - - login_as(tp_user) - visit "/tp/cohorts/#{cohort.id}" - - # Check progress dashboard - expect(page).to have_content('Waiting: 10') - expect(page).to have_content('In Progress: 15') - expect(page).to have_content('Completed: 25') - - # Click on status filter - click_link 'Waiting' - - expect(page).to have_css('.student-item', count: 10) - end - - it 'TP exports cohort data to Excel' do - cohort = create(:cohort, :with_completed_students) - - login_as(tp_user) - visit "/tp/cohorts/#{cohort.id}/export" - - # Download file - click_button 'Export to Excel' - - expect(page.response_headers['Content-Type']).to include('spreadsheet') - expect(page.response_headers['Content-Disposition']).to include('cohort_export.xlsx') - end - end - - describe 'Student Portal - Complete Workflow' do - it 'Student uploads document and completes form' do - # Student receives email link - email = ActionMailer::Base.deliveries.last - token = extract_token_from_email(email.body) - - # Access portal with token - visit "/student/cohorts/#{cohort.id}?token=#{token}" - - expect(page).to have_content('Welcome, Student') - - # Upload document - attach_file 'Upload ID', Rails.root.join('spec/fixtures/files/student_id.pdf') - click_button 'Upload' - - expect(page).to have_content('Document uploaded successfully') - - # Fill form - fill_in 'Full Name', with: 'John Doe' - fill_in 'Date of Birth', with: '1990-01-01' - check 'I agree to terms' - - # Sign - within('.signature-pad') do - page.execute_script("drawSignature()") - end - - click_button 'Submit' - - expect(page).to have_content('Submission complete') - expect(page).to have_content('Status: Pending Sponsor Approval') - end - - it 'Student saves draft and resumes later' do - token = cohort.student_tokens.first - - visit "/student/cohorts/#{cohort.id}?token=#{token}" - - # Fill partial form - fill_in 'Full Name', with: 'Jane Smith' - click_button 'Save Draft' - - expect(page).to have_content('Draft saved') - - # Return later - visit "/student/cohorts/#{cohort.id}?token=#{token}" - - expect(page).to have_field('Full Name', with: 'Jane Smith') - end - - it 'Student receives email notifications' do - token = cohort.student_tokens.first - - # Trigger notification - visit "/student/cohorts/#{cohort.id}?token=#{token}" - - # Check email was sent - email = ActionMailer::Base.deliveries.last - expect(email.to).to include(cohort.students.first.email) - expect(email.subject).to include('Document Ready for Signing') - end - end - - describe 'Sponsor Portal - Complete Workflow' do - it 'Sponsor bulk signs all students' do - # Cohort with all students ready - cohort = create(:cohort, :with_ready_students) - - # Access sponsor portal - visit "/sponsor/cohorts/#{cohort.id}?token=#{cohort.sponsor_token}" - - expect(page).to have_content('Cohort: UAT Test') - expect(page).to have_content('Students Ready: 50') - - # Bulk sign - within('.bulk-sign-section') do - fill_in 'Your Full Name', with: 'Sponsor Representative' - within('.signature-pad') do - page.execute_script("drawSignature()") - end - check 'I certify all documents are complete' - click_button 'Sign All Documents' - end - - expect(page).to have_content('Successfully signed 50 documents') - expect(page).to have_content('All students notified') - - # Verify all submissions signed - cohort.reload - expect(cohort.submissions.where(sponsor_signed: true).count).to eq(50) - end - - it 'Sponsor tracks progress across tabs' do - cohort = create(:cohort, :with_mixed_statuses) - - visit "/sponsor/cohorts/#{cohort.id}?token=#{cohort.sponsor_token}" - - # Check Waiting tab - click_link 'Waiting' - expect(page).to have_css('.student-item', count: 10) - - # Check In Progress tab - click_link 'In Progress' - expect(page).to have_css('.student-item', count: 15) - - # Check Completed tab - click_link 'Completed' - expect(page).to have_css('.student-item', count: 25) - end - - it 'Sponsor receives single email notification' do - cohort = create(:cohort, :with_ready_students) - - # Clear previous emails - ActionMailer::Base.deliveries.clear - - # Trigger email - visit "/sponsor/cohorts/#{cohort.id}?token=#{cohort.sponsor_token}" - - # Check only one email sent - emails = ActionMailer::Base.deliveries - expect(emails.count).to eq(1) - expect(emails.last.subject).to include('Documents Ready for Signing') - expect(emails.last.to).to include(cohort.sponsor_email) - end - end - - describe 'Multi-User Concurrent Access' do - it 'handles 10 concurrent users' do - cohort = create(:cohort, :with_50_students) - - # Simulate concurrent access - threads = [] - - 10.times do |i| - threads << Thread.new do - # Each thread acts as different user - if i < 5 - # Students - student = cohort.students[i] - token = student.tokens.create!.token - - page = Capybara::Session.new(:selenium) - page.visit "/student/cohorts/#{cohort.id}?token=#{token}" - page.fill_in 'Full Name', with: "Student #{i}" - page.click_button 'Submit' - else - # Sponsors (multiple sponsors for testing) - sponsor_email = "sponsor#{i}@test.com" - cohort.update(sponsor_email: sponsor_email) - - page = Capybara::Session.new(:selenium) - page.visit "/sponsor/cohorts/#{cohort.id}?token=#{cohort.sponsor_token}" - page.click_button 'Sign All Documents' - end - end - end - - threads.each(&:join) - - # Verify all completed - expect(cohort.submissions.where(sponsor_signed: true).count).to eq(50) - end - end - - describe 'Edge Cases and Error Handling' do - it 'handles expired token gracefully' do - expired_token = generate_expired_token - - visit "/student/cohorts/#{cohort.id}?token=#{expired_token}" - - expect(page).to have_content('Token expired') - expect(page).to have_button('Request New Link') - end - - it 'prevents duplicate submissions' do - token = cohort.student_tokens.first - - # Submit once - visit "/student/cohorts/#{cohort.id}?token=#{token}" - click_button 'Submit' - - # Try to submit again - visit "/student/cohorts/#{cohort.id}?token=#{token}" - click_button 'Submit' - - expect(page).to have_content('Already submitted') - end - - it 'handles network failures gracefully' do - # Simulate network failure - allow_any_instance_of(ActionDispatch::Request).to receive(:save).and_raise(StandardError) - - token = cohort.student_tokens.first - visit "/student/cohorts/#{cohort.id}?token=#{token}" - - click_button 'Submit' - - expect(page).to have_content('Connection error') - expect(page).to have_button('Retry') - end - end - - describe 'Accessibility Testing' do - it 'passes WCAG 2.1 AA standards' do - visit "/student/cohorts/#{cohort.id}?token=#{cohort.student_tokens.first}" - - # Check for alt text - expect(page).to have_css('img[alt]') - - # Check for labels - expect(page).to have_css('label[for]') - - # Check for keyboard navigation - page.evaluate_script('document.activeElement') - page.send_keys(:tab) - expect(page.evaluate_script('document.activeElement')).not_to be_nil - - # Check color contrast (via CSS analysis) - styles = page.evaluate_script('getComputedStyle(document.body)') - expect(styles).not_to be_nil - end - - it 'works with screen readers' do - visit "/student/cohorts/#{cohort.id}?token=#{cohort.student_tokens.first}" - - # Check ARIA labels - expect(page).to have_css('[aria-label]') - expect(page).to have_css('[role="button"]') - end - end -end -``` - -**UAT Test Data Factory:** -```ruby -# spec/factories/uat_factory.rb -FactoryBot.define do - factory :cohort do - name { "UAT Cohort #{Time.now.to_i}" } - tp_email { "tp@trainingprovider.com" } - sponsor_email { "sponsor@company.com" } - - trait :with_50_students do - after(:create) do |cohort| - 50.times do |i| - student = create(:student, cohort: cohort, email: "student#{i}@test.com") - create(:submission, cohort: cohort, student: student, status: 'waiting') - end - end - end - - trait :with_completed_students do - after(:create) do |cohort| - 50.times do |i| - student = create(:student, cohort: cohort, email: "student#{i}@test.com") - create(:submission, cohort: cohort, student: student, status: 'completed') - end - end - end - - trait :with_ready_students do - after(:create) do |cohort| - 50.times do |i| - student = create(:student, cohort: cohort, email: "student#{i}@test.com") - create(:submission, cohort: cohort, student: student, - status: 'waiting', tp_signed: true, student_signed: true) - end - end - end - - trait :with_mixed_statuses do - after(:create) do |cohort| - 10.times do |i| - student = create(:student, cohort: cohort) - create(:submission, cohort: cohort, student: student, status: 'waiting') - end - 15.times do |i| - student = create(:student, cohort: cohort) - create(:submission, cohort: cohort, student: student, status: 'in_progress') - end - 25.times do |i| - student = create(:student, cohort: cohort) - create(:submission, cohort: cohort, student: student, status: 'completed') - end - end - end - end -end -``` - -**UAT Test Scripts:** -```bash -#!/bin/bash -# script/run_uat.sh - -echo "=== FloDoc User Acceptance Testing ===" - -# Setup test environment -echo "Setting up test data..." -bundle exec rails runner " - require 'rake' - Rake::Task['db:seed'].invoke - puts 'Test data created' -" - -# Run UAT tests -echo "Running UAT scenarios..." -bundle exec rspec spec/acceptance/uat_spec.rb --format documentation - -# Generate UAT report -echo "Generating UAT report..." -bundle exec rails runner " - report = { - timestamp: Time.current, - total_tests: RSpec.world.example_count, - passed: RSpec.world.examples.select(&:passed?).count, - failed: RSpec.world.examples.select(&:failed?).count, - duration: RSpec.world.duration - } - - File.write('tmp/uat_report.json', JSON.pretty_generate(report)) - puts 'UAT Report saved to tmp/uat_report.json' -" - -echo "=== UAT Complete ===" -``` - -**UAT Checklist:** -```markdown -UAT Checklist - -TP Portal -- [ ] Can create cohort with 50 students -- [ ] Can upload template PDF -- [ ] Can sign first document -- [ ] Auto-fill works for remaining students -- [ ] Can monitor all student statuses -- [ ] Can export to Excel -- [ ] Can finalize cohort -- [ ] Email notifications work -- [ ] Token renewal works -- [ ] Mobile responsive - -Student Portal -- [ ] Can access with email link -- [ ] Can upload documents -- [ ] Can fill all field types -- [ ] Can sign documents -- [ ] Can save drafts -- [ ] Can resume later -- [ ] Receives notifications -- [ ] Mobile responsive -- [ ] Accessible (WCAG 2.1 AA) - -Sponsor Portal -- [ ] Can access with email link -- [ ] Can bulk sign all students -- [ ] Can track progress by status -- [ ] Receives ONE email per cohort -- [ ] No duplicate emails -- [ ] Mobile responsive -- [ ] Accessible (WCAG 2.1 AA) - -Workflow Integration -- [ ] Complete 3-party workflow works -- [ ] TP signs first, auto-fills to all -- [ ] Students receive emails -- [ ] Students complete submissions -- [ ] Sponsor receives single email -- [ ] Sponsor bulk signs -- [ ] TP reviews and finalizes -- [ ] Excel export contains all data -- [ ] Audit trail complete - -Performance -- [ ] Cohort creation <2s -- [ ] Bulk signing <5s -- [ ] Excel export <10s -- [ ] Concurrent users handled -- [ ] No data loss - -Security -- [ ] Tokens expire correctly -- [ ] Token renewal works -- [ ] No unauthorized access -- [ ] Data isolation enforced -- [ ] Audit logs complete - -Error Handling -- [ ] Expired tokens handled -- [ ] Duplicate submissions prevented -- [ ] Network failures handled -- [ ] Clear error messages -- [ ] Recovery options provided -``` - -**UAT Feedback Template:** -```ruby -# app/models/uat_feedback.rb -class UATFeedback < ApplicationRecord - belongs_to :user - belongs_to :cohort, optional: true - - validates :portal, presence: true, inclusion: { in: %w[tp student sponsor] } - validates :rating, presence: true, inclusion: { in: 1..5 } - validates :scenario, presence: true - - # Scenarios: - # - cohort_creation - # - document_upload - # - form_filling - # - signing - # - bulk_signing - # - export - # - navigation - # - mobile - # - performance - # - overall - - scope :critical, -> { where(rating: 1..2) } - scope :positive, -> { where(rating: 4..5) } -end -``` - -##### Acceptance Criteria - -**TP Portal UAT:** -1. ✅ Can create cohort with 50+ students in <2 seconds -2. ✅ Can upload and configure template PDF -3. ✅ Can sign first document and auto-fill to all students -4. ✅ Can monitor real-time progress across all students -5. ✅ Can export complete cohort data to Excel -6. ✅ Can finalize cohort after sponsor approval -7. ✅ Email notifications delivered correctly -8. ✅ Token renewal works without data loss -9. ✅ Mobile responsive on tablets and phones -10. ✅ No data loss during any operation - -**Student Portal UAT:** -1. ✅ Can access portal via email link -2. ✅ Can upload required documents -3. ✅ Can fill all 12 field types correctly -4. ✅ Can sign documents with signature pad -5. ✅ Can save drafts and resume later -6. ✅ Receives timely email notifications -7. ✅ Cannot access other students' documents -8. ✅ Mobile responsive and touch-friendly -9. ✅ WCAG 2.1 AA accessibility compliant -10. ✅ Clear error messages and guidance - -**Sponsor Portal UAT:** -1. ✅ Can access portal via email link -2. ✅ Can bulk sign all students at once -3. ✅ Can track progress across three tabs (Waiting/In Progress/Completed) -4. ✅ Receives exactly ONE email per cohort -5. ✅ No duplicate email notifications -6. ✅ Mobile responsive on all devices -7. ✅ WCAG 2.1 AA accessibility compliant -8. ✅ Signature appears on all student documents -9. ✅ Cannot access other cohorts -10. ✅ Clear progress indicators - -**End-to-End Workflow UAT:** -1. ✅ Complete 3-party workflow succeeds -2. ✅ TP signs first, auto-fills to all students -3. ✅ All students receive emails and can complete -4. ✅ Sponsor receives single email notification -5. ✅ Sponsor bulk signs all students -6. ✅ TP reviews and finalizes cohort -7. ✅ Excel export contains complete data -8. ✅ Audit trail captures all events -9. ✅ No data corruption or loss -10. ✅ All state transitions work correctly - -**Performance UAT:** -1. ✅ Cohort creation with 50 students <2 seconds -2. ✅ Bulk signing 50 students <5 seconds -3. ✅ Excel export 50 rows <10 seconds -4. ✅ Email delivery 50 messages <30 seconds -5. ✅ Concurrent access (10 users) <2 seconds -6. ✅ Student uploads (50 concurrent) <5 seconds -7. ✅ Database queries <20 per page load -8. ✅ API responses <500ms average -9. ✅ No memory leaks -10. ✅ No N+1 queries - -**Security UAT:** -1. ✅ Tokens expire after 30 days -2. ✅ Token renewal works without data loss -3. ✅ Expired tokens rejected with clear error -4. ✅ Cross-portal access prevented -5. ✅ Data isolation between cohorts enforced -6. ✅ No SQL injection vulnerabilities -7. ✅ No XSS vulnerabilities -8. ✅ All sensitive operations logged -9. ✅ GDPR compliance validated -10. ✅ No unauthorized access possible - -**UX/Usability UAT:** -1. ✅ Navigation is intuitive across all portals -2. ✅ User instructions are clear and helpful -3. ✅ Error messages are actionable -4. ✅ Success feedback is provided -5. ✅ Loading states are clear -6. ✅ Mobile experience is excellent -7. ✅ Desktop experience is excellent -8. ✅ Touch gestures work on mobile -9. ✅ Keyboard navigation works -10. ✅ Screen readers work correctly - -**Data Integrity UAT:** -1. ✅ No data loss during workflow -2. ✅ All documents generated correctly -3. ✅ All signatures appear correctly -4. ✅ Audit trail is complete and accurate -5. ✅ State management is correct -6. ✅ Email delivery is reliable -7. ✅ Excel export is accurate -8. ✅ Token system works correctly -9. ✅ Error recovery works -10. ✅ Data consistency maintained - -##### Integration Verification (IV1-4) - -**IV1: API Integration** -- All API endpoints tested through UAT scenarios -- Real-world data flow validated -- Error handling tested with actual user actions -- Performance measured under realistic load -- Security validated with actual access patterns - -**IV2: Pinia Store** -- State management tested through complete workflows -- Token handling validated with real expiration scenarios -- Store actions tested with actual user interactions -- Data persistence verified across sessions - -**IV3: Getters** -- Computed properties tested with real data -- Performance validated with large datasets -- Filtering and sorting tested with actual use cases -- Caching behavior verified - -**IV4: Token Routing** -- Token generation tested with real email delivery -- Token validation tested with expired tokens -- Renewal flow tested with actual user actions -- Security validated with tampering attempts - -##### Test Requirements - -**UAT Test Suite:** -```ruby -# spec/acceptance/uat_spec.rb -# (Full implementation in Technical Implementation Notes) -``` - -**UAT Test Data:** -```ruby -# spec/factories/uat_factory.rb -# (Full implementation in Technical Implementation Notes) -``` - -**UAT Scripts:** -```bash -# script/run_uat.sh -# (Full implementation in Technical Implementation Notes) -``` - -**UAT Checklist:** -```markdown -# docs/uat_checklist.md -# (Full implementation in Technical Implementation Notes) -``` - -**Feedback Collection:** -```ruby -# app/controllers/uat_feedback_controller.rb -class UATFeedbackController < ApplicationController - def create - @feedback = UATFeedback.new(feedback_params) - - if @feedback.save - # Notify team of critical feedback - if @feedback.rating <= 2 - SecurityMailer.critical_feedback(@feedback).deliver_later - end - - render json: { message: 'Feedback received' }, status: :created - else - render json: { errors: @feedback.errors }, status: :unprocessable_entity - end - end - - private - - def feedback_params - params.require(:feedback).permit(:portal, :scenario, :rating, :comments, :user_id, :cohort_id) - end -end -``` - -##### Rollback Procedure - -**If critical UAT failures found:** -1. Pause production deployment -2. Document all failures with screenshots/logs -3. Prioritize failures by severity -4. Fix critical issues immediately -5. Re-run affected UAT scenarios -6. Get stakeholder sign-off on fixes -7. Re-schedule UAT if needed - -**If data integrity issues:** -1. Stop all testing immediately -2. Preserve test data for analysis -3. Identify root cause -4. Fix data corruption -5. Restore from backup if needed -6. Re-run integrity tests -7. Validate with stakeholders - -**If performance issues:** -1. Document performance metrics -2. Identify bottlenecks -3. Implement optimizations -4. Re-run performance tests -5. Validate against NFRs -6. Get stakeholder approval - -**If security vulnerabilities:** -1. Immediately stop testing -2. Document vulnerability -3. Follow security rollback procedure (Story 7.4) -4. Fix and re-test -5. Get security review - -**If UX issues:** -1. Document all UX problems -2. Get stakeholder input on priority -3. Implement UX improvements -4. Re-run affected scenarios -5. Get stakeholder re-approval - -**Data Safety:** -- All UAT uses isolated test database -- No production data used -- Test data can be reset easily -- All changes tracked in version control -- Stakeholder feedback preserved - -##### Risk Assessment - -**Medium Risk** because: -- UAT involves real users with varying skill levels -- Time constraints may limit test coverage -- Stakeholder availability may be limited -- Real-world scenarios may reveal unexpected issues -- User feedback may require significant changes - -**Specific Risks:** - -1. **Incomplete Test Coverage**: Missing critical user scenarios - - **Mitigation**: Use comprehensive checklist, involve all stakeholders, test edge cases - -2. **Stakeholder Availability**: Key users unavailable for testing - - **Mitigation**: Schedule UAT well in advance, provide flexible time slots, record sessions - -3. **User Error**: Testers make mistakes that invalidate results - - **Mitigation**: Clear instructions, guided sessions, support available - -4. **Scope Creep**: Users request new features during UAT - - **Mitigation**: Clear scope definition, change control process, defer to post-launch - -5. **Performance Issues**: Real usage reveals bottlenecks - - **Mitigation**: Load testing before UAT, performance monitoring during UAT - -6. **Data Issues**: Test data doesn't match real scenarios - - **Mitigation**: Use realistic test data, involve stakeholders in data creation - -7. **Feedback Overload**: Too much feedback to process - - **Mitigation**: Structured feedback forms, prioritization framework, dedicated review sessions - -8. **Negative User Experience**: Users frustrated with system - - **Mitigation**: User training, clear documentation, responsive support - -9. **Integration Failures**: Third-party systems fail during UAT - - **Mitigation**: Mock external services, have fallback plans, document dependencies - -10. **Timeline Pressure**: UAT takes longer than planned - - **Mitigation**: Buffer time in schedule, prioritize critical tests, phase UAT if needed - -**Mitigation Strategies:** -- **Pre-UAT Training**: Train users before testing -- **Clear Scope**: Define what's in/out of scope -- **Structured Feedback**: Use standardized forms -- **Dedicated Support**: Have team available during UAT -- **Regular Check-ins**: Daily standups during UAT -- **Prioritization**: Focus on critical workflows first -- **Documentation**: Record all findings -- **Change Control**: Manage scope creep -- **Post-UAT Review**: Learn from feedback -- **Stakeholder Sign-off**: Formal approval process - -##### Success Metrics - -**Test Completion:** -- 100% of UAT scenarios executed -- 100% of checklist items verified -- 100% of stakeholders participated -- 0 critical blockers - -**Pass Rate:** -- 95% of scenarios pass on first attempt -- 100% of scenarios pass after fixes -- 0 high-severity failures -- <5 medium-severity issues - -**User Satisfaction:** -- Average rating ≥4.0 out of 5 -- ≥80% of users rate system as "Easy to use" -- ≥90% of users rate system as "Reliable" -- ≥85% of users would recommend system - -**Performance:** -- All NFRs met during UAT -- No performance complaints -- Average task completion time <2 minutes -- System uptime ≥99% during UAT - -**Data Integrity:** -- 0 data loss incidents -- 100% audit trail completeness -- 100% document generation accuracy -- 0 state management errors - -**Security:** -- 0 security incidents -- 100% of security controls validated -- 0 unauthorized access attempts -- All tokens function correctly - -**Workflow Completion:** -- 100% of complete workflows succeed -- Average workflow completion <10 minutes -- 0 workflow deadlocks -- All state transitions work - -**Feedback Quality:** -- ≥90% of feedback is actionable -- <10% of feedback is out of scope -- All critical feedback addressed -- Stakeholder satisfaction with process - ---- - -### 6.8 Phase 8: Deployment & Documentation - -**Focus**: Production infrastructure setup, deployment automation, monitoring configuration, and comprehensive documentation for operational excellence - -This phase prepares FloDoc v3 for production deployment with robust infrastructure, automated CI/CD pipelines, comprehensive monitoring and alerting, and complete documentation for ongoing operations and maintenance. - -#### Story 8.0: Development Infrastructure Setup (Local Docker) - -**Status**: Draft -**Priority**: High -**Epic**: Phase 8 - Deployment & Documentation -**Estimated Effort**: 2 days -**Risk Level**: Low - -##### User Story - -**As a** Developer, -**I want** to set up a local Docker-based development infrastructure with PostgreSQL and Redis, -**So that** I can demonstrate the complete FloDoc system to management without cloud costs or complexity. - -##### Background - -Before investing in production AWS infrastructure, we need a working demonstration environment that: -- Can be started with a single command -- Requires no cloud costs or complex setup -- Accurately represents the production architecture -- Allows management to validate the 3-portal cohort management system -- Can be used for UAT and stakeholder demonstrations - -This story establishes the foundation using Docker Compose, which provides the same architecture as production (PostgreSQL + Redis + Rails app) but runs entirely on a local machine or demo server. - -##### Technical Implementation Notes - -**Docker Compose Configuration:** -```yaml -# docker-compose.dev.yml -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - container_name: floDoc-dev-db - environment: - POSTGRES_USER: floDoc - POSTGRES_PASSWORD: floDoc_dev_password - POSTGRES_DB: floDoc_development - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U floDoc"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - container_name: floDoc-dev-redis - ports: - - "6379:6379" - command: redis-server --requirepass floDoc_dev_redis_password - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 3s - retries: 5 - - app: - build: - context: . - dockerfile: Dockerfile.dev - container_name: floDoc-dev-app - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - ports: - - "3000:3000" - environment: - - RAILS_ENV=development - - DATABASE_URL=postgres://floDoc:floDoc_dev_password@postgres:5432/floDoc_development - - REDIS_URL=redis://:floDoc_dev_redis_password@redis:6379/0 - - SECRET_KEY_BASE=dev_secret_key_base_change_in_production - - JWT_SECRET=dev_jwt_secret_change_in_production - - S3_BUCKET=floDoc-dev-storage - - AWS_REGION=us-east-1 - - AWS_ACCESS_KEY_ID=minio_access - - AWS_SECRET_ACCESS_KEY=minio_secret - - AWS_ENDPOINT_URL=http://minio:9000 - - RAILS_MAX_THREADS=5 - - WEB_CONCURRENCY=2 - volumes: - - .:/app - - app_logs:/app/log - - app_storage:/app/storage - stdin_open: true - tty: true - command: > - sh -c "bundle install && - rm -f tmp/pids/server.pid && - bundle exec rails db:prepare && - bundle exec rails assets:precompile && - bundle exec rails server -b 0.0.0.0 -p 3000" - - sidekiq: - build: - context: . - dockerfile: Dockerfile.dev - container_name: floDoc-dev-sidekiq - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - environment: - - RAILS_ENV=development - - DATABASE_URL=postgres://floDoc:floDoc_dev_password@postgres:5432/floDoc_development - - REDIS_URL=redis://:floDoc_dev_redis_password@redis:6379/0 - - SECRET_KEY_BASE=dev_secret_key_base_change_in_production - - JWT_SECRET=dev_jwt_secret_change_in_production - - S3_BUCKET=floDoc-dev-storage - - AWS_REGION=us-east-1 - - AWS_ACCESS_KEY_ID=minio_access - - AWS_SECRET_ACCESS_KEY=minio_secret - - AWS_ENDPOINT_URL=http://minio:9000 - volumes: - - .:/app - - app_logs:/app/log - command: bundle exec sidekiq -C config/sidekiq.yml - - minio: - image: minio/minio:latest - container_name: floDoc-dev-minio - ports: - - "9000:9000" - - "9001:9001" - environment: - MINIO_ROOT_USER: minio_access - MINIO_ROOT_PASSWORD: minio_secret - command: server /data --console-address ":9001" - volumes: - - minio_data:/data - - mailhog: - image: mailhog/mailhog:latest - container_name: floDoc-dev-mailhog - ports: - - "1025:1025" - - "8025:8025" - logging: - driver: none - -volumes: - postgres_data: - redis_data: - app_logs: - app_storage: - minio_data: -``` - -**Development Dockerfile:** -```dockerfile -# Dockerfile.dev -FROM ruby:3.4-slim - -RUN apt-get update -qq && \ - apt-get install -y \ - build-essential \ - libpq-dev \ - libxml2-dev \ - libxslt1-dev \ - nodejs \ - npm \ - curl \ - git \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY Gemfile Gemfile.lock ./ -RUN bundle config set --local deployment 'true' && \ - bundle install --jobs 4 --retry 3 - -COPY . . - -RUN mkdir -p tmp/pids - -EXPOSE 3000 - -CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0", "-p", "3000"] -``` - -**Configuration Files:** - -```yaml -# config/database.yml (development) -development: - adapter: postgresql - encoding: unicode - database: floDoc_development - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - host: <%= ENV.fetch("DB_HOST") { "localhost" } %> - port: <%= ENV.fetch("DB_PORT") { 5432 } %> - username: <%= ENV.fetch("DB_USER") { "floDoc" } %> - password: <%= ENV.fetch("DB_PASSWORD") { "floDoc_dev_password" } %> -``` - -```yaml -# config/redis.yml (development) -development: - url: <%= ENV.fetch("REDIS_URL", "redis://:floDoc_dev_redis_password@localhost:6379/0") %> - timeout: 5 - reconnect_attempts: 1 - pool: - size: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - timeout: 5 -``` - -```yaml -# config/storage.yml (development) -development: - service: S3 - access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID", "minio_access") %> - secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY", "minio_secret") %> - region: <%= ENV.fetch("AWS_REGION", "us-east-1") %> - bucket: <%= ENV.fetch("S3_BUCKET", "floDoc-dev-storage") %> - endpoint: <%= ENV.fetch("AWS_ENDPOINT_URL", "http://localhost:9000") %> - force_path_style: true - public: false -``` - -```bash -# .env.dev -RAILS_ENV=development -DATABASE_URL=postgres://floDoc:floDoc_dev_password@localhost:5432/floDoc_development -REDIS_URL=redis://:floDoc_dev_redis_password@localhost:6379/0 - -SECRET_KEY_BASE=dev_secret_key_base_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef -JWT_SECRET=dev_jwt_secret_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef - -S3_BUCKET=floDoc-dev-storage -AWS_REGION=us-east-1 -AWS_ACCESS_KEY_ID=minio_access -AWS_SECRET_ACCESS_KEY=minio_secret -AWS_ENDPOINT_URL=http://localhost:9000 - -SMTP_ADDRESS=localhost -SMTP_PORT=1025 -SMTP_USERNAME= -SMTP_PASSWORD= -SMTP_TLS=false -SMTP_AUTH=none - -RAILS_MAX_THREADS=5 -WEB_CONCURRENCY=2 -RAILS_LOG_LEVEL=debug -``` - -**Startup Scripts:** - -```bash -# scripts/dev-setup.sh -#!/bin/bash -set -e - -echo "=== FloDoc Development Infrastructure Setup ===" - -if ! docker info > /dev/null 2>&1; then - echo "❌ Docker is not running" - exit 1 -fi - -echo "✅ Docker is running" - -if [ ! -f .env ]; then - cp .env.dev .env - echo "✅ Created .env file" -else - echo "✅ .env file already exists" -fi - -mkdir -p storage log - -echo "=== Starting Services ===" -docker-compose -f docker-compose.dev.yml up -d --build - -echo "=== Waiting for Services ===" - -echo "Waiting for PostgreSQL..." -until docker exec floDoc-dev-db pg_isready -U floDoc > /dev/null 2>&1; do - sleep 2 -done -echo "✅ PostgreSQL ready" - -echo "Waiting for Redis..." -until docker exec floDoc-dev-redis redis-cli -a floDoc_dev_redis_password ping > /dev/null 2>&1; do - sleep 2 -done -echo "✅ Redis ready" - -echo "=== Running Database Setup ===" -docker exec floDoc-dev-app bundle exec rails db:prepare -docker exec floDoc-dev-app bundle exec rails db:seed - -echo "" -echo "=== FloDoc Development Environment Ready! ===" -echo "" -echo "Access Points:" -echo " - Application: http://localhost:3000" -echo " - MailHog UI: http://localhost:8025" -echo " - Minio UI: http://localhost:9001 (login: minio_access / minio_secret)" -echo "" -echo "Useful Commands:" -echo " - View logs: docker-compose -f docker-compose.dev.yml logs -f app" -echo " - Run console: docker exec -it floDoc-dev-app bundle exec rails console" -echo " - Stop services: docker-compose -f docker-compose.dev.yml down" -echo " - Full reset: docker-compose -f docker-compose.dev.yml down -v" -``` - -```bash -# scripts/dev-teardown.sh -#!/bin/bash -set -e - -echo "=== Stopping FloDoc Development Environment ===" -docker-compose -f docker-compose.dev.yml down - -echo "✅ Development environment stopped" -echo "" -echo "To start again: ./scripts/dev-setup.sh" -``` - -```bash -# scripts/dev-health.sh -#!/bin/bash -echo "=== FloDoc Development Health Check ===" -echo "" - -echo "1. Checking containers..." -for container in floDoc-dev-db floDoc-dev-redis floDoc-dev-minio floDoc-dev-app floDoc-dev-sidekiq floDoc-dev-mailhog; do - if docker ps | grep -q "$container"; then - echo " ✅ $container" - else - echo " ❌ $container NOT running" - fi -done - -echo "" -echo "2. Checking services..." - -if docker exec floDoc-dev-db pg_isready -U floDoc > /dev/null 2>&1; then - echo " ✅ PostgreSQL" -else - echo " ❌ PostgreSQL NOT ready" -fi - -if docker exec floDoc-dev-redis redis-cli -a floDoc_dev_redis_password ping > /dev/null 2>&1; then - echo " ✅ Redis" -else - echo " ❌ Redis NOT ready" -fi - -if curl -sf http://localhost:3000/health > /dev/null 2>&1; then - echo " ✅ Application" -else - echo " ❌ Application NOT responding" -fi - -echo "" -echo "3. Access URLs:" -echo " - App: http://localhost:3000" -echo " - MailHog: http://localhost:8025" -echo " - Minio: http://localhost:9001" -``` - -**Quick Start:** -```bash -# One command setup -./scripts/dev-setup.sh - -# Access application -open http://localhost:3000 - -# View emails -open http://localhost:8025 - -# View files -open http://localhost:9001 -``` - -**Design System Compliance:** - -Per FR28, all components must use design system assets from `@.claude/skills/frontend-design/design-system/`. The local Docker setup ensures the same TailwindCSS 3.4.17 + DaisyUI 3.9.4 configuration runs identically to production, with custom FloDoc branding replacing DaisyUI defaults. - -##### Acceptance Criteria - -**Infrastructure:** -1. ✅ Docker Compose file created with all services -2. ✅ PostgreSQL container running and accessible -3. ✅ Redis container running and accessible -4. ✅ Minio (S3-compatible) container running -5. ✅ MailHog container running for email testing -6. ✅ Rails app container running on port 3000 -7. ✅ Sidekiq container running for background jobs - -**Configuration:** -1. ✅ Database configuration for development -2. ✅ Redis configuration for development -3. ✅ Storage configuration for Minio -4. ✅ Email configuration for MailHog -5. ✅ Environment variables properly set -6. ✅ Secrets configured for development - -**Automation:** -1. ✅ Setup script automates initialization -2. ✅ Teardown script stops and cleans up -3. ✅ Health check script verifies services -4. ✅ All scripts executable and documented - -**Functionality:** -1. ✅ Application starts successfully -2. ✅ Database migrations run -3. ✅ Seed data can be loaded -4. ✅ File uploads work (via Minio) -5. ✅ Emails captured by MailHog -6. ✅ Background jobs process via Sidekiq -7. ✅ All three portals accessible - -**Documentation:** -1. ✅ README with setup instructions -2. ✅ Quick start guide -3. ✅ Management demo script - -##### Integration Verification (IV1-4) - -**IV1: API Integration** -- Verify Docker Compose starts all services -- Test PostgreSQL connection from Rails -- Test Redis connection from Sidekiq -- Verify Minio accepts S3 API calls -- Confirm MailHog receives SMTP emails - -**IV2: Pinia Store** -- N/A (Infrastructure story) - -**IV3: Getters** -- N/A (Infrastructure story) - -**IV4: Token Routing** -- N/A (Infrastructure story) - -##### Test Requirements - -**Infrastructure Tests:** -```ruby -# spec/infrastructure/docker_dev_spec.rb -RSpec.describe 'Development Infrastructure' do - it 'has PostgreSQL container running' do - container = Docker::Container.get('floDoc-dev-db') - expect(container.info['State']['Running']).to be true - end - - it 'has Redis container running' do - container = Docker::Container.get('floDoc-dev-redis') - expect(container.info['State']['Running']).to be true - end - - it 'can connect to PostgreSQL' do - conn = PG.connect( - host: 'localhost', port: 5432, - dbname: 'floDoc_development', - user: 'floDoc', password: 'floDoc_dev_password' - ) - expect(conn).not_to be_nil - conn.close - end - - it 'can connect to Redis' do - redis = Redis.new( - host: 'localhost', port: 6379, - password: 'floDoc_dev_redis_password' - ) - expect(redis.ping).to eq('PONG') - end - - it 'application responds on port 3000' do - response = Net::HTTP.get(URI('http://localhost:3000/health')) - expect(response).to include('ok') - end -end -``` - -**Bash Tests:** -```bash -#!/bin/bash -# test-dev-infrastructure.sh -set -e - -echo "Testing Development Infrastructure..." - -for container in floDoc-dev-db floDoc-dev-redis floDoc-dev-minio floDoc-dev-app floDoc-dev-sidekiq; do - if docker ps | grep -q "$container"; then - echo " ✅ $container" - else - echo " ❌ $container FAILED" - exit 1 - fi -done - -if docker exec floDoc-dev-db pg_isready -U floDoc > /dev/null 2>&1; then - echo " ✅ PostgreSQL" -else - echo " ❌ PostgreSQL FAILED" - exit 1 -fi - -if docker exec floDoc-dev-redis redis-cli -a floDoc_dev_redis_password ping > /dev/null 2>&1; then - echo " ✅ Redis" -else - echo " ❌ Redis FAILED" - exit 1 -fi - -if curl -sf http://localhost:3000/health > /dev/null 2>&1; then - echo " ✅ Application" -else - echo " ❌ Application FAILED" - exit 1 -fi - -echo "✅ All tests passed!" -``` - -##### Rollback Procedure - -**If setup fails:** -1. Stop everything: `docker-compose -f docker-compose.dev.yml down` -2. Clean slate: `docker-compose -f docker-compose.dev.yml down -v` -3. Manual cleanup: `docker system prune -a` - -**Data Safety:** -- All data in Docker volumes -- Easy to reset to clean state -- No production data at risk - -##### Risk Assessment - -**Low Risk because:** -- Runs entirely in Docker containers -- No cloud dependencies or costs -- Easy to reset/destroy -- No production data involved - -**Specific Risks:** -1. **Port Conflicts**: Ports 3000, 5432, 6379, 9000, 8025 may be in use - - **Mitigation**: Check ports before setup, provide customization options - -2. **Docker Resource Limits**: May run out of memory/Disk - - **Mitigation**: Document minimum requirements - -3. **Cross-Platform Issues**: Docker behavior differs on Mac/Windows/Linux - - **Mitigation**: Test on all platforms, provide platform-specific notes - -**Mitigation Strategies:** -- Clear documentation with step-by-step setup -- Automated scripts for one-command operations -- Health checks to verify all services -- Platform-specific notes in README - -##### Success Metrics - -**Setup Experience:** -- Single command to start: `./scripts/dev-setup.sh` -- Time to ready: < 5 minutes -- Success rate: 100% on supported platforms - -**Functionality:** -- All 3 portals accessible -- File uploads work -- Emails captured by MailHog -- Background jobs process - -**Management Demo:** -- Can showcase complete workflow -- No technical issues during demo -- Clear access instructions - -**Developer Experience:** -- Easy to start/stop -- Fast iteration cycle -- Debugging tools available -- No cloud costs - -##### Comparison with Future Production (Story 8.1) - -**Architecture Parity:** -``` -Development (Story 8.0) → Production (Story 8.1) -───────────────────────────────────────────────────── -Local PostgreSQL → AWS RDS PostgreSQL -Local Redis → AWS ElastiCache Redis -Minio (S3-compatible) → AWS S3 -MailHog (local email) → AWS SES -Local Docker → AWS ECS/EC2 -``` - -**Key Differences:** -- **Cost**: $0 vs ~$500/month -- **Setup Time**: 10 minutes vs 2-3 hours -- **Complexity**: Low vs High -- **Scalability**: Single machine vs Auto-scaling - -**Migration Path:** -Story 8.0 → Management Demo → Approval → Story 8.1 (Production AWS) - -##### Management Demo Script - -```bash -# Pre-demo setup -./scripts/dev-setup.sh - -# Demo flow: -echo "=== FloDoc 3-Portal Cohort Management Demo ===" -echo "" -echo "1. Admin Portal (TP): http://localhost:3000/admin" -echo " - Create cohort" -echo " - Upload student list" -echo " - Review submissions" -echo "" -echo "2. Student Portal: http://localhost:3000/student" -echo " - Receive email (check MailHog: http://localhost:8025)" -echo " - Upload documents" -echo " - Sign forms" -echo "" -echo "3. Sponsor Portal: http://localhost:3000/sponsor" -echo " - Receive single cohort email" -echo " - Review all students" -echo " - Bulk sign" -echo "" -echo "4. Admin Review: http://localhost:3000/admin" -echo " - Verify all signatures" -echo " - Export cohort data" -echo " - Download completed documents" -echo "" -echo "Files: http://localhost:9001 (Minio)" -echo "Emails: http://localhost:8025 (MailHog)" -``` - -##### Why This Approach - -**Benefits:** -1. **Fast Demonstration**: Working system in 10 minutes -2. **Zero Cost**: No cloud spending during development -3. **Accurate Architecture**: Same components as production -4. **Easy Iteration**: Quick changes for management feedback -5. **Risk-Free**: No production data or infrastructure at stake -6. **Clear Path**: Easy migration to AWS when ready - -**When to Use Story 8.1:** -- After management approval -- When ready for production deployment -- When scalability is required -- When security compliance is needed -- When team is ready for DevOps operations - - -#### Story 8.0.1: Management Demo Readiness & Validation - -**Status**: Draft -**Priority**: High -**Epic**: Phase 8 - Deployment & Documentation -**Estimated Effort**: 3 days -**Risk Level**: Low - -##### User Story - -**As a** Product Manager, -**I want** to validate the complete 3-portal cohort management workflow end-to-end, -**So that** I can demonstrate FloDoc v3 to management with confidence and real data. - -##### Background - -After setting up the local Docker infrastructure (Story 8.0), we need to: -- Test the complete 3-portal workflow with real data -- Validate all acceptance criteria from previous stories -- Create a polished demo script for management -- Ensure the system is production-ready for demonstration -- Identify and fix any remaining issues before stakeholder review - -This story focuses on **using** the system, not building it. We'll create sample cohorts, enroll students, complete signatures, and verify the entire flow works as designed. - -##### Technical Implementation Notes - -**Demo Data Setup:** -```ruby -# scripts/demo-data.rb -#!/usr/bin/env ruby -require_relative '../config/environment' - -# Create sample institution -institution = Institution.create!( - name: "Tech Training Academy", - contact_email: "admin@techtraining.com" -) - -# Create sample cohort -cohort = Cohort.create!( - name: "Spring 2025 - Web Development Bootcamp", - institution: institution, - start_date: Date.new(2025, 3, 1), - end_date: Date.new(2025, 6, 30), - status: "active" -) - -# Create sample students (5 students) -students = [ - { name: "Alice Johnson", email: "alice@example.com", phone: "555-0101" }, - { name: "Bob Smith", email: "bob@example.com", phone: "555-0102" }, - { name: "Carol Davis", email: "carol@example.com", phone: "555-0103" }, - { name: "David Wilson", email: "david@example.com", phone: "555-0104" }, - { name: "Eve Martinez", email: "eve@example.com", phone: "555-0105" } -].map do |student_data| - CohortEnrollment.create!( - cohort: cohort, - student_name: student_data[:name], - student_email: student_data[:email], - student_phone: student_data[:phone], - status: "pending" - ) -end - -# Create sponsor -sponsor = Sponsor.create!( - name: "TechCorp Industries", - contact_name: "Jane Doe", - contact_email: "jane.doe@techcorp.com", - contact_phone: "555-9999" -) - -# Assign sponsor to cohort -cohort.update!(sponsor: sponsor) - -puts "✅ Demo data created!" -puts " Cohort: #{cohort.name}" -puts " Students: #{students.count}" -puts " Sponsor: #{sponsor.name}" -``` - -**Workflow Test Script:** -```bash -# scripts/test-workflow.sh -#!/bin/bash -set -e - -echo "=== Testing Complete FloDoc Workflow ===" -echo "" - -# 1. Start infrastructure -echo "1. Starting Docker infrastructure..." -docker-compose -f docker-compose.dev.yml up -d --build -sleep 30 - -# 2. Load demo data -echo "2. Loading demo data..." -docker exec floDoc-dev-app bundle exec ruby scripts/demo-data.rb - -# 3. Test TP Portal (Admin) -echo "3. Testing TP Portal..." -echo " - Login: http://localhost:3000/admin" -echo " - Create cohort: ✓" -echo " - Upload student list: ✓" -echo " - Review submissions: ✓" - -# 4. Test Student Portal -echo "4. Testing Student Portal..." -echo " - Student receives email (MailHog: http://localhost:8025): ✓" -echo " - Student uploads documents: ✓" -echo " - Student signs forms: ✓" - -# 5. Test Sponsor Portal -echo "5. Testing Sponsor Portal..." -echo " - Sponsor receives single cohort email: ✓" -echo " - Sponsor reviews all students: ✓" -echo " - Sponsor bulk signs: ✓" - -# 6. Test TP Review -echo "6. Testing TP Review..." -echo " - Verify all signatures: ✓" -echo " - Export cohort data (Excel): ✓" -echo " - Download completed documents: ✓" - -echo "" -echo "=== Workflow Test Complete ===" -echo "All systems operational!" -``` - -**Vue Component Verification Checklist:** -```javascript -// Verification script for manual testing -const verificationChecklist = { - // TP Portal - tpPortal: { - login: "http://localhost:3000/admin", - cohortCreation: true, - studentImport: true, - submissionReview: true, - excelExport: true - }, - - // Student Portal - studentPortal: { - emailNotification: "MailHog: http://localhost:8025", - documentUpload: true, - signatureWorkflow: true, - stateTracking: true - }, - - // Sponsor Portal - sponsorPortal: { - singleEmail: true, - bulkReview: true, - bulkSign: true, - tabNavigation: true - }, - - // Integration - integration: { - sidekiqJobs: true, - minioStorage: true, - mailhogEmails: true, - postgresData: true, - redisCaching: true - } -} -``` - -**Design System Validation:** -- Verify TailwindCSS 3.4.17 classes render correctly -- Confirm custom colors replace DaisyUI defaults -- Test responsive layouts on mobile/tablet/desktop -- Validate icon usage from design system -- Check typography hierarchy matches spec - -**Progressive Disclosure Testing:** -```javascript -// Test scenarios -const testScenarios = [ - { - name: "First-time TP user", - steps: ["Login", "See cohort list", "Create cohort", "Upload students", "Review tab"], - expected: "Should only see relevant options" - }, - { - name: "Student receiving email", - steps: ["Receive email", "Click link", "Upload doc", "Sign", "Done"], - expected: "Should never see admin/sponsor features" - }, - { - name: "Sponsor reviewing", - steps: ["Login", "See single cohort", "Review all students", "Sign once", "Done"], - expected: "Should see all students in one view" - } -] -``` - -##### Acceptance Criteria - -**Functional:** -1. ✅ Complete 3-portal workflow tested end-to-end -2. ✅ Sample cohort with 5+ students created -3. ✅ All students complete their signing workflow -4. ✅ Sponsor successfully signs once for entire cohort -5. ✅ Excel export generates valid file with all data -6. ✅ All documents signed and stored correctly - -**UI/UX:** -1. ✅ All three portals render correctly -2. ✅ Progressive disclosure works as designed -3. ✅ Tab navigation functions properly -4. ✅ Design system compliance verified -5. ✅ Mobile responsiveness tested - -**Integration:** -1. ✅ Sidekiq processes all background jobs -2. ✅ Minio stores all uploaded files -3. ✅ MailHog captures all emails -4. ✅ PostgreSQL maintains correct data state -5. ✅ Redis caching works properly - -**Quality:** -1. ✅ No critical bugs blocking demo -2. ✅ Performance acceptable (< 2s page loads) -3. ✅ Error handling graceful -4. ✅ Data integrity verified - -**Demo Readiness:** -1. ✅ Demo script created and tested -2. ✅ Management can access all portals -3. ✅ Sample data is realistic and compelling -4. ✅ All stakeholders can complete their tasks - -##### Integration Verification (IV1-4) - -**IV1: API Integration** -- Verify all API endpoints respond correctly -- Test cohort creation via API -- Verify submission state transitions -- Confirm file upload/download works -- Test email notification triggers - -**IV2: Pinia Store** -- Verify cohort store manages state correctly -- Test student enrollment state updates -- Confirm sponsor assignment state -- Validate tab switching state management -- Test error state handling - -**IV3: Getters** -- Verify `pendingStudents` getter returns correct count -- Test `completedSubmissions` getter filters properly -- Confirm `cohortStatus` getter calculates correctly -- Validate `sponsorEmailStatus` getter - -**IV4: Token Routing** -- Verify ad-hoc tokens generate correctly -- Test token expiration handling -- Confirm token renewal workflow -- Validate token-based portal access - -##### Test Requirements - -**End-to-End Workflow Test:** -```ruby -# spec/system/cohort_workflow_spec.rb -RSpec.describe 'Complete Cohort Workflow', type: :system do - let(:institution) { create(:institution) } - let(:cohort) { create(:cohort, institution: institution) } - - scenario 'TP creates cohort, students sign, sponsor signs, TP reviews' do - # 1. TP Portal - visit admin_path - login_as_tp_user - - expect(page).to have_content('Create Cohort') - click_button 'Create Cohort' - fill_in 'Name', with: 'Spring 2025 Bootcamp' - click_button 'Save' - - expect(page).to have_content('Cohort created') - cohort = Cohort.last - - # 2. Add students - click_button 'Import Students' - upload_csv_with_5_students - expect(cohort.enrollments.count).to eq(5) - - # 3. Student Portal (simulate 3 students) - 3.times do |i| - enrollment = cohort.enrollments[i] - token = enrollment.generate_token - - visit student_path(token) - expect(page).to have_content(enrollment.student_name) - - attach_file 'Document', Rails.root.join('spec/fixtures/sample.pdf') - click_button 'Sign' - expect(enrollment.reload.status).to eq('completed') - end - - # 4. Sponsor Portal - sponsor_token = cohort.sponsor.generate_token - visit sponsor_path(sponsor_token) - - expect(page).to have_content(cohort.name) - expect(page).to have_content('3 of 5 students completed') - - click_button 'Sign All' - expect(cohort.reload.sponsor_signed_at).not_to be_nil - - # 5. TP Review - visit admin_path - click_link cohort.name - - expect(page).to have_content('Completed') - expect(page).to have_button('Export Excel') - - click_button 'Export Excel' - expect(page).to have_content('Download started') - end -end -``` - -**Component Tests:** -```javascript -// spec/javascript/portal/components/CohortCard.spec.js -import { mount } from '@vue/test-utils' -import CohortCard from '@/admin/components/CohortCard.vue' -import { createPinia, setActivePinia } from 'pinia' - -describe('CohortCard', () => { - beforeEach(() => { - setActivePinia(createPinia()) - }) - - it('displays cohort information correctly', () => { - const cohort = { - name: 'Spring 2025', - studentCount: 10, - completedCount: 7, - status: 'active' - } - - const wrapper = mount(CohortCard, { - props: { cohort } - }) - - expect(wrapper.text()).toContain('Spring 2025') - expect(wrapper.text()).toContain('7/10 completed') - }) - - it('shows progress bar for completion rate', () => { - const wrapper = mount(CohortCard, { - props: { - cohort: { studentCount: 10, completedCount: 5 } - } - }) - - const progress = wrapper.find('[data-test="progress-bar"]') - expect(progress.attributes('style')).toContain('50%') - }) -}) -``` - -**Bash Integration Test:** -```bash -#!/bin/bash -# scripts/validate-demo.sh -set -e - -echo "=== FloDoc Demo Validation ===" -echo "" - -# Check infrastructure -echo "1. Infrastructure Check" -docker ps | grep floDoc-dev-db || exit 1 -docker ps | grep floDoc-dev-redis || exit 1 -docker ps | grep floDoc-dev-app || exit 1 -echo " ✅ All containers running" - -# Check database -echo "2. Database Check" -docker exec floDoc-dev-db psql -U floDoc -d floDoc_development -c "SELECT COUNT(*) FROM cohorts;" || exit 1 -echo " ✅ Database accessible" - -# Check application -echo "3. Application Check" -curl -sf http://localhost:3000/health || exit 1 -echo " ✅ Application responding" - -# Check Sidekiq -echo "4. Sidekiq Check" -docker exec floDoc-dev-app bundle exec sidekiqctl stat || exit 1 -echo " ✅ Sidekiq processing" - -# Check Minio -echo "5. Storage Check" -curl -sf http://localhost:9000/minio/health/live || exit 1 -echo " ✅ Minio running" - -# Check MailHog -echo "6. Email Check" -curl -sf http://localhost:8025/api/v2/messages || exit 1 -echo " ✅ MailHog accessible" - -echo "" -echo "✅ All validation checks passed!" -echo "System ready for management demo!" -``` - -##### Rollback Procedure - -**If demo validation fails:** -1. Stop all services: `docker-compose -f docker-compose.dev.yml down` -2. Reset database: `docker-compose -f docker-compose.dev.yml down -v` -3. Clear Minio data: `docker volume rm floDoc-dev-minio_data` -4. Restart clean: `./scripts/dev-setup.sh` -5. Reload demo data: `docker exec floDoc-dev-app bundle exec ruby scripts/demo-data.rb` - -**Data Safety:** -- All demo data is ephemeral (Docker volumes only) -- No production data at risk -- Easy to reset to clean state -- No permanent changes to application code - -##### Risk Assessment - -**Low Risk because:** -- Running on local Docker infrastructure -- No production data involved -- All operations are reversible -- No external dependencies - -**Specific Risks:** -1. **Incomplete Workflow**: Some edge cases may not be tested - - **Mitigation**: Follow comprehensive test script, test all 5 sample students - -2. **Performance Issues**: Slow page loads during demo - - **Mitigation**: Pre-warm cache, test page loads beforehand, have backup screenshots ready - -3. **Demo Script Confusion**: Forgetting steps during presentation - - **Mitigation**: Create printed checklist, rehearse demo 2-3 times - -4. **Technical Glitches**: Docker/network issues during demo - - **Mitigation**: Have backup environment ready, record demo as video fallback - -**Mitigation Strategies:** -- Rehearse complete workflow 3 times before demo -- Have troubleshooting commands ready -- Prepare backup screenshots -- Document known issues and workarounds -- Create 5-minute and 15-minute demo versions - -##### Success Metrics - -**Demo Success:** -- Management can log into all 3 portals -- Complete workflow demonstrated in < 15 minutes -- All stakeholders can complete their tasks -- No critical bugs encountered -- Questions answered with working system - -**Technical Success:** -- 100% of acceptance criteria verified -- All 5 sample students processed successfully -- Excel export generates valid file -- All emails captured and viewable -- Performance meets expectations (< 2s loads) - -**Business Success:** -- Management understands 3-portal value proposition -- Stakeholders see single-email sponsor benefit -- Bulk operations demonstrated clearly -- Cohort management workflow validated -- Approval to proceed to production infrastructure - ---- - -#### Story 8.5: User Communication & Training Materials - -**Status**: Draft -**Priority**: High (Blocking - Required Before Development) -**Epic**: Phase 8 - Deployment & Documentation -**Estimated Effort**: 2 days -**Risk Level**: Medium - -##### User Story - -**As a** Training Provider (TP Admin), -**I want** clear guidance on using FloDoc's 3-portal system, -**So that** I can manage cohorts effectively without confusion. - -##### Background - -Existing DocuSeal users need to understand: -- What changed (3-portal workflow) -- How to use new features (cohort management) -- Where to get help (support channels) -- What's different (ad-hoc student/sponsor access) - -Without this communication, adoption will suffer and support will be overwhelmed. This story addresses PO Validation Issue #3 (User Communication & Training Plan Missing). - -**Key Deliverables:** -1. Migration announcement for existing users -2. TP Portal "Getting Started" guide -3. Student Portal onboarding tutorial -4. Sponsor Portal quick-start guide -5. FAQ with 20 common questions -6. Support contact process - -##### Technical Implementation Notes - -**Documentation Structure:** -``` -docs/user/ -├── migration-announcement.md -├── tp-portal-guide.md -├── student-portal-tutorial.md -├── sponsor-portal-guide.md -└── faq.md -``` - -**Email Templates:** -```ruby -# app/views/mailers/user_announcement/ -├── migration_email.html.erb # Existing users -├── welcome_floDoc.html.erb # New users -└── feature_highlights.html.erb # Feature overview -``` - -**UI Help Integration:** -```vue - - - - -``` - -**Help Button Component:** -```vue - - - - -``` - -**Error Help Mapping:** -```typescript -// app/javascript/shared/error-help.ts -export const ERROR_HELP: Record = { - 'token_expired': { - message: 'Your access link has expired', - action: 'Request a new link from your training provider' - }, - 'invalid_token': { - message: 'This link is invalid', - action: 'Contact your training provider' - }, - 'cohort_not_found': { - message: 'Cohort not found', - action: 'Check your email for the correct link' - } -} -``` - -##### Acceptance Criteria - -**Functional:** -1. ✅ Migration announcement email sent to all existing DocuSeal users -2. ✅ TP Portal "Getting Started" guide created (5 steps) -3. ✅ Student Portal onboarding tutorial (3 steps, mobile-friendly) -4. ✅ Sponsor Portal quick-start guide (bulk signing focus) -5. ✅ FAQ document with 20 common questions -6. ✅ Support contact process defined -7. ✅ Help overlay on first login for TP Portal -8. ✅ Help button accessible on all major screens -9. ✅ Error messages link to contextual help - -**UI/UX:** -1. ✅ Help buttons visible in all portals -2. ✅ Tutorial tooltips on first login -3. ✅ Mobile-responsive documentation -4. ✅ Consistent help iconography - -**Integration:** -1. ✅ Email templates integrate with existing Devise mailer -2. ✅ Help content served via `/help` routes -3. ✅ Error help displays in all portals - -**Security:** -1. ✅ No sensitive data in documentation -2. ✅ Token links in emails are single-use -3. ✅ Help pages don't expose internal URLs - -**Quality:** -1. ✅ All documentation reviewed by PO -2. ✅ No spelling/grammar errors -3. ✅ Consistent branding and tone -4. ✅ Links verified and working - -##### Integration Verification (IV1-4) - -**IV1: API Integration** -- Email sending uses existing Devise mailer infrastructure -- Help content served via static pages or API endpoints -- Error help API returns contextual guidance - -**IV2: Pinia Store** -- Help state management for "show on first login" -- `useHelpStore` with `showTutorial` state -- `dismissTutorial()` action - -**IV3: Getters** -- `showTutorial: boolean` - determines if help overlay shows -- `currentHelpSection: string` - loads appropriate guide - -**IV4: Token Routing** -- Email links use secure single-use tokens -- Token validation before showing help content -- Token expiration (24h) enforced - -##### Rollback Procedure - -**If communication fails:** -1. Revert email templates to original -2. Remove help overlays from portals -3. Restore original DocuSeal documentation -4. Notify users of rollback -5. Investigate and fix issues - -**If documentation is inaccurate:** -1. Update markdown files immediately -2. Redeploy help content -3. Send correction email if needed -4. Track corrections in changelog - -**Data Safety:** -- No database changes required -- No production data at risk -- Documentation can be updated independently - -##### Risk Assessment - -**MEDIUM because:** -- User confusion could lead to support overload -- Poor adoption affects project success -- Requires coordination with support team -- Documentation must be accurate - -**Specific Risks:** -1. **Email Fatigue**: Too many emails annoy users - - **Mitigation**: Single well-crafted announcement, opt-out option - -2. **Documentation Overload**: Too much information - - **Mitigation**: Concise guides, progressive disclosure, video tutorials - -3. **Support Unprepared**: Team not ready for questions - - **Mitigation**: Support team training session before launch - -4. **Inaccurate Help**: Wrong information damages trust - - **Mitigation**: PO review, user testing, version tracking - -**Mitigation Strategies:** -- Phased communication rollout -- Clear, concise documentation (< 200 words per guide) -- Support team training session -- User feedback collection -- Regular documentation updates - -##### Success Metrics - -**User Adoption:** -- 80% user adoption rate within 30 days -- <10 support tickets per week -- Positive user feedback (>4/5 rating) -- <5% rollback requests - -**Documentation Quality:** -- 100% of links working -- 0 spelling/grammar errors -- User testing pass rate >90% -- Support team confidence >80% - -**Communication Effectiveness:** -- Email open rate >60% -- Help button usage >30% of sessions -- Tutorial completion rate >70% -- FAQ page views >100/month - ---- - -#### Story 8.6: In-App User Documentation & Help System - -**Status**: Draft (Deferred - Post-MVP) -**Priority**: Medium -**Epic**: Phase 8 - Deployment & Documentation -**Estimated Effort**: 1.5 days -**Risk Level**: Low - -##### User Story - -**As a** User (TP Admin, Student, or Sponsor), -**I want** contextual help and documentation, -**So that** I can solve problems without contacting support. - -##### Background - -Deferred to post-MVP per PO validation. This story adds in-app help system after core functionality is validated. - -**Note**: This story is deferred pending successful MVP validation. - -##### Acceptance Criteria (Deferred) - -**Functional:** -1. ✅ Help button on every major screen -2. ✅ Modal with contextual guides -3. ✅ Error code explanations -4. ✅ Searchable FAQ -5. ✅ Keyboard shortcut reference - -**UI/UX:** -1. ✅ Help accessible in 1 click -2. ✅ Mobile-friendly help modals -3. ✅ Consistent help iconography - ---- - -#### Story 8.7: Knowledge Transfer & Operations Documentation - -**Status**: Draft (Deferred - Post-MVP) -**Priority**: Medium -**Epic**: Phase 8 - Deployment & Documentation -**Estimated Effort**: 1 day -**Risk Level**: Medium - -##### User Story - -**As a** Support/Operations Team, -**I want** comprehensive runbooks and documentation, -**So that** I can support FloDoc without ad-hoc knowledge transfer. - -##### Background - -Deferred to post-MVP per PO validation. This story creates operations documentation after system is proven. - -**Note**: This story is deferred pending successful MVP validation. - -##### Acceptance Criteria (Deferred) - -**Functional:** -1. ✅ Operations runbook created -2. ✅ Troubleshooting guide (10 common issues) -3. ✅ Deployment procedure documented -4. ✅ Incident response plan -5. ✅ Code review checklist -6. ✅ Support team training session held - ---- +**Deliverables**: +- Docker Compose configuration +- Setup scripts +- Demo data generation +- Validation checklist +--- \ No newline at end of file diff --git a/docs/stories/1.1.institution-admin.md b/docs/stories/1.1.institution-admin.md deleted file mode 100644 index 7b22b9f1..00000000 --- a/docs/stories/1.1.institution-admin.md +++ /dev/null @@ -1,1011 +0,0 @@ -# - -## Status - -**Approved** - ---- - -## Story - -**As a** system administrator, -**I want** to create and manage training institutions with multiple admin users (super and regular admins), -**so that** private training institutions can manage their cohorts independently. - ---- - -## Acceptance Criteria - -1. Database schema for institutions and admin roles exists -2. Super admins can create institutions and invite other admins -3. Regular admins can manage cohorts within their institution -4. Admins cannot access other institutions' data -5. Role-based permissions are enforced at API and UI levels - ---- - -## Tasks / Subtasks - -### Database Setup (AC: #1) - WINSTON'S ARCHITECTURAL REVISION - -#### **Critical: 4-Layer Data Isolation Foundation** -- [ ] **Migration 1: Add institution_id to account_access** - - [ ] Add `institution_id` reference to `account_accesses` table (nullable initially) - - [ ] Add foreign key constraint: `fk_account_accesses_to_institutions` - - [ ] Backfill existing data: Link users to their institution via account - - [ ] Make non-nullable: `change_column_null :account_access, :institution_id, false` - - [ ] Add unique index: `[user_id, institution_id]` (prevents duplicate roles) - -- [ ] **Migration 2: Update institutions table** - - [ ] Add fields: account_id, name, registration_number, address, contact_email, contact_phone, super_admin_id, settings - - [ ] Add indexes: account_id (unique), registration_number (unique within account), super_admin_id - - [ ] Add foreign key: super_admin_id → users.id - -- [ ] **Migration 3: Create cohort_admin_invitations table** - - [ ] Add fields: email, hashed_token, token_preview, role, institution_id, sent_at, expires_at, used_at, created_by_id - - [ ] Add indexes: institution_id, email, expires_at - - [ ] **Important:** Store SHA-256 hashed tokens, never plaintext - -- [ ] **Migration 4: Update account_access roles** - - [ ] Add cohort_admin and cohort_super_admin to role enum - - [ ] Add index on role for performance - -- [ ] **Rollback Strategy (CRITICAL)** - - [ ] Test rollback on production-like data - - [ ] Document step-by-step rollback procedure - - [ ] Verify no data loss during rollback - -### Models (AC: #1, #2, #3, #4) - WINSTON'S 4-LAYER ISOLATION - -#### **Layer 1: Institution Model (Foundation)** -- [ ] Create `app/models/institution.rb` - - [ ] Add belongs_to :account - - [ ] Add belongs_to :super_admin (class_name: 'User') - - [ ] Add has_many :cohorts - - [ ] Add has_many :sponsors - - [ ] Add has_many :account_accesses (NEW - critical for isolation) - - [ ] Add validations (name, registration_number, uniqueness within account) - - [ ] **CRITICAL SCOPE:** `scope :for_user, ->(user) { where(id: user.institutions.select(:id)) }` - - [ ] **CRITICAL SCOPE:** `scope :managed_by, ->(user) { where(super_admin_id: user.id) }` - - [ ] **CRITICAL METHOD:** `def accessible_by?(user) account_accesses.exists?(user_id: user.id) end` - -#### **Layer 2: User Model Extension** -- [ ] Extend `app/models/user.rb` (add to existing) - - [ ] Add has_many :account_accesses, dependent: :destroy - - [ ] Add has_many :institutions, through: :account_accesses - - [ ] Add has_many :managed_institutions, class_name: 'Institution', foreign_key: 'super_admin_id' - - [ ] **CRITICAL METHOD:** `def can_access_institution?(institution) institutions.exists?(institution.id) || managed_institutions.exists?(institution.id) end` - - [ ] **CRITICAL METHOD:** `def cohort_super_admin? account_accesses.exists?(role: 'cohort_super_admin') end` - - [ ] **CRITICAL METHOD:** `def cohort_admin? account_accesses.exists?(role: 'cohort_admin') end` - -#### **Layer 3: Account Access Model (Security Core)** -- [ ] Extend `app/models/account_access.rb` - - [ ] Add belongs_to :institution (NEW - critical for isolation) - - [ ] Add cohort_admin and cohort_super_admin to role enum - - [ ] **CRITICAL VALIDATION:** `validates :user_id, uniqueness: { scope: :institution_id }` - - [ ] Add scopes: `cohort_admins`, `cohort_super_admins`, `for_institution(institution)` - -#### **Layer 4: Invitation Token Model (Cryptographic Security)** -- [ ] Create `app/models/cohort_admin_invitation.rb` - - [ ] Add belongs_to :institution - - - [ ] Add belongs_to :created_by, class_name: 'User' - - [ ] **CRITICAL:** Store `hashed_token` (SHA-256), never plaintext - - [ ] Add `token_preview` for debugging (first 8 chars + '...') - - [ ] **CRITICAL METHODS:** - - `def generate_token` - uses SecureRandom.urlsafe_base64(64) - - `def valid_token?(raw_token)` - validates hash + Redis single-use - - `def expired?` - checks expires_at - - `def used?` - checks used_at - - [ ] Validations: email format, role inclusion, expires_at presence - - [ ] Scopes: `active`, `used`, `cleanup_expired` - - [ ] **Rate limiting:** Max 5 pending invitations per email - -#### **Layer 5: Security Event Model (Audit Trail)** -- [ ] Create `app/models/security_event.rb` - - [ ] Add user_id, event_type, ip_address, details (jsonb) - - [ ] Add indexes: user_id, event_type, created_at - - [ ] **CRITICAL METHOD:** `def self.log(event_type, user, details = {})` - -### API Controllers (AC: #2, #3, #4, #5) - WINSTON'S 4-LAYER SECURITY - -#### **Layer 1: API Base Controller Security Extensions** -- [ ] Extend `app/controllers/api/api_base_controller.rb` - - [ ] **CRITICAL:** Add `verify_institution_access` before_action - ```ruby - def verify_institution_access - return true unless params[:institution_id].present? - institution = Institution.find_by(id: params[:institution_id]) - unless institution && current_user.can_access_institution?(institution) - log_security_event(:unauthorized_institution_access) - render json: { error: 'Access denied to this institution' }, status: :forbidden - return false - end - true - end - ``` - - [ ] **CRITICAL:** Add `verify_institution_role(required_role)` method - - [ ] **CRITICAL:** Add `log_security_event(event_type, details)` method - - [ ] Add security event logging for all authorization failures - -#### **Layer 2: Institutions Controller** -- [ ] Create `app/controllers/api/v1/institutions_controller.rb` - - [ ] Add `before_action :verify_institution_access` (except index/create) - - [ ] **Authorization:** - - `create` - requires cohort_super_admin role on account - - `update/destroy` - requires cohort_super_admin role + ownership - - `index/show` - requires any institution access - - [ ] Implement actions with 4-layer isolation: - ```ruby - def index - @institutions = Institution.for_user(current_user) # Layer 1 scope - end - - def show - @institution = Institution.for_user(current_user).find(params[:id]) # Layer 1 + 2 - end - ``` - - [ ] Add strong parameters with validation - - [ ] Add error handling with security event logging - -#### **Layer 3: Admin Invitations Controller** -- [ ] Create `app/controllers/api/v1/admin/invitations_controller.rb` - - [ ] **CRITICAL:** Rate limiting - max 5 pending invitations per email - - [ ] **CRITICAL:** Redis-backed single-use token enforcement - - [ ] Actions: - - `create` - cohort_super_admin only, email validation, rate limit - - `index` - scoped to institution, cohort_admin+ access - - `revoke` - cohort_super_admin only, mark used_at - - [ ] **CRITICAL:** Use `InvitationService` for business logic - - [ ] Email delivery via `CohortAdminInvitationJob` (async) - -#### **Layer 4: Invitation Acceptance Controller** -- [ ] Create `app/controllers/api/v1/admin/invitation_acceptance_controller.rb` - - [ ] **CRITICAL:** Token validation with Redis single-use check - - [ ] Email verification (token only valid for matching email) - - [ ] Create `AccountAccess` record on successful acceptance - - [ ] One-time use only - immediate invalidation - -#### **Layer 5: Security Event Controller (Monitoring)** -- [ ] Create `app/controllers/api/v1/admin/security_events_controller.rb` - - [ ] cohort_super_admin only access - - [ ] Filter by user, institution, event_type, date range - - [ ] Pagination support - - [ ] Export capability for audit purposes - -### Web Controllers (AC: #2, #3) -- [ ] Create `app/controllers/cohorts/admin_controller.rb` - - [ ] Add authentication and role checks - - [ ] Implement institution list, new, create, show actions - - [ ] Add invite form handling -- [ ] Add routes to `config/routes.rb` - - [ ] Add API routes under `/api/v1/cohorts/` - - [ ] Add web routes under `/cohorts/admin/` - -### Frontend Components (AC: #2, #3) -- [ ] Create `app/javascript/cohorts/admin/InstitutionWizard.vue` - - [ ] Build form with all institution fields - - [ ] Add validation and error handling - - [ ] Connect to API client - - [ ] Add success/error notifications -- [ ] Create `app/javascript/cohorts/admin/AdminInviteModal.vue` - - [ ] Build invite form with role selection - - [ ] Add email validation - - [ ] Connect to invitation API -- [ ] Create `app/javascript/cohorts/admin/InstitutionList.vue` - - [ ] Display institutions table - - [ ] Add navigation to institution details -- [ ] Update `app/javascript/api/client.js` - - [ ] Add institution API methods - - [ ] Add invitation API methods - -### Jobs & Mailers (AC: #2) - WINSTON'S ASYNCHRONOUS SECURITY - -#### **Invitation Job Architecture** -- [ ] **Create `app/services/invitation_service.rb`** - ```ruby - class InvitationService - def self.create_invitation(institution, email, role, created_by) - # Rate limiting check - pending = CohortAdminInvitation.where(email: email, institution: institution, used_at: nil) - .where('expires_at > ?', Time.current) - .count - raise RateLimit::LimitApproached if pending >= 5 - - invitation = CohortAdminInvitation.create!( - institution: institution, - email: email, - role: role, - created_by: created_by - ) - - # Async email delivery - CohortAdminInvitationJob.perform_async(invitation.id) - invitation - end - - def self.accept_invitation(raw_token, accepting_user) - invitation = CohortAdminInvitation.active.find_by(token_preview: raw_token[0..8] + '...') - return nil unless invitation - return nil unless invitation.email == accepting_user.email - return nil unless invitation.valid_token?(raw_token) - - # Atomic transaction - AccountAccess.create!( - account: invitation.institution.account, - user: accepting_user, - institution: invitation.institution, - role: invitation.role - ) - - invitation - end - end - ``` - -- [ ] **Create `app/jobs/cohort_admin_invitation_job.rb`** - - [ ] `include Sidekiq::Job` - - [ ] `sidekiq_options queue: :mailers, retry: 5` - - [ ] Check invitation validity before sending - - [ ] Handle email delivery failures gracefully - - [ ] Log security event on successful delivery - -#### **Mailer Architecture** -- [ ] **Create `app/mailers/cohort_mailer.rb`** - - [ ] Inherits from `ApplicationMailer` - - [ ] **CRITICAL:** Never include raw token in logs - - [ ] **CRITICAL:** Use HTTPS invitation URL - - [ ] **CRITICAL:** Token only in email, not in URL params - - [ ] Methods: - - `admin_invitation(invitation)` - sends invitation email - - `super_admin_demoted(user, institution)` - security notification - -- [ ] **Email Templates** (`app/views/cohort_mailer/`) - - [ ] `admin_invitation.html.erb` - HTML version - - [ ] `admin_invitation.text.erb` - Plain text version - - [ ] **Security:** Include institution name, role, expiration - - [ ] **CTA:** Direct link to acceptance flow - - [ ] **Warning:** "Do not forward" disclaimer - -#### **Additional Jobs** -- [ ] **Create `app/jobs/security_alert_job.rb`** - - [ ] Send immediate alerts for critical events - - [ ] Queue: `critical_security` - - [ ] Retry: 3 attempts max - -- [ ] **Create `app/jobs/invitation_cleanup_job.rb`** - - [ ] Daily job to remove expired invitations - - [ ] Queue: `default` - - [ ] Retain for 1 week after expiration for audit - -### Authorization & Security (AC: #4, #5) - WINSTON'S DEFENSE-IN-DEPTH - -#### **4-Layer Security Architecture (MANDATORY)** - -**Layer 1: Database-Level Security** -- [ ] **Foreign Key Constraints:** All relationships must have FK constraints -- [ ] **Unique Indexes:** `[user_id, institution_id]` on account_access -- [ ] **Scoped Queries:** ALL database queries use `Institution.for_user(user)` -- [ ] **Non-nullable:** institution_id on account_access after backfill -- [ ] **Test:** Verify SQL injection attempts fail - -**Layer 2: Model-Level Security** -- [ ] **Update `lib/ability.rb` (Cancancan)** - ```ruby - # Add to Ability.initialize - if user.cohort_super_admin? - can :manage, Institution, id: user.managed_institutions.select(:id) - can :manage, Cohort, institution_id: user.managed_institutions.select(:id) - can :manage, Sponsor, institution_id: user.managed_institutions.select(:id) - can :manage, CohortAdminInvitation, institution_id: user.managed_institutions.select(:id) - end - - if user.cohort_admin? - can :read, Institution, id: user.institutions.select(:id) - can :manage, Cohort, institution_id: user.institutions.select(:id) - can :read, Sponsor, institution_id: user.institutions.select(:id) - end - ``` -- [ ] **User Model Methods:** Add `can_access_institution?`, `cohort_super_admin?`, `cohort_admin?` -- [ ] **Institution Model Methods:** Add `accessible_by?(user)` verification -- [ ] **Account Access Validation:** `validates :user_id, uniqueness: { scope: :institution_id }` - -**Layer 3: Controller-Level Security** -- [ ] **API Base Controller Extensions:** - - [ ] `verify_institution_access` - checks institution_id parameter - - [ ] `verify_institution_role(role)` - checks role + institution - - [ ] `log_security_event(type, details)` - audit logging -- [ ] **All Controllers:** Add `before_action :verify_institution_access` where appropriate -- [ ] **Strong Parameters:** All controllers use strong params with validation -- [ ] **Error Handling:** 403 Forbidden for unauthorized access, log all attempts - -**Layer 4: UI-Level Security** -- [ ] **Vue Route Guards:** Prevent manual URL navigation to unauthorized paths -- [ ] **API Client Validation:** Validate institution_id before sending requests -- [ ] **Context Storage:** Store institution context in Vuex, validate server-side -- [ ] **Role-Based UI:** Show/hide elements based on `cohort_super_admin?` / `cohort_admin?` - -#### **Token Security Architecture (CRITICAL)** - -**Cryptographic Token System:** -- [ ] **Token Generation:** `SecureRandom.urlsafe_base64(64)` - 512 bits entropy -- [ ] **Storage:** SHA-256 hash stored in database, never plaintext -- [ ] **Preview:** Store first 8 chars + '...' for debugging only -- [ ] **Single-Use:** Redis-backed enforcement with atomic operations -- [ ] **Expiration:** 24-hour default, configurable per invitation -- [ ] **Rate Limiting:** Max 5 pending invitations per email per institution - -**Redis Enforcement:** -- [ ] **Setup:** Configure Redis connection in `config/initializers/redis.rb` -- [ ] **Atomic Operations:** Use `SET key value NX EX 86400` for single-use -- [ ] **Race Condition Prevention:** Concurrent requests handled correctly -- [ ] **TTL Management:** Automatic cleanup after expiration - -**Invitation Flow Security:** -- [ ] **Email Verification:** Token only valid for matching email address -- [ ] **Domain Validation:** Optional email domain verification for institutions -- [ ] **Acceptance Controller:** One-time use, immediate invalidation -- [ ] **AccountAccess Creation:** Atomic transaction, rollback on failure - -#### **Security Event Logging & Monitoring** - -**Security Event Model:** -- [ ] **Create `app/models/security_event.rb`** - ```ruby - class SecurityEvent < ApplicationRecord - belongs_to :user, optional: true - - validates :event_type, presence: true - validates :ip_address, presence: true - - def self.log(event_type, user = nil, details = {}) - create!( - user: user, - event_type: event_type, - ip_address: details[:ip_address] || '0.0.0.0', - details: details.except(:ip_address) - ) - end - end - ``` - -**Event Types to Log:** -- [ ] `:unauthorized_institution_access` - Cross-institution access attempt -- [ ] `:insufficient_privileges` - Role-based access denied -- [ ] `:token_validation_failure` - Invalid/expired token attempt -- [ ] `:rate_limit_exceeded` - Too many invitation attempts -- [ ] `:invitation_accepted` - Successful admin invitation acceptance -- [ ] `:super_admin_demoted` - Role change that invalidates tokens - -**Monitoring & Alerting:** -- [ ] **Alert Thresholds:** - - >5 unauthorized access attempts/hour → Security alert - - >20 token validation failures/hour → Potential attack - - Any super_admin demotion → Immediate notification -- [ ] **Dashboard:** Security events view for cohort_super_admin -- [ ] **Export:** CSV/PDF export for audit compliance - -#### **Integration with Existing DocuSeal Security** - -**Authentication Compatibility:** -- [ ] **Devise + JWT:** No changes to existing auth system -- [ ] **2FA Support:** Existing 2FA continues to work -- [ ] **API Tokens:** Existing access tokens unaffected -- [ ] **Session Management:** No changes to session handling - -**Authorization Compatibility:** -- [ ] **Existing Abilities:** All existing CanCanCan abilities preserved -- [ ] **Additive Only:** New roles added to existing enum -- [ ] **Account Isolation:** Existing account-level isolation maintained -- [ ] **Template/Submission Access:** Existing permissions unchanged - -**Data Isolation Compatibility:** -- [ ] **Account-Level:** Existing `account_id` isolation preserved -- [ ] **Institution-Level:** New `institution_id` isolation within accounts -- [ ] **Combined Scopes:** Queries must satisfy both levels -- [ ] **Backward Compatibility:** Existing data remains accessible - -#### **Security Testing Requirements** - -**Unit Tests:** -- [ ] **Model Scopes:** `Institution.for_user(user)` returns correct records -- [ ] **User Methods:** `can_access_institution?`, role checks work correctly -- [ ] **Token Security:** Hash generation, validation, single-use enforcement -- [ ] **Rate Limiting:** Max 5 invitations per email enforced -- [ ] **Validation:** Uniqueness constraints prevent duplicates - -**Request Tests:** -- [ ] **Cross-Institution Access:** All endpoints tested with wrong institution_id -- [ ] **Role-Based Access:** Each role tested for allowed/denied actions -- [ ] **Token Security:** Reuse, expiration, wrong email scenarios -- [ ] **Security Events:** All violations logged correctly -- [ ] **Rate Limiting:** 429 responses when limit exceeded - -**Integration Tests:** -- [ ] **Complete Invitation Flow:** Create → Email → Accept → Access -- [ ] **Concurrent Access:** Multiple users from different institutions -- [ ] **Race Conditions:** Token validation under load -- [ ] **Rollback Scenarios:** Migration rollback preserves security - -**Security Audit:** -- [ ] **Penetration Testing:** Attempt cross-institution data access -- [ ] **Token Analysis:** Verify cryptographic strength -- [ ] **Redis Security:** Verify atomic operations prevent race conditions -- [ ] **OWASP Check:** Review for common vulnerabilities - -#### **Deployment Security Checklist** - -**Pre-Production:** -- [ ] **Security Audit:** Third-party review of token system -- [ ] **Penetration Test:** Attempt to breach data isolation -- [ ] **Performance Test:** Verify security doesn't degrade performance >10% -- [ ] **Rollback Test:** Verify security events persist after rollback - -**Production Monitoring:** -- [ ] **Security Dashboard:** Real-time event monitoring -- [ ] **Alert System:** Immediate notification of violations -- [ ] **Audit Trail:** All events retained for compliance -- [ ] **Incident Response:** Documented procedure for security breaches - -**Emergency Procedures:** -- [ ] **Token Revocation:** Ability to invalidate all pending invitations -- [ ] **Access Lockdown:** Emergency role removal capability -- [ ] **Data Breach Protocol:** Steps if isolation is compromised -- [ ] **Recovery Plan:** Restore security without data loss - -### Integration Verification - WINSTON'S COMPREHENSIVE TESTING - -#### **IV1: Existing DocuSeal Authentication Compatibility** -- [ ] **Test:** Existing user login flows work unchanged -- [ ] **Test:** JWT tokens work for legacy endpoints -- [ ] **Test:** 2FA continues to function normally -- [ ] **Test:** API access tokens unaffected -- [ ] **Test:** Session management unchanged - -#### **IV2: Role System Compatibility** -- [ ] **Test:** Existing DocuSeal roles (member, admin) unaffected -- [ ] **Test:** New roles (cohort_admin, cohort_super_admin) are additive only -- [ ] **Test:** No conflicts between old and new role enums -- [ ] **Test:** Existing template/submission access unchanged -- [ ] **Test:** Account-level isolation preserved - -#### **IV3: Performance Impact** -- [ ] **Benchmark:** Existing user operations before/after -- [ ] **Target:** <10% performance degradation on existing flows -- [ ] **Test:** Query performance with 1000+ institutions -- [ ] **Test:** Concurrent user load (100+ simultaneous) -- [ ] **Test:** Database query optimization (EXPLAIN ANALYZE) - -#### **IV4: New Architecture Security (WINSTON'S MANDATORY)** -- [ ] **Test:** 4-layer isolation with malicious inputs - - [ ] Attempt cross-institution access via altered institution_id - - [ ] Test SQL injection attempts on scoped queries - - [ ] Verify 403 responses for all unauthorized attempts -- [ ] **Test:** Redis token enforcement under concurrent load - - [ ] 50 concurrent token validation attempts - - [ ] Race condition prevention verified - - [ ] Single-use enforcement works correctly -- [ ] **Test:** Security event logging captures all violations - - [ ] All 6 event types logged correctly - - [ ] IP address captured accurately - - [ ] Details JSON contains relevant information -- [ ] **Test:** Rate limiting prevents invitation spam - - [ ] 6th invitation attempt returns 429 - - [ ] Counter resets correctly - - [ ] Per-email limit enforced per institution -- [ ] **Test:** Token security scenarios - - [ ] Token reuse attempts fail - - [ ] Expired tokens rejected - - [ ] Wrong email address rejected - - [ ] Concurrent same-token validation handled correctly - -#### **IV5: Integration with Existing Features** -- [ ] **Test:** Template sharing works with new institutions -- [ ] **Test:** Submission workflows integrate correctly -- [ ] **Test:** Webhook delivery unaffected -- [ ] **Test:** Email notifications work for new roles -- [ ] **Test:** Export functionality includes new data - -### Testing -- [ ] **Model Tests:** `spec/models/institution_spec.rb` - - [ ] Validations - - [ ] Associations - - [ ] Scopes (for_user) - - [ ] Data isolation -- [ ] **Request Tests:** `spec/requests/api/v1/institutions_spec.rb` - - [ ] Authentication requirements - - [ ] Authorization (super_admin only) - - [ ] Success/failure scenarios - - [ ] Error responses -- [ ] **System Tests:** `spec/system/institution_management_spec.rb` - - [ ] Institution creation flow - - [ ] Admin invitation flow - - [ ] Data isolation verification - - [ ] Role-based access control - ---- - -## Dev Notes - -### Overview -This is the **foundation story** for the entire 3-portal cohort management system. All subsequent stories depend on the institution and admin management infrastructure created here. - -### Key Integration Points - -**Existing DocuSeal Systems to Reference:** -- `app/models/user.rb` - Authentication patterns (Devise + 2FA) -- `app/models/account.rb` - Multi-tenancy structure -- `app/models/account_access.rb` - Role-based permissions (extend this) -- `app/controllers/api/api_base_controller.rb` - API authentication -- `lib/ability.rb` - Cancancan authorization rules -- `app/javascript/template_builder/` - Form builder integration patterns - -**Critical Files for Implementation:** -- `config/routes.rb` - Add cohort management routes -- `app/javascript/api/client.js` - Add API methods -- `app/views/mailers/` - Email templates location - -### Winston's Architecture Requirements (MANDATORY) - -#### **4-Layer Data Isolation Foundation** -This is the **cornerstone** of the entire architecture. Without this, the story cannot proceed safely. - -**Layer 1: Database Constraints** -- `account_access.institution_id` with FK constraint -- Unique index `[user_id, institution_id]` -- Non-nullable after backfill - -**Layer 2: Model Scopes** -- `Institution.for_user(user)` - used in ALL queries -- `User.can_access_institution?(institution)` - verification method -- `Institution.accessible_by?(user)` - security check - -**Layer 3: Controller Authorization** -- `verify_institution_access` before_action -- `verify_institution_role(role)` for role checks -- `log_security_event` for audit trail - -**Layer 4: UI Validation** -- Vue route guards -- API client pre-validation -- Context-based access control - -#### **Cryptographic Token Security** -- **Generation:** `SecureRandom.urlsafe_base64(64)` (512 bits) -- **Storage:** SHA-256 hash only, never plaintext -- **Enforcement:** Redis-backed single-use with atomic operations -- **Expiration:** 24-hour default, strict validation - -#### **Security Event Logging** -- **Model:** `SecurityEvent` with user, event_type, ip_address, details -- **Events:** 6 types covering all security violations -- **Monitoring:** Real-time alerts for critical thresholds -- **Audit:** Export capability for compliance - -#### **Integration Compatibility** -- **Additive Only:** No changes to existing DocuSeal schemas -- **Authentication:** Devise + JWT unchanged -- **Authorization:** Existing CanCanCan abilities preserved -- **Performance:** <10% degradation on existing operations - -#### **Implementation Sequence (CRITICAL)** -1. ✅ **Database migrations** (institution_id on account_access) -2. ✅ **Model layer** (4-layer isolation foundation) -3. ✅ **Security layer** (token system + event logging) -4. ✅ **Controller layer** (authorization + validation) -5. ✅ **UI layer** (route guards + context management) -6. ✅ **Testing** (comprehensive security tests) -7. ✅ **Features** (invitation flow, CRUD operations) - -**⚠️ DO NOT implement features before security foundation is complete and tested.** - -### Technical Constraints (from Architecture) - -**Must Follow:** -- Ruby 3.4.2, Rails 7.x exact versions -- Vue 3.3.2 with Composition API (`