# Maintenance Flows Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add `maintenance` as a first-class flow type with batch multi-target session launching, saved target lists, and APScheduler-based automatic session creation on a cron schedule. **Architecture:** New `tree_type='maintenance'` (DB migration), two new tables (`target_lists`, `maintenance_schedules`), `batch_id` + `target_label` on sessions, APScheduler in-process, and a new maintenance detail page in the frontend that owns scheduling + batch launch. Execution of individual sessions reuses `ProceduralNavigationPage`. **Tech Stack:** FastAPI + SQLAlchemy async + Alembic (backend), APScheduler 3.x (`apscheduler`), React 19 + TypeScript + Zustand (frontend), Tailwind CSS v3, Lucide React icons. **Design doc:** `docs/plans/2026-02-17-maintenance-flows-design.md` --- ## Phase 1: Database + API ### Task 1: Expand `tree_type` check constraint **Files:** - Modify: `backend/app/models/tree.py:22-35` - Modify: `backend/app/schemas/tree.py:10` **Step 1: Update the model check constraint** In `backend/app/models/tree.py`, change: ```python CheckConstraint( "tree_type IN ('troubleshooting', 'procedural')", name='ck_trees_tree_type' ), ``` to: ```python CheckConstraint( "tree_type IN ('troubleshooting', 'procedural', 'maintenance')", name='ck_trees_tree_type' ), ``` **Step 2: Update the TreeType literal in schemas** In `backend/app/schemas/tree.py`, change: ```python TreeType = Literal['troubleshooting', 'procedural'] ``` to: ```python TreeType = Literal['troubleshooting', 'procedural', 'maintenance'] ``` **Step 3: Generate Alembic migration** ```bash cd backend && alembic revision --autogenerate -m "add maintenance tree type" ``` Open the generated file in `backend/alembic/versions/`. The autogenerate will likely produce an empty migration because check constraints aren't always detected. Replace the `upgrade()` and `downgrade()` functions with: ```python def upgrade() -> None: op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type") op.execute( "ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type " "CHECK (tree_type IN ('troubleshooting', 'procedural', 'maintenance'))" ) def downgrade() -> None: # First set any maintenance trees back to procedural op.execute("UPDATE trees SET tree_type = 'procedural' WHERE tree_type = 'maintenance'") op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type") op.execute( "ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type " "CHECK (tree_type IN ('troubleshooting', 'procedural'))" ) ``` **Step 4: Apply migration** ```bash cd backend && alembic upgrade head ``` Expected: `Running upgrade ... -> ` with no errors. **Step 5: Verify manually** ```bash docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d+ trees" | grep tree_type ``` Expected: constraint shown as `CHECK (tree_type = ANY (ARRAY['troubleshooting'::text, 'procedural'::text, 'maintenance'::text]))`. **Step 6: Write a failing test** Create `backend/tests/test_maintenance_tree_type.py`: ```python """Tests for maintenance tree type.""" import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_create_maintenance_tree(client: AsyncClient, engineer_headers: dict): """Maintenance tree type is accepted by the API.""" resp = await client.post( "/trees/", json={ "name": "Update FSLogix", "description": "Monthly FSLogix update procedure", "tree_type": "maintenance", "tree_structure": { "steps": [ {"id": "step-1", "type": "procedure_step", "title": "Download installer", "description": "Get latest FSLogix from Microsoft", "content_type": "action"}, {"id": "step-end", "type": "procedure_end", "title": "Complete"}, ] }, }, headers=engineer_headers, ) assert resp.status_code == 201, resp.text data = resp.json() assert data["tree_type"] == "maintenance" @pytest.mark.asyncio async def test_list_maintenance_trees_filter(client: AsyncClient, engineer_headers: dict): """Filtering by tree_type=maintenance returns only maintenance trees.""" # Create one maintenance tree await client.post( "/trees/", json={ "name": "Maintenance Only", "tree_type": "maintenance", "tree_structure": { "steps": [ {"id": "s1", "type": "procedure_step", "title": "Step", "description": "Do it", "content_type": "action"}, {"id": "end", "type": "procedure_end", "title": "Done"}, ] }, }, headers=engineer_headers, ) resp = await client.get("/trees/?tree_type=maintenance", headers=engineer_headers) assert resp.status_code == 200 trees = resp.json() assert all(t["tree_type"] == "maintenance" for t in trees) assert len(trees) >= 1 ``` **Step 7: Run the test to verify it fails** ```bash cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts=" ``` Expected: FAIL — the check constraint hasn't been applied yet to the test DB (or tree_type is rejected). If you haven't created the test DB migration, run `alembic -x testing=true upgrade head` or apply manually. **Step 8: Apply migration to test DB** ```bash DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head ``` **Step 9: Run the tests again to verify they pass** ```bash cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts=" ``` Expected: PASS. **Step 10: Commit** ```bash git add backend/app/models/tree.py backend/app/schemas/tree.py backend/alembic/versions/ backend/tests/test_maintenance_tree_type.py git commit -m "feat: add maintenance tree_type with db migration and tests" ``` --- ### Task 2: Add `target_lists` table and API **Files:** - Create: `backend/app/models/target_list.py` - Create: `backend/app/schemas/target_list.py` - Create: `backend/app/api/endpoints/target_lists.py` - Modify: `backend/app/api/router.py` - Create: `backend/tests/test_target_lists.py` **Step 1: Write the failing tests first** Create `backend/tests/test_target_lists.py`: ```python """Tests for target lists CRUD.""" import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_create_target_list(client: AsyncClient, engineer_headers: dict): resp = await client.post( "/target-lists/", json={ "name": "RDS Farm A", "description": "Production RDS servers", "targets": [ {"label": "RDS-01", "notes": "192.168.1.10"}, {"label": "RDS-02", "notes": "192.168.1.11"}, ], }, headers=engineer_headers, ) assert resp.status_code == 201, resp.text data = resp.json() assert data["name"] == "RDS Farm A" assert len(data["targets"]) == 2 @pytest.mark.asyncio async def test_list_target_lists(client: AsyncClient, engineer_headers: dict): resp = await client.get("/target-lists/", headers=engineer_headers) assert resp.status_code == 200 assert isinstance(resp.json(), list) @pytest.mark.asyncio async def test_update_target_list(client: AsyncClient, engineer_headers: dict): create = await client.post( "/target-lists/", json={"name": "Old Name", "targets": [{"label": "SRV-01"}]}, headers=engineer_headers, ) list_id = create.json()["id"] resp = await client.put( f"/target-lists/{list_id}", json={"name": "New Name", "targets": [{"label": "SRV-01"}, {"label": "SRV-02"}]}, headers=engineer_headers, ) assert resp.status_code == 200 assert resp.json()["name"] == "New Name" assert len(resp.json()["targets"]) == 2 @pytest.mark.asyncio async def test_delete_target_list(client: AsyncClient, engineer_headers: dict): create = await client.post( "/target-lists/", json={"name": "To Delete", "targets": [{"label": "X"}]}, headers=engineer_headers, ) list_id = create.json()["id"] resp = await client.delete(f"/target-lists/{list_id}", headers=engineer_headers) assert resp.status_code == 204 # Verify gone get = await client.get(f"/target-lists/{list_id}", headers=engineer_headers) assert get.status_code == 404 ``` **Step 2: Run to verify tests fail** ```bash cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts=" ``` Expected: FAIL — `/target-lists/` route doesn't exist yet. **Step 3: Create the model** Create `backend/app/models/target_list.py`: ```python import uuid from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING from sqlalchemy import String, Text, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB from app.core.database import Base if TYPE_CHECKING: from app.models.user import User from app.models.team import Team class TargetList(Base): __tablename__ = "target_lists" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) team_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=False, index=True ) created_by: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # targets: [{"label": "RDS-01", "notes": "optional notes"}, ...] targets: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) ``` **Step 4: Create schemas** Create `backend/app/schemas/target_list.py`: ```python from datetime import datetime from typing import Optional from uuid import UUID from pydantic import BaseModel, Field class TargetEntry(BaseModel): label: str = Field(..., min_length=1, max_length=255) notes: Optional[str] = Field(None, max_length=500) class TargetListCreate(BaseModel): name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None targets: list[TargetEntry] = Field(..., min_length=1) class TargetListUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None targets: Optional[list[TargetEntry]] = None class TargetListResponse(BaseModel): id: UUID team_id: UUID created_by: Optional[UUID] name: str description: Optional[str] targets: list[TargetEntry] created_at: datetime updated_at: datetime model_config = {"from_attributes": True} ``` **Step 5: Generate + apply Alembic migration for target_lists** ```bash cd backend && alembic revision --autogenerate -m "add target_lists table" ``` Review the generated migration to ensure it creates `target_lists` with correct columns. Then: ```bash alembic upgrade head # Also apply to test DB: DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head ``` **Step 6: Create the endpoint** Create `backend/app/api/endpoints/target_lists.py`: ```python """Target lists CRUD endpoints.""" from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_active_user, get_db from app.models.target_list import TargetList from app.models.user import User from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse router = APIRouter(prefix="/target-lists", tags=["target-lists"]) @router.get("/", response_model=list[TargetListResponse]) async def list_target_lists( current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): """List all target lists for the current user's team.""" if not current_user.team_id: return [] result = await db.execute( select(TargetList) .where(TargetList.team_id == current_user.team_id) .order_by(TargetList.name) ) return result.scalars().all() @router.post("/", response_model=TargetListResponse, status_code=201) async def create_target_list( data: TargetListCreate, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): """Create a new target list for the current team.""" if not current_user.team_id: raise HTTPException(status_code=400, detail="User must belong to a team") target_list = TargetList( team_id=current_user.team_id, created_by=current_user.id, name=data.name, description=data.description, targets=[t.model_dump() for t in data.targets], ) db.add(target_list) await db.commit() await db.refresh(target_list) return target_list @router.get("/{list_id}", response_model=TargetListResponse) async def get_target_list( list_id: UUID, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): result = await db.execute( select(TargetList).where( TargetList.id == list_id, TargetList.team_id == current_user.team_id, ) ) target_list = result.scalar_one_or_none() if not target_list: raise HTTPException(status_code=404, detail="Target list not found") return target_list @router.put("/{list_id}", response_model=TargetListResponse) async def update_target_list( list_id: UUID, data: TargetListUpdate, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): result = await db.execute( select(TargetList).where( TargetList.id == list_id, TargetList.team_id == current_user.team_id, ) ) target_list = result.scalar_one_or_none() if not target_list: raise HTTPException(status_code=404, detail="Target list not found") if data.name is not None: target_list.name = data.name if data.description is not None: target_list.description = data.description if data.targets is not None: target_list.targets = [t.model_dump() for t in data.targets] await db.commit() await db.refresh(target_list) return target_list @router.delete("/{list_id}", status_code=204) async def delete_target_list( list_id: UUID, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): result = await db.execute( select(TargetList).where( TargetList.id == list_id, TargetList.team_id == current_user.team_id, ) ) target_list = result.scalar_one_or_none() if not target_list: raise HTTPException(status_code=404, detail="Target list not found") await db.delete(target_list) await db.commit() ``` **Step 7: Register in router** In `backend/app/api/router.py`, add: ```python from app.api.endpoints import target_lists # ... api_router.include_router(target_lists.router) ``` Also add the import of `TargetList` to `backend/app/models/__init__.py` if that file exists (so Alembic detects it), or just ensure the model is imported somewhere in the startup path. Check `backend/app/core/database.py` — if it imports models explicitly, add `TargetList` there. **Step 8: Run the tests to verify they pass** ```bash cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts=" ``` Expected: PASS (4 tests). **Step 9: Commit** ```bash git add backend/app/models/target_list.py backend/app/schemas/target_list.py \ backend/app/api/endpoints/target_lists.py backend/app/api/router.py \ backend/alembic/versions/ backend/tests/test_target_lists.py git commit -m "feat: add target_lists table, schema, and CRUD endpoints" ``` --- ### Task 3: Add `batch_id` + `target_label` to sessions + batch launch endpoint **Files:** - Modify: `backend/app/models/session.py` - Modify: `backend/app/schemas/` (session schemas — find `SessionCreate` or `SessionResponse`) - Modify: `backend/app/api/endpoints/sessions.py` - Create: `backend/tests/test_batch_sessions.py` **Step 1: Write failing tests** Create `backend/tests/test_batch_sessions.py`: ```python """Tests for batch session launching.""" import pytest from httpx import AsyncClient async def _create_maintenance_tree(client, headers): resp = await client.post( "/trees/", json={ "name": "Patch RDS", "tree_type": "maintenance", "tree_structure": { "steps": [ {"id": "s1", "type": "procedure_step", "title": "Install patch", "description": "Run installer", "content_type": "action"}, {"id": "end", "type": "procedure_end", "title": "Done"}, ] }, }, headers=headers, ) assert resp.status_code == 201 return resp.json()["id"] @pytest.mark.asyncio async def test_batch_launch_creates_one_session_per_target( client: AsyncClient, engineer_headers: dict ): tree_id = await _create_maintenance_tree(client, engineer_headers) resp = await client.post( "/sessions/batch", json={ "tree_id": tree_id, "targets": [ {"label": "RDS-01", "notes": "192.168.1.10"}, {"label": "RDS-02", "notes": "192.168.1.11"}, {"label": "RDS-03"}, ], }, headers=engineer_headers, ) assert resp.status_code == 201, resp.text data = resp.json() assert data["count"] == 3 assert data["batch_id"] is not None sessions = data["sessions"] assert len(sessions) == 3 # All share the same batch_id batch_ids = {s["batch_id"] for s in sessions} assert len(batch_ids) == 1 # Each has a distinct target_label labels = {s["target_label"] for s in sessions} assert labels == {"RDS-01", "RDS-02", "RDS-03"} @pytest.mark.asyncio async def test_batch_launch_rejects_empty_targets( client: AsyncClient, engineer_headers: dict ): tree_id = await _create_maintenance_tree(client, engineer_headers) resp = await client.post( "/sessions/batch", json={"tree_id": tree_id, "targets": []}, headers=engineer_headers, ) assert resp.status_code == 422 ``` **Step 2: Run to verify tests fail** ```bash cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts=" ``` Expected: FAIL — `/sessions/batch` doesn't exist. **Step 3: Add columns to the Session model** In `backend/app/models/session.py`, after the existing columns, add: ```python # Batch tracking (maintenance flows) batch_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), nullable=True, index=True ) target_label: Mapped[Optional[str]] = mapped_column( String(255), nullable=True ) ``` **Step 4: Generate + apply migration** ```bash cd backend && alembic revision --autogenerate -m "add batch_id and target_label to sessions" alembic upgrade head DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head ``` **Step 5: Find and update the SessionResponse schema** Search for where `SessionResponse` or the session response schema is defined: ```bash grep -r "class Session" backend/app/schemas/ -l ``` Open that file and add `batch_id` and `target_label` fields to both the create/response schemas: ```python # In the response schema: batch_id: Optional[UUID] = None target_label: Optional[str] = None ``` **Step 6: Add the batch launch endpoint** In `backend/app/api/endpoints/sessions.py`, find where other session routes are defined and add at the end: ```python from pydantic import BaseModel, Field import uuid as uuid_mod class BatchTarget(BaseModel): label: str = Field(..., min_length=1, max_length=255) notes: Optional[str] = None class BatchLaunchRequest(BaseModel): tree_id: UUID targets: list[BatchTarget] = Field(..., min_length=1) class BatchLaunchResponse(BaseModel): batch_id: UUID count: int sessions: list # list of session response dicts @router.post("/batch", status_code=201) async def batch_launch_sessions( data: BatchLaunchRequest, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): """Create one session per target for a maintenance flow batch run.""" # Verify tree exists and belongs to user's team / is accessible tree_result = await db.execute( select(Tree).where(Tree.id == data.tree_id) ) tree = tree_result.scalar_one_or_none() if not tree: raise HTTPException(status_code=404, detail="Tree not found") if tree.tree_type != "maintenance": raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows") batch_id = uuid_mod.uuid4() created_sessions = [] for target in data.targets: session = Session( tree_id=tree.id, user_id=current_user.id, tree_snapshot=tree.tree_structure, path_taken=[], decisions=[], custom_steps=[], session_variables={}, batch_id=batch_id, target_label=target.label, ) db.add(session) created_sessions.append(session) await db.flush() for s in created_sessions: await db.refresh(s) await db.commit() return { "batch_id": str(batch_id), "count": len(created_sessions), "sessions": [ { "id": str(s.id), "batch_id": str(s.batch_id), "target_label": s.target_label, "tree_id": str(s.tree_id), } for s in created_sessions ], } ``` Make sure `Tree` is imported in `sessions.py` — add `from app.models.tree import Tree` if not already present. **Step 7: Run the tests** ```bash cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts=" ``` Expected: PASS (2 tests). **Step 8: Commit** ```bash git add backend/app/models/session.py backend/app/schemas/ backend/app/api/endpoints/sessions.py \ backend/alembic/versions/ backend/tests/test_batch_sessions.py git commit -m "feat: add batch_id/target_label to sessions and batch launch endpoint" ``` --- ### Task 4: `maintenance_schedules` table + API **Files:** - Create: `backend/app/models/maintenance_schedule.py` - Create: `backend/app/schemas/maintenance_schedule.py` - Create: `backend/app/api/endpoints/maintenance_schedules.py` - Modify: `backend/app/api/router.py` - Create: `backend/tests/test_maintenance_schedules.py` **Step 1: Write failing tests** Create `backend/tests/test_maintenance_schedules.py`: ```python """Tests for maintenance schedule CRUD.""" import pytest from httpx import AsyncClient async def _create_maintenance_tree(client, headers): resp = await client.post( "/trees/", json={ "name": "Scheduled Maintenance", "tree_type": "maintenance", "tree_structure": { "steps": [ {"id": "s1", "type": "procedure_step", "title": "Step 1", "description": "Do it", "content_type": "action"}, {"id": "end", "type": "procedure_end", "title": "Done"}, ] }, }, headers=headers, ) return resp.json()["id"] @pytest.mark.asyncio async def test_create_schedule(client: AsyncClient, engineer_headers: dict): tree_id = await _create_maintenance_tree(client, engineer_headers) resp = await client.post( f"/maintenance-schedules/", json={ "tree_id": tree_id, "cron_expression": "0 9 15 * *", "timezone": "America/New_York", }, headers=engineer_headers, ) assert resp.status_code == 201, resp.text data = resp.json() assert data["cron_expression"] == "0 9 15 * *" assert data["is_active"] is True assert data["next_run_at"] is not None @pytest.mark.asyncio async def test_get_schedule_for_tree(client: AsyncClient, engineer_headers: dict): tree_id = await _create_maintenance_tree(client, engineer_headers) await client.post( "/maintenance-schedules/", json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"}, headers=engineer_headers, ) resp = await client.get(f"/maintenance-schedules/tree/{tree_id}", headers=engineer_headers) assert resp.status_code == 200 assert resp.json()["cron_expression"] == "0 0 1 * *" @pytest.mark.asyncio async def test_disable_schedule(client: AsyncClient, engineer_headers: dict): tree_id = await _create_maintenance_tree(client, engineer_headers) create = await client.post( "/maintenance-schedules/", json={"tree_id": tree_id, "cron_expression": "0 6 * * 1", "timezone": "UTC"}, headers=engineer_headers, ) sched_id = create.json()["id"] resp = await client.patch( f"/maintenance-schedules/{sched_id}", json={"is_active": False}, headers=engineer_headers, ) assert resp.status_code == 200 assert resp.json()["is_active"] is False ``` **Step 2: Run to verify they fail** ```bash cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts=" ``` Expected: FAIL. **Step 3: Create the model** Create `backend/app/models/maintenance_schedule.py`: ```python import uuid from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING from sqlalchemy import String, DateTime, ForeignKey, Boolean from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base class MaintenanceSchedule(Base): __tablename__ = "maintenance_schedules" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) tree_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False, index=True, unique=True # one schedule per tree ) created_by: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) cron_expression: Mapped[str] = mapped_column(String(100), nullable=False) timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC") target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("target_lists.id", ondelete="SET NULL"), nullable=True ) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) next_run_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True ) last_run_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) ``` **Step 4: Create schemas** Create `backend/app/schemas/maintenance_schedule.py`: ```python from datetime import datetime from typing import Optional from uuid import UUID from pydantic import BaseModel, Field class MaintenanceScheduleCreate(BaseModel): tree_id: UUID cron_expression: str = Field(..., min_length=9, max_length=100) timezone: str = Field("UTC", max_length=100) target_list_id: Optional[UUID] = None class MaintenanceScheduleUpdate(BaseModel): cron_expression: Optional[str] = Field(None, min_length=9, max_length=100) timezone: Optional[str] = Field(None, max_length=100) target_list_id: Optional[UUID] = None is_active: Optional[bool] = None class MaintenanceScheduleResponse(BaseModel): id: UUID tree_id: UUID created_by: Optional[UUID] cron_expression: str timezone: str target_list_id: Optional[UUID] is_active: bool next_run_at: Optional[datetime] last_run_at: Optional[datetime] created_at: datetime updated_at: datetime model_config = {"from_attributes": True} ``` **Step 5: Create endpoint** Create `backend/app/api/endpoints/maintenance_schedules.py`: ```python """Maintenance schedule CRUD endpoints.""" from typing import Annotated, Optional from uuid import UUID from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from croniter import croniter from app.api.deps import get_current_active_user, get_db from app.models.maintenance_schedule import MaintenanceSchedule from app.models.user import User from app.schemas.maintenance_schedule import ( MaintenanceScheduleCreate, MaintenanceScheduleUpdate, MaintenanceScheduleResponse, ) router = APIRouter(prefix="/maintenance-schedules", tags=["maintenance-schedules"]) def _compute_next_run(cron_expression: str, tz_name: str) -> datetime: """Compute next run time from cron expression.""" import pytz tz = pytz.timezone(tz_name) now = datetime.now(tz) cron = croniter(cron_expression, now) return cron.get_next(datetime).astimezone(timezone.utc) @router.post("/", response_model=MaintenanceScheduleResponse, status_code=201) async def create_schedule( data: MaintenanceScheduleCreate, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): # Check no existing schedule for this tree existing = await db.execute( select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == data.tree_id) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Schedule already exists for this tree. Update the existing one.") next_run = _compute_next_run(data.cron_expression, data.timezone) schedule = MaintenanceSchedule( tree_id=data.tree_id, created_by=current_user.id, cron_expression=data.cron_expression, timezone=data.timezone, target_list_id=data.target_list_id, is_active=True, next_run_at=next_run, ) db.add(schedule) await db.commit() await db.refresh(schedule) return schedule @router.get("/tree/{tree_id}", response_model=MaintenanceScheduleResponse) async def get_schedule_for_tree( tree_id: UUID, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): result = await db.execute( select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id) ) schedule = result.scalar_one_or_none() if not schedule: raise HTTPException(status_code=404, detail="No schedule found for this tree") return schedule @router.patch("/{schedule_id}", response_model=MaintenanceScheduleResponse) async def update_schedule( schedule_id: UUID, data: MaintenanceScheduleUpdate, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], ): result = await db.execute( select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule_id) ) schedule = result.scalar_one_or_none() if not schedule: raise HTTPException(status_code=404, detail="Schedule not found") if data.cron_expression is not None: schedule.cron_expression = data.cron_expression if data.timezone is not None: schedule.timezone = data.timezone if data.target_list_id is not None: schedule.target_list_id = data.target_list_id if data.is_active is not None: schedule.is_active = data.is_active # Recompute next_run_at if cron or timezone changed if data.cron_expression or data.timezone: schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone) await db.commit() await db.refresh(schedule) return schedule ``` **Step 6: Install dependencies** ```bash cd backend && pip install croniter pytz apscheduler ``` Add to `backend/requirements.txt`: ``` croniter>=2.0.0 pytz>=2024.1 apscheduler>=3.10.0 ``` **Step 7: Register in router** In `backend/app/api/router.py`, add: ```python from app.api.endpoints import maintenance_schedules # ... api_router.include_router(maintenance_schedules.router) ``` **Step 8: Generate + apply migration** ```bash cd backend && alembic revision --autogenerate -m "add maintenance_schedules table" alembic upgrade head DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head ``` **Step 9: Run tests** ```bash cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts=" ``` Expected: PASS (3 tests). **Step 10: Run all backend tests to check nothing broke** ```bash cd backend && pytest --override-ini="addopts=" ``` Expected: All pass (or same failures as before this feature). **Step 11: Commit** ```bash git add backend/app/models/maintenance_schedule.py backend/app/schemas/maintenance_schedule.py \ backend/app/api/endpoints/maintenance_schedules.py backend/app/api/router.py \ backend/alembic/versions/ backend/requirements.txt backend/tests/test_maintenance_schedules.py git commit -m "feat: add maintenance_schedules table, schema, and CRUD endpoints" ``` --- ## Phase 2: Core Frontend UI ### Task 5: Types, API clients, and routing **Files:** - Create: `frontend/src/types/maintenance.ts` - Modify: `frontend/src/types/index.ts` - Create: `frontend/src/api/targetLists.ts` - Create: `frontend/src/api/maintenanceSchedules.ts` - Modify: `frontend/src/api/index.ts` - Modify: `frontend/src/lib/routing.ts` **Step 1: Create TypeScript types** Create `frontend/src/types/maintenance.ts`: ```typescript export interface TargetEntry { label: string notes?: string } export interface TargetList { id: string team_id: string created_by?: string name: string description?: string targets: TargetEntry[] created_at: string updated_at: string } export interface TargetListCreate { name: string description?: string targets: TargetEntry[] } export interface MaintenanceSchedule { id: string tree_id: string created_by?: string cron_expression: string timezone: string target_list_id?: string is_active: boolean next_run_at?: string last_run_at?: string created_at: string updated_at: string } export interface MaintenanceScheduleCreate { tree_id: string cron_expression: string timezone: string target_list_id?: string } export interface MaintenanceScheduleUpdate { cron_expression?: string timezone?: string target_list_id?: string is_active?: boolean } export interface BatchLaunchRequest { tree_id: string targets: TargetEntry[] } export interface BatchLaunchResponse { batch_id: string count: number sessions: Array<{ id: string batch_id: string target_label: string tree_id: string }> } ``` **Step 2: Export from index** In `frontend/src/types/index.ts`, add at the end: ```typescript export type { TargetEntry, TargetList, TargetListCreate, MaintenanceSchedule, MaintenanceScheduleCreate, MaintenanceScheduleUpdate, BatchLaunchRequest, BatchLaunchResponse, } from './maintenance' ``` **Step 3: Create API clients** Create `frontend/src/api/targetLists.ts`: ```typescript import { apiClient } from './client' import type { TargetList, TargetListCreate } from '@/types' export const targetListsApi = { list: (): Promise => apiClient.get('/target-lists/').then(r => r.data), get: (id: string): Promise => apiClient.get(`/target-lists/${id}`).then(r => r.data), create: (data: TargetListCreate): Promise => apiClient.post('/target-lists/', data).then(r => r.data), update: (id: string, data: Partial): Promise => apiClient.put(`/target-lists/${id}`, data).then(r => r.data), delete: (id: string): Promise => apiClient.delete(`/target-lists/${id}`).then(() => undefined), } ``` Create `frontend/src/api/maintenanceSchedules.ts`: ```typescript import { apiClient } from './client' import type { MaintenanceSchedule, MaintenanceScheduleCreate, MaintenanceScheduleUpdate, BatchLaunchRequest, BatchLaunchResponse, } from '@/types' export const maintenanceSchedulesApi = { getForTree: (treeId: string): Promise => apiClient.get(`/maintenance-schedules/tree/${treeId}`).then(r => r.data), create: (data: MaintenanceScheduleCreate): Promise => apiClient.post('/maintenance-schedules/', data).then(r => r.data), update: (id: string, data: MaintenanceScheduleUpdate): Promise => apiClient.patch(`/maintenance-schedules/${id}`, data).then(r => r.data), } export const batchLaunchApi = { launch: (data: BatchLaunchRequest): Promise => apiClient.post('/sessions/batch', data).then(r => r.data), } ``` **Step 4: Export from api/index.ts** In `frontend/src/api/index.ts`, add: ```typescript export { targetListsApi } from './targetLists' export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules' ``` **Step 5: Update routing helper** In `frontend/src/lib/routing.ts`, update `getTreeNavigatePath`: ```typescript export function getTreeNavigatePath( treeId: string, treeType?: string ): string { if (treeType === 'procedural') { return `/flows/${treeId}/navigate` } if (treeType === 'maintenance') { return `/flows/${treeId}/maintenance` } return `/trees/${treeId}/navigate` } export function getTreeEditorPath( treeId: string, treeType?: string ): string { if (treeType === 'procedural' || treeType === 'maintenance') { return `/flows/${treeId}/edit` } return `/trees/${treeId}/edit` } ``` **Step 6: Build check** ```bash cd frontend && npm run build ``` Expected: No TypeScript errors. **Step 7: Commit** ```bash git add frontend/src/types/maintenance.ts frontend/src/types/index.ts \ frontend/src/api/targetLists.ts frontend/src/api/maintenanceSchedules.ts \ frontend/src/api/index.ts frontend/src/lib/routing.ts git commit -m "feat: add maintenance types, API clients, and routing for maintenance flows" ``` --- ### Task 6: Sidebar + TreeLibraryPage updates **Files:** - Modify: `frontend/src/components/layout/Sidebar.tsx` - Modify: `frontend/src/pages/TreeLibraryPage.tsx` **Step 1: Update Sidebar to count and show maintenance flows** In `frontend/src/components/layout/Sidebar.tsx`: Change `treeCounts` state (line ~32): ```typescript const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 }) ``` In the `fetchData` function, update the counts: ```typescript const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length setTreeCounts({ total, troubleshooting, procedural, maintenance }) ``` Update the nav sub-items (line ~145): ```typescript children={[ { href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined }, { href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined }, { href: '/trees?type=maintenance', label: 'Maintenance', count: treeCounts.maintenance || undefined }, ]} ``` **Step 2: Update TreeLibraryPage type filter** In `frontend/src/pages/TreeLibraryPage.tsx`: Change the `typeFilter` state type and initialization (line ~40): ```typescript const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>( urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all' ) ``` Update the sync effect (line ~47): ```typescript if (t === 'troubleshooting' || t === 'procedural' || t === 'maintenance') { setTypeFilter(t) } else { setTypeFilter('all') } ``` **Step 3: Add maintenance badge to tree cards** Find the tree card component(s) in `frontend/src/components/library/` (likely `TreeGridView.tsx`, `TreeListView.tsx`, `TreeTableView.tsx`). In each, find where the `tree_type` badge is rendered and add the maintenance case: Look for something like `tree_type === 'procedural'` and add: ```tsx {tree.tree_type === 'maintenance' && ( Maintenance )} ``` Import `Wrench` from `lucide-react` at the top of those files. **Step 4: Build check** ```bash cd frontend && npm run build ``` Expected: No errors. **Step 5: Commit** ```bash git add frontend/src/components/layout/Sidebar.tsx frontend/src/pages/TreeLibraryPage.tsx \ frontend/src/components/library/ git commit -m "feat: add maintenance flows to sidebar nav and library type filter" ``` --- ### Task 7: BatchLaunchModal component **Files:** - Create: `frontend/src/components/maintenance/BatchLaunchModal.tsx` **Step 1: Create the component** Create `frontend/src/components/maintenance/BatchLaunchModal.tsx`: ```tsx import { useState } from 'react' import { X, List, Clock, PenLine, ExternalLink } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { targetListsApi, batchLaunchApi } from '@/api' import type { TargetList, TargetEntry } from '@/types' interface BatchLaunchModalProps { treeId: string treeName: string onClose: () => void onLaunched: (batchId: string, count: number) => void } type TabId = 'saved' | 'previous' | 'manual' | 'psa' export function BatchLaunchModal({ treeId, treeName, onClose, onLaunched }: BatchLaunchModalProps) { const [activeTab, setActiveTab] = useState('manual') const [savedLists, setSavedLists] = useState(null) const [selectedListId, setSelectedListId] = useState(null) const [manualInput, setManualInput] = useState('') const [isLoading, setIsLoading] = useState(false) const [isLaunching, setIsLaunching] = useState(false) const loadSavedLists = async () => { if (savedLists !== null) return try { const lists = await targetListsApi.list() setSavedLists(lists) } catch { toast.error('Failed to load saved lists') } } const handleTabChange = (tab: TabId) => { setActiveTab(tab) if (tab === 'saved') loadSavedLists() } const getTargets = (): TargetEntry[] => { if (activeTab === 'saved' && selectedListId && savedLists) { const list = savedLists.find(l => l.id === selectedListId) return list?.targets ?? [] } if (activeTab === 'manual') { return manualInput .split('\n') .map(l => l.trim()) .filter(Boolean) .map(label => ({ label })) } return [] } const targets = getTargets() const handleLaunch = async () => { if (targets.length === 0) { toast.error('Add at least one target before launching') return } setIsLaunching(true) try { const result = await batchLaunchApi.launch({ tree_id: treeId, targets }) toast.success(`Launched ${result.count} sessions`) onLaunched(result.batch_id, result.count) } catch { toast.error('Failed to launch batch') } finally { setIsLaunching(false) } } const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [ { id: 'manual', label: 'Manual Entry', icon: }, { id: 'saved', label: 'Saved List', icon: }, { id: 'previous', label: 'Previous Run', icon: }, { id: 'psa', label: 'PSA/RMM Import', icon: }, ] return (
{/* Header */}

Batch Launch

{treeName}

{/* Tabs */}
{tabs.map(tab => ( ))}
{/* Tab Content */}
{activeTab === 'manual' && (