feat(project): 댓글·첨부·단계편집·알림센터 + 라이브러리 self-host

아사나식 기능 4종 추가:
- 댓글: 업무 모달에서 등록/삭제, 관련자 인앱 알림(task_comments)
- 첨부: 업로드(20MB)/다운로드/삭제, 파일은 DATA_DIR/project/<task_id>/,
  DB엔 메타만(task_attachments)
- 단계 편집: 보드 칸반에서 이름변경/완료토글/순서이동/삭제/추가
- 알림센터(인앱): 배정·완료·댓글 시 수신자별 알림, 벨 미읽음 배지,
  /project/inbox 페이지, 읽음/모두읽음(project_notifications)

라이브러리 self-host: vis-timeline + Material Symbols 를 static/vendor/ 로
내려받아 CDN 의존 제거.

DB: scripts/sql/project_db_002_attachments_notifications.sql (서버서 적용 필요).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:32:00 +09:00
parent ef7f76821d
commit 776f655db4
13 changed files with 1002 additions and 28 deletions
+190 -1
View File
@@ -494,6 +494,195 @@ class ProjectStore:
store.norm_str(detail)),
)
# ════════════════════════════════════════════════════════════
# 댓글 (task_comments)
# ════════════════════════════════════════════════════════════
def list_comments(self, *, task_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM task_comments WHERE task_id = %s "
"ORDER BY created_at ASC, id ASC",
(task_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_comment(
self, *, task_id: int, author_email: str, author_name: str, body: str
) -> dict[str, Any]:
text = store.norm_str(body)
if not text:
raise ValueError("댓글 내용이 비었습니다.")
with self._pool.connection() as conn:
row = conn.execute(
"INSERT INTO task_comments (task_id, author_email, author_name, body) "
"VALUES (%s,%s,%s,%s) RETURNING *",
(task_id, store.norm_str(author_email, lower=True),
store.norm_str(author_name), text),
).fetchone()
return self._serialize(row)
def delete_comment(self, *, comment_id: int, requester: str, is_admin: bool) -> None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT author_email FROM task_comments WHERE id = %s", (comment_id,)
).fetchone()
if not row:
raise KeyError(comment_id)
if not is_admin and row["author_email"] != store.norm_str(requester, lower=True):
raise PermissionError("본인 댓글만 삭제할 수 있습니다.")
conn.execute("DELETE FROM task_comments WHERE id = %s", (comment_id,))
# ════════════════════════════════════════════════════════════
# 첨부 (task_attachments) — 메타만 저장. 파일은 라우터가 디스크 처리.
# ════════════════════════════════════════════════════════════
def list_attachments(self, *, task_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM task_attachments WHERE task_id = %s "
"ORDER BY created_at DESC, id DESC",
(task_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_attachment(
self, *, task_id: int, filename: str, stored_name: str,
content_type: str, size_bytes: int, uploaded_by: str,
) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"INSERT INTO task_attachments "
"(task_id, filename, stored_name, content_type, size_bytes, uploaded_by) "
"VALUES (%s,%s,%s,%s,%s,%s) RETURNING *",
(task_id, store.norm_str(filename), stored_name,
store.norm_str(content_type), int(size_bytes),
store.norm_str(uploaded_by, lower=True)),
).fetchone()
return self._serialize(row)
def get_attachment(self, *, attachment_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM task_attachments WHERE id = %s", (attachment_id,)
).fetchone()
return self._serialize(row) if row else None
def delete_attachment(self, *, attachment_id: int) -> dict[str, Any]:
"""삭제 후 행(파일 경로 정리용 stored_name 포함)을 반환."""
with self._pool.connection() as conn:
row = conn.execute(
"DELETE FROM task_attachments WHERE id = %s RETURNING *", (attachment_id,)
).fetchone()
if not row:
raise KeyError(attachment_id)
return self._serialize(row)
# ════════════════════════════════════════════════════════════
# 단계 순서/이름 편집
# ════════════════════════════════════════════════════════════
def update_stage(
self, *, stage_id: int, name: str | None = None,
is_done_stage: bool | None = None,
) -> dict[str, Any]:
sets: list[str] = []
params: list[Any] = []
if name is not None:
nm = store.norm_str(name)
if not nm:
raise ValueError("단계 이름은 필수입니다.")
sets.append("name = %s")
params.append(nm)
if is_done_stage is not None:
sets.append("is_done_stage = %s")
params.append(bool(is_done_stage))
if not sets:
raise ValueError("변경할 내용이 없습니다.")
params.append(stage_id)
with self._pool.connection() as conn:
row = conn.execute(
f"UPDATE project_stages SET {', '.join(sets)} WHERE id = %s RETURNING *",
params,
).fetchone()
if not row:
raise KeyError(stage_id)
return self._serialize(row)
def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None:
"""주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신."""
with self._pool.connection() as conn:
with conn.transaction():
for i, sid in enumerate(ordered_ids):
conn.execute(
"UPDATE project_stages SET sort_order = %s "
"WHERE id = %s AND project_id = %s",
(i, int(sid), project_id),
)
# ════════════════════════════════════════════════════════════
# 알림센터 (project_notifications)
# ════════════════════════════════════════════════════════════
def add_notification(
self, *, recipient_email: str, actor_email: str = "",
project_id: int | None = None, task_id: int | None = None,
type: str = "", title: str = "", body: str = "",
) -> None:
rcpt = store.norm_str(recipient_email, lower=True)
if not rcpt:
return
# 본인이 본인에게 보내는 알림은 생략(아사나도 자기 행동은 알림 안 함)
if rcpt == store.norm_str(actor_email, lower=True):
return
with self._pool.connection() as conn:
conn.execute(
"INSERT INTO project_notifications "
"(recipient_email, actor_email, project_id, task_id, type, title, body) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(rcpt, store.norm_str(actor_email, lower=True), project_id, task_id,
type, store.norm_str(title), store.norm_str(body)),
)
def list_notifications(
self, *, recipient_email: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
rcpt = store.norm_str(recipient_email, lower=True)
clause = "WHERE recipient_email = %s"
params: list[Any] = [rcpt]
if unread_only:
clause += " AND is_read = FALSE"
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM project_notifications {clause} "
"ORDER BY created_at DESC, id DESC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def count_unread(self, *, recipient_email: str) -> int:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM project_notifications "
"WHERE recipient_email = %s AND is_read = FALSE",
(store.norm_str(recipient_email, lower=True),),
).fetchone()
return int(row["n"] or 0)
def mark_read(self, *, notification_id: int, recipient_email: str) -> None:
with self._pool.connection() as conn:
conn.execute(
"UPDATE project_notifications SET is_read = TRUE "
"WHERE id = %s AND recipient_email = %s",
(notification_id, store.norm_str(recipient_email, lower=True)),
)
def mark_all_read(self, *, recipient_email: str) -> int:
with self._pool.connection() as conn:
cur = conn.execute(
"UPDATE project_notifications SET is_read = TRUE "
"WHERE recipient_email = %s AND is_read = FALSE",
(store.norm_str(recipient_email, lower=True),),
)
return cur.rowcount or 0
# ════════════════════════════════════════════════════════════
# 직렬화
# ════════════════════════════════════════════════════════════
@@ -513,7 +702,7 @@ class ProjectStore:
out[k] = int(out[k])
except (TypeError, ValueError):
pass
for k in ("is_done_stage",):
for k in ("is_done_stage", "is_read"):
if k in out and out[k] is not None:
out[k] = bool(out[k])
return out