import json import re from typing import Any, Dict, Optional 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, 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() @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": 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": 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) teams = list(teams_collection.find({"$or": [{"owner_id": user["id"]}, {"member_ids": user["id"]}]}, {"_id": 0})) return {"teams": 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) 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": 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 team["owner_id"] != user["id"]: raise HTTPException(status_code=404, detail="Team not found") member = users_collection.find_one({"id": req.user_id}, {"_id": 0, "password_hash": 0, "password_salt": 0}) if not member: raise HTTPException(status_code=404, detail="User not found") teams_collection.update_one({"id": team_id}, {"$addToSet": {"member_ids": req.user_id}, "$set": {"updated_at": get_vn_now().isoformat()}}) return {"message": "Member added", "member": 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") return {"issues": list(issues_collection.find({"project_id": project_id}, {"_id": 0}).sort("updated_at", -1))} @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, "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"]: 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.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}