import json import re from typing import Any, Dict, List, Optional from bson import ObjectId from fastapi import APIRouter, File, Form, Header, HTTPException, UploadFile from google.genai import types from core import MODEL_NAME, issues_collection, model_client, projects_collection, teams_collection, users_collection, sessions_collection from schemas import ( AuthLoginRequest, AuthSignupRequest, IssueCreateRequest, IssueReorderRequest, IssueUpdateRequest, ProjectCreateRequest, ProjectSuggestRequest, ProjectUpdateRequest, TeamCreateRequest, TeamMemberRequest, ) from services import ( _create_session, _hash_password, _verify_password, get_vn_now, project_visibility_query, require_session_user, store_uploaded_image, unique_ids, ) router = APIRouter() def _json_safe(value: Any) -> Any: if isinstance(value, ObjectId): return str(value) if isinstance(value, list): return [_json_safe(item) for item in value] if isinstance(value, dict): return {key: _json_safe(item) for key, item in value.items()} return value def _id_candidates(raw_id: str) -> List[Any]: candidates: List[Any] = [raw_id] if isinstance(raw_id, str) and ObjectId.is_valid(raw_id): candidates.append(ObjectId(raw_id)) return candidates def _issue_path_parts(issue: Dict[str, Any]) -> List[str]: path = str(issue.get("requirement_node_path") or "").strip() if path: parts = [part.strip() for part in path.split(">") if part.strip()] if parts: return parts tags = issue.get("tags") or [] if isinstance(tags, list) and tags: first_tag = str(tags[0]).strip() if first_tag: return [first_tag] return ["Unassigned"] def _build_issue_tree(issues: List[Dict[str, Any]]) -> Dict[str, Any]: root: Dict[str, Any] = {"id": "root", "label": "root", "children": {}, "issues": []} for issue in issues: node = root for depth, part in enumerate(_issue_path_parts(issue)): children = node.setdefault("children", {}) if part not in children: children[part] = { "id": f"{node['id']}::{part.lower().replace(' ', '_')}", "label": part, "depth": depth, "children": {}, "issues": [], } node = children[part] node.setdefault("issues", []).append(issue) def normalize(tree_node: Dict[str, Any]) -> Dict[str, Any]: children_map = tree_node.get("children") or {} children = [normalize(child) for child in children_map.values()] issue_items = tree_node.get("issues") or [] issue_count = len(issue_items) + sum(child["issue_count"] for child in children) return { "id": tree_node.get("id"), "label": tree_node.get("label"), "depth": tree_node.get("depth", 0), "issue_count": issue_count, "issues": issue_items, "children": children, } normalized = normalize(root) return {"nodes": normalized["children"]} @router.post("/auth/signup") async def auth_signup(req: AuthSignupRequest): email = req.email.strip().lower() name = req.name.strip() password = req.password.strip() if not email or not name or not password: raise HTTPException(status_code=400, detail="Missing required fields") if users_collection.find_one({"email": email}): raise HTTPException(status_code=409, detail="Email already exists") salt_hex, password_hash = _hash_password(password) user = { "id": str(get_vn_now().timestamp()).replace(".", "") + "u", "name": name, "email": email, "password_salt": salt_hex, "password_hash": password_hash, "created_at": get_vn_now().isoformat(), "updated_at": get_vn_now().isoformat(), } users_collection.insert_one(user) session = _create_session(user["id"]) return {"user": {"id": user["id"], "name": user["name"], "email": user["email"]}, "token": session["token"], "expires_at": session["expires_at"]} @router.post("/auth/login") async def auth_login(req: AuthLoginRequest): email = req.email.strip().lower() password = req.password.strip() user = users_collection.find_one({"email": email}) if not user or not _verify_password(password, user["password_salt"], user["password_hash"]): raise HTTPException(status_code=401, detail="Invalid email or password") session = _create_session(user["id"]) return {"user": {"id": user["id"], "name": user["name"], "email": user["email"]}, "token": session["token"], "expires_at": session["expires_at"]} @router.post("/auth/logout") async def auth_logout(x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): if x_session_token: sessions_collection.update_one({"token": x_session_token}, {"$set": {"revoked_at": get_vn_now().isoformat()}}) return {"message": "Logged out"} @router.get("/auth/me") async def auth_me(x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): return {"user": _json_safe(require_session_user(x_session_token))} @router.get("/users/search") async def search_users(q: str, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): require_session_user(x_session_token) regex = re.compile(re.escape(q.strip()), re.IGNORECASE) users = list(users_collection.find({"$or": [{"name": regex}, {"email": regex}]}, {"_id": 0, "password_hash": 0, "password_salt": 0}).limit(12)) return {"users": _json_safe(users)} @router.get("/teams") async def list_teams(x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) user_id = str(user.get("id", "")) teams = list( teams_collection.find( { "$or": [ {"owner_id": {"$in": _id_candidates(user_id)}}, {"member_ids": {"$in": _id_candidates(user_id)}}, ] }, {"_id": 0}, ) ) return {"teams": _json_safe(teams)} @router.post("/teams") async def create_team(req: TeamCreateRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) user_id = str(user.get("id", "")) team = { "id": str(get_vn_now().timestamp()).replace(".", "") + "t", "name": req.name.strip(), "description": (req.description or "").strip(), "owner_id": user_id, "member_ids": [user_id], "created_at": get_vn_now().isoformat(), "updated_at": get_vn_now().isoformat(), } teams_collection.insert_one(team) return {"team": _json_safe(team)} @router.post("/teams/{team_id}/members") async def add_team_member(team_id: str, req: TeamMemberRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) team = teams_collection.find_one({"id": team_id}) if not team or str(team.get("owner_id", "")) != str(user.get("id", "")): raise HTTPException(status_code=404, detail="Team not found") member = users_collection.find_one( {"id": {"$in": _id_candidates(req.user_id)}}, {"_id": 0, "password_hash": 0, "password_salt": 0}, ) if not member: raise HTTPException(status_code=404, detail="User not found") member_id = str(member.get("id", req.user_id)) teams_collection.update_one( {"id": team_id}, { "$addToSet": {"member_ids": member_id}, "$set": {"updated_at": get_vn_now().isoformat()}, }, ) return {"message": "Member added", "member": _json_safe(member)} @router.get("/projects") async def list_projects(x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) projects = list(projects_collection.find(project_visibility_query(user["id"]), {"_id": 0}).sort("updated_at", -1)) return {"projects": projects} @router.post("/projects") async def create_project(req: ProjectCreateRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = { "id": str(get_vn_now().timestamp()).replace(".", "") + "p", "name": req.name.strip(), "description": (req.description or "").strip(), "owner_id": user["id"], "team_ids": [team_id for team_id in req.team_ids if team_id], "member_ids": unique_ids([user["id"]], req.member_ids), "created_at": get_vn_now().isoformat(), "updated_at": get_vn_now().isoformat(), } projects_collection.insert_one(project) return {"project": project} @router.get("/projects/{project_id}") async def get_project(project_id: str, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = projects_collection.find_one({"id": project_id}, {"_id": 0}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") return {"project": project} @router.patch("/projects/{project_id}") async def update_project(project_id: str, req: ProjectUpdateRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = projects_collection.find_one({"id": project_id}) if not project or project["owner_id"] != user["id"]: raise HTTPException(status_code=404, detail="Project not found") update_data: Dict[str, Any] = {"updated_at": get_vn_now().isoformat()} for field_name in ["name", "description", "team_ids", "member_ids"]: value = getattr(req, field_name) if value is not None: update_data[field_name] = value if not isinstance(value, str) else value.strip() projects_collection.update_one({"id": project_id}, {"$set": update_data}) return {"message": "Project updated"} @router.delete("/projects/{project_id}") async def delete_project(project_id: str, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = projects_collection.find_one({"id": project_id}) if not project or project["owner_id"] != user["id"]: raise HTTPException(status_code=404, detail="Project not found") projects_collection.delete_one({"id": project_id}) issues_collection.delete_many({"project_id": project_id}) return {"message": "Project deleted"} @router.get("/projects/{project_id}/issues") async def list_project_issues(project_id: str, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = projects_collection.find_one({"id": project_id}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") issues = list( issues_collection.find({"project_id": project_id}, {"_id": 0}).sort( [("position_order", 1), ("updated_at", -1)] ) ) return {"issues": issues} @router.get("/projects/{project_id}/issue-tree") async def get_project_issue_tree(project_id: str, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = projects_collection.find_one({"id": project_id}, {"_id": 0}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") issues = list( issues_collection.find({"project_id": project_id}, {"_id": 0}).sort( [("position_order", 1), ("updated_at", -1)] ) ) tree = _build_issue_tree(issues) return { "project_id": project_id, "project_name": project.get("name", ""), "total_issues": len(issues), "tree": tree, } @router.post("/projects/{project_id}/issues") async def create_project_issue(project_id: str, req: IssueCreateRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = projects_collection.find_one({"id": project_id}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") issue = { "id": str(get_vn_now().timestamp()).replace(".", "") + "i", "project_id": project_id, "title": req.title.strip(), "description": (req.description or "").strip(), "severity": req.severity, "status": req.status, "assignee_id": req.assignee_id, "tags": req.tags, "requirement_text": req.requirement_text, "requirement_node_id": req.requirement_node_id, "requirement_node_title": req.requirement_node_title, "requirement_node_path": req.requirement_node_path, "requirement_node_path_titles": req.requirement_node_path_titles, "requirement_node_path_ids": req.requirement_node_path_ids, "requirement_node_depth": req.requirement_node_depth, "requirement_document_id": req.requirement_document_id, "requirement_document_name": req.requirement_document_name, "position_order": req.position_order if req.position_order is not None else int(get_vn_now().timestamp() * 1000), "attachment_urls": req.attachment_urls, "reporter_id": user["id"], "created_at": get_vn_now().isoformat(), "updated_at": get_vn_now().isoformat(), } issues_collection.insert_one(issue) return {"issue": issue} @router.patch("/issues/{issue_id}") async def update_project_issue(issue_id: str, req: IssueUpdateRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) issue = issues_collection.find_one({"id": issue_id}) if not issue: raise HTTPException(status_code=404, detail="Issue not found") project = projects_collection.find_one({"id": issue["project_id"]}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") update_data: Dict[str, Any] = {"updated_at": get_vn_now().isoformat()} for field_name in ["title", "description", "severity", "status", "assignee_id", "tags", "attachment_urls", "requirement_node_id", "requirement_node_title", "requirement_node_path", "requirement_node_depth", "requirement_document_id", "requirement_document_name", "position_order"]: value = getattr(req, field_name) if value is not None: update_data[field_name] = value if not isinstance(value, str) else value.strip() if req.requirement_node_path_titles is not None: update_data["requirement_node_path_titles"] = req.requirement_node_path_titles if req.requirement_node_path_ids is not None: update_data["requirement_node_path_ids"] = req.requirement_node_path_ids issues_collection.update_one({"id": issue_id}, {"$set": update_data}) return {"message": "Issue updated"} @router.patch("/issues/{issue_id}/reorder") async def reorder_issue(issue_id: str, req: IssueReorderRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) issue = issues_collection.find_one({"id": issue_id}, {"_id": 0}) if not issue: raise HTTPException(status_code=404, detail="Issue not found") project = projects_collection.find_one({"id": issue["project_id"]}, {"_id": 0}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") issues_collection.update_one( {"id": issue_id}, { "$set": { "position_order": req.position_order, "updated_at": get_vn_now().isoformat(), } }, ) return { "message": "Issue reordered", "issue_id": issue_id, "position_order": req.position_order, } @router.delete("/issues/{issue_id}") async def delete_project_issue(issue_id: str, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) issue = issues_collection.find_one({"id": issue_id}) if not issue: raise HTTPException(status_code=404, detail="Issue not found") project = projects_collection.find_one({"id": issue["project_id"]}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") issues_collection.delete_one({"id": issue_id}) return {"message": "Issue deleted"} @router.post("/projects/{project_id}/suggest-issues") async def suggest_project_issues(project_id: str, req: ProjectSuggestRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) project = projects_collection.find_one({"id": project_id}, {"_id": 0}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") existing_issues = list(issues_collection.find({"project_id": project_id}, {"_id": 0, "title": 1, "severity": 1, "status": 1})) prompt = f""" Bạn là AI product manager cho công cụ quản lý dự án kiểu ClickUp. Hãy đề xuất issue/task chi tiết từ requirement sau. PROJECT: {json.dumps(project, ensure_ascii=False)} EXISTING ISSUES: {json.dumps(existing_issues, ensure_ascii=False)} REQUIREMENT: {req.requirement_text} Output JSON: {{"items": [{{"title": "...", "description": "...", "severity": "low|medium|high|critical", "status": "open", "tags": ["..."], "suggestion_reason": "..."}}]}} """ response = model_client.models.generate_content( model=MODEL_NAME, contents=req.requirement_text, config=types.GenerateContentConfig(system_instruction=prompt), ) result_text = (response.text or "").strip() json_match = re.search(r"\{[\s\S]*\}", result_text) if not json_match: raise HTTPException(status_code=500, detail="AI response did not contain JSON") payload = json.loads(json_match.group()) return {"items": payload.get("items", [])} @router.post("/uploads/images") async def upload_image(file: UploadFile = File(...), scope: str = Form(...), scope_id: str = Form(...), x_session_token: Optional[str] = Header(None, alias="X-Session-Token")): user = require_session_user(x_session_token) if scope not in {"project", "issue", "team", "user"}: raise HTTPException(status_code=400, detail="Invalid upload scope") if scope in {"project", "issue"}: if scope == "project": project = projects_collection.find_one({"id": scope_id}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") if scope == "issue": issue = issues_collection.find_one({"id": scope_id}) if not issue: raise HTTPException(status_code=404, detail="Issue not found") project = projects_collection.find_one({"id": issue["project_id"]}) if not project or user["id"] not in unique_ids([project.get("owner_id", "")], project.get("member_ids", [])): raise HTTPException(status_code=404, detail="Project not found") raw_bytes = await file.read() if not raw_bytes: raise HTTPException(status_code=400, detail="Empty file") asset = store_uploaded_image(raw_bytes, file.filename or "image", scope, scope_id) return {"asset": asset}