Phase 1 MVP - Complete implementation

- Auth: register, login, JWT with refresh tokens, blocklist
- Projects/Folders/Documents CRUD with soft deletes
- Tags CRUD and assignment
- FTS5 search with highlights and tag filtering
- ADR-001, ADR-002, ADR-003 compliant
- Security fixes applied (JWT_SECRET_KEY, exception handler, cookie secure)
- 25 tests passing
This commit is contained in:
Motoko
2026-03-30 15:17:27 +00:00
parent 33f19e02f8
commit 7f3e8a8f53
41 changed files with 2858 additions and 0 deletions

1
app/routers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routers package

167
app/routers/auth.py Normal file
View File

@@ -0,0 +1,167 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.schemas.auth import AgentCreate, AgentLogin, AgentResponse, RefreshResponse, TokenResponse
from app.services import auth as auth_service
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
COOKIE_NAME = "refresh_token"
COOKIE_PATH = "/api/v1/auth/refresh"
COOKIE_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
def _set_refresh_cookie(response: Response, token: str):
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=COOKIE_MAX_AGE,
httponly=True,
secure=True, # True in production with HTTPS
samesite="lax",
path=COOKIE_PATH,
)
def _clear_refresh_cookie(response: Response):
response.set_cookie(
key=COOKIE_NAME,
value="",
max_age=0,
httponly=True,
secure=True, # True in production with HTTPS
samesite="lax",
path=COOKIE_PATH,
)
async def get_current_agent(request: Request, db: AsyncSession) -> Agent:
"""Get the current authenticated agent from request."""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Not authenticated")
token = auth_header[7:]
payload = auth_service.decode_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
jti = payload.get("jti")
if jti:
is_blocked = await auth_service.is_token_blocklisted(db, jti)
if is_blocked:
raise HTTPException(status_code=401, detail="Token revoked")
agent_id = payload.get("sub")
if not agent_id:
raise HTTPException(status_code=401, detail="Invalid token payload")
agent = await auth_service.get_agent_by_id(db, agent_id)
if not agent:
raise HTTPException(status_code=401, detail="Agent not found")
return agent
@router.post("/register", response_model=AgentResponse, status_code=201)
async def register(payload: AgentCreate, db: AsyncSession = Depends(get_db)):
existing = await auth_service.get_agent_by_username(db, payload.username)
if existing:
raise HTTPException(status_code=400, detail="Username already exists")
agent = await auth_service.create_agent(db, payload.username, payload.password)
return AgentResponse.model_validate(agent)
@router.post("/login", response_model=TokenResponse)
async def login(
payload: AgentLogin,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
agent = await auth_service.get_agent_by_username(db, payload.username)
if not agent or not auth_service.verify_password(payload.password, agent.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token, jti = auth_service.create_access_token(agent.id, agent.role)
refresh_token = auth_service.create_refresh_token()
user_agent = request.headers.get("user-agent")
client_ip = request.client.host if request.client else None
await auth_service.save_refresh_token(db, agent.id, refresh_token, user_agent, client_ip)
_set_refresh_cookie(response, refresh_token)
return TokenResponse(access_token=access_token)
@router.get("/me", response_model=AgentResponse)
async def get_me(request: Request, db: AsyncSession = Depends(get_db)):
agent = await get_current_agent(request, db)
return AgentResponse.model_validate(agent)
@router.post("/refresh", response_model=RefreshResponse)
async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="No refresh token")
user_agent = request.headers.get("user-agent")
client_ip = request.client.host if request.client else None
result = await auth_service.rotate_refresh_token(db, token, user_agent, client_ip)
if not result:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
new_rt, new_token = result
agent = await auth_service.get_agent_by_id(db, new_rt.user_id)
if not agent:
raise HTTPException(status_code=401, detail="Agent not found")
access_token, jti = auth_service.create_access_token(agent.id, agent.role)
_set_refresh_cookie(response, new_token)
return RefreshResponse(access_token=access_token)
@router.post("/logout", status_code=204)
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
token = request.cookies.get(COOKIE_NAME)
if token:
await auth_service.revoke_refresh_token(db, token)
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
access_token = auth_header[7:]
payload = auth_service.decode_token(access_token)
if payload and "jti" in payload:
exp = datetime.utcfromtimestamp(payload["exp"])
await auth_service.add_to_blocklist(db, payload["jti"], exp)
_clear_refresh_cookie(response)
return Response(status_code=204)
@router.post("/logout-all", status_code=204)
async def logout_all(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
auth_header = request.headers.get("authorization", "")
agent = None
if auth_header.startswith("Bearer "):
access_token = auth_header[7:]
payload = auth_service.decode_token(access_token)
if payload and "sub" in payload:
agent = await auth_service.get_agent_by_id(db, payload["sub"])
if agent:
await auth_service.revoke_all_user_tokens(db, agent.id)
if payload and "jti" in payload:
exp = datetime.utcfromtimestamp(payload["exp"])
await auth_service.add_to_blocklist(db, payload["jti"], exp)
_clear_refresh_cookie(response)
return Response(status_code=204)

435
app/routers/documents.py Normal file
View File

@@ -0,0 +1,435 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.document import Document
from app.models.folder import Folder
from app.models.project import Project
from app.models.tag import DocumentTag, Tag
from app.routers.auth import get_current_agent
from app.schemas.document import (
DocumentBriefResponse,
DocumentContentUpdate,
DocumentCreate,
DocumentListResponse,
DocumentResponse,
DocumentUpdate,
TagInfo,
)
from app.schemas.tag import DocumentTagsAssign
router = APIRouter(tags=["documents"])
def build_doc_path(project_id: str, doc_id: str, folder_id: str | None, folder_path: str | None) -> str:
if folder_id and folder_path:
return f"{folder_path}/{doc_id}"
return f"/{project_id}/{doc_id}"
async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
result = await db.execute(
text("""
SELECT t.id, t.name, t.color
FROM active_tags t
JOIN document_tags dt ON t.id = dt.tag_id
WHERE dt.document_id = :doc_id
"""),
{"doc_id": doc_id}
)
rows = result.fetchall()
return [TagInfo(id=r.id, name=r.name, color=r.color) for r in rows]
async def document_to_response(db: AsyncSession, doc: Document) -> DocumentResponse:
tags = await get_document_tags(db, doc.id)
return DocumentResponse(
id=doc.id,
title=doc.title,
content=doc.content,
project_id=doc.project_id,
folder_id=doc.folder_id,
path=doc.path,
tags=tags,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
@router.get("/api/v1/projects/{project_id}/documents", response_model=DocumentListResponse)
async def list_documents(
request: Request,
project_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute(
select(Document).where(
Document.project_id == project_id,
Document.is_deleted == False,
).order_by(Document.created_at.desc())
)
docs = result.scalars().all()
responses = []
for doc in docs:
tags = await get_document_tags(db, doc.id)
responses.append(DocumentBriefResponse(
id=doc.id,
title=doc.title,
project_id=doc.project_id,
folder_id=doc.folder_id,
path=doc.path,
tags=tags,
created_at=doc.created_at,
updated_at=doc.updated_at,
))
return DocumentListResponse(documents=responses)
@router.post("/api/v1/projects/{project_id}/documents", response_model=DocumentResponse, status_code=201)
async def create_document(
request: Request,
project_id: str,
payload: DocumentCreate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
folder_path = None
if payload.folder_id:
folder_result = await db.execute(
select(Folder).where(
Folder.id == payload.folder_id,
Folder.project_id == project_id,
Folder.is_deleted == False,
)
)
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=400, detail="Folder not found")
folder_path = folder.path
doc_id = str(uuid.uuid4())
path = build_doc_path(project_id, doc_id, payload.folder_id, folder_path)
doc = Document(
id=doc_id,
title=payload.title,
content=payload.content,
project_id=project_id,
folder_id=payload.folder_id,
path=path,
)
db.add(doc)
await db.flush()
return await document_to_response(db, doc)
@router.get("/api/v1/documents/{document_id}", response_model=DocumentResponse)
async def get_document(
request: Request,
document_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == False,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Document not found")
return await document_to_response(db, doc)
@router.put("/api/v1/documents/{document_id}", response_model=DocumentResponse)
async def update_document(
request: Request,
document_id: str,
payload: DocumentUpdate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == False,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if payload.title is not None:
doc.title = payload.title
if payload.folder_id is not None:
if payload.folder_id:
folder_result = await db.execute(
select(Folder).where(
Folder.id == payload.folder_id,
Folder.project_id == doc.project_id,
Folder.is_deleted == False,
)
)
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=400, detail="Folder not found")
doc.path = f"{folder.path}/{doc.id}"
else:
doc.path = f"/{doc.project_id}/{doc.id}"
doc.folder_id = payload.folder_id
doc.updated_at = datetime.utcnow()
await db.flush()
return await document_to_response(db, doc)
@router.delete("/api/v1/documents/{document_id}", status_code=204)
async def delete_document(
request: Request,
document_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == False,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
doc.is_deleted = True
doc.deleted_at = datetime.utcnow()
doc.deleted_by = agent.id
await db.flush()
return None
@router.put("/api/v1/documents/{document_id}/content", response_model=DocumentResponse)
async def update_document_content(
request: Request,
document_id: str,
payload: DocumentContentUpdate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == False,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
doc.content = payload.content
doc.updated_at = datetime.utcnow()
await db.flush()
return await document_to_response(db, doc)
@router.post("/api/v1/documents/{document_id}/restore", response_model=DocumentResponse)
async def restore_document(
request: Request,
document_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == True,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
doc.is_deleted = False
doc.deleted_at = None
doc.deleted_by = None
await db.flush()
return await document_to_response(db, doc)
@router.post("/api/v1/documents/{document_id}/tags", status_code=204)
async def assign_tags(
request: Request,
document_id: str,
payload: DocumentTagsAssign,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == False,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
for tag_id in payload.tag_ids:
tag_result = await db.execute(
select(Tag).where(
Tag.id == tag_id,
Tag.is_deleted == False,
)
)
tag = tag_result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=400, detail=f"Tag {tag_id} not found")
existing = await db.execute(
select(DocumentTag).where(
DocumentTag.document_id == document_id,
DocumentTag.tag_id == tag_id,
)
)
if not existing.scalar_one_or_none():
dt = DocumentTag(document_id=document_id, tag_id=tag_id)
db.add(dt)
await db.flush()
return None
@router.delete("/api/v1/documents/{document_id}/tags/{tag_id}", status_code=204)
async def remove_tag(
request: Request,
document_id: str,
tag_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Document).where(
Document.id == document_id,
Document.is_deleted == False,
)
)
doc = result.scalar_one_or_none()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
proj_result = await db.execute(
select(Project).where(
Project.id == doc.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
await db.execute(
delete(DocumentTag).where(
DocumentTag.document_id == document_id,
DocumentTag.tag_id == tag_id,
)
)
await db.flush()
return None

251
app/routers/folders.py Normal file
View File

@@ -0,0 +1,251 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.folder import Folder
from app.models.project import Project
from app.schemas.folder import FolderCreate, FolderListResponse, FolderResponse, FolderUpdate
from app.routers.auth import get_current_agent
router = APIRouter(tags=["folders"])
def build_folder_path(project_id: str, folder_id: str, parent_id: str | None, parent_path: str | None) -> str:
if parent_id and parent_path:
return f"{parent_path}/{folder_id}"
return f"/{project_id}/{folder_id}"
@router.get("/api/v1/projects/{project_id}/folders", response_model=FolderListResponse)
async def list_folders(
request: Request,
project_id: str,
parent_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
query = select(Folder).where(
Folder.project_id == project_id,
Folder.parent_id == parent_id,
Folder.is_deleted == False,
).order_by(Folder.name)
result = await db.execute(query)
folders = result.scalars().all()
return FolderListResponse(folders=[FolderResponse.model_validate(f) for f in folders])
@router.post("/api/v1/projects/{project_id}/folders", response_model=FolderResponse, status_code=201)
async def create_folder(
request: Request,
project_id: str,
payload: FolderCreate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
parent_path = None
if payload.parent_id:
parent_result = await db.execute(
select(Folder).where(
Folder.id == payload.parent_id,
Folder.project_id == project_id,
Folder.is_deleted == False,
)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=400, detail="Parent folder not found")
parent_path = parent.path
folder_id = str(uuid.uuid4())
path = build_folder_path(project_id, folder_id, payload.parent_id, parent_path)
folder = Folder(
id=folder_id,
name=payload.name,
project_id=project_id,
parent_id=payload.parent_id,
path=path,
)
db.add(folder)
await db.flush()
return FolderResponse.model_validate(folder)
@router.get("/api/v1/folders/{folder_id}", response_model=FolderResponse)
async def get_folder(
request: Request,
folder_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == False,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Folder not found")
return FolderResponse.model_validate(folder)
@router.put("/api/v1/folders/{folder_id}", response_model=FolderResponse)
async def update_folder(
request: Request,
folder_id: str,
payload: FolderUpdate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == False,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if payload.name is not None:
folder.name = payload.name
if payload.parent_id is not None:
if payload.parent_id == folder_id:
raise HTTPException(status_code=400, detail="Cannot set folder as its own parent")
parent_result = await db.execute(
select(Folder).where(
Folder.id == payload.parent_id,
Folder.project_id == folder.project_id,
Folder.is_deleted == False,
)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=400, detail="Parent folder not found")
folder.parent_id = payload.parent_id
folder.path = f"{parent.path}/{folder.id}"
folder.updated_at = datetime.utcnow()
await db.flush()
return FolderResponse.model_validate(folder)
@router.delete("/api/v1/folders/{folder_id}", status_code=204)
async def delete_folder(
request: Request,
folder_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == False,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
folder.is_deleted = True
folder.deleted_at = datetime.utcnow()
folder.deleted_by = agent.id
await db.flush()
return None
@router.post("/api/v1/folders/{folder_id}/restore", response_model=FolderResponse)
async def restore_folder(
request: Request,
folder_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == True,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
folder.is_deleted = False
folder.deleted_at = None
folder.deleted_by = None
await db.flush()
return FolderResponse.model_validate(folder)

146
app/routers/projects.py Normal file
View File

@@ -0,0 +1,146 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectListResponse, ProjectResponse, ProjectUpdate
from app.routers.auth import get_current_agent
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
@router.get("", response_model=ProjectListResponse)
async def list_projects(
request: Request,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Project).where(
Project.agent_id == agent.id,
Project.is_deleted == False,
).order_by(Project.created_at.desc())
)
projects = result.scalars().all()
return ProjectListResponse(projects=[ProjectResponse.model_validate(p) for p in projects])
@router.post("", response_model=ProjectResponse, status_code=201)
async def create_project(
request: Request,
payload: ProjectCreate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
project = Project(
id=str(uuid.uuid4()),
name=payload.name,
description=payload.description,
agent_id=agent.id,
)
db.add(project)
await db.flush()
return ProjectResponse.model_validate(project)
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
request: Request,
project_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return ProjectResponse.model_validate(project)
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
request: Request,
project_id: str,
payload: ProjectUpdate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if payload.name is not None:
project.name = payload.name
if payload.description is not None:
project.description = payload.description
project.updated_at = datetime.utcnow()
await db.flush()
return ProjectResponse.model_validate(project)
@router.delete("/{project_id}", status_code=204)
async def delete_project(
request: Request,
project_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.is_deleted = True
project.deleted_at = datetime.utcnow()
project.deleted_by = agent.id
await db.flush()
return None
@router.post("/{project_id}/restore", response_model=ProjectResponse)
async def restore_project(
request: Request,
project_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == True,
)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.is_deleted = False
project.deleted_at = None
project.deleted_by = None
await db.flush()
return ProjectResponse.model_validate(project)

36
app/routers/search.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.routers.auth import get_current_agent
from app.schemas.search import SearchResponse
from app.services.search import search_documents
router = APIRouter(prefix="/api/v1/search", tags=["search"])
@router.get("", response_model=SearchResponse)
async def search(
request: Request,
q: str = Query(..., min_length=1),
project_id: str | None = Query(None),
tags: str | None = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
tag_list = None
if tags:
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
return await search_documents(
db=db,
query=q,
agent_id=agent.id,
project_id=project_id,
tags=tag_list,
limit=limit,
offset=offset,
)

79
app/routers/tags.py Normal file
View File

@@ -0,0 +1,79 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.tag import Tag
from app.routers.auth import get_current_agent
from app.schemas.tag import TagCreate, TagListResponse, TagResponse
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
@router.get("", response_model=TagListResponse)
async def list_tags(
request: Request,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Tag).where(
Tag.is_deleted == False,
).order_by(Tag.name)
)
tags = result.scalars().all()
return TagListResponse(tags=[TagResponse.model_validate(t) for t in tags])
@router.post("", response_model=TagResponse, status_code=201)
async def create_tag(
request: Request,
payload: TagCreate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
existing = await db.execute(
select(Tag).where(
Tag.name == payload.name,
Tag.is_deleted == False,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Tag with this name already exists")
tag = Tag(
id=str(uuid.uuid4()),
name=payload.name,
color=payload.color,
)
db.add(tag)
await db.flush()
return TagResponse.model_validate(tag)
@router.post("/{tag_id}/restore", response_model=TagResponse)
async def restore_tag(
request: Request,
tag_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Tag).where(
Tag.id == tag_id,
Tag.is_deleted == True,
)
)
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
tag.is_deleted = False
tag.deleted_at = None
tag.deleted_by = None
await db.flush()
return TagResponse.model_validate(tag)