SafePath Training — Phase 1F: Onboarding Adaptations
Overview
The current onboarding flow (9 unified steps) is HazCom-centric — step labels, descriptions, and coordinator logic assume the primary use case is chemical safety. When an EHS Trainer signs up with SAFEPATH as their primary module, several adaptations are needed to ensure a smooth experience.
This document covers:
- Context-aware onboarding step metadata — Adapt titles/descriptions based on enabled modules
- Coordinator step adaptation — "Training Coordinator" vs "Program Coordinator"
- SafePath quickstart wizard — Post-onboarding guided setup
- Starter course template seeding — Draft course templates for common OSHA topics
- SafePath capability constants — Tier-gated feature codes (paralleling ChemIQ capabilities)
- Default role permissions — Ensure TRAINER role has SafePath-specific permissions
Prerequisite: Phase 1A (database schema) must be complete. This phase can run in parallel with Phases 1B–1E.
Files to create/modify:
tellus-ehs-hazcom-service/app/core/constants.py(modify — add SafePathCapability, context-aware metadata)tellus-ehs-hazcom-service/app/services/adminhq/onboarding_service.py(modify — context-aware step metadata)tellus-ehs-hazcom-service/app/services/safepath/quickstart_service.py(new)tellus-ehs-hazcom-service/app/api/v1/safepath/quickstart.py(new)tellus-ehs-hazcom-service/app/schemas/safepath/quickstart.py(new)docs/data_model/seed_data_v3.sql(modify — add SafePath module features + TRAINER role permissions)
Problem Statement
Current Onboarding for EHS Trainers
When an EHS Trainer signs up today:
- INDUSTRY step — Selects "EHS Trainer" →
company_type_idis set - MODULES step — SAFEPATH is DEFAULT_ON (pre-checked), CHEMIQ is OPTIONAL
- DETAILS step — Works fine (generic company info)
- COORDINATOR step — Says "Program Coordinator (OSHA Step 1)" ← HazCom language
- ROLES step — Creates all system roles (ADMIN, MANAGER, COORDINATOR, TRAINER, EMPLOYEE, VIEWER, CONTRACTOR, CONSULTANT)
- SITES step — Says "Add your company sites" ← Fine, but for trainers these are often client sites, not owned sites
- TEAM step — Says "Add users and send invitations" ← Fine
- FINALIZE — Marks onboarding complete, redirects to... empty SafePath dashboard
Gaps
| Issue | Impact |
|---|---|
| Coordinator step title says "Program Coordinator (OSHA Step 1)" | Confusing for trainers — they need a "Training Coordinator" or "Lead Trainer" |
| Coordinator step description references OSHA HazCom program | Irrelevant if CHEMIQ is not enabled |
| No post-onboarding guidance for SafePath | Trainer lands on empty dashboard, unclear what to do first |
| No starter course templates | Product metric says "< 15 min to first course created" — starting from blank makes this hard |
| No SafePath capability constants | Unlike ChemIQ which has ChemIQCapability enum, SafePath has no tier-gated feature codes |
| TRAINER role exists but has no SafePath-specific permissions | Seed data only grants permissions for existing modules |
Solution 1: Context-Aware Onboarding Step Metadata
Instead of changing the 9-step flow, make the step titles and descriptions adapt based on the company's enabled modules and company type.
Modified Constants
File: tellus-ehs-hazcom-service/app/core/constants.py
Add context-aware metadata after the existing ONBOARDING_STEP_METADATA:
# ============================================================================
# Context-Aware Onboarding Step Overrides
# ============================================================================
# When a company's primary module is not ChemIQ (e.g., EHS Trainer with
# SAFEPATH as the primary module), certain step titles and descriptions
# should adapt to their context.
ONBOARDING_STEP_OVERRIDES: Dict[str, Dict[str, Dict[str, str]]] = {
# Key: company_type_code -> step_code -> override fields
"EHS_TRAINER": {
OnboardingStep.COORDINATOR: {
"title": "Training Coordinator",
"description": "Assign a lead trainer or training coordinator for your organization",
},
OnboardingStep.SITES: {
"title": "Training Sites",
"description": "Add sites where training will be conducted (your offices or client facilities)",
},
OnboardingStep.TEAM: {
"title": "Trainers & Staff",
"description": "Add your trainers, instructors, and support staff",
},
},
"EHS_CONSULTANT": {
OnboardingStep.COORDINATOR: {
"title": "Lead Consultant",
"description": "Assign a lead consultant for managing client engagements",
},
OnboardingStep.SITES: {
"title": "Client Sites",
"description": "Add your client sites where you provide EHS services",
},
},
"CONSTRUCTION": {
OnboardingStep.COORDINATOR: {
"title": "Safety Coordinator",
"description": "Assign a safety coordinator for your construction projects",
},
OnboardingStep.SITES: {
"title": "Job Sites",
"description": "Add your active construction job sites",
},
},
}
def get_step_metadata_for_company(
company_type_code: Optional[str] = None,
) -> Dict[str, Dict[str, any]]:
"""Get onboarding step metadata, optionally customized for a company type.
Returns a copy of ONBOARDING_STEP_METADATA with overrides applied
for the given company type.
Usage:
metadata = get_step_metadata_for_company("EHS_TRAINER")
# metadata[OnboardingStep.COORDINATOR]["title"] == "Training Coordinator"
"""
import copy
metadata = copy.deepcopy(ONBOARDING_STEP_METADATA)
if company_type_code and company_type_code in ONBOARDING_STEP_OVERRIDES:
overrides = ONBOARDING_STEP_OVERRIDES[company_type_code]
for step_code, step_overrides in overrides.items():
if step_code in metadata:
metadata[step_code].update(step_overrides)
return metadata
Modified Onboarding Service
File: tellus-ehs-hazcom-service/app/services/adminhq/onboarding_service.py
Add a method to return context-aware checklist:
def get_checklist_with_metadata(
self, company_id: uuid.UUID, company_type_code: Optional[str] = None
) -> List[dict]:
"""Get checklist items enriched with context-aware metadata.
Returns checklist items with titles/descriptions adapted to
the company's industry type.
"""
from app.core.constants import get_step_metadata_for_company
items = self.get_checklist(company_id)
metadata = get_step_metadata_for_company(company_type_code)
enriched = []
for item in items:
step_meta = metadata.get(item.step_code, {})
enriched.append({
"step_code": item.step_code,
"status": item.status,
"title": step_meta.get("title", item.step_code),
"description": step_meta.get("description", ""),
"required": step_meta.get("required", False),
"order": step_meta.get("order", 0),
"meta_json": item.meta_json,
})
return enriched
Modified Coordinator Handling
File: tellus-ehs-hazcom-service/app/services/adminhq/company_service.py
In the _handle_program_coordinator method, the invitation email currently hardcodes role_name="Program Coordinator". Change it to be context-aware:
def _handle_program_coordinator(
self,
company_id: uuid.UUID,
company: Company,
coordinator_data,
invited_by_user_id: Optional[uuid.UUID]
) -> bool:
"""Handle program coordinator invite or assignment"""
# Determine the appropriate role name based on company type
role_name = "Program Coordinator" # Default (HazCom-centric)
if company.company_type_id:
company_type = self.db.query(CompanyType).filter(
CompanyType.company_type_id == company.company_type_id
).first()
if company_type:
role_name_map = {
"EHS_TRAINER": "Training Coordinator",
"EHS_CONSULTANT": "Lead Consultant",
"CONSTRUCTION": "Safety Coordinator",
}
role_name = role_name_map.get(company_type.code, "Program Coordinator")
# ... rest of existing logic, using role_name variable instead of hardcoded string ...
# The invitation email now says "Training Coordinator" for EHS Trainers
Solution 2: SafePath Quickstart Wizard
After onboarding completes, if SAFEPATH is enabled, present a guided quickstart that gets the trainer to their first course in < 15 minutes (matching the product success metric).
Quickstart Schemas
File: tellus-ehs-hazcom-service/app/schemas/safepath/quickstart.py
"""SafePath Quickstart Schemas"""
from typing import Optional, List
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
class QuickstartStatus(BaseModel):
"""Current status of the SafePath quickstart."""
is_complete: bool = False
steps_completed: List[str] = []
steps_remaining: List[str] = []
has_courses: bool = False
has_assignments: bool = False
has_certifications: bool = False
starter_templates_available: int = 0
class QuickstartStep(BaseModel):
"""A single quickstart step."""
step_code: str
title: str
description: str
is_complete: bool = False
action_url: str = Field(description="Frontend route to complete this step")
is_optional: bool = False
class QuickstartChecklist(BaseModel):
"""Full quickstart checklist response."""
steps: List[QuickstartStep]
completion_percent: float
estimated_minutes_remaining: int
class StarterTemplateItem(BaseModel):
"""A starter course template available for seeding."""
template_id: str = Field(description="Matches course category name")
title: str
description: str
category_name: str
osha_standard_ref: Optional[str] = None
estimated_duration_minutes: int
lesson_count: int
has_quiz: bool
class SeedTemplatesRequest(BaseModel):
"""Request to seed starter course templates."""
template_ids: List[str] = Field(
...,
min_length=1,
description="List of template IDs to create as draft courses",
)
Quickstart Service
File: tellus-ehs-hazcom-service/app/services/safepath/quickstart_service.py
"""SafePath Quickstart Service
Provides post-onboarding guided setup for SafePath:
- Quickstart checklist (create first course, assign training, etc.)
- Starter course template seeding
- Progress tracking
"""
from typing import Optional, List
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.db.models.safepath import (
Course,
Assignment,
Certification,
CourseCategory,
Lesson,
Quiz,
QuizQuestion,
)
from app.schemas.safepath.quickstart import (
QuickstartStatus,
QuickstartStep,
QuickstartChecklist,
StarterTemplateItem,
)
# ============================================================================
# Quickstart Step Definitions
# ============================================================================
QUICKSTART_STEPS = [
{
"step_code": "create_course",
"title": "Create Your First Course",
"description": "Create a training course with lessons and a quiz. Use a starter template or build from scratch.",
"action_url": "/safepath/courses/create",
"is_optional": False,
"check": lambda svc, cid: svc._has_courses(cid),
"minutes": 5,
},
{
"step_code": "publish_course",
"title": "Publish a Course",
"description": "Review and publish your course to make it available for assignment.",
"action_url": "/safepath/courses",
"is_optional": False,
"check": lambda svc, cid: svc._has_published_course(cid),
"minutes": 2,
},
{
"step_code": "assign_training",
"title": "Assign Training to Employees",
"description": "Assign your published course to team members with a due date.",
"action_url": "/safepath/assignments",
"is_optional": False,
"check": lambda svc, cid: svc._has_assignments(cid),
"minutes": 3,
},
{
"step_code": "import_certifications",
"title": "Import Existing Certifications",
"description": "Log existing employee certifications (OSHA cards, forklift licenses, etc.).",
"action_url": "/safepath/certifications",
"is_optional": True,
"check": lambda svc, cid: svc._has_certifications(cid),
"minutes": 5,
},
{
"step_code": "review_matrix",
"title": "Review Training Matrix",
"description": "View your organization's training compliance at a glance.",
"action_url": "/safepath/matrix",
"is_optional": True,
"check": lambda svc, cid: svc._has_assignments(cid), # Reuse — matrix needs assignments
"minutes": 2,
},
]
# ============================================================================
# Starter Course Templates
# ============================================================================
STARTER_TEMPLATES = [
{
"template_id": "general_safety_orientation",
"title": "General Safety Orientation",
"description": "New employee safety orientation covering workplace hazards, emergency procedures, and PPE basics.",
"category_name": "General Safety Orientation",
"osha_standard_ref": None,
"estimated_duration_minutes": 45,
"lessons": [
{"title": "Welcome & Workplace Safety Overview", "type": "text", "order": 0},
{"title": "Recognizing Workplace Hazards", "type": "text", "order": 1},
{"title": "Emergency Procedures & Evacuation", "type": "text", "order": 2},
{"title": "Personal Protective Equipment (PPE)", "type": "text", "order": 3},
{"title": "Reporting Incidents & Near Misses", "type": "text", "order": 4},
],
"quiz_questions": [
{
"text": "Who is responsible for workplace safety?",
"type": "mcq_single",
"options": [
{"id": "a", "text": "Only the safety coordinator", "is_correct": False},
{"id": "b", "text": "Everyone in the workplace", "is_correct": True},
{"id": "c", "text": "Only management", "is_correct": False},
{"id": "d", "text": "Only OSHA inspectors", "is_correct": False},
],
},
{
"text": "What should you do first when you discover a fire?",
"type": "mcq_single",
"options": [
{"id": "a", "text": "Try to put it out yourself", "is_correct": False},
{"id": "b", "text": "Call 911 immediately", "is_correct": False},
{"id": "c", "text": "Activate the fire alarm and evacuate", "is_correct": True},
{"id": "d", "text": "Continue working", "is_correct": False},
],
},
{
"text": "PPE should be used as the primary method of hazard control.",
"type": "true_false",
"options": [
{"id": "true", "text": "True", "is_correct": False},
{"id": "false", "text": "False", "is_correct": True},
],
"explanation": "PPE is the last line of defense in the hierarchy of controls. Engineering controls and administrative controls should be prioritized first.",
},
],
},
{
"template_id": "hazcom_ghs",
"title": "Hazard Communication (HazCom/GHS) Training",
"description": "OSHA HazCom standard training covering GHS labels, SDS documents, and chemical hazard communication.",
"category_name": "Hazard Communication (HazCom/GHS)",
"osha_standard_ref": "29 CFR 1910.1200",
"estimated_duration_minutes": 60,
"lessons": [
{"title": "Introduction to HazCom & GHS", "type": "text", "order": 0},
{"title": "Understanding GHS Labels & Pictograms", "type": "text", "order": 1},
{"title": "Reading Safety Data Sheets (SDS)", "type": "text", "order": 2},
{"title": "Chemical Storage & Handling", "type": "text", "order": 3},
{"title": "Emergency Response for Chemical Spills", "type": "text", "order": 4},
],
"quiz_questions": [
{
"text": "How many sections does a GHS-compliant Safety Data Sheet (SDS) contain?",
"type": "mcq_single",
"options": [
{"id": "a", "text": "8 sections", "is_correct": False},
{"id": "b", "text": "12 sections", "is_correct": False},
{"id": "c", "text": "16 sections", "is_correct": True},
{"id": "d", "text": "20 sections", "is_correct": False},
],
},
{
"text": "The GHS skull-and-crossbones pictogram indicates which hazard?",
"type": "mcq_single",
"options": [
{"id": "a", "text": "Flammable material", "is_correct": False},
{"id": "b", "text": "Acute toxicity (fatal or toxic)", "is_correct": True},
{"id": "c", "text": "Corrosive material", "is_correct": False},
{"id": "d", "text": "Environmental hazard", "is_correct": False},
],
},
{
"text": "Employers must maintain SDS for every hazardous chemical in the workplace.",
"type": "true_false",
"options": [
{"id": "true", "text": "True", "is_correct": True},
{"id": "false", "text": "False", "is_correct": False},
],
},
],
},
{
"template_id": "fall_protection",
"title": "Fall Protection Safety Training",
"description": "OSHA fall protection requirements, equipment inspection, and safe work practices at heights.",
"category_name": "Fall Protection",
"osha_standard_ref": "29 CFR 1926.501",
"estimated_duration_minutes": 45,
"lessons": [
{"title": "OSHA Fall Protection Standards Overview", "type": "text", "order": 0},
{"title": "Types of Fall Protection Systems", "type": "text", "order": 1},
{"title": "Harness Inspection & Proper Fit", "type": "text", "order": 2},
{"title": "Ladder Safety & Scaffolding", "type": "text", "order": 3},
],
"quiz_questions": [
{
"text": "At what height must fall protection be provided in general industry?",
"type": "mcq_single",
"options": [
{"id": "a", "text": "4 feet", "is_correct": True},
{"id": "b", "text": "6 feet", "is_correct": False},
{"id": "c", "text": "10 feet", "is_correct": False},
{"id": "d", "text": "15 feet", "is_correct": False},
],
"explanation": "OSHA requires fall protection at 4 feet in general industry (1910.28) and 6 feet in construction (1926.501).",
},
{
"text": "A damaged harness can still be used if the damage is minor.",
"type": "true_false",
"options": [
{"id": "true", "text": "True", "is_correct": False},
{"id": "false", "text": "False", "is_correct": True},
],
"explanation": "Any damaged fall protection equipment must be immediately removed from service and replaced.",
},
],
},
{
"template_id": "lockout_tagout",
"title": "Lockout/Tagout (LOTO) Training",
"description": "Control of hazardous energy procedures, equipment-specific lockout steps, and authorized employee training.",
"category_name": "Lockout/Tagout (LOTO)",
"osha_standard_ref": "29 CFR 1910.147",
"estimated_duration_minutes": 45,
"lessons": [
{"title": "What is LOTO and Why It Matters", "type": "text", "order": 0},
{"title": "Types of Hazardous Energy", "type": "text", "order": 1},
{"title": "LOTO Procedure Steps", "type": "text", "order": 2},
{"title": "Authorized vs Affected Employees", "type": "text", "order": 3},
],
"quiz_questions": [
{
"text": "What is the purpose of lockout/tagout?",
"type": "mcq_single",
"options": [
{"id": "a", "text": "To prevent unauthorized use of equipment", "is_correct": False},
{"id": "b", "text": "To prevent unexpected startup of machinery during servicing", "is_correct": True},
{"id": "c", "text": "To lock storage rooms", "is_correct": False},
{"id": "d", "text": "To restrict access to the building", "is_correct": False},
],
},
],
},
{
"template_id": "fire_safety",
"title": "Fire Safety & Emergency Action Plan",
"description": "Fire prevention, extinguisher use, evacuation procedures, and emergency action planning.",
"category_name": "Fire Safety / Emergency Action",
"osha_standard_ref": "29 CFR 1910.38",
"estimated_duration_minutes": 30,
"lessons": [
{"title": "Fire Prevention in the Workplace", "type": "text", "order": 0},
{"title": "Fire Extinguisher Types & PASS Technique", "type": "text", "order": 1},
{"title": "Evacuation Procedures & Assembly Points", "type": "text", "order": 2},
],
"quiz_questions": [
{
"text": "What does PASS stand for in fire extinguisher use?",
"type": "mcq_single",
"options": [
{"id": "a", "text": "Push, Aim, Spray, Sweep", "is_correct": False},
{"id": "b", "text": "Pull, Aim, Squeeze, Sweep", "is_correct": True},
{"id": "c", "text": "Point, Activate, Spray, Stop", "is_correct": False},
{"id": "d", "text": "Pull, Activate, Spray, Stop", "is_correct": False},
],
},
],
},
]
class QuickstartService:
"""Service for SafePath post-onboarding quickstart."""
def __init__(self, db: Session):
self.db = db
# ================================================================
# Quickstart Checklist
# ================================================================
def get_quickstart_status(self, company_id: UUID) -> QuickstartStatus:
"""Get current quickstart completion status."""
has_courses = self._has_courses(company_id)
has_assignments = self._has_assignments(company_id)
has_certs = self._has_certifications(company_id)
completed = []
remaining = []
for step_def in QUICKSTART_STEPS:
if step_def["check"](self, company_id):
completed.append(step_def["step_code"])
else:
remaining.append(step_def["step_code"])
# Count available templates
existing_titles = {
c.title for c in
self.db.query(Course.title).filter(Course.company_id == company_id).all()
}
templates_available = sum(
1 for t in STARTER_TEMPLATES
if t["title"] not in existing_titles
)
return QuickstartStatus(
is_complete=len(remaining) == 0,
steps_completed=completed,
steps_remaining=remaining,
has_courses=has_courses,
has_assignments=has_assignments,
has_certifications=has_certs,
starter_templates_available=templates_available,
)
def get_quickstart_checklist(self, company_id: UUID) -> QuickstartChecklist:
"""Get the full quickstart checklist with step details."""
steps = []
total_minutes = 0
for step_def in QUICKSTART_STEPS:
is_complete = step_def["check"](self, company_id)
steps.append(QuickstartStep(
step_code=step_def["step_code"],
title=step_def["title"],
description=step_def["description"],
is_complete=is_complete,
action_url=step_def["action_url"],
is_optional=step_def["is_optional"],
))
if not is_complete:
total_minutes += step_def["minutes"]
completed = sum(1 for s in steps if s.is_complete)
total = len(steps)
percent = round((completed / total) * 100, 1) if total > 0 else 0.0
return QuickstartChecklist(
steps=steps,
completion_percent=percent,
estimated_minutes_remaining=total_minutes,
)
# ================================================================
# Starter Course Templates
# ================================================================
def list_starter_templates(self, company_id: UUID) -> List[StarterTemplateItem]:
"""List available starter course templates.
Filters out templates for which a course with the same title
already exists in the company.
"""
existing_titles = {
c.title for c in
self.db.query(Course.title).filter(Course.company_id == company_id).all()
}
items = []
for t in STARTER_TEMPLATES:
if t["title"] not in existing_titles:
items.append(StarterTemplateItem(
template_id=t["template_id"],
title=t["title"],
description=t["description"],
category_name=t["category_name"],
osha_standard_ref=t.get("osha_standard_ref"),
estimated_duration_minutes=t.get("estimated_duration_minutes", 30),
lesson_count=len(t.get("lessons", [])),
has_quiz=len(t.get("quiz_questions", [])) > 0,
))
return items
def seed_starter_templates(
self,
company_id: UUID,
created_by: UUID,
template_ids: List[str],
) -> List[Course]:
"""Create draft courses from selected starter templates.
Each template creates:
- 1 Course (status=draft)
- N Lessons (text type with placeholder content)
- 1 Quiz with questions (if template includes quiz)
Courses are created as drafts so the trainer can customize
content before publishing.
"""
created_courses = []
for template_id in template_ids:
template = next(
(t for t in STARTER_TEMPLATES if t["template_id"] == template_id),
None,
)
if not template:
continue
# Check if course with same title already exists
existing = (
self.db.query(Course)
.filter(
Course.company_id == company_id,
Course.title == template["title"],
)
.first()
)
if existing:
continue
# Find matching category
category = (
self.db.query(CourseCategory)
.filter(CourseCategory.name == template["category_name"])
.first()
)
# Create course
course = Course(
company_id=company_id,
title=template["title"],
description=template["description"],
category_id=category.category_id if category else None,
osha_standard_ref=template.get("osha_standard_ref"),
estimated_duration_minutes=template.get("estimated_duration_minutes"),
passing_score_percent=80,
max_retakes=3,
status="draft",
version_number=1,
created_by=created_by,
)
self.db.add(course)
self.db.flush()
# Create lessons
for lesson_def in template.get("lessons", []):
lesson = Lesson(
course_id=course.course_id,
title=lesson_def["title"],
lesson_type=lesson_def.get("type", "text"),
content={
"body": f"<p><em>Add your training content for \"{lesson_def['title']}\" here.</em></p>"
},
sort_order=lesson_def.get("order", 0),
completion_threshold_percent=80,
)
self.db.add(lesson)
# Create quiz if template has questions
quiz_questions = template.get("quiz_questions", [])
if quiz_questions:
quiz = Quiz(
course_id=course.course_id,
title=f"{template['title']} Quiz",
sort_order=0,
)
self.db.add(quiz)
self.db.flush()
for idx, q_def in enumerate(quiz_questions):
question = QuizQuestion(
quiz_id=quiz.quiz_id,
question_type=q_def["type"],
question_text=q_def["text"],
options=q_def["options"],
explanation=q_def.get("explanation"),
sort_order=idx,
)
self.db.add(question)
created_courses.append(course)
self.db.flush()
return created_courses
# ================================================================
# Private Checks
# ================================================================
def _has_courses(self, company_id: UUID) -> bool:
return self.db.query(
self.db.query(Course)
.filter(Course.company_id == company_id)
.exists()
).scalar()
def _has_published_course(self, company_id: UUID) -> bool:
return self.db.query(
self.db.query(Course)
.filter(Course.company_id == company_id, Course.status == "published")
.exists()
).scalar()
def _has_assignments(self, company_id: UUID) -> bool:
return self.db.query(
self.db.query(Assignment)
.filter(Assignment.company_id == company_id)
.exists()
).scalar()
def _has_certifications(self, company_id: UUID) -> bool:
return self.db.query(
self.db.query(Certification)
.filter(Certification.company_id == company_id)
.exists()
).scalar()
Quickstart API Endpoints
File: tellus-ehs-hazcom-service/app/api/v1/safepath/quickstart.py
"""SafePath Quickstart API Endpoints"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.api.v1.adminhq.auth import get_user_context, UserContext
from app.services.safepath.quickstart_service import QuickstartService
from app.schemas.safepath.quickstart import (
QuickstartStatus,
QuickstartChecklist,
StarterTemplateItem,
SeedTemplatesRequest,
)
router = APIRouter(prefix="/training", tags=["SafePath Quickstart"])
@router.get("/quickstart/status", response_model=QuickstartStatus)
def get_quickstart_status(
ctx: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""Get SafePath quickstart completion status.
Used by the dashboard to decide whether to show the quickstart wizard.
"""
service = QuickstartService(db)
return service.get_quickstart_status(ctx.company_id)
@router.get("/quickstart/checklist", response_model=QuickstartChecklist)
def get_quickstart_checklist(
ctx: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""Get the full quickstart checklist with step details."""
service = QuickstartService(db)
return service.get_quickstart_checklist(ctx.company_id)
@router.get("/quickstart/templates", response_model=list[StarterTemplateItem])
def list_starter_templates(
ctx: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""List available starter course templates.
Returns templates that haven't already been created in this company.
"""
service = QuickstartService(db)
return service.list_starter_templates(ctx.company_id)
@router.post("/quickstart/templates/seed", status_code=201)
def seed_starter_templates(
data: SeedTemplatesRequest,
ctx: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""Create draft courses from selected starter templates.
Creates courses with placeholder lesson content and quiz questions.
Trainer can customize content before publishing.
"""
service = QuickstartService(db)
courses = service.seed_starter_templates(
company_id=ctx.company_id,
created_by=ctx.user_id,
template_ids=data.template_ids,
)
db.commit()
return {
"success": True,
"courses_created": len(courses),
"course_ids": [str(c.course_id) for c in courses],
}
Solution 3: SafePath Capability Constants
Parallel to ChemIQCapability, define tier-gated capabilities for SafePath.
File: tellus-ehs-hazcom-service/app/core/constants.py
Add after CHEMIQ_CAPABILITY_METADATA:
# ============================================================================
# SafePath Module Capability Codes
# ============================================================================
class SafePathCapability(str, Enum):
"""SafePath module capability codes - matches feature_capabilities.capability_code"""
# Course Management (Starter+)
COURSE_CREATE = "safepath.course.create"
COURSE_LESSON_VIDEO = "safepath.course.lesson.video"
COURSE_LESSON_PDF = "safepath.course.lesson.pdf"
COURSE_LESSON_TEXT = "safepath.course.lesson.text"
COURSE_QUIZ_CREATE = "safepath.course.quiz.create"
# Course Management (Standard+)
COURSE_VERSIONING = "safepath.course.versioning"
COURSE_MULTI_LANGUAGE = "safepath.course.multi_language"
COURSE_BULK_IMPORT = "safepath.course.bulk_import"
# Assignment (Starter+)
ASSIGNMENT_MANUAL = "safepath.assignment.manual"
ASSIGNMENT_BULK = "safepath.assignment.bulk"
# Assignment (Standard+)
ASSIGNMENT_AUTO_RULES = "safepath.assignment.auto_rules"
ASSIGNMENT_RECURRING = "safepath.assignment.recurring"
# Delivery (Starter+)
DELIVERY_ONLINE = "safepath.delivery.online"
DELIVERY_CLASSROOM = "safepath.delivery.classroom"
# Certification (Starter+)
CERTIFICATION_TRACKING = "safepath.certification.tracking"
CERTIFICATION_EVIDENCE_UPLOAD = "safepath.certification.evidence_upload"
# Certification (Standard+)
CERTIFICATION_EXPIRATION_ALERTS = "safepath.certification.expiration_alerts"
CERTIFICATION_AUTO_RENEWAL = "safepath.certification.auto_renewal"
# Reporting (Standard+)
REPORTS_COMPLETION = "safepath.reports.completion"
REPORTS_TRANSCRIPT = "safepath.reports.transcript"
REPORTS_CERTIFICATION_STATUS = "safepath.reports.certification_status"
REPORTS_TRAINING_MATRIX = "safepath.reports.training_matrix"
# Reporting (Pro)
REPORTS_OSHA_AUDIT_PACKAGE = "safepath.reports.osha_audit_package"
REPORTS_CUSTOM = "safepath.reports.custom"
REPORTS_SCHEDULED_EXPORT = "safepath.reports.scheduled_export"
# Dashboard (Starter+)
DASHBOARD_EMPLOYEE = "safepath.dashboard.employee"
# Dashboard (Standard+)
DASHBOARD_ADMIN = "safepath.dashboard.admin"
DASHBOARD_SITE_COMPARISON = "safepath.dashboard.site_comparison"
# AI Features (Pro)
AI_COURSE_BUILDER = "safepath.ai.course_builder"
AI_QUIZ_GENERATOR = "safepath.ai.quiz_generator"
AI_EXPLAINER = "safepath.ai.explainer"
# Multi-Company (Pro)
MULTI_COMPANY_MANAGEMENT = "safepath.multi_company.management"
MULTI_COMPANY_TEMPLATE_SHARING = "safepath.multi_company.template_sharing"
SAFEPATH_CAPABILITY_METADATA: Dict[str, Dict[str, any]] = {
SafePathCapability.COURSE_CREATE: {"tier": "STARTER", "description": "Create training courses with lessons"},
SafePathCapability.COURSE_LESSON_VIDEO: {"tier": "STARTER", "description": "Video lesson support"},
SafePathCapability.COURSE_LESSON_PDF: {"tier": "STARTER", "description": "PDF lesson support"},
SafePathCapability.COURSE_LESSON_TEXT: {"tier": "STARTER", "description": "Text/rich content lessons"},
SafePathCapability.COURSE_QUIZ_CREATE: {"tier": "STARTER", "description": "Create quizzes with multiple question types"},
SafePathCapability.COURSE_VERSIONING: {"tier": "STANDARD", "description": "Course version management"},
SafePathCapability.COURSE_MULTI_LANGUAGE: {"tier": "STANDARD", "description": "Multi-language course support"},
SafePathCapability.COURSE_BULK_IMPORT: {"tier": "STANDARD", "description": "Bulk import courses from templates or SCORM"},
SafePathCapability.ASSIGNMENT_MANUAL: {"tier": "STARTER", "description": "Manually assign training to employees"},
SafePathCapability.ASSIGNMENT_BULK: {"tier": "STARTER", "description": "Bulk assign training by site or role"},
SafePathCapability.ASSIGNMENT_AUTO_RULES: {"tier": "STANDARD", "description": "Auto-assign training on triggers (new hire, role change)"},
SafePathCapability.ASSIGNMENT_RECURRING: {"tier": "STANDARD", "description": "Recurring/periodic training assignments"},
SafePathCapability.DELIVERY_ONLINE: {"tier": "STARTER", "description": "Online self-paced training delivery"},
SafePathCapability.DELIVERY_CLASSROOM: {"tier": "STARTER", "description": "Record in-person/classroom training sessions"},
SafePathCapability.CERTIFICATION_TRACKING: {"tier": "STARTER", "description": "Track employee certifications"},
SafePathCapability.CERTIFICATION_EVIDENCE_UPLOAD: {"tier": "STARTER", "description": "Upload certification evidence documents"},
SafePathCapability.CERTIFICATION_EXPIRATION_ALERTS: {"tier": "STANDARD", "description": "Automated expiration alerts and reminders"},
SafePathCapability.CERTIFICATION_AUTO_RENEWAL: {"tier": "STANDARD", "description": "Auto-create retraining assignments before expiration"},
SafePathCapability.REPORTS_COMPLETION: {"tier": "STANDARD", "description": "Training completion summary reports"},
SafePathCapability.REPORTS_TRANSCRIPT: {"tier": "STANDARD", "description": "Individual employee training transcripts"},
SafePathCapability.REPORTS_CERTIFICATION_STATUS: {"tier": "STANDARD", "description": "Certification status reports"},
SafePathCapability.REPORTS_TRAINING_MATRIX: {"tier": "STANDARD", "description": "Training matrix (employees x courses)"},
SafePathCapability.REPORTS_OSHA_AUDIT_PACKAGE: {"tier": "PRO", "description": "OSHA audit-ready training documentation package"},
SafePathCapability.REPORTS_CUSTOM: {"tier": "PRO", "description": "Custom report builder"},
SafePathCapability.REPORTS_SCHEDULED_EXPORT: {"tier": "PRO", "description": "Scheduled report email delivery"},
SafePathCapability.DASHBOARD_EMPLOYEE: {"tier": "STARTER", "description": "Employee self-service training dashboard"},
SafePathCapability.DASHBOARD_ADMIN: {"tier": "STANDARD", "description": "Admin/manager compliance dashboard"},
SafePathCapability.DASHBOARD_SITE_COMPARISON: {"tier": "STANDARD", "description": "Cross-site compliance comparison"},
SafePathCapability.AI_COURSE_BUILDER: {"tier": "PRO", "description": "AI-assisted course content generation"},
SafePathCapability.AI_QUIZ_GENERATOR: {"tier": "PRO", "description": "AI-generated quiz questions from lesson content"},
SafePathCapability.AI_EXPLAINER: {"tier": "PRO", "description": "AI-powered regulatory explainer in training context"},
SafePathCapability.MULTI_COMPANY_MANAGEMENT: {"tier": "PRO", "description": "Manage training across multiple client companies"},
SafePathCapability.MULTI_COMPANY_TEMPLATE_SHARING: {"tier": "PRO", "description": "Share course templates across client companies"},
}
Solution 4: TRAINER Role Permissions Seed Data
The TRAINER system role already exists in seed data but needs SafePath-specific permissions.
Add to seed data (SQL insert for core_config_system_role_permissions):
-- SafePath permissions for TRAINER role
-- These should be added after the SafePath permission entries are created
-- First, ensure SafePath permissions exist in the permissions table
INSERT INTO permissions (code, display_name, description, module_code) VALUES
('safepath.courses.create', 'Create Courses', 'Create and edit training courses', 'SAFEPATH'),
('safepath.courses.publish', 'Publish Courses', 'Publish courses for assignment', 'SAFEPATH'),
('safepath.courses.view', 'View Courses', 'View training courses', 'SAFEPATH'),
('safepath.assignments.create', 'Create Assignments', 'Assign training to employees', 'SAFEPATH'),
('safepath.assignments.view', 'View Assignments', 'View training assignments', 'SAFEPATH'),
('safepath.results.view', 'View Results', 'View training results and scores', 'SAFEPATH'),
('safepath.certifications.create', 'Log Certifications', 'Log new certifications', 'SAFEPATH'),
('safepath.certifications.view', 'View Certifications', 'View certification records', 'SAFEPATH'),
('safepath.classroom.create', 'Record Classroom Sessions', 'Record in-person training sessions', 'SAFEPATH'),
('safepath.dashboard.view', 'View Dashboard', 'View training dashboard', 'SAFEPATH'),
('safepath.dashboard.admin', 'View Admin Dashboard', 'View admin compliance dashboard', 'SAFEPATH'),
('safepath.reports.view', 'View Reports', 'Generate and view training reports', 'SAFEPATH'),
('safepath.reports.export', 'Export Reports', 'Export training reports as CSV/PDF/XLSX', 'SAFEPATH'),
('safepath.matrix.view', 'View Training Matrix', 'View the training compliance matrix', 'SAFEPATH'),
('safepath.quickstart.manage', 'Manage Quickstart', 'Access quickstart wizard and seed templates', 'SAFEPATH')
ON CONFLICT (code) DO NOTHING;
-- Grant SafePath permissions to TRAINER role
INSERT INTO system_role_permissions (system_role_id, permission_id)
SELECT sr.system_role_id, p.permission_id
FROM system_roles sr
CROSS JOIN permissions p
WHERE sr.code = 'TRAINER'
AND p.code IN (
'safepath.courses.create',
'safepath.courses.publish',
'safepath.courses.view',
'safepath.assignments.create',
'safepath.assignments.view',
'safepath.results.view',
'safepath.certifications.create',
'safepath.certifications.view',
'safepath.classroom.create',
'safepath.dashboard.view',
'safepath.dashboard.admin',
'safepath.reports.view',
'safepath.reports.export',
'safepath.matrix.view',
'safepath.quickstart.manage'
)
ON CONFLICT DO NOTHING;
-- Grant SafePath view permissions to EMPLOYEE role
INSERT INTO system_role_permissions (system_role_id, permission_id)
SELECT sr.system_role_id, p.permission_id
FROM system_roles sr
CROSS JOIN permissions p
WHERE sr.code = 'EMPLOYEE'
AND p.code IN (
'safepath.courses.view',
'safepath.assignments.view',
'safepath.results.view',
'safepath.certifications.view',
'safepath.dashboard.view'
)
ON CONFLICT DO NOTHING;
-- Grant all SafePath permissions to ADMIN role
INSERT INTO system_role_permissions (system_role_id, permission_id)
SELECT sr.system_role_id, p.permission_id
FROM system_roles sr
CROSS JOIN permissions p
WHERE sr.code = 'ADMIN'
AND p.module_code = 'SAFEPATH'
ON CONFLICT DO NOTHING;
-- Grant all SafePath permissions to COORDINATOR role
INSERT INTO system_role_permissions (system_role_id, permission_id)
SELECT sr.system_role_id, p.permission_id
FROM system_roles sr
CROSS JOIN permissions p
WHERE sr.code = 'COORDINATOR'
AND p.module_code = 'SAFEPATH'
ON CONFLICT DO NOTHING;
Solution 5: SafePath Module Features Seed Data
Register SafePath features in the module features table (paralleling ChemIQ features):
-- SafePath module features
INSERT INTO module_features (module_id, code, name, description, display_order)
SELECT
(SELECT module_id FROM modules WHERE code = 'SAFEPATH'),
feature_code, feature_name, feature_desc, feature_order
FROM (VALUES
('COURSE_MANAGEMENT', 'Course Management', 'Create and manage training courses with lessons, quizzes, and versioning', 1),
('ASSIGNMENT_ENGINE', 'Assignment Engine', 'Assign training to employees manually, in bulk, or via auto-rules', 2),
('TRAINING_DELIVERY', 'Training Delivery', 'Online self-paced and in-person classroom training delivery', 3),
('CERTIFICATION_TRACKING', 'Certification Tracking', 'Track employee certifications, expirations, and evidence documents', 4),
('COMPLIANCE_DASHBOARD', 'Compliance Dashboard', 'Employee and admin dashboards with compliance metrics', 5),
('TRAINING_MATRIX', 'Training Matrix', 'Employees x courses compliance matrix', 6),
('REPORTS_EXPORTS', 'Reports & Exports', 'Completion reports, transcripts, certification reports with CSV/PDF/XLSX export', 7),
('STARTER_TEMPLATES', 'Starter Templates', 'Pre-built OSHA course templates for quick setup', 8)
) AS t(feature_code, feature_name, feature_desc, feature_order);
Frontend Integration
Dashboard Quickstart Banner
The SafePath dashboard (tellus-ehs-hazcom-ui/src/pages/safepath/index.tsx) should check quickstart status on mount:
// In the SafePath dashboard component
import { getQuickstartStatus } from '../../services/safepath-api';
// On mount:
const quickstart = await getQuickstartStatus();
if (!quickstart.is_complete) {
// Show quickstart wizard banner at top of dashboard
// "Get started with SafePath — 3 steps remaining (~10 min)"
}
API Service Methods
Add to tellus-ehs-hazcom-ui/src/services/safepath-api.ts:
import type {
QuickstartStatus,
QuickstartChecklist,
StarterTemplateItem,
} from '../types/safepath';
// Quickstart
export async function getQuickstartStatus(): Promise<QuickstartStatus> {
const { data } = await api.get(`${BASE}/quickstart/status`);
return data;
}
export async function getQuickstartChecklist(): Promise<QuickstartChecklist> {
const { data } = await api.get(`${BASE}/quickstart/checklist`);
return data;
}
export async function getStarterTemplates(): Promise<StarterTemplateItem[]> {
const { data } = await api.get(`${BASE}/quickstart/templates`);
return data;
}
export async function seedStarterTemplates(
templateIds: string[]
): Promise<{ success: boolean; courses_created: number; course_ids: string[] }> {
const { data } = await api.post(`${BASE}/quickstart/templates/seed`, {
template_ids: templateIds,
});
return data;
}
TypeScript Types
Add to tellus-ehs-hazcom-ui/src/types/safepath.ts:
// Quickstart Types
export interface QuickstartStatus {
is_complete: boolean;
steps_completed: string[];
steps_remaining: string[];
has_courses: boolean;
has_assignments: boolean;
has_certifications: boolean;
starter_templates_available: number;
}
export interface QuickstartStep {
step_code: string;
title: string;
description: string;
is_complete: boolean;
action_url: string;
is_optional: boolean;
}
export interface QuickstartChecklist {
steps: QuickstartStep[];
completion_percent: number;
estimated_minutes_remaining: number;
}
export interface StarterTemplateItem {
template_id: string;
title: string;
description: string;
category_name: string;
osha_standard_ref: string | null;
estimated_duration_minutes: number;
lesson_count: number;
has_quiz: boolean;
}
Verification Checklist
- Context-aware metadata:
GET /onboarding/checklistfor an EHS Trainer company returns "Training Coordinator" (not "Program Coordinator") for the coordinator step - Coordinator email: Invitation email says "Training Coordinator" for EHS Trainer companies
- Quickstart status:
GET /training/quickstart/statusreturns correct completion state - Quickstart checklist:
GET /training/quickstart/checklistreturns all 5 steps with correct completion booleans - Starter templates list:
GET /training/quickstart/templatesreturns 5 templates for a fresh company - Seed templates:
POST /training/quickstart/templates/seedwith["general_safety_orientation", "hazcom_ghs"]creates 2 draft courses with lessons and quizzes - No duplicates: Calling seed again with the same template IDs creates 0 additional courses
- Templates filtered: After seeding,
GET /training/quickstart/templatesno longer shows seeded templates - SafePath capabilities:
SafePathCapabilityenum has entries for all tier-gated features - TRAINER permissions: TRAINER role has all SafePath CRUD permissions; EMPLOYEE role has view-only
- Module features: SAFEPATH module has 8 feature entries in
module_featurestable - Dashboard integration: SafePath dashboard shows quickstart banner when
is_complete=false