ideas: add JSON-file-backed ideas API (list, create, vote, delete)

Ideas and votes are stored as flat JSON files in /var/bincio/_ideas/,
following the same filesystem-first philosophy as segments and efforts.
Vote toggling uses fcntl exclusive locking to prevent concurrent writes.
This commit is contained in:
Davide Scaini
2026-05-13 19:27:54 +02:00
parent cf9817e853
commit 9553ca5ce7
+107
View File
@@ -2719,6 +2719,113 @@ async def user_segment_summary(handle: str) -> JSONResponse:
return JSONResponse(result)
# ── Ideas ────────────────────────────────────────────────────────────────────
import fcntl as _fcntl
def _ideas_dir(data_dir: Path) -> Path:
d = data_dir / "_ideas"
d.mkdir(parents=True, exist_ok=True)
return d
class IdeaBody(BaseModel):
title: str
body: str = ""
@app.get("/api/ideas")
async def list_ideas(
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
dd = _get_data_dir()
ideas = []
for path in sorted(_ideas_dir(dd).glob("*.json")):
try:
idea = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
votes = idea.get("votes", [])
idea["vote_count"] = len(votes)
idea["my_vote"] = user.handle in votes
ideas.append(idea)
ideas.sort(key=lambda x: (-x["vote_count"], -x["created_at"]))
return JSONResponse({"ideas": ideas})
@app.post("/api/ideas")
async def create_idea(
data: IdeaBody,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
title = data.title.strip()[:200]
body = data.body.strip()[:2000]
if not title:
raise HTTPException(400, "Title required")
dd = _get_data_dir()
idea_id = secrets.token_hex(8)
idea = {
"id": idea_id,
"title": title,
"body": body,
"author": user.handle,
"created_at": int(time.time()),
"votes": [],
}
path = _ideas_dir(dd) / f"{idea_id}.json"
path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8")
return JSONResponse({"id": idea_id})
@app.post("/api/ideas/{idea_id}/vote")
async def toggle_idea_vote(
idea_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
dd = _get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f)
votes: list = idea.get("votes", [])
if user.handle in votes:
votes.remove(user.handle)
voted = False
else:
votes.append(user.handle)
voted = True
idea["votes"] = votes
f.seek(0)
f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"voted": voted, "votes": len(votes)})
@app.delete("/api/ideas/{idea_id}")
async def delete_idea(
idea_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
dd = _get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
try:
idea = json.loads(path.read_text(encoding="utf-8"))
except Exception:
raise HTTPException(500, "Could not read idea")
if not user.is_admin and idea.get("author") != user.handle:
raise HTTPException(403, "Forbidden")
path.unlink()
return JSONResponse({"ok": True})
# ── Feedback ──────────────────────────────────────────────────────────────────
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}