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
+172 -22
View File
@@ -125,9 +125,9 @@ class ProjectStore:
for i, (stage_name, is_done) in enumerate(store.DEFAULT_STAGES):
conn.execute(
"INSERT INTO project_stages "
"(project_id, name, sort_order, is_done_stage) "
"VALUES (%s,%s,%s,%s)",
(pid, stage_name, i, is_done),
"(project_id, name, sort_order, is_done_stage, created_by) "
"VALUES (%s,%s,%s,%s,%s)",
(pid, stage_name, i, is_done, store.norm_str(created_by, lower=True)),
)
self._log(conn, project_id=pid, actor=created_by,
action=store.ACTION_CREATED, detail=nm)
@@ -238,11 +238,11 @@ class ProjectStore:
return [self._serialize(r) for r in rows]
def add_stage(
self, *, project_id: int, name: str, is_done_stage: bool = False
self, *, project_id: int, name: str, is_done_stage: bool = False, created_by: str = ""
) -> dict[str, Any]:
nm = store.norm_str(name)
if not nm:
raise ValueError("단계 이름은 필수입니다.")
raise ValueError("세션 이름은 필수입니다.")
with self._pool.connection() as conn:
nxt = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS n "
@@ -250,12 +250,20 @@ class ProjectStore:
(project_id,),
).fetchone()
row = conn.execute(
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage) "
"VALUES (%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage)),
"INSERT INTO project_stages (project_id, name, sort_order, is_done_stage, created_by) "
"VALUES (%s,%s,%s,%s,%s) RETURNING *",
(project_id, nm, int(nxt["n"]), bool(is_done_stage),
store.norm_str(created_by, lower=True)),
).fetchone()
return self._serialize(row)
def get_stage(self, *, stage_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM project_stages WHERE id = %s", (stage_id,)
).fetchone()
return self._serialize(row) if row else None
def delete_stage(self, *, stage_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM project_stages WHERE id = %s", (stage_id,))
@@ -265,14 +273,47 @@ class ProjectStore:
# ════════════════════════════════════════════════════════════
# 업무 (tasks)
# ════════════════════════════════════════════════════════════
# 업무 목록에 함께 붙이는 하위업무 집계(댓글수와 같은 방식). 여러 쿼리에서
# 재사용하므로 상수로 뽑아둔다.
_SUBTASK_COUNTS_SQL = (
"(SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id) AS subtask_total, "
"(SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id "
" AND st.completed_at IS NOT NULL) AS subtask_done"
)
def list_tasks(
self,
*,
project_id: int | None = None,
assignee_email: str | None = None,
include_subprojects: bool = False,
parent_task_id: int | None = None,
top_level_only: bool = True,
include_links: bool = True,
limit: int = 1000,
) -> list[dict[str, Any]]:
"""업무 목록.
기본은 최상위 업무만(`parent_task_id IS NULL`) — 하위 업무는 부모 안에서만
보인다(아사나와 동일, 보드/캘린더/리스트 카드로 따로 뜨지 않는다).
`parent_task_id` 를 주면 그 부모의 하위 업무만 받는다.
`project_id` 하나만 지정하고(`include_subprojects=False`, 최상위 조회일
때) `include_links=True`(기본값)이면, 그 프로젝트에 멀티호밍
(`task_project_links`)으로 연결된 다른 프로젝트의 업무도 함께 반환한다.
프로젝트 상세 화면이 '이 프로젝트만' 보여줄 때 쓰는 경로가 이 조합이다.
다른 조합(홈 전체보기·내 업무 등, `project_id=None`)은 연결 업무를 섞지
않는다 — 같은 업무가 여러 곳에서 중복으로 집계되는 것을 막기 위함이다.
"""
link_mode = (
include_links and project_id is not None
and not include_subprojects and parent_task_id is None
)
if link_mode:
return self._list_tasks_for_project_with_links(
project_id=project_id, assignee_email=assignee_email, limit=limit,
)
clauses: list[str] = []
params: list[Any] = []
if project_id is not None:
@@ -292,13 +333,19 @@ class ProjectStore:
if assignee_email:
clauses.append("t.assignee_email = %s")
params.append(store.norm_str(assignee_email, lower=True))
if parent_task_id is not None:
clauses.append("t.parent_task_id = %s")
params.append(parent_task_id)
elif top_level_only:
clauses.append("t.parent_task_id IS NULL")
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT t.*, p.name AS project_name, p.color AS project_color, "
f" s.name AS stage_name, s.is_done_stage, "
f" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count "
f" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count, "
f" {self._SUBTASK_COUNTS_SQL} "
f"FROM tasks t "
f"JOIN projects p ON p.id = t.project_id "
f"LEFT JOIN project_stages s ON s.id = t.stage_id "
@@ -308,15 +355,61 @@ class ProjectStore:
).fetchall()
return [self._serialize(r) for r in rows]
def _list_tasks_for_project_with_links(
self, *, project_id: int, assignee_email: str | None, limit: int,
) -> list[dict[str, Any]]:
"""`list_tasks` 의 "이 프로젝트 + 여기 연결된 업무" 경로.
`t.*` 뒤에 같은 이름(`stage_id`)을 다시 셀렉트해 덮어쓴다 — psycopg 의
dict_row 는 중복 컬럼명이 있으면 마지막 값으로 덮어쓰므로, "이 프로젝트
맥락에서의 세션"(연결이면 link.stage_id, 기본홈이면 t.stage_id)이
최종값이 된다. 원래 소속 프로젝트 배지(`project_name`/`project_color`)는
`t.project_id` 기준 그대로 둬 "다른 프로젝트에서 온 업무"임을 알 수 있게
한다.
"""
clauses = [
"t.parent_task_id IS NULL",
"(t.project_id = %(pid)s OR link.project_id = %(pid)s)",
]
params: dict[str, Any] = {"pid": project_id, "limit": int(limit)}
if assignee_email:
clauses.append("t.assignee_email = %(assignee)s")
params["assignee"] = store.norm_str(assignee_email, lower=True)
where = "WHERE " + " AND ".join(clauses)
with self._pool.connection() as conn:
rows = conn.execute(
f"""
SELECT t.*, p.name AS project_name, p.color AS project_color,
(SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count,
{self._SUBTASK_COUNTS_SQL},
(link.id IS NOT NULL) AS is_linked,
COALESCE(es.name, s.name) AS stage_name,
COALESCE(es.is_done_stage, s.is_done_stage) AS is_done_stage,
COALESCE(link.stage_id, t.stage_id) AS stage_id
FROM tasks t
JOIN projects p ON p.id = t.project_id
LEFT JOIN project_stages s ON s.id = t.stage_id
LEFT JOIN task_project_links link
ON link.task_id = t.id AND link.project_id = %(pid)s
AND t.project_id <> %(pid)s
LEFT JOIN project_stages es ON es.id = link.stage_id
{where}
ORDER BY t.sort_order ASC, t.id ASC LIMIT %(limit)s
""",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def get_task(self, *, task_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT t.*, p.name AS project_name, p.color AS project_color, "
" s.name AS stage_name, s.is_done_stage, "
" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count "
"FROM tasks t JOIN projects p ON p.id = t.project_id "
"LEFT JOIN project_stages s ON s.id = t.stage_id "
"WHERE t.id = %s",
f"SELECT t.*, p.name AS project_name, p.color AS project_color, "
f" s.name AS stage_name, s.is_done_stage, "
f" (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comment_count, "
f" {self._SUBTASK_COUNTS_SQL} "
f"FROM tasks t JOIN projects p ON p.id = t.project_id "
f"LEFT JOIN project_stages s ON s.id = t.stage_id "
f"WHERE t.id = %s",
(task_id,),
).fetchone()
return self._serialize(row) if row else None
@@ -336,6 +429,7 @@ class ProjectStore:
start_time: str | None = None,
due_time: str | None = None,
created_by: str = "",
parent_task_id: int | None = None,
) -> dict[str, Any]:
ttl = store.validate_task_title(title)
pr = store.validate_priority(priority)
@@ -346,8 +440,18 @@ class ProjectStore:
assignee = store.norm_str(assignee_email, lower=True)
with self._pool.connection() as conn:
with conn.transaction():
# stage 미지정 시 프로젝트의 첫 단계로
if stage_id is None:
if parent_task_id is not None:
# 하위 업무는 부모와 같은 프로젝트에 속하고(담당자 선택지 등이
# 맞아떨어지게), 보드에는 안 보이므로 세션을 갖지 않는다.
parent = conn.execute(
"SELECT project_id FROM tasks WHERE id = %s", (parent_task_id,)
).fetchone()
if not parent:
raise KeyError(f"상위 업무를 찾을 수 없습니다: {parent_task_id}")
project_id = parent["project_id"]
stage_id = None
elif stage_id is None:
# stage 미지정 시 프로젝트의 첫 세션으로
st = conn.execute(
"SELECT id FROM project_stages WHERE project_id = %s "
"ORDER BY sort_order ASC, id ASC LIMIT 1",
@@ -362,14 +466,14 @@ class ProjectStore:
row = conn.execute(
"""
INSERT INTO tasks
(project_id, stage_id, title, description, assignee_email,
assignee_name, priority, start_date, due_date,
(project_id, stage_id, parent_task_id, title, description,
assignee_email, assignee_name, priority, start_date, due_date,
start_time, due_time, sort_order, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
project_id, stage_id, ttl, store.norm_str(description),
project_id, stage_id, parent_task_id, ttl, store.norm_str(description),
assignee, store.norm_str(assignee_name), pr, sd, dd,
sti, dti, int(nxt["n"]), store.norm_str(created_by, lower=True),
),
@@ -482,6 +586,51 @@ class ProjectStore:
).fetchone()
return bool(row and row["is_done_stage"])
# ════════════════════════════════════════════════════════════
# 멀티호밍 (task_project_links) — 업무 1개가 여러 프로젝트/세션에 동시 소속.
# 원래 소속(tasks.project_id/stage_id)은 여기서 건드리지 않는다("기본 홈"은
# 업무 삭제로만 없어진다). 같은 프로젝트에 중복 연결하면 세션만 갱신한다.
# ════════════════════════════════════════════════════════════
def add_task_link(
self, *, task_id: int, project_id: int, stage_id: int | None = None,
created_by: str = "",
) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO task_project_links (task_id, project_id, stage_id, created_by)
VALUES (%s,%s,%s,%s)
ON CONFLICT (task_id, project_id) DO UPDATE
SET stage_id = EXCLUDED.stage_id
RETURNING *
""",
(task_id, project_id, stage_id, store.norm_str(created_by, lower=True)),
).fetchone()
return self._serialize(row)
def remove_task_link(self, *, task_id: int, project_id: int) -> None:
with self._pool.connection() as conn:
conn.execute(
"DELETE FROM task_project_links WHERE task_id = %s AND project_id = %s",
(task_id, project_id),
)
def list_task_links(self, *, task_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT l.*, p.name AS project_name, p.color AS project_color,
s.name AS stage_name
FROM task_project_links l
JOIN projects p ON p.id = l.project_id
LEFT JOIN project_stages s ON s.id = l.stage_id
WHERE l.task_id = %s
ORDER BY l.created_at ASC
""",
(task_id,),
).fetchall()
return [self._serialize(r) for r in rows]
# ════════════════════════════════════════════════════════════
# 활동 이력
# ════════════════════════════════════════════════════════════
@@ -748,7 +897,8 @@ class ProjectStore:
out[k] = v.strftime("%H:%M")
elif isinstance(v, date):
out[k] = v.isoformat()
for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order"):
for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order",
"parent_task_id"):
if k in out and out[k] is not None:
try:
out[k] = int(out[k])