diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index faece16..f56f8e6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -203,6 +203,14 @@ Full protocol + inter-session messaging: `.claude/COORDINATION_PROTOCOL.md` - **Frontend Design:** Auto-invoke `/frontend-design` skill after ANY UI change (HTML/CSS/JSX/styling) - **Sequential Thinking:** Use for genuine complexity — rejection loops, 3+ critical issues, architectural decisions - **Task Management:** Complex work (>3 steps) → TaskCreate. Persist to `.claude/active-tasks.json`. +- **Auto Todo Creation:** When wrapping up a task that has unresolved follow-up, open items, or deferred work, POST to `POST /api/coord/todos` with `auto_created: true` and `source_context` describing why. Assign `project_key` if project-scoped; assign `assigned_to_user` if only relevant to one tech. Sub-tasks: set `parent_id` to link under a parent todo. Never create a todo for something already being done in the current session. + +### Querying Todos + +- "What needs to be done with \?" → `GET /api/coord/todos?project_key=&status_filter=pending` +- "What are my open todos?" → `GET /api/coord/todos?for_user=&status_filter=pending` +- "Show all todos including done" → add `status_filter=all` +- "Mark done" → `PUT /api/coord/todos/` with `{"status": "done", "completed_by": ""}` ### Cross-Session Messages (MANDATORY) diff --git a/.claude/scripts/sync.sh b/.claude/scripts/sync.sh index a2e514e..f7e48ad 100755 --- a/.claude/scripts/sync.sh +++ b/.claude/scripts/sync.sh @@ -417,7 +417,84 @@ else cd "$CLAUDETOOLS_DIR" fi -# Phase 7: Summary +# Phase 7: Pending To-Do Review +echo "" +echo "=== Phase 7: Pending To-Dos ===" + +COORD_API="http://172.16.3.30:8001" +TODO_USER="" +TODO_MACHINE="$MACHINE" +if [ -f ".claude/identity.json" ]; then + TODO_USER=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "") +fi + +if [ -n "$TODO_USER" ]; then + TODO_JSON=$(curl -s --max-time 5 \ + "${COORD_API}/api/coord/todos?for_user=${TODO_USER}&for_machine=${TODO_MACHINE}&status_filter=pending&limit=200" \ + 2>/dev/null || echo "[]") + + TODO_COUNT=$($PYTHON -c " +import json, sys +try: + data = json.loads('''$TODO_JSON''') + print(len(data)) +except: + print(0) +" 2>/dev/null || echo 0) + + if [ "$TODO_COUNT" -gt 0 ]; then + echo -e "${YELLOW} $TODO_COUNT pending item(s) for ${TODO_USER}/${TODO_MACHINE}:${NC}" + $PYTHON - < "Personal" +groups = {} +for t in todos: + if t.get("parent_id"): + continue # sub-tasks shown under parent + key = t.get("project_key") or "Personal" + groups.setdefault(key, []).append(t) + +# Build sub-task lookup +subtask_map = {} +for t in todos: + pid = t.get("parent_id") + if pid: + subtask_map.setdefault(pid, []).append(t) + +CYAN = "\033[0;36m" +RESET = "\033[0m" +YELLOW= "\033[1;33m" + +for group_name in sorted(groups.keys(), key=lambda x: (x == "Personal", x)): + print(f"\n {CYAN}[{group_name}]{RESET}") + for t in groups[group_name]: + assigned = "" + if t.get("assigned_to_user") and t["assigned_to_user"] != "$TODO_USER": + assigned = f" -> {t['assigned_to_user']}" + if t.get("assigned_to_machine") and t["assigned_to_machine"].upper() != "$TODO_MACHINE".upper(): + assigned += f"/{t['assigned_to_machine']}" + auto = " [auto]" if t.get("auto_created") else "" + print(f" [ ] {t['text']}{auto}{assigned} (id:{t['id'][:8]})") + for st in subtask_map.get(t["id"], []): + print(f" [ ] {st['text']} (id:{st['id'][:8]})") +PYEOF + echo "" + else + echo -e "${GREEN}[OK]${NC} No pending to-dos." + fi +else + echo -e "${YELLOW}[INFO]${NC} No identity — skipping todo check." +fi + +# Phase 8: Summary echo "" echo "=== Sync Summary ===" diff --git a/api/main.py b/api/main.py index cb7a66d..df237ea 100644 --- a/api/main.py +++ b/api/main.py @@ -49,6 +49,7 @@ from api.routers import ( coord_components, coord_messages, coord_status, + coord_todos, ) # Import middleware @@ -162,6 +163,7 @@ app.include_router(coord_locks.router, prefix="/api/coord/locks", tags=["Coordin app.include_router(coord_components.router, prefix="/api/coord/components", tags=["Coordination"]) app.include_router(coord_messages.router, prefix="/api/coord/messages", tags=["Coordination"]) app.include_router(coord_status.router, prefix="/api/coord/status", tags=["Coordination"]) +app.include_router(coord_todos.router, prefix="/api/coord/todos", tags=["Coordination"]) if __name__ == "__main__": diff --git a/api/models/__init__.py b/api/models/__init__.py index ce390c7..3e037ce 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -10,6 +10,7 @@ from api.models.coord_work_item import CoordWorkItem from api.models.coord_session_lock import CoordSessionLock from api.models.coord_component_state import CoordComponentState from api.models.coord_message import CoordMessage +from api.models.coord_todo import CoordTodo from api.models.backup_log import BackupLog from api.models.base import Base, TimestampMixin, UUIDMixin from api.models.billable_time import BillableTime @@ -57,6 +58,7 @@ __all__ = [ "CoordSessionLock", "CoordComponentState", "CoordMessage", + "CoordTodo", "BackupLog", "Base", "BillableTime", diff --git a/api/models/coord_todo.py b/api/models/coord_todo.py new file mode 100644 index 0000000..a5b0aec --- /dev/null +++ b/api/models/coord_todo.py @@ -0,0 +1,96 @@ +"""Coordination personal/project to-do item model.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import CHAR, Boolean, CheckConstraint, DateTime, ForeignKey, Index, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base, TimestampMixin, UUIDMixin + + +class CoordTodo(Base, UUIDMixin, TimestampMixin): + """A personal or project-scoped to-do item, optionally nested under a parent.""" + + __tablename__ = "coord_todos" + + text: Mapped[str] = mapped_column( + Text, + nullable=False, + doc="The to-do item text" + ) + + project_key: Mapped[Optional[str]] = mapped_column( + String(100), + doc="Project scope; NULL means personal" + ) + + parent_id: Mapped[Optional[str]] = mapped_column( + CHAR(36), + ForeignKey("coord_todos.id", ondelete="CASCADE"), + doc="Parent to-do ID for sub-tasks; NULL means top-level" + ) + + assigned_to_user: Mapped[Optional[str]] = mapped_column( + String(50), + doc="Target user, e.g. 'mike' or 'howard'; NULL = any user" + ) + + assigned_to_machine: Mapped[Optional[str]] = mapped_column( + String(100), + doc="Target hostname; NULL = any machine" + ) + + auto_created: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + doc="True when Claude auto-generated this item" + ) + + source_context: Mapped[Optional[str]] = mapped_column( + Text, + doc="Why Claude auto-created this item" + ) + + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="pending", + doc="Status: pending / done / cancelled" + ) + + completed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + doc="When the item was marked done" + ) + + completed_by: Mapped[Optional[str]] = mapped_column( + String(100), + doc="Session or user that completed the item" + ) + + created_by_user: Mapped[str] = mapped_column( + String(50), + nullable=False, + doc="User who created the item" + ) + + created_by_machine: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="Hostname where the item was created" + ) + + __table_args__ = ( + CheckConstraint( + "status IN ('pending', 'done', 'cancelled')", + name="ck_coord_todos_status", + ), + Index("idx_coord_todos_project_status", "project_key", "status"), + Index("idx_coord_todos_assigned", "assigned_to_user", "assigned_to_machine", "status"), + Index("idx_coord_todos_parent", "parent_id"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/api/routers/coord_todos.py b/api/routers/coord_todos.py new file mode 100644 index 0000000..9003ecf --- /dev/null +++ b/api/routers/coord_todos.py @@ -0,0 +1,81 @@ +"""Coordination to-do items router.""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from api.database import get_db +from api.schemas.coord_todo import CoordTodoCreate, CoordTodoResponse, CoordTodoUpdate +from api.services import coord_todo_service + +router = APIRouter() + + +@router.get("", response_model=list[CoordTodoResponse], status_code=status.HTTP_200_OK) +def list_todos( + project_key: str | None = Query(default=None), + assigned_to_user: str | None = Query(default=None), + assigned_to_machine: str | None = Query(default=None), + for_user: str | None = Query(default=None, description="Return items for this user OR unassigned"), + for_machine: str | None = Query(default=None, description="Return items for this machine OR any machine"), + status_filter: str = Query(default="pending"), + include_subtasks: bool = Query(default=True), + skip: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1000), + db: Session = Depends(get_db), +): + """List to-do items with optional filters. Pass status_filter=all to include every status.""" + todos = coord_todo_service.get_todos( + db, + project_key=project_key, + assigned_to_user=assigned_to_user, + assigned_to_machine=assigned_to_machine, + for_user=for_user, + for_machine=for_machine, + status_filter=status_filter, + include_subtasks=include_subtasks, + skip=skip, + limit=limit, + ) + return [CoordTodoResponse.model_validate(t) for t in todos] + + +@router.post("", response_model=CoordTodoResponse, status_code=status.HTTP_201_CREATED) +def create_todo( + data: CoordTodoCreate, + db: Session = Depends(get_db), +): + """Create a new to-do item.""" + todo = coord_todo_service.create_todo(db, data) + return CoordTodoResponse.model_validate(todo) + + +@router.get("/{todo_id}", response_model=CoordTodoResponse, status_code=status.HTTP_200_OK) +def get_todo( + todo_id: UUID, + db: Session = Depends(get_db), +): + """Get a single to-do item including its sub-tasks.""" + todo = coord_todo_service.get_todo_by_id(db, todo_id) + return CoordTodoResponse.model_validate(todo) + + +@router.put("/{todo_id}", response_model=CoordTodoResponse, status_code=status.HTTP_200_OK) +def update_todo( + todo_id: UUID, + data: CoordTodoUpdate, + db: Session = Depends(get_db), +): + """Update a to-do item. Setting status to 'done' records completed_at automatically.""" + todo = coord_todo_service.update_todo(db, todo_id, data) + return CoordTodoResponse.model_validate(todo) + + +@router.delete("/{todo_id}", response_model=dict, status_code=status.HTTP_200_OK) +def delete_todo( + todo_id: UUID, + db: Session = Depends(get_db), +): + """Delete a to-do item and all its sub-tasks.""" + return coord_todo_service.delete_todo(db, todo_id) diff --git a/api/schemas/coord_todo.py b/api/schemas/coord_todo.py new file mode 100644 index 0000000..e5220c6 --- /dev/null +++ b/api/schemas/coord_todo.py @@ -0,0 +1,57 @@ +"""Pydantic schemas for CoordTodo.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class CoordTodoCreate(BaseModel): + """Input schema for creating a to-do item.""" + + text: str = Field(..., description="To-do item text") + project_key: Optional[str] = Field(None, description="Project scope; NULL = personal", max_length=100) + parent_id: Optional[UUID] = Field(None, description="Parent to-do ID for sub-tasks") + assigned_to_user: Optional[str] = Field(None, description="Target user, e.g. 'mike'", max_length=50) + assigned_to_machine: Optional[str] = Field(None, description="Target hostname", max_length=100) + auto_created: bool = Field(False, description="True when Claude auto-generated this item") + source_context: Optional[str] = Field(None, description="Why Claude auto-created this item") + created_by_user: str = Field(..., description="User creating the item", max_length=50) + created_by_machine: str = Field(..., description="Hostname where the item is created", max_length=100) + + +class CoordTodoUpdate(BaseModel): + """Input schema for updating a to-do item.""" + + text: Optional[str] = None + status: Optional[str] = Field(None, description="pending / done / cancelled") + assigned_to_user: Optional[str] = Field(None, max_length=50) + assigned_to_machine: Optional[str] = Field(None, max_length=100) + project_key: Optional[str] = Field(None, max_length=100) + completed_by: Optional[str] = Field(None, description="Session or user completing the item", max_length=100) + + +class CoordTodoResponse(BaseModel): + """Output schema for a to-do item.""" + + id: UUID + text: str + project_key: Optional[str] + parent_id: Optional[UUID] + assigned_to_user: Optional[str] + assigned_to_machine: Optional[str] + auto_created: bool + source_context: Optional[str] + status: str + completed_at: Optional[datetime] + completed_by: Optional[str] + created_by_user: str + created_by_machine: str + created_at: datetime + updated_at: datetime + subtasks: list[CoordTodoResponse] = Field(default_factory=list) + + model_config = {"from_attributes": True} diff --git a/api/services/coord_todo_service.py b/api/services/coord_todo_service.py new file mode 100644 index 0000000..2ed8019 --- /dev/null +++ b/api/services/coord_todo_service.py @@ -0,0 +1,170 @@ +"""Service layer for CoordTodo.""" + +from datetime import datetime, timezone +from typing import Optional +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from api.models.coord_todo import CoordTodo +from api.schemas.coord_todo import CoordTodoCreate, CoordTodoUpdate + +_VALID_STATUSES = {"pending", "done", "cancelled"} + + +def get_todos( + db: Session, + project_key: Optional[str] = None, + assigned_to_user: Optional[str] = None, + assigned_to_machine: Optional[str] = None, + for_user: Optional[str] = None, + for_machine: Optional[str] = None, + status_filter: str = "pending", + include_subtasks: bool = True, + skip: int = 0, + limit: int = 100, +) -> list[CoordTodo]: + """Return a flat list of to-do items with optional filters. + + assigned_to_user/machine: exact match (admin queries). + for_user/for_machine: inclusive match — items for this user/machine OR unassigned. + Used by sync/save to surface relevant pending work. + + When include_subtasks is False, only top-level items (parent_id IS NULL) are returned. + """ + from sqlalchemy import or_ + + q = db.query(CoordTodo) + + if not include_subtasks: + q = q.filter(CoordTodo.parent_id.is_(None)) + + if project_key is not None: + q = q.filter(CoordTodo.project_key == project_key) + + if assigned_to_user is not None: + q = q.filter(CoordTodo.assigned_to_user == assigned_to_user) + + if assigned_to_machine is not None: + q = q.filter(CoordTodo.assigned_to_machine == assigned_to_machine) + + if for_user is not None: + q = q.filter(or_(CoordTodo.assigned_to_user == for_user, CoordTodo.assigned_to_user.is_(None))) + + if for_machine is not None: + q = q.filter(or_(CoordTodo.assigned_to_machine == for_machine, CoordTodo.assigned_to_machine.is_(None))) + + if status_filter != "all": + q = q.filter(CoordTodo.status == status_filter) + + return q.order_by(CoordTodo.created_at.asc()).offset(skip).limit(limit).all() + + +def get_todo_by_id(db: Session, todo_id: UUID) -> CoordTodo: + """Return a single to-do item with its sub-tasks loaded, or raise 404.""" + todo = db.query(CoordTodo).filter(CoordTodo.id == str(todo_id)).first() + if not todo: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Todo {todo_id} not found" + ) + todo.subtasks = ( + db.query(CoordTodo) + .filter(CoordTodo.parent_id == str(todo_id)) + .order_by(CoordTodo.created_at.asc()) + .all() + ) + return todo + + +def create_todo(db: Session, todo_in: CoordTodoCreate) -> CoordTodo: + """Persist a new to-do item.""" + if todo_in.parent_id is not None: + parent = db.query(CoordTodo).filter(CoordTodo.id == str(todo_in.parent_id)).first() + if not parent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Parent todo {todo_in.parent_id} not found" + ) + + data = todo_in.model_dump() + if data.get("parent_id") is not None: + data["parent_id"] = str(data["parent_id"]) + + try: + todo = CoordTodo(**data) + db.add(todo) + db.commit() + db.refresh(todo) + todo.subtasks = [] + return todo + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create todo: {e}" + ) + + +def update_todo(db: Session, todo_id: UUID, todo_in: CoordTodoUpdate) -> CoordTodo: + """Apply a partial update to a to-do item. + + When status changes to 'done', completed_at is set to the current UTC time. + When status changes away from 'done', completed_at is cleared. + """ + todo = get_todo_by_id(db, todo_id) + + patch = todo_in.model_dump(exclude_unset=True) + + if "status" in patch and patch["status"] not in _VALID_STATUSES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid status '{patch['status']}'; must be one of {sorted(_VALID_STATUSES)}" + ) + + for field, value in patch.items(): + setattr(todo, field, value) + + if "status" in patch: + if patch["status"] == "done" and todo.completed_at is None: + todo.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) + elif patch["status"] != "done": + todo.completed_at = None + + try: + db.commit() + db.refresh(todo) + todo.subtasks = ( + db.query(CoordTodo) + .filter(CoordTodo.parent_id == str(todo_id)) + .order_by(CoordTodo.created_at.asc()) + .all() + ) + return todo + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update todo: {e}" + ) + + +def delete_todo(db: Session, todo_id: UUID) -> dict: + """Delete a to-do item and all its sub-tasks. + + Sub-tasks are deleted first to satisfy FK constraints on databases that + don't enforce CASCADE at the application level. + """ + todo = get_todo_by_id(db, todo_id) + try: + db.query(CoordTodo).filter(CoordTodo.parent_id == str(todo_id)).delete(synchronize_session=False) + db.delete(todo) + db.commit() + return {"message": "Todo deleted", "todo_id": str(todo_id)} + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete todo: {e}" + ) diff --git a/migrations/versions/20260526_120000_coord_todos.py b/migrations/versions/20260526_120000_coord_todos.py new file mode 100644 index 0000000..8744c26 --- /dev/null +++ b/migrations/versions/20260526_120000_coord_todos.py @@ -0,0 +1,53 @@ +"""coord_todos + +Revision ID: 20260526_120000 +Revises: 20260512_120000 +Create Date: 2026-05-26 12:00:00 + +Creates the coord_todos table for personal and project-scoped to-do items, +supporting sub-tasks via a self-referencing parent_id FK. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260526_120000" +down_revision: Union[str, None] = "20260512_120000" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create coord_todos table.""" + op.create_table( + "coord_todos", + sa.Column("id", sa.CHAR(36), primary_key=True), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("project_key", sa.String(100), nullable=True), + sa.Column("parent_id", sa.CHAR(36), sa.ForeignKey("coord_todos.id", ondelete="CASCADE"), nullable=True), + sa.Column("assigned_to_user", sa.String(50), nullable=True), + sa.Column("assigned_to_machine", sa.String(100), nullable=True), + sa.Column("auto_created", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("source_context", sa.Text(), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("completed_by", sa.String(100), nullable=True), + sa.Column("created_by_user", sa.String(50), nullable=False), + sa.Column("created_by_machine", sa.String(100), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")), + sa.CheckConstraint( + "status IN ('pending', 'done', 'cancelled')", + name="ck_coord_todos_status", + ), + ) + op.create_index("idx_coord_todos_project_status", "coord_todos", ["project_key", "status"]) + op.create_index("idx_coord_todos_assigned", "coord_todos", ["assigned_to_user", "assigned_to_machine", "status"]) + op.create_index("idx_coord_todos_parent", "coord_todos", ["parent_id"]) + + +def downgrade() -> None: + """Drop coord_todos table.""" + op.drop_table("coord_todos") diff --git a/projects/msp-pricing/docs/web-email-hosting-pricing.md b/projects/msp-pricing/docs/web-email-hosting-pricing.md index a975883..8a1331e 100644 --- a/projects/msp-pricing/docs/web-email-hosting-pricing.md +++ b/projects/msp-pricing/docs/web-email-hosting-pricing.md @@ -1,6 +1,6 @@ # Web & Email Hosting Pricing Structure -**Last Updated:** 2026-02-01 +**Last Updated:** 2026-05-26 **Source:** MSP Pricing Chat - Web/Email Hosting Discussion --- @@ -152,7 +152,7 @@ ### Email Security & Filtering **Price:** $3/mailbox/month -**Platforms:** MailProtector (Emailservice.io) / INKY (via Kaseya) +**Platform:** MailProtector (Emailservice.io) **Features:** - Anti-phishing protection @@ -315,7 +315,8 @@ Total Monthly: $280 - **Website Maintenance:** $35-500/month (small/medium), $300-2,500/month (complex) ### ACG Position -- **Hourly Rate:** $130-165/hour (in line with professional MSP/agency rates) +- **Standard Hourly Rate:** $175/hour +- **Block Time Effective Rate:** $130-150/hour (depending on block size — never expires, in line with professional MSP/agency rates) - **GPS Support Plans:** $85-100/hour effective (significant value) - **Web Hosting:** Competitive with managed/specialty hosts - **Email Hosting:** Budget-friendly alternative to M365 for IMAP users @@ -350,5 +351,5 @@ Total Monthly: $280 --- -**Last Updated:** 2026-02-01 +**Last Updated:** 2026-05-26 **Protecting Tucson Businesses Since 2001**