# Project Structure - FloDoc Architecture
**Document**: File Organization & Conventions
**Version**: 1.0
**Last Updated**: 2026-01-14
---
## 📁 Root Directory Structure
```
floDoc-v3/
├── app/ # Rails application code
├── config/ # Configuration files
├── db/ # Database migrations and schema
├── docs/ # Documentation
│ ├── architecture/ # Architecture docs (this folder)
│ ├── prd/ # Product requirements (sharded)
│ ├── po/ # Product Owner validation
│ ├── qa/ # QA assessments & gates
│ └── stories/ # Developer story files
├── lib/ # Library code
├── spec/ # Tests
├── app/javascript/ # Frontend code
├── .bmad-core/ # BMAD workflow configuration
├── docker-compose.yml # Local Docker setup
└── Gemfile, package.json # Dependencies
```
---
## 🎯 Application Directory Structure
### `app/models/` - Business Logic
```
app/models/
├── application_record.rb # Base model
├── user.rb # Devise authentication
├── account.rb # Multi-tenancy (existing)
├── template.rb # Document templates (existing)
├── submission.rb # Document workflows (existing)
├── submitter.rb # Signers (existing)
│
├── # NEW FloDoc Models
├── institution.rb # Single training institution
├── cohort.rb # Training program cohort
├── cohort_enrollment.rb # Student enrollment
│
├── # Concerns & Utilities
├── feature_flag.rb # Feature flag system
├── account_access.rb # Role-based access (existing)
└── ability.rb # Cancancan abilities (existing)
```
**Key Patterns**:
- All models inherit from `ApplicationRecord`
- Use `strip_attributes` for data cleaning
- Include soft delete via `deleted_at` timestamp
- Follow Rails naming conventions (singular, lowercase)
**Example Model**:
```ruby
# app/models/cohort.rb
class Cohort < ApplicationRecord
include SoftDeletable
strip_attributes
belongs_to :institution
belongs_to :template
has_many :cohort_enrollments, dependent: :destroy
validates :name, :program_type, :sponsor_email, presence: true
validates :status, inclusion: { in: %w[draft active completed] }
scope :active, -> { where(status: 'active') }
end
```
---
### `app/controllers/` - Request Handling
```
app/controllers/
├── application_controller.rb # Base controller
├── dashboard_controller.rb # TP dashboard (existing)
│
├── # NEW FloDoc Controllers
├── tp/ # TP Portal namespace
│ ├── base_controller.rb
│ ├── cohorts_controller.rb
│ ├── enrollments_controller.rb
│ └── dashboard_controller.rb
│
├── student/ # Student Portal namespace
│ ├── base_controller.rb
│ ├── enrollment_controller.rb
│ └── documents_controller.rb
│
├── sponsor/ # Sponsor Portal namespace
│ ├── base_controller.rb
│ ├── dashboard_controller.rb
│ └── signing_controller.rb
│
├── # API Controllers
├── api/
│ ├── v1/
│ │ ├── base_controller.rb
│ │ ├── cohorts_controller.rb
│ │ ├── enrollments_controller.rb
│ │ └── webhooks_controller.rb
│ └── v2/ # Future versions
│
├── # Settings & Admin
├── settings/
│ ├── profile_controller.rb
│ └── security_controller.rb
│
└── # Existing DocuSeal Controllers
├── templates_controller.rb
├── submissions_controller.rb
└── submitters_controller.rb
```
**Controller Patterns**:
**TP Portal Controller**:
```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.order(created_at: :desc)
end
def create
@cohort = current_institution.cohorts.new(cohort_params)
if @cohort.save
redirect_to tp_cohort_path(@cohort), notice: 'Cohort created'
else
render :new
end
end
private
def cohort_params
params.require(:cohort).permit(:name, :program_type, :sponsor_email, :template_id)
end
end
```
**Ad-hoc Portal Controller**:
```ruby
# app/controllers/student/enrollment_controller.rb
class Student::EnrollmentController < ApplicationController
skip_before_action :authenticate_user!
def show
@enrollment = CohortEnrollment.find_by!(token: params[:token])
redirect_to root_path, alert: 'Invalid token' unless @enrollment.pending?
end
def submit
@enrollment = CohortEnrollment.find_by!(token: params[:token])
# Process submission
end
end
```
**API Controller**:
```ruby
# app/api/v1/cohorts_controller.rb
class Api::V1::CohortsController < Api::V1::BaseController
def index
@cohorts = current_institution.cohorts
render json: @cohorts
end
def create
@cohort = current_institution.cohorts.new(cohort_params)
if @cohort.save
render json: @cohort, status: :created
else
render json: { errors: @cohort.errors }, status: :unprocessable_entity
end
end
private
def cohort_params
params.permit(:name, :program_type, :sponsor_email, :template_id)
end
end
```
---
### `app/javascript/` - Frontend Code
```
app/javascript/
├── application.js # Main entry point
├── packs/ # Webpack packs
│ └── application.js
│
├── # NEW FloDoc Portals
├── tp/ # TP Portal (Admin)
│ ├── index.js # Vue app entry
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── CohortList.vue
│ │ ├── CohortCreate.vue
│ │ ├── CohortDetail.vue
│ │ ├── StudentManagement.vue
│ │ └── SponsorPortal.vue
│ ├── components/
│ │ ├── CohortCard.vue
│ │ ├── CohortForm.vue
│ │ ├── StudentTable.vue
│ │ └── StatusBadge.vue
│ ├── stores/
│ │ ├── cohortStore.js # Pinia store
│ │ └── uiStore.js
│ └── api/
│ └── cohorts.js # API client
│
├── student/ # Student Portal
│ ├── index.js
│ ├── views/
│ │ ├── Enrollment.vue
│ │ ├── DocumentUpload.vue
│ │ ├── FormFill.vue
│ │ └── SubmissionStatus.vue
│ ├── components/
│ │ ├── UploadZone.vue
│ │ ├── FormField.vue
│ │ └── ProgressTracker.vue
│ ├── stores/
│ │ └── enrollmentStore.js
│ └── api/
│ └── enrollment.js
│
├── sponsor/ # Sponsor Portal
│ ├── index.js
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── BulkSigning.vue
│ │ ├── DocumentPreview.vue
│ │ └── ProgressTracker.vue
│ ├── components/
│ │ ├── CohortSummary.vue
│ │ ├── SigningTable.vue
│ │ └── BulkSignButton.vue
│ ├── stores/
│ │ └── sponsorStore.js
│ └── api/
│ └── sponsor.js
│
├── # Shared Components
├── components/
│ ├── ui/
│ │ ├── Button.vue
│ │ ├── Modal.vue
│ │ ├── Alert.vue
│ │ └── LoadingSpinner.vue
│ └── layout/
│ ├── Header.vue
│ ├── Sidebar.vue
│ └── Footer.vue
│
├── # Existing DocuSeal UI
├── template_builder/ # PDF form builder
├── elements/ # Web components
├── submission_form/ # Multi-step signing
│
└── # Utilities
├── utils/
│ ├── auth.js
│ ├── api.js
│ └── validators.js
└── plugins/
└── axios.js
```
**Vue Component Pattern**:
```vue
```
**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