Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Thumbs.db
frontend/test-results/
frontend/playwright-report/
backend/.pytest_tmp/

.pytest_tmp/
.pytest_cache/

This file was deleted.

1 change: 0 additions & 1 deletion .pytest_tmp/basetemp/test_upload_badge_imagecurrent

This file was deleted.

This file was deleted.

1 change: 0 additions & 1 deletion .pytest_tmp/basetemp/test_upload_storybook_pagecurrent

This file was deleted.

4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Important paths:

Local frontend API calls use `frontend/proxy.conf.json` to reach the backend at `http://127.0.0.1:8000`.

Page components are loaded through explicit standalone `loadComponent` route boundaries. Public, authentication, onboarding, Learn, Teach, Studio, Organization, Admin, and legacy compatibility routes keep their existing guards and URLs while downloading feature code on demand. Recognized dynamic-import failures use the lazy `/load-error` recovery route.

## Backend

Location: `backend/`
Expand All @@ -65,6 +67,8 @@ Important paths:
- `backend/alembic/versions/` contains database migrations.
- `backend/tests/` contains the backend test suite.

The application exposes `/health/live` and database-backed `/health/ready`. HTTP responses include a privacy-safe request ID and baseline security headers; backend request logs record method, path, status, duration, and correlation ID without logging bearer tokens. These are foundations rather than a complete metrics, tracing, or deployment platform; see [docs/platform-maturity](docs/platform-maturity/observability-baseline.md).

## Existing Domain Boundaries

Reuse existing systems before adding new ones:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ curriculum/ Seed curriculum and package material

For a deeper overview, see [ARCHITECTURE.md](ARCHITECTURE.md).

Platform maturity evidence, including the measured frontend bundle work and the security, observability, operations, dependency, and backend-capability baselines, is indexed in [docs/platform-maturity](docs/platform-maturity/phase-7-baseline.md). These documents are readiness inputs; they do not claim that EchoEd 1.0 is production-ready.

## Local Development

### Backend
Expand All @@ -82,6 +84,8 @@ alembic upgrade head
uvicorn app.main:app --reload
```

The backend exposes `/health/live` for process liveness and `/health/ready` for database readiness. Deployment systems should use the readiness endpoint before sending traffic.

### Frontend

```powershell
Expand Down
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ These are not Phase 1 requirements:
- Production user onboarding.
- Payments, marketplace, or monetization.

## Phase 7: Platform Maturity Foundation

Phase 7 preserves the completed role-based experience while reducing initial frontend loading cost and establishing evidence-based security, observability, operational, dependency, and backend-capability baselines. Larger product capabilities remain independent future OpenSpec changes. See the [platform-maturity roadmap](docs/platform-maturity/future-openspec-roadmap.md) for priorities and dependencies; passing this foundation does not make EchoEd 1.0 production-ready.

## Roadmap Principles

- Trust before scale.
Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ Security reports may cover:
- Cross-site scripting, injection, or data exposure concerns.
- Vulnerabilities in documented setup or deployment instructions.

## Current Baseline

The focused [Phase 7 security baseline](docs/platform-maturity/security-baseline.md) records repository evidence, severity, narrow remediations, and deferred security work. Phase 7 adds privacy-safe authentication logging, authenticated diagnostic access, active organization-membership enforcement, bounded image-upload validation, request correlation, baseline response headers, and patched Angular runtime packages. It is not a penetration test.

The unauthenticated forum mutation boundary, administrative response minimization, rate limiting, comprehensive audit events, and production security policy remain explicit future work. Do not use the current demo with real learner or production data.

## Reporting a Vulnerability

Send security reports to:
Expand Down
10 changes: 6 additions & 4 deletions backend/app/api/routes/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from app.crud.badges import BADGE_RULES, calculate_streak_days, get_or_create_badge
from app.database import get_db
from app.deps import require_roles, require_org_roles
from app.section_scope import require_scoped_section
from app.enum import MembershipStatus, ProgressStatus
from app.models import (
AccessGrant,
Expand Down Expand Up @@ -1045,29 +1046,30 @@ def get_section_summary(
db: Session = Depends(get_db),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
section = require_scoped_section(db, membership, section_id)
total_enrolled = (
db.query(Enrollment)
.filter(Enrollment.section_id == section_id)
.filter(Enrollment.section_id == section.id)
.count()
)
completed_lessons = (
db.query(SegmentProgress)
.filter(
SegmentProgress.section_id == section_id,
SegmentProgress.section_id == section.id,
SegmentProgress.status == ProgressStatus.COMPLETED,
)
.count()
)
completed_units = (
db.query(StudentUnitProgress)
.filter(
StudentUnitProgress.section_id == section_id,
StudentUnitProgress.section_id == section.id,
StudentUnitProgress.status == ProgressStatus.COMPLETED,
)
.count()
)
return {
"section_id": section_id,
"section_id": str(section.id),
"totals": {
"enrolled": total_enrolled,
"lessons_completed": completed_lessons,
Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/routes/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from app.database import get_db
from app.deps import get_current_user, require_org_roles
from app.models import Assignment, AssignmentSubmission
from app.section_scope import require_scoped_section
from app.enum import AssignmentTargetType, AssignmentSubmissionStatus
from app.schemas import (
AssignmentCreateRequest,
Expand All @@ -24,8 +25,9 @@ def create_assignment(
current_user=Depends(get_current_user),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
section = require_scoped_section(db, membership, section_id)
assignment = Assignment(
section_id=section_id,
section_id=section.id,
target_type=AssignmentTargetType(payload.target_type),
target_id=payload.target_id,
due_at=payload.due_at,
Expand All @@ -42,9 +44,10 @@ def create_assignment(
def list_assignments(
section_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
return db.query(Assignment).filter(Assignment.section_id == section_id).all()
section = require_scoped_section(db, membership, section_id)
return db.query(Assignment).filter(Assignment.section_id == section.id).all()


@router.post("/assignments/{assignment_id}/submit", response_model=AssignmentSubmissionResponse)
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.auth import (
authenticate_user,
create_access_token,
get_current_user,
hash_password,
resolve_active_organization,
)
Expand Down Expand Up @@ -116,5 +117,5 @@ def login(


@router.get("/auth/protected")
def protected_route():
def protected_route(current_user: User = Depends(get_current_user)):
return {"message": "Authenticated"}
76 changes: 75 additions & 1 deletion backend/app/api/routes/orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,30 @@
from app.database import get_db
from app.deps import get_current_user
from app.enum import OrganizationType, OrganizationRole
from app.models import Organization, OrganizationMembership
from app.models import Enrollment, Organization, OrganizationMembership, Section, User
from app.schemas import (
OrganizationCreate,
OrganizationResponse,
OrganizationMemberResponse,
OrganizationSectionResponse,
OrganizationSwitchResponse,
OrganizationUpdate,
)
from app.deps import require_org_roles

router = APIRouter()


def _require_requested_organization(org_id: str, membership: OrganizationMembership) -> uuid.UUID:
try:
org_uuid = uuid.UUID(org_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid organization id") from exc
if membership.organization_id != org_uuid:
raise HTTPException(status_code=403, detail="Organization mismatch")
return org_uuid


@router.get("/orgs", response_model=list[OrganizationResponse])
def list_orgs(
db: Session = Depends(get_db),
Expand Down Expand Up @@ -45,6 +58,67 @@ def list_orgs(
]


@router.get("/orgs/{org_id}/members", response_model=list[OrganizationMemberResponse])
def list_org_members(
org_id: str,
db: Session = Depends(get_db),
membership=Depends(require_org_roles("org_admin")),
):
org_uuid = _require_requested_organization(org_id, membership)
rows = (
db.query(OrganizationMembership, User)
.join(User, User.id == OrganizationMembership.user_id)
.filter(OrganizationMembership.organization_id == org_uuid)
.order_by(User.firstname.asc(), User.lastname.asc(), User.username.asc())
.all()
)
return [
{
"id": row.id,
"user_id": user.id,
"display_name": f"{user.firstname or ''} {user.lastname or ''}".strip() or user.username,
"username": user.username,
"role": row.role.value,
"status": row.status.value,
"joined_at": row.created_at,
}
for row, user in rows
]


@router.get("/orgs/{org_id}/sections", response_model=list[OrganizationSectionResponse])
def list_org_sections(
org_id: str,
db: Session = Depends(get_db),
membership=Depends(require_org_roles("org_admin")),
):
org_uuid = _require_requested_organization(org_id, membership)
sections = (
db.query(Section)
.filter(Section.organization_id == org_uuid)
.order_by(Section.name.asc(), Section.created_at.asc())
.all()
)
results = []
for section in sections:
enrollments = db.query(Enrollment).filter(Enrollment.section_id == section.id).all()
results.append(
{
"id": section.id,
"organization_id": section.organization_id,
"course_version_id": section.course_version_id,
"name": section.name,
"mode": section.mode.value,
"start_date": section.start_date,
"end_date": section.end_date,
"created_by": section.created_by,
"learner_count": sum(1 for row in enrollments if row.role_in_section == "student"),
"teacher_count": sum(1 for row in enrollments if row.role_in_section in {"teacher", "instructor"}),
}
)
return results


@router.post("/orgs", response_model=OrganizationResponse)
def create_org(
payload: OrganizationCreate,
Expand Down
21 changes: 18 additions & 3 deletions backend/app/api/routes/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

from app.database import get_db
from app.deps import get_current_user, require_org_roles
from app.models import Section, Enrollment, User
from app.models import Section, Enrollment, OrganizationMembership, User
from app.enum import SectionMode
from app.schemas import SectionCreateRequest, SectionResponse, EnrollmentCreateRequest, EnrollmentResponse
from app.section_scope import require_scoped_section

router = APIRouter()

Expand Down Expand Up @@ -56,7 +57,8 @@ def section_roster(
db: Session = Depends(get_db),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
return db.query(Enrollment).filter(Enrollment.section_id == section_id).all()
section = require_scoped_section(db, membership, section_id)
return db.query(Enrollment).filter(Enrollment.section_id == section.id).all()


@router.post("/sections/{section_id}/enrollments", response_model=EnrollmentResponse)
Expand All @@ -66,6 +68,7 @@ def add_enrollment(
db: Session = Depends(get_db),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
section = require_scoped_section(db, membership, section_id)
if not payload.user_id and not payload.email:
raise HTTPException(status_code=400, detail="Provide a user_id or email")

Expand All @@ -78,7 +81,19 @@ def add_enrollment(
if not user:
raise HTTPException(status_code=404, detail="User not found")

enrollment = Enrollment(section_id=section_id, user_id=user.id)
active_membership = (
db.query(OrganizationMembership)
.filter(
OrganizationMembership.organization_id == membership.organization_id,
OrganizationMembership.user_id == user.id,
OrganizationMembership.status == "active",
)
.first()
)
if active_membership is None:
raise HTTPException(status_code=400, detail="User is not an active member of this organization")

enrollment = Enrollment(section_id=section.id, user_id=user.id)
db.add(enrollment)
db.commit()
db.refresh(enrollment)
Expand Down
Loading
Loading