diff --git a/app/api.py b/app/api.py index 7f591da..86a3e12 100644 --- a/app/api.py +++ b/app/api.py @@ -107,3 +107,80 @@ def post_skip(req: SkipRequest): _write_jsonl(_queue_file(), reordered) _last_action = {"type": "skip", "item": match} return {"ok": True} + + +class DiscardRequest(BaseModel): + id: str + + +@app.post("/api/discard") +def post_discard(req: DiscardRequest): + global _last_action + items = _read_jsonl(_queue_file()) + match = next((x for x in items if x["id"] == req.id), None) + if not match: + raise HTTPException(404, f"Item {req.id!r} not found in queue") + record = {**match, "label": "__discarded__", + "discarded_at": datetime.now(timezone.utc).isoformat()} + _append_jsonl(_discarded_file(), record) + _write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id]) + _last_action = {"type": "discard", "item": match} # store ORIGINAL match, not enriched record + return {"ok": True} + + +@app.delete("/api/label/undo") +def delete_undo(): + global _last_action + if not _last_action: + raise HTTPException(404, "No action to undo") + action = _last_action + _last_action = None + + item = action["item"] # always the original clean queue item + + if action["type"] == "label": + # Remove last entry from score file + records = _read_jsonl(_score_file()) + _write_jsonl(_score_file(), records[:-1]) + elif action["type"] == "discard": + # Remove last entry from discarded file + records = _read_jsonl(_discarded_file()) + _write_jsonl(_discarded_file(), records[:-1]) + elif action["type"] == "skip": + # Item is at back of queue — move it to front + items = _read_jsonl(_queue_file()) + reordered = [item] + [x for x in items if x["id"] != item["id"]] + _write_jsonl(_queue_file(), reordered) + return {"undone": {"type": action["type"], "item": item}} + + # For label and discard: restore item to front of queue + items = _read_jsonl(_queue_file()) + _write_jsonl(_queue_file(), [item] + items) + return {"undone": {"type": action["type"], "item": item}} + + +# Label metadata — 10 labels matching label_tool.py +_LABEL_META = [ + {"name": "interview_scheduled", "emoji": "\U0001f4c5", "color": "#4CAF50", "key": "1"}, + {"name": "offer_received", "emoji": "\U0001f389", "color": "#2196F3", "key": "2"}, + {"name": "rejected", "emoji": "\u274c", "color": "#F44336", "key": "3"}, + {"name": "positive_response", "emoji": "\U0001f44d", "color": "#FF9800", "key": "4"}, + {"name": "survey_received", "emoji": "\U0001f4cb", "color": "#9C27B0", "key": "5"}, + {"name": "neutral", "emoji": "\u2b1c", "color": "#607D8B", "key": "6"}, + {"name": "event_rescheduled", "emoji": "\U0001f504", "color": "#FF5722", "key": "7"}, + {"name": "digest", "emoji": "\U0001f4f0", "color": "#00BCD4", "key": "8"}, + {"name": "new_lead", "emoji": "\U0001f91d", "color": "#009688", "key": "9"}, + {"name": "hired", "emoji": "\U0001f38a", "color": "#FFC107", "key": "h"}, +] + + +@app.get("/api/config/labels") +def get_labels(): + return _LABEL_META + + +# Static SPA — MUST be last (catches all unmatched paths) +_DIST = _ROOT / "web" / "dist" +if _DIST.exists(): + from fastapi.staticfiles import StaticFiles + app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="spa") diff --git a/tests/test_api.py b/tests/test_api.py index ebfb59d..0a9ea23 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -85,3 +85,67 @@ def test_skip_moves_to_back(client, queue_with_items): def test_skip_unknown_id_returns_404(client, queue_with_items): r = client.post("/api/skip", json={"id": "nope"}) assert r.status_code == 404 + + +# --- Part A: POST /api/discard --- + +def test_discard_writes_to_discarded_file(client, queue_with_items): + from app import api as api_module + r = client.post("/api/discard", json={"id": "id1"}) + assert r.status_code == 200 + discarded = api_module._read_jsonl(api_module._discarded_file()) + assert len(discarded) == 1 + assert discarded[0]["id"] == "id1" + assert discarded[0]["label"] == "__discarded__" + +def test_discard_removes_from_queue(client, queue_with_items): + from app import api as api_module + client.post("/api/discard", json={"id": "id1"}) + queue = api_module._read_jsonl(api_module._queue_file()) + assert not any(x["id"] == "id1" for x in queue) + + +# --- Part B: DELETE /api/label/undo --- + +def test_undo_label_removes_from_score(client, queue_with_items): + from app import api as api_module + client.post("/api/label", json={"id": "id0", "label": "neutral"}) + r = client.delete("/api/label/undo") + assert r.status_code == 200 + data = r.json() + assert data["undone"]["type"] == "label" + score = api_module._read_jsonl(api_module._score_file()) + assert score == [] + +def test_undo_discard_removes_from_discarded(client, queue_with_items): + from app import api as api_module + client.post("/api/discard", json={"id": "id0"}) + r = client.delete("/api/label/undo") + assert r.status_code == 200 + discarded = api_module._read_jsonl(api_module._discarded_file()) + assert discarded == [] + +def test_undo_skip_restores_to_front(client, queue_with_items): + from app import api as api_module + client.post("/api/skip", json={"id": "id0"}) + r = client.delete("/api/label/undo") + assert r.status_code == 200 + queue = api_module._read_jsonl(api_module._queue_file()) + assert queue[0]["id"] == "id0" + +def test_undo_with_no_action_returns_404(client): + r = client.delete("/api/label/undo") + assert r.status_code == 404 + + +# --- Part C: GET /api/config/labels --- + +def test_config_labels_returns_metadata(client): + r = client.get("/api/config/labels") + assert r.status_code == 200 + labels = r.json() + assert len(labels) == 10 + assert labels[0]["key"] == "1" + assert "emoji" in labels[0] + assert "color" in labels[0] + assert "name" in labels[0]