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:
+190
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user