UUID everywhere: all API URLs use ID instead of slug
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s

- /projects/{project_id} instead of /projects/{slug}
- /members/{member_id} instead of /members/{slug}
- /projects/{project_id}/files/* instead of /projects/{slug}/files/*
- /projects/{project_id}/members/* instead of /projects/{slug}/members/*
- create_task: ?project_id= instead of ?project_slug=
- list_tasks: removed project_slug filter (use project_id)
- addProjectMember: accepts member_id in body
- Heartbeat timeout: lookup by Member.id
- Slug remains only for: display, uniqueness check, login form
This commit is contained in:
markov 2026-02-27 09:37:16 +01:00
parent b8e836fd07
commit 6069a2da1f
5 changed files with 150 additions and 216 deletions

View File

@ -56,6 +56,18 @@ class MemberUpdate(BaseModel):
agent_config: AgentConfigSchema | None = None agent_config: AgentConfigSchema | None = None
# --- Helpers ---
async def _get_member_by_id(member_id: str, db: AsyncSession) -> Member:
result = await db.execute(
select(Member).where(Member.id == uuid.UUID(member_id)).options(selectinload(Member.agent_config))
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
return member
# --- Endpoints --- # --- Endpoints ---
@router.get("/members", response_model=list[MemberOut]) @router.get("/members", response_model=list[MemberOut])
@ -72,15 +84,9 @@ async def list_members(
return [member_out(m) for m in result.scalars()] return [member_out(m) for m in result.scalars()]
@router.get("/members/{slug}", response_model=MemberOut) @router.get("/members/{member_id}", response_model=MemberOut)
async def get_member(slug: str, db: AsyncSession = Depends(get_db)): async def get_member(member_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute( return member_out(await _get_member_by_id(member_id, db))
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
return member_out(member)
@router.post("/members", response_model=MemberCreateResponse) @router.post("/members", response_model=MemberCreateResponse)
@ -104,7 +110,7 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
status=MemberStatus.OFFLINE, status=MemberStatus.OFFLINE,
) )
db.add(member) db.add(member)
await db.flush() # get member.id await db.flush()
if req.agent_config and req.type == MemberType.AGENT: if req.agent_config and req.type == MemberType.AGENT:
config = AgentConfig( config = AgentConfig(
@ -140,14 +146,9 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
).model_dump() ).model_dump()
@router.patch("/members/{slug}", response_model=MemberOut) @router.patch("/members/{member_id}", response_model=MemberOut)
async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)): async def update_member(member_id: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute( member = await _get_member_by_id(member_id, db)
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
if req.name is not None: if req.name is not None:
member.name = req.name member.name = req.name
@ -178,14 +179,9 @@ async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends
return member_out(member) return member_out(member)
@router.post("/members/{slug}/regenerate-token") @router.post("/members/{member_id}/regenerate-token")
async def regenerate_token(slug: str, db: AsyncSession = Depends(get_db)): async def regenerate_token(member_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute( member = await _get_member_by_id(member_id, db)
select(Member).where(Member.slug == slug)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
if member.type != MemberType.AGENT: if member.type != MemberType.AGENT:
raise HTTPException(400, "Only agent tokens can be regenerated") raise HTTPException(400, "Only agent tokens can be regenerated")
token = f"tb-{secrets.token_hex(32)}" token = f"tb-{secrets.token_hex(32)}"
@ -194,14 +190,9 @@ async def regenerate_token(slug: str, db: AsyncSession = Depends(get_db)):
return {"token": token} return {"token": token}
@router.post("/members/{slug}/revoke-token") @router.post("/members/{member_id}/revoke-token")
async def revoke_token(slug: str, db: AsyncSession = Depends(get_db)): async def revoke_token(member_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute( member = await _get_member_by_id(member_id, db)
select(Member).where(Member.slug == slug)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
if member.type != MemberType.AGENT: if member.type != MemberType.AGENT:
raise HTTPException(400, "Only agent tokens can be revoked") raise HTTPException(400, "Only agent tokens can be revoked")
member.token = None member.token = None
@ -227,20 +218,16 @@ async def update_my_status(
return {"status": status} return {"status": status}
@router.delete("/members/{slug}") @router.delete("/members/{member_id}")
async def delete_member( async def delete_member(
slug: str, member_id: str,
current_member: Member = Depends(get_current_member), current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Soft delete member by setting is_active=False. Only owners can delete.""" """Soft delete member by setting is_active=False. Only owners can delete."""
if current_member.role != MemberRole.OWNER: if current_member.role != MemberRole.OWNER:
raise HTTPException(403, "Only owners can delete members") raise HTTPException(403, "Only owners can delete members")
result = await db.execute(select(Member).where(Member.slug == slug)) member = await _get_member_by_id(member_id, db)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
member.is_active = False member.is_active = False
await db.commit() await db.commit()
return {"ok": True} return {"ok": True}

View File

@ -8,7 +8,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, or_ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -33,15 +33,10 @@ class ProjectFileUpdate(BaseModel):
def _to_member_brief(member: Member | None) -> MemberBrief | None: def _to_member_brief(member: Member | None) -> MemberBrief | None:
if not member: if not member:
return None return None
return MemberBrief( return MemberBrief(id=str(member.id), slug=member.slug, name=member.name)
id=str(member.id),
slug=member.slug,
name=member.name
)
def project_file_out(f: ProjectFile) -> ProjectFileOut: def project_file_out(f: ProjectFile) -> ProjectFileOut:
"""Convert ProjectFile to ProjectFileOut schema."""
return ProjectFileOut( return ProjectFileOut(
id=str(f.id), id=str(f.id),
filename=f.filename, filename=f.filename,
@ -53,8 +48,8 @@ def project_file_out(f: ProjectFile) -> ProjectFileOut:
updated_at=f.updated_at.isoformat() if f.updated_at else "", updated_at=f.updated_at.isoformat() if f.updated_at else "",
) )
async def _get_member(request: Request, db: AsyncSession) -> Member: async def _get_member(request: Request, db: AsyncSession) -> Member:
"""Get member from request state, re-fetched from DB to avoid detached objects."""
member_id = getattr(request.state, "member_id", None) member_id = getattr(request.state, "member_id", None)
if not member_id: if not member_id:
raise HTTPException(401, "Not authenticated") raise HTTPException(401, "Not authenticated")
@ -65,64 +60,66 @@ async def _get_member(request: Request, db: AsyncSession) -> Member:
return member return member
async def _get_project(slug: str, db: AsyncSession) -> Project: async def _get_project(project_id: str, db: AsyncSession) -> Project:
result = await db.execute(select(Project).where(Project.slug == slug)) result = await db.execute(select(Project).where(Project.id == uuid.UUID(project_id)))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
raise HTTPException(404, "Project not found") raise HTTPException(404, "Project not found")
return project return project
def _project_dir(project: Project) -> str:
"""Get filesystem directory for project files. Uses slug for human-readable paths."""
d = os.path.join(PROJECTS_DIR, project.slug)
os.makedirs(d, exist_ok=True)
return d
# --- Endpoints --- # --- Endpoints ---
@router.get("/projects/{slug}/files", response_model=list[ProjectFileOut]) @router.get("/projects/{project_id}/files", response_model=list[ProjectFileOut])
async def list_project_files( async def list_project_files(
slug: str, project_id: str,
search: Optional[str] = Query(None, description="Search by filename"), search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
project = await _get_project(slug, db) project = await _get_project(project_id, db)
q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader)) q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader))
if search: if search:
q = q.where(ProjectFile.filename.ilike(f"%{search}%")) q = q.where(ProjectFile.filename.ilike(f"%{search}%"))
q = q.order_by(ProjectFile.created_at.desc()) q = q.order_by(ProjectFile.created_at.desc())
result = await db.execute(q) result = await db.execute(q)
return [project_file_out(f) for f in result.scalars().all()] return [project_file_out(f) for f in result.scalars().all()]
@router.post("/projects/{slug}/files", response_model=ProjectFileOut) @router.post("/projects/{project_id}/files", response_model=ProjectFileOut)
async def upload_project_file( async def upload_project_file(
request: Request, request: Request,
slug: str, project_id: str,
file: UploadFile = File(...), file: UploadFile = File(...),
description: Optional[str] = Form(None), description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
member = await _get_member(request, db) member = await _get_member(request, db)
project = await _get_project(slug, db) project = await _get_project(project_id, db)
content = await file.read() content = await file.read()
if len(content) > MAX_FILE_SIZE: if len(content) > MAX_FILE_SIZE:
raise HTTPException(413, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)") raise HTTPException(413, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)")
if not file.filename: if not file.filename:
raise HTTPException(400, "Filename is required") raise HTTPException(400, "Filename is required")
safe_filename = os.path.basename(file.filename).strip() safe_filename = os.path.basename(file.filename).strip()
if not safe_filename or safe_filename.startswith('.'): if not safe_filename or safe_filename.startswith('.'):
raise HTTPException(400, "Invalid filename") raise HTTPException(400, "Invalid filename")
project_dir = os.path.join(PROJECTS_DIR, slug) proj_dir = _project_dir(project)
os.makedirs(project_dir, exist_ok=True) full_path = os.path.realpath(os.path.join(proj_dir, safe_filename))
if not full_path.startswith(os.path.realpath(proj_dir) + os.sep):
full_path = os.path.realpath(os.path.join(project_dir, safe_filename))
if not full_path.startswith(os.path.realpath(project_dir) + os.sep):
raise HTTPException(400, "Invalid filename") raise HTTPException(400, "Invalid filename")
result = await db.execute( result = await db.execute(
select(ProjectFile).where( select(ProjectFile).where(
ProjectFile.project_id == project.id, ProjectFile.project_id == project.id,
@ -130,24 +127,20 @@ async def upload_project_file(
) )
) )
existing_file = result.scalar_one_or_none() existing_file = result.scalar_one_or_none()
storage_path = safe_filename
with open(full_path, "wb") as f: with open(full_path, "wb") as f:
f.write(content) f.write(content)
mime_type = file.content_type or mimetypes.guess_type(file.filename)[0] mime_type = file.content_type or mimetypes.guess_type(file.filename)[0]
if existing_file: if existing_file:
existing_file.description = description existing_file.description = description
existing_file.mime_type = mime_type existing_file.mime_type = mime_type
existing_file.size = len(content) existing_file.size = len(content)
existing_file.uploaded_by = member.id existing_file.uploaded_by = member.id
existing_file.storage_path = storage_path existing_file.storage_path = safe_filename
await db.commit() await db.commit()
await db.refresh(existing_file, ["uploader"]) await db.refresh(existing_file, ["uploader"])
return project_file_out(existing_file) return project_file_out(existing_file)
else: else:
pf = ProjectFile( pf = ProjectFile(
@ -157,24 +150,21 @@ async def upload_project_file(
mime_type=mime_type, mime_type=mime_type,
size=len(content), size=len(content),
uploaded_by=member.id, uploaded_by=member.id,
storage_path=storage_path, storage_path=safe_filename,
) )
db.add(pf) db.add(pf)
await db.commit() await db.commit()
await db.refresh(pf, ["uploader"]) await db.refresh(pf, ["uploader"])
return project_file_out(pf) return project_file_out(pf)
@router.get("/projects/{slug}/files/{file_id}") @router.get("/projects/{project_id}/files/{file_id}")
async def get_project_file( async def get_project_file(
slug: str, project_id: str,
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
project = await _get_project(slug, db) project = await _get_project(project_id, db)
result = await db.execute( result = await db.execute(
select(ProjectFile).where( select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id), ProjectFile.id == uuid.UUID(file_id),
@ -182,22 +172,19 @@ async def get_project_file(
).options(selectinload(ProjectFile.uploader)) ).options(selectinload(ProjectFile.uploader))
) )
pf = result.scalar_one_or_none() pf = result.scalar_one_or_none()
if not pf: if not pf:
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return project_file_out(pf) return project_file_out(pf)
@router.get("/projects/{slug}/files/{file_id}/download") @router.get("/projects/{project_id}/files/{file_id}/download")
async def download_project_file( async def download_project_file(
slug: str, project_id: str,
file_id: str, file_id: str,
token: Optional[str] = Query(None), # Auth handled by middleware (query param for img src) token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
project = await _get_project(slug, db) project = await _get_project(project_id, db)
result = await db.execute( result = await db.execute(
select(ProjectFile).where( select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id), ProjectFile.id == uuid.UUID(file_id),
@ -205,36 +192,30 @@ async def download_project_file(
) )
) )
pf = result.scalar_one_or_none() pf = result.scalar_one_or_none()
if not pf: if not pf:
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug)) proj_dir = os.path.realpath(_project_dir(project))
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path)) full_path = os.path.realpath(os.path.join(proj_dir, pf.storage_path))
if not full_path.startswith(project_dir + os.sep): if not full_path.startswith(proj_dir + os.sep):
raise HTTPException(400, "Invalid file path") raise HTTPException(400, "Invalid file path")
if not os.path.exists(full_path): if not os.path.exists(full_path):
raise HTTPException(404, "File not found on disk") raise HTTPException(404, "File not found on disk")
return FileResponse( return FileResponse(full_path, filename=pf.filename, media_type=pf.mime_type or "application/octet-stream")
full_path,
filename=pf.filename,
media_type=pf.mime_type or "application/octet-stream",
)
@router.patch("/projects/{slug}/files/{file_id}", response_model=ProjectFileOut) @router.patch("/projects/{project_id}/files/{file_id}", response_model=ProjectFileOut)
async def update_project_file( async def update_project_file(
request: Request, request: Request,
slug: str, project_id: str,
file_id: str, file_id: str,
data: ProjectFileUpdate, data: ProjectFileUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
member = await _get_member(request, db) member = await _get_member(request, db)
project = await _get_project(slug, db) project = await _get_project(project_id, db)
result = await db.execute( result = await db.execute(
select(ProjectFile).where( select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id), ProjectFile.id == uuid.UUID(file_id),
@ -242,29 +223,27 @@ async def update_project_file(
).options(selectinload(ProjectFile.uploader)) ).options(selectinload(ProjectFile.uploader))
) )
pf = result.scalar_one_or_none() pf = result.scalar_one_or_none()
if not pf: if not pf:
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
if data.description is not None: if data.description is not None:
pf.description = data.description pf.description = data.description
await db.commit() await db.commit()
await db.refresh(pf) await db.refresh(pf)
return project_file_out(pf) return project_file_out(pf)
@router.delete("/projects/{slug}/files/{file_id}") @router.delete("/projects/{project_id}/files/{file_id}")
async def delete_project_file( async def delete_project_file(
request: Request, request: Request,
slug: str, project_id: str,
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
member = await _get_member(request, db) member = await _get_member(request, db)
project = await _get_project(slug, db) project = await _get_project(project_id, db)
result = await db.execute( result = await db.execute(
select(ProjectFile).where( select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id), ProjectFile.id == uuid.UUID(file_id),
@ -272,16 +251,14 @@ async def delete_project_file(
) )
) )
pf = result.scalar_one_or_none() pf = result.scalar_one_or_none()
if not pf: if not pf:
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug)) proj_dir = os.path.realpath(_project_dir(project))
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path)) full_path = os.path.realpath(os.path.join(proj_dir, pf.storage_path))
if full_path.startswith(project_dir + os.sep) and os.path.exists(full_path): if full_path.startswith(proj_dir + os.sep) and os.path.exists(full_path):
os.remove(full_path) os.remove(full_path)
await db.delete(pf) await db.delete(pf)
await db.commit() await db.commit()
return {"ok": True} return {"ok": True}

View File

@ -1,5 +1,7 @@
"""Projects API.""" """Projects API."""
import uuid as _uuid
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@ -23,13 +25,14 @@ class ProjectCreate(BaseModel):
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
name: str | None = None name: str | None = None
slug: str | None = None
description: str | None = None description: str | None = None
repo_urls: list[str] | None = None repo_urls: list[str] | None = None
status: str | None = None status: str | None = None
class ProjectMemberAdd(BaseModel): class ProjectMemberAdd(BaseModel):
slug: str member_id: str # UUID
class ProjectMemberOut(BaseModel): class ProjectMemberOut(BaseModel):
@ -43,6 +46,16 @@ class ProjectMemberOut(BaseModel):
from_attributes = True from_attributes = True
# --- Helpers ---
async def _get_project(project_id: str, db: AsyncSession) -> Project:
result = await db.execute(select(Project).where(Project.id == _uuid.UUID(project_id)))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
return project
async def _project_out(p: Project, db: AsyncSession) -> ProjectOut: async def _project_out(p: Project, db: AsyncSession) -> ProjectOut:
chat_result = await db.execute( chat_result = await db.execute(
select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT) select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT)
@ -60,19 +73,17 @@ async def _project_out(p: Project, db: AsyncSession) -> ProjectOut:
) )
# --- Endpoints ---
@router.get("/projects", response_model=list[ProjectOut]) @router.get("/projects", response_model=list[ProjectOut])
async def list_projects(db: AsyncSession = Depends(get_db)): async def list_projects(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Project).where(Project.status == ProjectStatus.ACTIVE)) result = await db.execute(select(Project).where(Project.status == ProjectStatus.ACTIVE))
return [await _project_out(p, db) for p in result.scalars()] return [await _project_out(p, db) for p in result.scalars()]
@router.get("/projects/{slug}", response_model=ProjectOut) @router.get("/projects/{project_id}", response_model=ProjectOut)
async def get_project(slug: str, db: AsyncSession = Depends(get_db)): async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Project).where(Project.slug == slug)) return await _project_out(await _get_project(project_id, db), db)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
return await _project_out(project, db)
@router.post("/projects", response_model=ProjectOut) @router.post("/projects", response_model=ProjectOut)
@ -90,7 +101,6 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db))
db.add(project) db.add(project)
await db.flush() await db.flush()
# Create project chat
chat = Chat(project_id=project.id, kind=ChatKind.PROJECT) chat = Chat(project_id=project.id, kind=ChatKind.PROJECT)
db.add(chat) db.add(chat)
@ -98,15 +108,18 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db))
return await _project_out(project, db) return await _project_out(project, db)
@router.patch("/projects/{slug}", response_model=ProjectOut) @router.patch("/projects/{project_id}", response_model=ProjectOut)
async def update_project(slug: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)): async def update_project(project_id: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Project).where(Project.slug == slug)) project = await _get_project(project_id, db)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
if req.name is not None: if req.name is not None:
project.name = req.name project.name = req.name
if req.slug is not None:
# Check slug uniqueness
existing = await db.execute(select(Project).where(Project.slug == req.slug, Project.id != project.id))
if existing.scalar_one_or_none():
raise HTTPException(409, f"Slug '{req.slug}' already taken")
project.slug = req.slug
if req.description is not None: if req.description is not None:
project.description = req.description project.description = req.description
if req.repo_urls is not None: if req.repo_urls is not None:
@ -118,65 +131,43 @@ async def update_project(slug: str, req: ProjectUpdate, db: AsyncSession = Depen
return await _project_out(project, db) return await _project_out(project, db)
@router.delete("/projects/{slug}") @router.delete("/projects/{project_id}")
async def delete_project(slug: str, request: Request, db: AsyncSession = Depends(get_db)): async def delete_project(project_id: str, request: Request, db: AsyncSession = Depends(get_db)):
member = getattr(request.state, "member", None) member = getattr(request.state, "member", None)
if not member or member.role != MemberRole.OWNER: if not member or member.role != MemberRole.OWNER:
raise HTTPException(403, "Only owners can delete projects") raise HTTPException(403, "Only owners can delete projects")
result = await db.execute(select(Project).where(Project.slug == slug)) project = await _get_project(project_id, db)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
await db.delete(project) await db.delete(project)
await db.commit() await db.commit()
return {"ok": True} return {"ok": True}
@router.get("/projects/{slug}/members", response_model=list[ProjectMemberOut]) @router.get("/projects/{project_id}/members", response_model=list[ProjectMemberOut])
async def get_project_members(slug: str, db: AsyncSession = Depends(get_db)): async def get_project_members(project_id: str, db: AsyncSession = Depends(get_db)):
"""Get members of a project.""" project = await _get_project(project_id, db)
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
# Join ProjectMember with Member to get member details
result = await db.execute( result = await db.execute(
select(Member, ProjectMember.role) select(Member, ProjectMember.role)
.join(ProjectMember, Member.id == ProjectMember.member_id) .join(ProjectMember, Member.id == ProjectMember.member_id)
.where(ProjectMember.project_id == project.id) .where(ProjectMember.project_id == project.id)
.options(selectinload(Member.agent_config)) .options(selectinload(Member.agent_config))
) )
members = [] return [
for member, role in result.all(): {"id": str(m.id), "name": m.name, "slug": m.slug, "type": m.type, "role": role}
members.append({ for m, role in result.all()
"id": str(member.id), ]
"name": member.name,
"slug": member.slug,
"type": member.type,
"role": role,
})
return members
@router.post("/projects/{slug}/members") @router.post("/projects/{project_id}/members")
async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession = Depends(get_db)): async def add_project_member(project_id: str, req: ProjectMemberAdd, db: AsyncSession = Depends(get_db)):
"""Add a member to a project.""" project = await _get_project(project_id, db)
# Get project
result = await db.execute(select(Project).where(Project.slug == slug)) result = await db.execute(select(Member).where(Member.id == _uuid.UUID(req.member_id)))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
# Get member
result = await db.execute(select(Member).where(Member.slug == req.slug))
member = result.scalar_one_or_none() member = result.scalar_one_or_none()
if not member: if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
# Check if already a member
result = await db.execute( result = await db.execute(
select(ProjectMember).where( select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.project_id == project.id,
@ -185,8 +176,7 @@ async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession
) )
if result.scalar_one_or_none(): if result.scalar_one_or_none():
raise HTTPException(409, "Member already in project") raise HTTPException(409, "Member already in project")
# Add member
project_member = ProjectMember( project_member = ProjectMember(
project_id=project.id, project_id=project.id,
member_id=member.id, member_id=member.id,
@ -194,41 +184,26 @@ async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession
) )
db.add(project_member) db.add(project_member)
await db.commit() await db.commit()
return {"ok": True} return {"ok": True}
@router.delete("/projects/{slug}/members/{member_slug}") @router.delete("/projects/{project_id}/members/{member_id}")
async def remove_project_member(slug: str, member_slug: str, db: AsyncSession = Depends(get_db)): async def remove_project_member(project_id: str, member_id: str, db: AsyncSession = Depends(get_db)):
"""Remove a member from a project.""" project = await _get_project(project_id, db)
# Get project
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
# Get member
result = await db.execute(select(Member).where(Member.slug == member_slug))
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
# Get project member
result = await db.execute( result = await db.execute(
select(ProjectMember).where( select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.project_id == project.id,
ProjectMember.member_id == member.id ProjectMember.member_id == _uuid.UUID(member_id)
) )
) )
project_member = result.scalar_one_or_none() project_member = result.scalar_one_or_none()
if not project_member: if not project_member:
raise HTTPException(404, "Member not in project") raise HTTPException(404, "Member not in project")
# Don't allow removing owner
if project_member.role == MemberRole.OWNER: if project_member.role == MemberRole.OWNER:
raise HTTPException(400, "Cannot remove project owner") raise HTTPException(400, "Cannot remove project owner")
await db.delete(project_member) await db.delete(project_member)
await db.commit() await db.commit()
return {"ok": True} return {"ok": True}

View File

@ -210,7 +210,6 @@ async def _resolve_member(db: AsyncSession, member_id: str) -> Member:
@router.get("/tasks", response_model=list[TaskOut]) @router.get("/tasks", response_model=list[TaskOut])
async def list_tasks( async def list_tasks(
project_id: Optional[str] = Query(None), project_id: Optional[str] = Query(None),
project_slug: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
assignee_id: Optional[str] = Query(None), assignee_id: Optional[str] = Query(None),
label: Optional[str] = Query(None), label: Optional[str] = Query(None),
@ -224,11 +223,6 @@ async def list_tasks(
) )
if project_id: if project_id:
q = q.where(Task.project_id == uuid.UUID(project_id)) q = q.where(Task.project_id == uuid.UUID(project_id))
elif project_slug:
result = await db.execute(select(Project).where(Project.slug == project_slug))
project = result.scalar_one_or_none()
if project:
q = q.where(Task.project_id == project.id)
if status: if status:
q = q.where(Task.status == status) q = q.where(Task.status == status)
if assignee_id: if assignee_id:
@ -248,13 +242,13 @@ async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
@router.post("/tasks", response_model=TaskOut) @router.post("/tasks", response_model=TaskOut)
async def create_task( async def create_task(
project_slug: str = Query(...), project_id: str = Query(...),
req: TaskCreate = ..., req: TaskCreate = ...,
current_member: Member = Depends(get_current_member), current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(
select(Project).where(Project.slug == project_slug).with_for_update() select(Project).where(Project.id == uuid.UUID(project_id)).with_for_update()
) )
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:

View File

@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import time import time
import uuid
import traceback import traceback
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -41,15 +42,15 @@ async def heartbeat_monitor():
for session_id in timed_out: for session_id in timed_out:
client = await manager.disconnect(session_id) client = await manager.disconnect(session_id)
if client and not manager.is_online(client.member_slug): if client and not manager.is_online(client.member_id):
async with async_session() as db: async with async_session() as db:
await db.execute( await db.execute(
update(Member).where(Member.slug == client.member_slug).values(status="offline") update(Member).where(Member.id == uuid.UUID(client.member_id)).values(status="offline")
) )
await db.commit() await db.commit()
await manager.broadcast_all( await manager.broadcast_all(
{"type": "agent.status", "data": {"slug": client.member_slug, "status": "offline"}}, {"type": "agent.status", "data": {"id": client.member_id, "slug": client.member_slug, "status": "offline"}},
exclude_slug=client.member_slug, exclude_member_id=client.member_id,
) )
logger.info("Heartbeat timeout: %s set offline", client.member_slug) logger.info("Heartbeat timeout: %s set offline", client.member_slug)