Admin: Daily Word pool curation (lookup + add/remove)

First games admin tool. A "Games" tab in the operator console for the Daily Word
answer pool.
* Lookup: is a word real (in the guess dictionary), the right length (5/6), and
  already in the pool — instant as you type.
* Add: appends to the pool, enforcing the invariant (alpha · 5/6 letters · in the
  guess dict) so the daily answer is always guessable. Remove: drops admin-added
  words (curated static ones stay).
* Additions persist in a new word_pool table (survives redeploys, unlike the
  baked-in JSON); the daily picker reads static pool ∪ DB additions. Guess dicts
  shipped with the package (goodnews/data/words-5/6.json) for server-side
  validation. Admin-gated endpoints + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 11:42:52 -04:00
parent 7e4d3e2cd9
commit 903b27fc8d
7 changed files with 230 additions and 2 deletions
+35
View File
@@ -340,6 +340,10 @@ class WordGuessRequest(BaseModel):
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
class WordPoolBody(BaseModel):
word: str
class EmailStartRequest(BaseModel):
email: str
@@ -1448,6 +1452,37 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=400, detail=res["error"])
return res
# --- Admin: Daily Word pool curation ---
@app.get("/api/admin/word/lookup")
def admin_word_lookup(word: str, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
res = games.lookup_word(word)
res["in_pool"] = bool(res["variant"]) and res["word"] in games.answer_pool(conn, res["variant"])
return res
@app.get("/api/admin/word/pool")
def admin_word_pool(request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
return games.pool_summary(conn)
@app.post("/api/admin/word/pool")
def admin_word_pool_add(body: WordPoolBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
res = games.add_pool_word(conn, body.word)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return {**res, "pool": games.pool_summary(conn)}
@app.delete("/api/admin/word/pool/{word}")
def admin_word_pool_remove(word: str, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
games.remove_pool_word(conn, word)
return games.pool_summary(conn)
@app.get("/api/since", response_model=FeedResponse)
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
# A calm welcome-back cue: accepted/non-dup/visible articles discovered