feat(api): CUST-WP-0018 — API hardening & code quality

T01: Fix datetime.utcnow() → datetime.now(tz=timezone.utc) in MCP server
T02: Wrap _get/_post/_patch/_delete with try/except; return error dicts
T03: Log warnings when write_log skips missing project path
T04: Add priority + due_date_before filters to GET /tasks/
T05: Add owner + slug filters to GET /workstreams/
T06: Add offset param to GET /progress/ for proper pagination
T07: Low-severity bundle:
  - CORS origins from CORS_ORIGINS env var (TD-017)
  - seed.py upsert domains+topics on re-run (TD-011)
  - normalise filter bar CSS → filter-text-input everywhere (TD-016)
  - add 30.5 avg-days-per-month comment in decisions.md (TD-019)
  - TD-009, TD-018 already resolved by existing code

Closes CUST-WP-0018.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 02:17:04 +01:00
parent 9c9d5db632
commit 9aa54f8133
12 changed files with 106 additions and 48 deletions

View File

@@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
@@ -21,9 +22,12 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
_cors_env = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_origins=_cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"], allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
allow_headers=["Content-Type"], allow_headers=["Content-Type"],
) )

View File

@@ -1,8 +1,11 @@
import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -171,10 +174,12 @@ async def _write_project_log(
break break
if not project_path: if not project_path:
logger.warning("write_log requested but no project_path found for topic %s", decision.topic_id)
return return
p = Path(project_path) p = Path(project_path)
if not p.is_dir(): if not p.is_dir():
logger.warning("write_log requested but project_path does not exist: %s", project_path)
return return
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)

View File

@@ -20,6 +20,7 @@ async def list_progress(
event_type: str | None = None, event_type: str | None = None,
since: datetime | None = None, since: datetime | None = None,
limit: int = Query(100, le=1000), limit: int = Query(100, le=1000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[ProgressEvent]: ) -> list[ProgressEvent]:
q = select(ProgressEvent) q = select(ProgressEvent)
@@ -33,7 +34,7 @@ async def list_progress(
q = q.where(ProgressEvent.event_type == event_type) q = q.where(ProgressEvent.event_type == event_type)
if since: if since:
q = q.where(ProgressEvent.created_at >= since) q = q.where(ProgressEvent.created_at >= since)
q = q.order_by(ProgressEvent.created_at.desc()).limit(limit) q = q.order_by(ProgressEvent.created_at.desc()).offset(offset).limit(limit)
result = await session.execute(q) result = await session.execute(q)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -1,4 +1,5 @@
import uuid import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
@@ -17,6 +18,8 @@ async def list_tasks(
status: TaskStatus | None = None, status: TaskStatus | None = None,
assignee: str | None = None, assignee: str | None = None,
needs_human: bool | None = Query(None), needs_human: bool | None = Query(None),
priority: str | None = None,
due_date_before: date | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Task]: ) -> list[Task]:
q = select(Task) q = select(Task)
@@ -28,6 +31,10 @@ async def list_tasks(
q = q.where(Task.assignee == assignee) q = q.where(Task.assignee == assignee)
if needs_human is not None: if needs_human is not None:
q = q.where(Task.needs_human == needs_human) q = q.where(Task.needs_human == needs_human)
if priority:
q = q.where(Task.priority == priority)
if due_date_before is not None:
q = q.where(Task.due_date <= due_date_before)
q = q.order_by(Task.created_at) q = q.order_by(Task.created_at)
result = await session.execute(q) result = await session.execute(q)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -17,6 +17,8 @@ async def list_workstreams(
repo_id: uuid.UUID | None = None, repo_id: uuid.UUID | None = None,
repo_goal_id: uuid.UUID | None = None, repo_goal_id: uuid.UUID | None = None,
status: WorkstreamStatus | None = None, status: WorkstreamStatus | None = None,
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Workstream]: ) -> list[Workstream]:
q = select(Workstream) q = select(Workstream)
@@ -28,6 +30,10 @@ async def list_workstreams(
q = q.where(Workstream.repo_goal_id == repo_goal_id) q = q.where(Workstream.repo_goal_id == repo_goal_id)
if status: if status:
q = q.where(Workstream.status == status) q = q.where(Workstream.status == status)
if owner:
q = q.where(Workstream.owner == owner)
if slug:
q = q.where(Workstream.slug == slug)
q = q.order_by(Workstream.updated_at.desc()) q = q.order_by(Workstream.updated_at.desc())
result = await session.execute(q) result = await session.execute(q)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -100,8 +100,8 @@ export default {
.kpi-infobox { background: var(--theme-background-alt, #f9f9f9); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1rem; position: relative; box-shadow: 0 1px 6px rgba(0,0,0,0.07); margin-bottom: 1.25rem; } .kpi-infobox { background: var(--theme-background-alt, #f9f9f9); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1rem; position: relative; box-shadow: 0 1px 6px rgba(0,0,0,0.07); margin-bottom: 1.25rem; }
.kpi-infobox-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-muted, #888); margin-bottom: 0.55rem; padding-right: 1.6rem; } .kpi-infobox-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-muted, #888); margin-bottom: 0.55rem; padding-right: 1.6rem; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } .filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.filter-search, .filter-owner { display: flex; align-items: center; } .filter-text-input { display: flex; align-items: center; }
.filter-search input, .filter-owner input { height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; } .filter-text-input input { height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; }
</style>`, </style>`,
footer: "Custodian State Hub — local-first, append-only, sovereignty-preserving.", footer: "Custodian State Hub — local-first, append-only, sovereignty-preserving.",
}; };

View File

@@ -68,7 +68,7 @@ const _filtersForm = Inputs.form(
{ {
template: ({type, status, search}) => html`<div class="filter-bar"> template: ({type, status, search}) => html`<div class="filter-bar">
${type}${status} ${type}${status}
<div class="filter-search">${search}</div> <div class="filter-text-input">${search}</div>
</div>`, </div>`,
} }
); );
@@ -103,7 +103,7 @@ function fmtDuration(ms) {
if (ms < 2 * d) return `${Math.floor(ms / h)}h`; if (ms < 2 * d) return `${Math.floor(ms / h)}h`;
if (ms < 2 * w) return `${Math.floor(ms / d)}d`; if (ms < 2 * w) return `${Math.floor(ms / d)}d`;
if (ms < 8 * w) return `${Math.floor(ms / w)}w`; if (ms < 8 * w) return `${Math.floor(ms / w)}w`;
return `${Math.round(ms / (30.5 * d))}mo`; return `${Math.round(ms / (30.5 * d))}mo`; // 30.5 = avg days per month (365/12)
} }
``` ```

View File

@@ -65,7 +65,7 @@ const _filtersForm = Inputs.form(
{ {
template: ({status, priority, domain, assignee}) => html`<div class="filter-bar"> template: ({status, priority, domain, assignee}) => html`<div class="filter-bar">
${status}${priority}${domain} ${status}${priority}${domain}
<div class="filter-owner">${assignee}</div> <div class="filter-text-input">${assignee}</div>
</div>`, </div>`,
} }
); );

View File

@@ -232,7 +232,7 @@ const _filtersForm = Inputs.form(
{ {
template: ({domain, status, owner}) => html`<div class="filter-bar"> template: ({domain, status, owner}) => html`<div class="filter-bar">
${domain}${status} ${domain}${status}
<div class="filter-owner">${owner}</div> <div class="filter-text-input">${owner}</div>
</div>`, </div>`,
} }
); );

View File

@@ -9,7 +9,7 @@ import json
import os import os
import re import re
import sys import sys
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -41,34 +41,54 @@ def _client() -> httpx.Client:
def _get(path: str, params: dict | None = None) -> Any: def _get(path: str, params: dict | None = None) -> Any:
if not path.endswith("/"): if not path.endswith("/"):
path = path + "/" path = path + "/"
with _client() as c: try:
r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None}) with _client() as c:
r.raise_for_status() r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None})
return r.json() r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as e:
return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
except Exception as e:
return {"error": f"Request failed: {e}"}
def _post(path: str, body: dict) -> Any: def _post(path: str, body: dict) -> Any:
if not path.endswith("/"): if not path.endswith("/"):
path = path + "/" path = path + "/"
with _client() as c: try:
r = c.post(path, json={k: v for k, v in body.items() if v is not None}) with _client() as c:
r.raise_for_status() r = c.post(path, json={k: v for k, v in body.items() if v is not None})
return r.json() r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as e:
return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
except Exception as e:
return {"error": f"Request failed: {e}"}
def _patch(path: str, body: dict) -> Any: def _patch(path: str, body: dict) -> Any:
if not path.endswith("/"): if not path.endswith("/"):
path = path + "/" path = path + "/"
with _client() as c: try:
r = c.patch(path, json={k: v for k, v in body.items() if v is not None}) with _client() as c:
r.raise_for_status() r = c.patch(path, json={k: v for k, v in body.items() if v is not None})
return r.json() r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as e:
return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
except Exception as e:
return {"error": f"Request failed: {e}"}
def _delete(path: str) -> None: def _delete(path: str) -> None:
with _client() as c: try:
r = c.delete(path) with _client() as c:
r.raise_for_status() r = c.delete(path)
r.raise_for_status()
except httpx.HTTPStatusError as e:
return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
except Exception as e:
return {"error": f"Request failed: {e}"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -526,7 +546,7 @@ def resolve_decision(
"decision_type": "made", "decision_type": "made",
"rationale": rationale, "rationale": rationale,
"decided_by": decided_by, "decided_by": decided_by,
"decided_at": datetime.utcnow().isoformat() + "Z", "decided_at": datetime.now(tz=timezone.utc).isoformat(),
}) })
_post("/progress", { _post("/progress", {
"topic_id": decision.get("topic_id"), "topic_id": decision.get("topic_id"),

View File

@@ -82,7 +82,7 @@ TOPICS = [
async def seed() -> None: async def seed() -> None:
async with async_session_factory() as session: async with async_session_factory() as session:
# ── Insert domains (idempotent) ─────────────────────────────────────── # ── Upsert domains ────────────────────────────────────────────────────
domain_by_slug: dict[str, Domain] = {} domain_by_slug: dict[str, Domain] = {}
for data in DOMAINS: for data in DOMAINS:
existing = await session.execute( existing = await session.execute(
@@ -90,7 +90,11 @@ async def seed() -> None:
) )
domain = existing.scalar_one_or_none() domain = existing.scalar_one_or_none()
if domain is not None: if domain is not None:
print(f" skip domain (exists): {data['slug']}") if domain.name != data["name"]:
domain.name = data["name"]
print(f" update domain: {data['slug']}")
else:
print(f" skip domain (no change): {data['slug']}")
else: else:
domain = Domain(slug=data["slug"], name=data["name"]) domain = Domain(slug=data["slug"], name=data["name"])
session.add(domain) session.add(domain)
@@ -98,24 +102,35 @@ async def seed() -> None:
print(f" insert domain: {data['slug']}") print(f" insert domain: {data['slug']}")
domain_by_slug[data["slug"]] = domain domain_by_slug[data["slug"]] = domain
# ── Insert topics (idempotent) ───────────────────────────────────────── # ── Upsert topics ─────────────────────────────────────────────────────
for data in TOPICS: for data in TOPICS:
existing = await session.execute( existing = await session.execute(
select(Topic).where(Topic.slug == data["slug"]) select(Topic).where(Topic.slug == data["slug"])
) )
if existing.scalar_one_or_none() is not None: topic = existing.scalar_one_or_none()
print(f" skip topic (exists): {data['slug']}")
continue
domain = domain_by_slug[data["domain_slug"]] domain = domain_by_slug[data["domain_slug"]]
topic = Topic( if topic is not None:
slug=data["slug"], changed = False
title=data["title"], if topic.title != data["title"]:
description=data["description"], topic.title = data["title"]
domain_id=domain.id, changed = True
status=TopicStatus.active, if topic.description != data["description"]:
) topic.description = data["description"]
session.add(topic) changed = True
print(f" insert topic: {data['slug']}") if topic.domain_id != domain.id:
topic.domain_id = domain.id
changed = True
print(f" {'update' if changed else 'skip'} topic ({'changed' if changed else 'no change'}): {data['slug']}")
else:
topic = Topic(
slug=data["slug"],
title=data["title"],
description=data["description"],
domain_id=domain.id,
status=TopicStatus.active,
)
session.add(topic)
print(f" insert topic: {data['slug']}")
await session.commit() await session.commit()
await engine.dispose() await engine.dispose()

View File

@@ -3,7 +3,7 @@ id: CUST-WP-0018
type: workplan type: workplan
title: "State Hub — API Hardening & Code Quality" title: "State Hub — API Hardening & Code Quality"
domain: custodian domain: custodian
status: active status: done
owner: custodian owner: custodian
topic_slug: custodian topic_slug: custodian
created: "2026-03-18" created: "2026-03-18"
@@ -36,7 +36,7 @@ TD-CUST-005 (N+1 selectin) deferred — not pressing at current scale.
```task ```task
id: CUST-WP-0018-T01 id: CUST-WP-0018-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "5045749c-22a5-4f37-81b1-6fc87ae7c580" state_hub_task_id: "5045749c-22a5-4f37-81b1-6fc87ae7c580"
``` ```
@@ -54,7 +54,7 @@ impact.
```task ```task
id: CUST-WP-0018-T02 id: CUST-WP-0018-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "8aadbaf8-d898-436e-8df0-7f095c916613" state_hub_task_id: "8aadbaf8-d898-436e-8df0-7f095c916613"
``` ```
@@ -83,7 +83,7 @@ except Exception as e:
```task ```task
id: CUST-WP-0018-T03 id: CUST-WP-0018-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "26f8d132-b2f4-4939-9497-a9ad64e0a73e" state_hub_task_id: "26f8d132-b2f4-4939-9497-a9ad64e0a73e"
``` ```
@@ -100,7 +100,7 @@ was skipped. Resolves TD-CUST-012.
```task ```task
id: CUST-WP-0018-T04 id: CUST-WP-0018-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "18da9d84-54a4-4028-8b8e-014d2b2f6ed6" state_hub_task_id: "18da9d84-54a4-4028-8b8e-014d2b2f6ed6"
``` ```
@@ -116,7 +116,7 @@ Add `priority: str | None` and `due_date_before: date | None` query params to
```task ```task
id: CUST-WP-0018-T05 id: CUST-WP-0018-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "488f448f-396d-4924-98a5-a2e84d4b1b95" state_hub_task_id: "488f448f-396d-4924-98a5-a2e84d4b1b95"
``` ```
@@ -132,7 +132,7 @@ Add `owner: str | None` and `slug: str | None` query params to
```task ```task
id: CUST-WP-0018-T06 id: CUST-WP-0018-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "dd7e9da8-19fb-4b02-a100-972c582dbaa9" state_hub_task_id: "dd7e9da8-19fb-4b02-a100-972c582dbaa9"
``` ```
@@ -148,7 +148,7 @@ Add `offset: int = 0` query param to `list_progress()` alongside existing
```task ```task
id: CUST-WP-0018-T07 id: CUST-WP-0018-T07
status: todo status: done
priority: low priority: low
state_hub_task_id: "b949805b-dd3e-43d6-89cc-631e3183f67c" state_hub_task_id: "b949805b-dd3e-43d6-89cc-631e3183f67c"
``` ```