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:
@@ -2719,6 +2719,113 @@ async def user_segment_summary(handle: str) -> JSONResponse:
|
|||||||
return JSONResponse(result)
|
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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
||||||
|
|||||||
Reference in New Issue
Block a user