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)
|
||||
|
||||
|
||||
# ── 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"}
|
||||
|
||||
Reference in New Issue
Block a user