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 cb2c4f9a0c
commit 2d0ce8f943
11 changed files with 98 additions and 40 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()