""" ThinkStorm Ideas & Workflow API Router Handles anonymous intake, public discovery, claims, work tracks, and graduation. """ import json import time from typing import Optional, List, Dict, Any from fastapi import APIRouter, Request, HTTPException, status, Depends from fastapi.responses import FileResponse from pydantic import BaseModel from ..config import config from ..database import get_db, next_sequence, get_utc_now from ..models import ( Idea, IdeaURL, LifecycleState, ProcessingState, User, UserRole, WorkTrack, WorkTrackState ) from ..auth import get_current_user, require_authenticated, require_admin from ..queue.worker import job_queue from ..services.gitea import GiteaAdapter from ..services.opengist import OpenGistAdapter from ..services.image_handler import ( validate_and_sanitize_image, save_image_artifact, get_image_artifact_path, ImageValidationError ) from ..prompts.catalog import get_all_prompts router = APIRouter(prefix="/api/ideas", tags=["ideas"]) gitea_svc = GiteaAdapter() opengist_svc = OpenGistAdapter() # Rate limiting sliding window in-memory cache: ip -> list of timestamps SUBMISSION_IP_LOG: Dict[str, List[float]] = {} def _resolve_gitea_claimant(conn, idea, current_user: User) -> str: """Authorizes claimant-controlled publication and returns the Gitea username.""" claimed_by = idea["claimed_by"] if not claimed_by: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="The idea must be claimed before it can be published to Gitea." ) if claimed_by != current_user.username and current_user.role != UserRole.ADMIN: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the current claimant can publish this idea to Gitea." ) claimant = conn.execute( "SELECT username, gitea_id FROM users WHERE username = ?", (claimed_by,) ).fetchone() if not claimant or claimant["gitea_id"] is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="The claimant must sign in with Gitea before publishing this idea." ) return claimant["username"] class IdeaSubmissionRequest(BaseModel): text: str class IdeaClaimRequest(BaseModel): pass class WorkTrackCreateRequest(BaseModel): work_type_id: str name: str model_override: Optional[str] = None class WorkTrackActivateRequest(BaseModel): model_override: Optional[str] = None @router.post("", status_code=status.HTTP_201_CREATED) async def submit_idea(request: Request): """ Public Anonymous Idea Submission. - Zero authentication required - Supports JSON body ({"text": "..."}) and multipart/form-data (text + optional image) - Single image accepted, sanitized (EXIF/GPS stripped), and preserved as artifact - Automatic TS-xxxx ID assignment - Immutable original text preservation - Rate-limiting & abuse prevention """ content_type = request.headers.get("content-type", "") raw_text = "" image_bytes = None image_filename = "" if "application/json" in content_type: try: body = await request.json() if isinstance(body, dict): raw_text = str(body.get("text") or "") except Exception: raise HTTPException(status_code=400, detail="Malformed JSON body.") elif "multipart/form-data" in content_type or "application/x-www-form-urlencoded" in content_type: try: form = await request.form() raw_text = str(form.get("text") or "") img_item = form.get("image") or form.get("file") if img_item and hasattr(img_item, "read"): image_bytes = await img_item.read() image_filename = getattr(img_item, "filename", "") except Exception as e: raise HTTPException(status_code=400, detail=f"Invalid form data: {str(e)}") else: # Fallback attempt JSON try: body = await request.json() if isinstance(body, dict): raw_text = str(body.get("text") or "") except Exception: raise HTTPException(status_code=400, detail="Invalid submission request.") raw_text = raw_text.strip() if not raw_text: raise HTTPException(status_code=400, detail="Submission text cannot be empty.") if len(raw_text) > config.max_submission_chars: raise HTTPException( status_code=400, detail=f"Submission exceeds maximum allowed length of {config.max_submission_chars} characters." ) # Rate Limiting Check client_ip = request.client.host if request.client else "127.0.0.1" now_ts = time.time() recent = [t for t in SUBMISSION_IP_LOG.get(client_ip, []) if now_ts - t < config.rate_limit_window_seconds] if len(recent) >= config.rate_limit_max_submissions: raise HTTPException(status_code=429, detail="Rate limit exceeded. Please wait before submitting another idea.") recent.append(now_ts) SUBMISSION_IP_LOG[client_ip] = recent idea_id = next_sequence("idea") now_iso = get_utc_now() submission_image_meta = None if image_bytes and len(image_bytes) > 0: try: sanitized_bytes, submission_image_meta = validate_and_sanitize_image( raw_bytes=image_bytes, original_filename=image_filename, source="web", idea_id=idea_id ) save_image_artifact(idea_id, sanitized_bytes, submission_image_meta) except ImageValidationError as e: raise HTTPException(status_code=400, detail=f"Image validation failed: {str(e)}") except Exception as e: raise HTTPException(status_code=400, detail=f"Could not process image upload: {str(e)}") img_json = json.dumps(submission_image_meta) if submission_image_meta else None with get_db() as conn: conn.execute( """ INSERT INTO ideas ( id, original_text, submitted_at, title, summary, lifecycle_state, processing_state, enrichment_level, submission_image, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, ?, ?, ?) """, (idea_id, raw_text, now_iso, "Processing New Idea...", "Analyzing submission text...", img_json, now_iso, now_iso) ) # Enqueue intake pipeline for foreground triage await job_queue.enqueue_foreground("intake", idea_id) return { "id": idea_id, "lifecycle_state": "SUBMITTED", "processing_state": "QUEUED", "message": "Idea successfully accepted and queued for processing.", "submitted_at": now_iso, "submission_image": submission_image_meta } @router.get("") async def list_ideas( state: Optional[str] = None, category: Optional[str] = None, tag: Optional[str] = None, enrichment_min: Optional[int] = None, q: Optional[str] = None, current_user: User = Depends(require_authenticated) ): """Browsing of ideas with filtering options (Requires authentication).""" query = """ SELECT i.*, GROUP_CONCAT(DISTINCT c.name) AS category_names, GROUP_CONCAT(DISTINCT t.name) AS tag_names FROM ideas i LEFT JOIN idea_categories ic ON i.id = ic.idea_id LEFT JOIN categories c ON ic.category_id = c.id LEFT JOIN idea_tags it ON i.id = it.idea_id LEFT JOIN tags t ON it.tag_id = t.id WHERE 1=1 """ params = [] if state and state.upper() != "ALL": query += " AND i.lifecycle_state = ?" params.append(state.upper()) elif state and state.upper() == "ALL": # Show all except TRASHED query += " AND i.lifecycle_state NOT IN ('TRASHED')" elif not state: # Default: show AVAILABLE, CLAIMED, ACTIVE, COMPLETED (hide QUARANTINED, REJECTED, and TRASHED) query += " AND i.lifecycle_state NOT IN ('QUARANTINED', 'REJECTED', 'TRASHED')" if category: query += " AND c.name = ?" params.append(category) if tag: query += " AND t.name = ?" params.append(tag.lstrip("#").lower()) if enrichment_min is not None: query += " AND i.enrichment_level >= ?" params.append(enrichment_min) if q: query += " AND (i.title LIKE ? OR i.summary LIKE ? OR i.original_text LIKE ?)" term = f"%{q}%" params.extend([term, term, term]) query += " GROUP BY i.id ORDER BY i.submitted_at DESC LIMIT 50" with get_db() as conn: rows = conn.execute(query, params).fetchall() results = [] for r in rows: sub_img = None if "submission_image" in r.keys() and r["submission_image"]: try: sub_img = json.loads(r["submission_image"]) if isinstance(r["submission_image"], str) else r["submission_image"] except Exception: sub_img = None results.append({ "id": r["id"], "title": r["title"] or "Untitled Idea", "summary": r["summary"], "lifecycle_state": r["lifecycle_state"], "processing_state": r["processing_state"], "enrichment_level": r["enrichment_level"], "claimed_by": r["claimed_by"], "submitted_at": r["submitted_at"], "categories": [c.strip() for c in r["category_names"].split(",")] if r["category_names"] else [], "tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else [], "submission_image": sub_img }) return results @router.post("/trash/empty") @router.delete("/trash") async def empty_trash(current_user: User = Depends(get_current_user)): """Permanently deletes all ideas currently in TRASHED state along with all their cascading records.""" with get_db() as conn: trashed_rows = conn.execute("SELECT id FROM ideas WHERE lifecycle_state = 'TRASHED'").fetchall() trashed_ids = [r["id"] for r in trashed_rows] if not trashed_ids: return {"deleted_count": 0, "message": "Trash is already empty."} for i_id in trashed_ids: conn.execute("DELETE FROM idea_categories WHERE idea_id = ?", (i_id,)) conn.execute("DELETE FROM idea_tags WHERE idea_id = ?", (i_id,)) conn.execute("DELETE FROM idea_urls WHERE idea_id = ?", (i_id,)) conn.execute("DELETE FROM idea_relationships WHERE source_idea_id = ? OR target_idea_id = ?", (i_id, i_id)) conn.execute("DELETE FROM processor_runs WHERE idea_id = ?", (i_id,)) conn.execute("DELETE FROM work_track_outputs WHERE work_track_id IN (SELECT id FROM work_tracks WHERE idea_id = ?)", (i_id,)) conn.execute("DELETE FROM external_resources WHERE idea_id = ?", (i_id,)) conn.execute("DELETE FROM work_tracks WHERE idea_id = ?", (i_id,)) conn.execute("DELETE FROM ideas WHERE id = ?", (i_id,)) now = get_utc_now() conn.execute( """ INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at) VALUES (?, 'EMPTY_TRASH', 'TRASH', 'ALL', ?, ?) """, (current_user.username, json.dumps({"deleted_count": len(trashed_ids), "deleted_ids": trashed_ids}), now) ) return { "deleted_count": len(trashed_ids), "deleted_ids": trashed_ids, "message": f"Successfully emptied trash. {len(trashed_ids)} idea(s) permanently deleted." } @router.get("/{idea_id}") async def get_idea_detail(idea_id: str, current_user: User = Depends(require_authenticated)): """Fetches full idea dossier with URLs, research docs, work tracks, and provenance (Requires authentication).""" is_admin = current_user.role == UserRole.ADMIN with get_db() as conn: idea_row = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea_row: raise HTTPException(status_code=404, detail="Idea not found.") # If quarantined, non-admins cannot view if idea_row["lifecycle_state"] == "QUARANTINED" and not is_admin: raise HTTPException(status_code=403, detail="This idea is currently under administrative safety review.") # URLs url_rows = conn.execute("SELECT * FROM idea_urls WHERE idea_id = ?", (idea_id,)).fetchall() urls = [ { "id": u["id"], "url": u["url"], "safety_state": u["safety_state"], "automation_policy": u["automation_policy"], "virustotal": json.loads(u["virustotal_data"] or "{}") } for u in url_rows ] # Categories & Tags cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()] tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()] track_rows = conn.execute("SELECT * FROM work_tracks WHERE idea_id = ?", (idea_id,)).fetchall() work_tracks = [] for tr in track_rows: output_rows = conn.execute( "SELECT * FROM work_track_outputs WHERE work_track_id = ? ORDER BY name ASC, version DESC", (tr["id"],) ).fetchall() ext_rows = conn.execute("SELECT * FROM external_resources WHERE work_track_id = ?", (tr["id"],)).fetchall() work_tracks.append({ "id": tr["id"], "work_type_id": tr["work_type_id"], "name": tr["name"], "state": tr["state"], "workflow_id": tr["workflow_id"], "model_override": tr["model_override"] if "model_override" in tr.keys() else None, "created_at": tr["created_at"], "started_at": tr["started_at"], "completed_at": tr["completed_at"], "outputs": [ { "id": o["id"], "name": o["name"], "artifact_path": o["artifact_path"], "content": o["content"], "version": o["version"] if "version" in o.keys() else 1, "is_current": bool(o["is_current"]) if "is_current" in o.keys() else True, "model_used": o["model_used"] if "model_used" in o.keys() else None, "created_at": o["created_at"] } for o in output_rows ], "external_resources": [{"type": e["resource_type"], "url": e["url"]} for e in ext_rows] }) # Provenance runs (with role-based prompt masking) run_rows = conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall() provenance = [] total_tokens_sum = 0 for rn in run_rows: in_tok = rn["input_tokens"] out_tok = rn["output_tokens"] tot_tok = rn["total_tokens"] total_tokens_sum += tot_tok provenance.append({ "id": rn["id"], "processor_name": rn["processor_name"], "stage": rn["stage"], "prompt_id": rn["prompt_id"], "prompt_version": rn["prompt_version"], "prompt_hash": rn["prompt_hash"], "model_policy": rn["model_policy"], "resolved_provider": rn["resolved_provider"], "resolved_model": rn["resolved_model"], "input_tokens": in_tok, "output_tokens": out_tok, "total_tokens": tot_tok, "started_at": rn["started_at"], "completed_at": rn["completed_at"], "duration_ms": rn["duration_ms"], "output_artifact": rn["output_artifact"], "output_data": json.loads(rn["output_data"] or "{}"), "status": rn["status"] }) # Relationships rels = [ {"target_idea_id": r["target_idea_id"], "relationship_type": r["relationship_type"], "notes": r["notes"]} for r in conn.execute("SELECT * FROM idea_relationships WHERE source_idea_id = ?", (idea_id,)).fetchall() ] # Image metadata sub_img = None if "submission_image" in idea_row.keys() and idea_row["submission_image"]: try: sub_img = json.loads(idea_row["submission_image"]) if isinstance(idea_row["submission_image"], str) else idea_row["submission_image"] except Exception: sub_img = None # Build multi-version research stages stage_definitions = { "IMAGE_CONTEXT": {"name": "Visual & Image Context", "doc_name": "image-context.md", "icon": "🖼️"}, "PRIOR_ART": {"name": "Prior Art & Alternatives", "doc_name": "prior-art.md", "icon": "🔍"}, "RESEARCH": {"name": "Deep Research Synthesis", "doc_name": "analysis.md", "icon": "📚"}, "FEASIBILITY": {"name": "Feasibility & Critique", "doc_name": "feasibility.md", "icon": "⚙️"} } research_stages = {} for stage_key, defn in stage_definitions.items(): stage_runs = [ rn for rn in provenance if rn["stage"] == stage_key and rn["output_data"] and rn["output_data"].get("content") ] stage_runs_sorted = sorted(stage_runs, key=lambda x: x["started_at"] or "") v_list = [] for idx, r_item in enumerate(stage_runs_sorted, start=1): v_obj = { "version": idx, "is_latest": False, "run_id": r_item["id"], "processor_name": r_item["processor_name"], "model": r_item["resolved_model"] or "Default Policy", "provider": r_item["resolved_provider"] or "", "tokens": { "input": r_item["input_tokens"], "output": r_item["output_tokens"], "total": r_item["total_tokens"] }, "started_at": r_item["started_at"], "completed_at": r_item["completed_at"], "duration_ms": r_item["duration_ms"], "prompt_id": r_item["prompt_id"], "prompt_version": r_item["prompt_version"], "artifact_path": r_item["output_artifact"] or f"research/{defn['doc_name']}", "content": r_item["output_data"].get("content", "") } v_list.append(v_obj) if v_list: v_list[-1]["is_latest"] = True latest_version = v_list[-1] else: latest_version = None research_stages[stage_key] = { "stage": stage_key, "name": defn["name"], "doc_name": defn["doc_name"], "icon": defn["icon"], "versions": v_list, "latest": latest_version, "total_versions": len(v_list) } claimant_gitea_linked = False if idea_row["claimed_by"]: claimant_row = conn.execute( "SELECT gitea_id FROM users WHERE username = ?", (idea_row["claimed_by"],) ).fetchone() claimant_gitea_linked = bool( claimant_row and claimant_row["gitea_id"] is not None ) actor_is_claimant = idea_row["claimed_by"] == current_user.username gitea_publish_allowed = bool( idea_row["claimed_by"] and claimant_gitea_linked and (actor_is_claimant or is_admin) ) if not idea_row["claimed_by"]: gitea_publish_reason = "Claim this idea before publishing it to Gitea." elif not actor_is_claimant and not is_admin: gitea_publish_reason = "Only the current claimant can publish this idea." elif not claimant_gitea_linked: gitea_publish_reason = "The claimant must sign in with Gitea before publishing." else: gitea_publish_reason = "" return { "id": idea_row["id"], "title": idea_row["title"], "summary": idea_row["summary"], "original_text": idea_row["original_text"], "submitted_at": idea_row["submitted_at"], "lifecycle_state": idea_row["lifecycle_state"], "processing_state": idea_row["processing_state"], "enrichment_level": idea_row["enrichment_level"], "claimed_by": idea_row["claimed_by"], "claimed_at": idea_row["claimed_at"], "released_at": idea_row["released_at"], "previous_lifecycle_state": idea_row["previous_lifecycle_state"], "trashed_at": idea_row["trashed_at"], "profile_id": idea_row["profile_id"], "opengist_id": idea_row["opengist_id"], "opengist_url": idea_row["opengist_url"], "gitea_repo_name": idea_row["gitea_repo_name"] if "gitea_repo_name" in idea_row.keys() else "", "gitea_repo_url": idea_row["gitea_repo_url"] if "gitea_repo_url" in idea_row.keys() else "", "gitea_publish_allowed": gitea_publish_allowed, "gitea_publish_reason": gitea_publish_reason, "submission_image": sub_img, "categories": cats, "tags": tags, "urls": urls, "work_tracks": work_tracks, "provenance": provenance, "research_stages": research_stages, "relationships": rels, "usage_summary": { "total_tokens": total_tokens_sum, "runs_count": len(provenance) } } @router.post("/{idea_id}/claim") async def claim_idea(idea_id: str, current_user: User = Depends(require_authenticated)): """Authenticated user claims an AVAILABLE idea.""" now = get_utc_now() with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") if idea["lifecycle_state"] != "AVAILABLE": raise HTTPException(status_code=400, detail=f"Idea in '{idea['lifecycle_state']}' state cannot be claimed.") conn.execute( """ UPDATE ideas SET lifecycle_state = 'CLAIMED', claimed_by = ?, claimed_at = ?, updated_at = ? WHERE id = ? """, (current_user.username, now, now, idea_id) ) return {"message": f"Idea {idea_id} successfully claimed by {current_user.username}."} @router.post("/{idea_id}/release") async def release_claim(idea_id: str, current_user: User = Depends(require_authenticated)): """Claimant or admin releases a claimed idea back to AVAILABLE.""" now = get_utc_now() with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="Only the claimant or an administrator can release this claim.") conn.execute( """ UPDATE ideas SET lifecycle_state = 'AVAILABLE', claimed_by = NULL, released_at = ?, updated_at = ? WHERE id = ? """, (now, now, idea_id) ) return {"message": f"Idea {idea_id} claim released and returned to AVAILABLE."} @router.post("/{idea_id}/activate") async def activate_idea(idea_id: str, current_user: User = Depends(require_authenticated)): """Explicitly transitions a CLAIMED idea to ACTIVE.""" now = get_utc_now() with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="Only the claimant can activate work.") conn.execute( "UPDATE ideas SET lifecycle_state = 'ACTIVE', updated_at = ? WHERE id = ?", (now, idea_id) ) return {"message": f"Idea {idea_id} is now ACTIVE."} @router.post("/{idea_id}/work-tracks") async def create_work_track( idea_id: str, payload: WorkTrackCreateRequest, current_user: User = Depends(require_authenticated) ): """Creates a new independent work track for a claimed idea.""" now = get_utc_now() with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="Only the claimant can create work tracks.") # Check work type wt = conn.execute("SELECT * FROM work_types WHERE id = ? AND enabled = 1", (payload.work_type_id,)).fetchone() if not wt: raise HTTPException(status_code=400, detail=f"Work type '{payload.work_type_id}' is invalid or disabled.") track_id = next_sequence("work_track") conn.execute( """ INSERT INTO work_tracks (id, idea_id, work_type_id, name, state, workflow_id, model_override, created_at) VALUES (?, ?, ?, ?, 'PLANNED', ?, ?, ?) """, (track_id, idea_id, payload.work_type_id, payload.name, wt["default_workflow_id"], payload.model_override, now) ) return { "id": track_id, "work_type_id": payload.work_type_id, "name": payload.name, "state": "PLANNED", "model_override": payload.model_override, "created_at": now } @router.post("/work-tracks/{track_id}/activate") async def activate_work_track( track_id: str, payload: Optional[WorkTrackActivateRequest] = None, current_user: User = Depends(get_current_user) ): """Deliberately begins work track workflow execution with optional model override.""" with get_db() as conn: track = conn.execute("SELECT * FROM work_tracks WHERE id = ?", (track_id,)).fetchone() if not track: raise HTTPException(status_code=404, detail="Work track not found.") idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone() if idea["claimed_by"] and idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="Only the claimant can activate this track.") if payload and payload.model_override: conn.execute("UPDATE work_tracks SET model_override = ? WHERE id = ?", (payload.model_override, track_id)) await job_queue.enqueue_foreground("work_track", track_id) return {"message": f"Work track {track_id} queued for activation and generation."} @router.post("/work-tracks/{track_id}/graduate") async def graduate_work_track_to_gitea(track_id: str, current_user: User = Depends(require_authenticated)): """Graduates an incubated Coding Project work track to Gitea.""" with get_db() as conn: track = conn.execute("SELECT * FROM work_tracks WHERE id = ?", (track_id,)).fetchone() if not track: raise HTTPException(status_code=404, detail="Work track not found.") if track["work_type_id"] != "CODING_PROJECT": raise HTTPException(status_code=400, detail="Only Coding Project work tracks can graduate to Gitea.") idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone() claimant_username = _resolve_gitea_claimant(conn, idea, current_user) try: res = await gitea_svc.graduate_project( idea["id"], idea["title"], idea["summary"], collaborator_username=claimant_username ) except RuntimeError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) ) from exc now = get_utc_now() with get_db() as conn: conn.execute( """ INSERT INTO external_resources (idea_id, work_track_id, resource_type, url, metadata, created_at) VALUES (?, ?, 'GITEA_REPOSITORY', ?, ?, ?) """, (idea["id"], track_id, res["repo_url"], json.dumps(res), now) ) return { "message": "Coding project successfully graduated to Gitea.", "repo_name": res["repo_name"], "repo_url": res["repo_url"] } @router.delete("/work-tracks/outputs/{output_id}") async def delete_work_track_output(output_id: int, current_user: User = Depends(get_current_user)): """Deletes a specific generated artifact version from a work track.""" with get_db() as conn: out = conn.execute( """ SELECT wto.*, wt.idea_id, i.claimed_by FROM work_track_outputs wto JOIN work_tracks wt ON wto.work_track_id = wt.id JOIN ideas i ON wt.idea_id = i.id WHERE wto.id = ? """, (output_id,) ).fetchone() if not out: raise HTTPException(status_code=404, detail="Artifact not found.") if out["claimed_by"] and out["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="Only the claimant or admin can delete artifacts.") track_id = out["work_track_id"] name = out["name"] is_curr = out["is_current"] ver = out["version"] conn.execute("DELETE FROM work_track_outputs WHERE id = ?", (output_id,)) # If the deleted artifact was marked is_current, promote the highest remaining version if is_curr: next_top = conn.execute( "SELECT id FROM work_track_outputs WHERE work_track_id = ? AND name = ? ORDER BY version DESC LIMIT 1", (track_id, name) ).fetchone() if next_top: conn.execute("UPDATE work_track_outputs SET is_current = 1 WHERE id = ?", (next_top["id"],)) return {"message": f"Artifact {name} (v{ver}) deleted successfully."} # ------------------------------------------------------------- # Idea Trash, Restore & Permanent Deletion # ------------------------------------------------------------- @router.post("/{idea_id}/trash") async def trash_idea(idea_id: str, current_user: User = Depends(get_current_user)): """Moves an idea to TRASHED state while preserving its previous lifecycle state.""" now = get_utc_now() with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") if idea["lifecycle_state"] == "TRASHED": return {"message": f"Idea {idea_id} is already in the trash.", "id": idea_id, "lifecycle_state": "TRASHED"} prev_state = idea["lifecycle_state"] conn.execute( """ UPDATE ideas SET lifecycle_state = 'TRASHED', previous_lifecycle_state = ?, trashed_at = ?, updated_at = ? WHERE id = ? """, (prev_state, now, now, idea_id) ) conn.execute( """ INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at) VALUES (?, 'TRASH_IDEA', 'IDEA', ?, ?, ?) """, (current_user.username, idea_id, json.dumps({"previous_state": prev_state}), now) ) return { "message": f"Idea {idea_id} moved to trash.", "id": idea_id, "lifecycle_state": "TRASHED", "previous_lifecycle_state": prev_state } @router.post("/{idea_id}/restore") async def restore_idea(idea_id: str, current_user: User = Depends(get_current_user)): """Restores a TRASHED idea back to its previous lifecycle state.""" now = get_utc_now() with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") if idea["lifecycle_state"] != "TRASHED": raise HTTPException(status_code=400, detail=f"Idea {idea_id} is not in the trash (current state: '{idea['lifecycle_state']}').") prev_state = idea["previous_lifecycle_state"] if not prev_state or prev_state in ("TRASHED", "SUBMITTED"): restore_target = "AVAILABLE" else: restore_target = prev_state conn.execute( """ UPDATE ideas SET lifecycle_state = ?, trashed_at = NULL, updated_at = ? WHERE id = ? """, (restore_target, now, idea_id) ) conn.execute( """ INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at) VALUES (?, 'RESTORE_IDEA', 'IDEA', ?, ?, ?) """, (current_user.username, idea_id, json.dumps({"restored_to": restore_target}), now) ) return { "message": f"Idea {idea_id} successfully restored to '{restore_target}'.", "id": idea_id, "lifecycle_state": restore_target } @router.delete("/{idea_id}/permanent") @router.delete("/{idea_id}") async def delete_idea_permanently(idea_id: str, current_user: User = Depends(get_current_user)): """Permanently deletes a single idea and all related records.""" with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") # Cascade delete all related records conn.execute("DELETE FROM idea_categories WHERE idea_id = ?", (idea_id,)) conn.execute("DELETE FROM idea_tags WHERE idea_id = ?", (idea_id,)) conn.execute("DELETE FROM idea_urls WHERE idea_id = ?", (idea_id,)) conn.execute("DELETE FROM idea_relationships WHERE source_idea_id = ? OR target_idea_id = ?", (idea_id, idea_id)) conn.execute("DELETE FROM processor_runs WHERE idea_id = ?", (idea_id,)) conn.execute("DELETE FROM work_track_outputs WHERE work_track_id IN (SELECT id FROM work_tracks WHERE idea_id = ?)", (idea_id,)) conn.execute("DELETE FROM external_resources WHERE idea_id = ?", (idea_id,)) conn.execute("DELETE FROM work_tracks WHERE idea_id = ?", (idea_id,)) conn.execute("DELETE FROM ideas WHERE id = ?", (idea_id,)) now = get_utc_now() conn.execute( """ INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at) VALUES (?, 'DELETE_PERMANENT', 'IDEA', ?, '{}', ?) """, (current_user.username, idea_id, now) ) return { "message": f"Idea {idea_id} permanently deleted.", "id": idea_id } @router.post("/{idea_id}/sync-opengist") async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_current_user)): """Manually triggers full OpenGist sync for an idea and all its artifacts.""" with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()] tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()] runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()] # Build research docs (chronologically latest run content takes precedence) research_docs = {} for r in runs: stage = r.get("stage") out_raw = r.get("output_data") or "{}" try: out_data = json.loads(out_raw) if isinstance(out_raw, str) else out_raw except Exception: out_data = {} content = out_data.get("content") if content: if stage == "IMAGE_CONTEXT": research_docs["image-context.md"] = content elif stage == "PRIOR_ART": research_docs["prior-art.md"] = content elif stage == "RESEARCH": research_docs["analysis.md"] = content elif stage == "FEASIBILITY": research_docs["feasibility.md"] = content res = await opengist_svc.persist_idea_artifact( idea_id=idea["id"], title=idea["title"] or "Untitled Idea", summary=idea["summary"] or "", original_text=idea["original_text"] or "", categories=cats, tags=tags, lifecycle_state=idea["lifecycle_state"], research_docs=research_docs, outputs={}, provenance_runs=runs, existing_gist_id=idea["opengist_id"] ) with get_db() as conn: conn.execute( """ UPDATE ideas SET opengist_id = ?, opengist_url = ?, updated_at = ? WHERE id = ? """, (res["opengist_id"], res["opengist_url"], get_utc_now(), idea_id) ) return { "message": "Dossier successfully published and synced to OpenGist.", "opengist_id": res["opengist_id"], "opengist_url": res["opengist_url"] } @router.post("/{idea_id}/sync-gitea") async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(require_authenticated)): """Publishes or re-syncs a claimed idea and grants its claimant write access.""" with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") claimant_username = _resolve_gitea_claimant(conn, idea, current_user) cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()] tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()] runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()] # Build research docs (chronologically latest run content takes precedence) research_docs = {} for r in runs: stage = r.get("stage") out_raw = r.get("output_data") or "{}" try: out_data = json.loads(out_raw) if isinstance(out_raw, str) else out_raw except Exception: out_data = {} content = out_data.get("content") if content: if stage == "IMAGE_CONTEXT": research_docs["image-context.md"] = content elif stage == "PRIOR_ART": research_docs["prior-art.md"] = content elif stage == "RESEARCH": research_docs["analysis.md"] = content elif stage == "FEASIBILITY": research_docs["feasibility.md"] = content # Collect work track outputs outputs = {} track_rows = conn.execute("SELECT id, name FROM work_tracks WHERE idea_id = ?", (idea_id,)).fetchall() for tr in track_rows: tr_outputs = conn.execute( "SELECT name, content, version FROM work_track_outputs WHERE work_track_id = ? AND is_current = 1", (tr["id"],) ).fetchall() for out in tr_outputs: track_slug = tr["name"].lower().replace(" ", "-") outputs[f"{track_slug}/{out['name']}"] = out["content"] try: res = await gitea_svc.persist_idea_dossier_repo( idea_id=idea["id"], title=idea["title"] or "Untitled Idea", summary=idea["summary"] or "", original_text=idea["original_text"] or "", categories=cats, tags=tags, lifecycle_state=idea["lifecycle_state"], research_docs=research_docs, outputs=outputs, provenance_runs=runs, existing_repo_url=( idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None ), publish_remote=True, collaborator_username=claimant_username ) except RuntimeError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) ) from exc now = get_utc_now() with get_db() as conn: conn.execute( """ UPDATE ideas SET gitea_repo_name = ?, gitea_repo_url = ?, updated_at = ? WHERE id = ? """, (res["repo_name"], res["repo_url"], now, idea_id) ) existing_resource = conn.execute( """ SELECT id FROM external_resources WHERE idea_id = ? AND resource_type = 'GITEA_DOSSIER' ORDER BY id ASC LIMIT 1 """, (idea_id,) ).fetchone() if existing_resource: conn.execute( """ UPDATE external_resources SET url = ?, metadata = ? WHERE id = ? """, (res["repo_url"], json.dumps(res), existing_resource["id"]) ) else: conn.execute( """ INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at) VALUES (?, 'GITEA_DOSSIER', ?, ?, ?) """, (idea_id, res["repo_url"], json.dumps(res), now) ) return { "message": ( f"Dossier published to '{res['repo_name']}' and write access granted " f"to '{claimant_username}'." ), "gitea_repo_name": res["repo_name"], "gitea_repo_url": res["repo_url"], "clone_url": res["clone_url"], "ssh_url": res["ssh_url"] } @router.post("/{idea_id}/reprocess") async def reprocess_idea( idea_id: str, bypass_duplicate_check: bool = True, current_user: User = Depends(get_current_user) ): """Triggers research reprocessing without automatically publishing to Gitea.""" with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") now = get_utc_now() conn.execute( "UPDATE ideas SET processing_state = 'QUEUED', updated_at = ? WHERE id = ?", (now, idea_id) ) await job_queue.enqueue_foreground("intake", idea_id, {"bypass_duplicate_check": bypass_duplicate_check}) return { "message": f"Idea {idea_id} enqueued for processing.", "idea_id": idea_id, "processing_state": "QUEUED" } @router.get("/{idea_id}/image") async def get_idea_image(idea_id: str): """Serves the canonical reference image attached to an idea.""" with get_db() as conn: row = conn.execute("SELECT submission_image FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="Idea not found.") raw_img = row["submission_image"] if "submission_image" in row.keys() else None if not raw_img: raise HTTPException(status_code=404, detail="No reference image associated with this idea.") meta = json.loads(raw_img) if isinstance(raw_img, str) else raw_img if not meta or not meta.get("present"): raise HTTPException(status_code=404, detail="No reference image associated with this idea.") img_path = get_image_artifact_path(idea_id, meta) if not img_path or not img_path.exists(): raise HTTPException(status_code=404, detail="Image artifact file not found on disk.") mime = meta.get("mime_type", "image/jpeg") return FileResponse( path=str(img_path), media_type=mime, filename=meta.get("original_filename", f"{idea_id}_reference_image") )