From 64a36c941afb98e4dc7d7d1006e3ad0f68da913e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Feb 2026 09:54:20 -0500 Subject: [PATCH] docs: add maintenance flows implementation plan 10-task plan across 4 phases: DB/API, frontend UI, APScheduler integration, and target lists settings. TDD throughout. Co-Authored-By: Claude Sonnet 4.5 --- .../2026-02-17-maintenance-flows-plan.md | 2390 +++++++++++++++++ 1 file changed, 2390 insertions(+) create mode 100644 docs/plans/2026-02-17-maintenance-flows-plan.md diff --git a/docs/plans/2026-02-17-maintenance-flows-plan.md b/docs/plans/2026-02-17-maintenance-flows-plan.md new file mode 100644 index 00000000..d92d2da4 --- /dev/null +++ b/docs/plans/2026-02-17-maintenance-flows-plan.md @@ -0,0 +1,2390 @@ +# 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' && ( +
+ +