feat(project): 3단계 구조 백엔드 기반 — 세션 삭제자·하위업무·멀티호밍

프로젝트→세션→업무 3단계(아사나식) 전환의 1단계(백엔드). "세션"은 새 개념이
아니라 기존 "단계"(project_stages, 칸반 컬럼)를 그대로 재사용한다.

- project_stages 에 created_by 추가 — 세션 삭제도 "만든 사람만"(_can_delete
  재사용) 규칙 적용. api_delete_stage 를 _require_manage 단독 체크에서
  생성자 체크로 변경.
- 프로젝트 생성을 관리자 전용 → 로그인 사용자 전체로 개방
  (api_create_project: _require_admin → _require_user). 홈 화면의 "새
  프로젝트"/수정 모달·버튼도 is_admin 게이트를 걷어냄(생성자가 자동으로
  owner 가 되어 _can_manage 통과 — 기존 로직 재사용). 멤버 배정 API는 계속
  관리자 전용이라, 비관리자가 새 프로젝트 모달을 열면 멤버 선택란은 안내
  문구만 보여주고 시도하지 않는다(불필요한 403 방지).
- 하위 업무 — 새 테이블 없이 tasks.parent_task_id 재사용(부모 삭제 시
  CASCADE). list_tasks 는 기본적으로 최상위만 반환(보드/캘린더/리스트에
  하위업무가 카드로 안 뜨게) + comment_count 와 같은 방식으로 subtask_total/
  subtask_done 집계 추가. create_task 에 parent_task_id 지원(하위업무는
  부모와 같은 프로젝트, 세션 없음).
- 멀티호밍(task_project_links, 아사나 기능) — 업무 하나가 여러 프로젝트/세션에
  동시에 속할 수 있다. list_tasks(project_id=X, 비재귀)가 자동으로 그
  프로젝트에 연결된 업무까지 포함하도록 확장(다른 호출 경로는 안 건드림 —
  홈/내업무 전체보기에서 중복 노출 방지). 신규 API: GET /api/tasks/{id}(하위
  업무·연결 포함 상세), POST/DELETE /api/tasks/{id}/links.
- 설명란 이미지 삽입 준비: store.sanitize_description_html — 허용 태그만
  남기고 스크립트/이벤트속성/위험 URL 스킴 제거(표준 html.parser, 외부
  라이브러리 없음). api_create_task/api_update_task 저장 직전 통과.

⚠️ 배포 전 필수: scripts/sql/project_db_003_sections_subtasks_links.sql 를
project_db 에 적용해야 한다(project_stages.created_by, tasks.parent_task_id,
task_project_links 없이는 이 커밋의 쿼리가 전부 실패한다). 그 안의 기존
설명 텍스트→HTML 이스케이프 UPDATE 문은 1회만 실행할 것.

프론트엔드(세션 드래그 재정렬, 사이드바 프로젝트별 보기, 하위업무/멀티호밍
UI, 설명란 이미지 붙여넣기 편집기)는 다음 커밋에서 이어간다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:15:25 +09:00
parent e14d303b82
commit 07f4d0fd5b
7 changed files with 447 additions and 45 deletions
+77 -4
View File
@@ -570,6 +570,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"page_title": project["name"],
"page_subtitle": project.get("description") or "프로젝트 보드",
"project": project,
"current_project_id": project_id,
"nav_projects": nav_projects,
"stages": stages,
"tasks": all_tasks,
@@ -619,8 +620,8 @@ async def api_list_projects(request: Request) -> JSONResponse:
async def api_create_project(
request: Request, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
"""최상위 프로젝트 생성 — 관리자 전용."""
user = _require_admin(request)
"""최상위 프로젝트 생성 — 로그인해 프로젝트 모듈에 접근할 수 있는 사용자 누구나."""
user = _require_user(request)
st = _db_or_503(request)
try:
project = st.create_project(
@@ -778,6 +779,7 @@ async def api_add_stage(
project_id=project_id,
name=payload.get("name", ""),
is_done_stage=bool(payload.get("is_done_stage", False)),
created_by=user["email"],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@@ -788,13 +790,20 @@ async def api_add_stage(
async def api_delete_stage(
request: Request, project_id: int, stage_id: int
) -> JSONResponse:
"""세션(단계) 삭제 — 만든 사람만(슈퍼관리자 예외). 생성자 정보가 없는 과거
데이터는 관리자에게 허용(잠김 방지) — `_can_delete` 규칙 그대로 재사용."""
user = _require_user(request)
st = _db_or_503(request)
_require_manage(request, st, user, project_id)
stage = st.get_stage(stage_id=stage_id)
if stage is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.")
if not _can_delete(user, stage.get("created_by")):
raise HTTPException(status_code=403, detail=_DELETE_DENIED)
try:
st.delete_stage(stage_id=stage_id)
except KeyError:
raise HTTPException(status_code=404, detail="단계를 찾을 수 없습니다.")
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.")
return JSONResponse({"ok": True})
@@ -834,7 +843,7 @@ async def api_create_task(
project_id=project_id,
title=payload.get("title", ""),
stage_id=payload.get("stage_id"),
description=payload.get("description", ""),
description=store.sanitize_description_html(payload.get("description", "")),
assignee_email=payload.get("assignee_email", ""),
assignee_name=payload.get("assignee_name", ""),
priority=payload.get("priority", "normal"),
@@ -843,9 +852,12 @@ async def api_create_task(
start_time=payload.get("start_time"),
due_time=payload.get("due_time"),
created_by=user["email"],
parent_task_id=payload.get("parent_task_id"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
# 생성과 동시에 담당자 지정 시 관리자 메일 + 담당자 인앱 알림
if task.get("assignee_email"):
_notify(
@@ -886,6 +898,8 @@ async def api_update_task(
if existing is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, existing["project_id"])
if "description" in payload:
payload["description"] = store.sanitize_description_html(payload["description"])
try:
task = st.update_task(task_id=task_id, fields=payload, actor=user["email"])
except ValueError as exc:
@@ -952,6 +966,65 @@ async def api_delete_task(request: Request, task_id: int) -> JSONResponse:
return JSONResponse({"ok": True})
@router.get("/api/tasks/{task_id}")
async def api_get_task(request: Request, task_id: int) -> JSONResponse:
"""업무 상세 — 하위 업무·연결된 프로젝트 포함. 업무 편집 팝업을 열 때 쓴다."""
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
task["subtasks"] = st.list_tasks(parent_task_id=task_id)
task["links"] = st.list_task_links(task_id=task_id)
return JSONResponse({"task": task})
# ────────────────────────────────────────────────────────────
# JSON API — 업무 멀티호밍 (다른 프로젝트/세션에 연결)
# ────────────────────────────────────────────────────────────
@router.post("/api/tasks/{task_id}/links")
async def api_add_task_link(
request: Request, task_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
"""업무를 다른 프로젝트(의 한 세션)에도 연결한다(아사나의 멀티호밍).
원래 소속(프로젝트)과 연결할 대상 프로젝트 양쪽에 대한 관리 권한이 필요하다.
"""
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
try:
target_project_id = int(payload.get("project_id"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="project_id 가 필요합니다.")
if target_project_id == task["project_id"]:
raise HTTPException(status_code=400, detail="이미 이 업무의 기본 프로젝트입니다.")
_require_manage(request, st, user, target_project_id)
stage_id = payload.get("stage_id")
link = st.add_task_link(
task_id=task_id, project_id=target_project_id,
stage_id=int(stage_id) if stage_id else None, created_by=user["email"],
)
return JSONResponse({"link": link}, status_code=201)
@router.delete("/api/tasks/{task_id}/links/{project_id}")
async def api_remove_task_link(request: Request, task_id: int, project_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
_require_manage(request, st, user, project_id)
st.remove_task_link(task_id=task_id, project_id=project_id)
return JSONResponse({"ok": True})
@router.get("/api/projects/{project_id}/activity")
async def api_activity(request: Request, project_id: int) -> JSONResponse:
_require_user(request)