feat(study): study_topics 학습 워크스페이스 컨테이너 도입

필기 세션과 자료(library document)를 한 학습 주제(예: 가스기사) 아래로 묶는
1차 컨테이너. 향후 단어장/오디오/문제세트 등 학습 자산이 같은 묶음으로 들어올 수
있도록 응답 구조(sections + stats)를 dict 기반으로 설계.

데이터 모델 (migrations 179~185):
- study_topics: user_id × name partial unique (active 행만), soft delete
- study_sessions.study_topic_id: 1:N nullable FK (ON DELETE SET NULL)
- study_topic_documents: 자료 N:M 매핑 (user_id 반정규화로 권한 격리)

설계 원칙:
- documents.category(자료실 UI 축)와 직교 → 자료실 facet/카테고리 미터치
- StudySession.certification/subject/topic 보존 (세부 메타로 계속 사용)
- study_type은 느슨한 분류 (강한 enum 미사용, jlpt_n3 등 확장 여지)
- polymorphic study_topic_items 영구 금지 → 자산 타입별 조인 테이블 추가 방식

API: /api/study-topics CRUD + /by-document/{id} + 자료/세션 매핑 엔드포인트.
프론트: /study/topics 목록 + /study/topics/[id] 통합 뷰(필기·자료 두 트랙) +
        write 폼에 워크스페이스 드롭다운 + study hub 진입 카드.

후속 PR-2 어학 UX, PR-3 오디오 자산, PR-4 AI retrieval scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 07:06:37 +09:00
parent f005da2e83
commit 63ed4d81e5
16 changed files with 1498 additions and 2 deletions
+27
View File
@@ -146,6 +146,8 @@ class StudySessionCreate(BaseModel):
canvas_width: int | None = None
canvas_height: int | None = None
strokes_json: dict[str, Any] | None = None
# 학습 워크스페이스 묶음. 미지정 시 미분류.
study_topic_id: int | None = None
class StudySessionUpdate(BaseModel):
@@ -171,6 +173,8 @@ class StudySessionUpdate(BaseModel):
user_corrected_text: str | None = None
review_state: str | None = None
next_review_at: datetime | None = None
# 주제 재할당 (NULL 로 분리도 가능)
study_topic_id: int | None = None
class StudySessionResponse(BaseModel):
@@ -202,6 +206,7 @@ class StudySessionResponse(BaseModel):
last_quiz_at: datetime | None
correct_count: int
incorrect_count: int
study_topic_id: int | None = None
assets: list[StudySessionAssetResponse]
created_at: datetime
updated_at: datetime
@@ -244,6 +249,7 @@ def _to_session_response(sess: StudySession) -> StudySessionResponse:
last_quiz_at=sess.last_quiz_at,
correct_count=sess.correct_count,
incorrect_count=sess.incorrect_count,
study_topic_id=sess.study_topic_id,
assets=[
StudySessionAssetResponse.model_validate(a) for a in (sess.assets or [])
],
@@ -284,6 +290,14 @@ async def create_study_session(
"""
_validate_create_payload(body)
# study_topic_id 가 주어지면 소유 검증 (다른 사용자의 주제로 매핑 차단)
if body.study_topic_id is not None:
from models.study_topic import StudyTopic as _Topic
topic = await session.get(_Topic, body.study_topic_id)
if topic is None or topic.user_id != user.id or topic.deleted_at is not None:
raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다")
sess = StudySession(
user_id=user.id,
study_type=body.study_type,
@@ -302,6 +316,7 @@ async def create_study_session(
canvas_width=body.canvas_width,
canvas_height=body.canvas_height,
strokes_json=body.strokes_json,
study_topic_id=body.study_topic_id,
)
session.add(sess)
await session.flush()
@@ -326,6 +341,7 @@ async def list_study_sessions(
asset_type: str | None = Query(None, description="이 asset_type 보유 세션만"),
mode: str | None = Query(None),
due_before: datetime | None = Query(None, description="next_review_at <= due_before"),
study_topic_id: int | None = Query(None, description="학습 워크스페이스(주제) id"),
order: str = Query("created_at"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -361,6 +377,8 @@ async def list_study_sessions(
base = base.where(StudySession.mode == mode)
if due_before is not None:
base = base.where(StudySession.next_review_at <= due_before)
if study_topic_id is not None:
base = base.where(StudySession.study_topic_id == study_topic_id)
# assets join filter — EXISTS 서브쿼리
if document_id is not None or asset_type is not None:
@@ -625,6 +643,14 @@ async def update_study_session(
raise HTTPException(status_code=422, detail="review_state 값이 올바르지 않습니다")
sess.review_state = body.review_state
# study_topic_id 변경 시 소유 검증
if "study_topic_id" in fields_set and body.study_topic_id is not None:
from models.study_topic import StudyTopic as _Topic
topic = await session.get(_Topic, body.study_topic_id)
if topic is None or topic.user_id != user.id or topic.deleted_at is not None:
raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다")
# 단순 매핑 필드 (검증 불필요)
SIMPLE_FIELDS = {
"certification", "language_code", "learning_level", "subject", "topic",
@@ -632,6 +658,7 @@ async def update_study_session(
"target_count", "repetition_count",
"canvas_width", "canvas_height", "strokes_json",
"ocr_text", "user_corrected_text", "next_review_at",
"study_topic_id",
}
for fname in SIMPLE_FIELDS & fields_set:
setattr(sess, fname, getattr(body, fname))
+629
View File
@@ -0,0 +1,629 @@
"""학습 워크스페이스(study_topic) API — 필기 세션 + 자료(library document) 묶음 컨테이너
핵심 개념:
- study_topic 은 단순 폴더/태그가 아니라 학습 자산 1차 컨테이너 (학습 워크스페이스).
- 향후 단어장/오디오/문제 세트 같은 자산이 같은 컨테이너 아래로 들어올 수 있도록
응답 구조를 sections + stats 형태로 설계 (현재는 sessions / documents 두 키만).
- documents.category(자료실 UI 축) 와 직교한 별도 분류 축. 자료실 facet/카테고리는 미터치.
- StudySession.certification/subject/topic 은 보존 — 본 컨테이너 와 직교한 세부 메타.
설계 제약:
- study_type 은 강한 enum 미사용. VALID_STUDY_TYPES 는 단순 권장값 (DB/Pydantic 강제 안 함).
- soft delete (deleted_at). active 행끼리만 (user_id, name) partial unique.
- 권한: 모든 쿼리에 user_id 강제. 다른 사용자 자료를 묶지 못함.
- 문서 매핑은 N:M (study_topic_documents). 세션 매핑은 1:N (study_sessions.study_topic_id).
- polymorphic 단일 study_topic_items 테이블은 만들지 않는다 (영구 금지).
"""
import logging
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.document import Document
from models.study_session import StudySession
from models.study_topic import StudyTopic, StudyTopicDocument
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
# ─── 권장값 (강제 enum 아님, UI 안내용) ───
RECOMMENDED_STUDY_TYPES: set[str] = {
"certification", "language", "school", "work", "general",
}
# ─── Pydantic 스키마 ───
class StudyTopicCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str | None = None
color: str | None = Field(default=None, max_length=20)
study_type: str | None = Field(default=None, max_length=40)
sort_order: int = 0
class StudyTopicUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = None
color: str | None = Field(default=None, max_length=20)
study_type: str | None = Field(default=None, max_length=40)
sort_order: int | None = None
class StudyTopicResponse(BaseModel):
"""주제 목록 응답 — 집계 카운트 포함."""
id: int
name: str
description: str | None
color: str | None
study_type: str | None
sort_order: int
session_count: int = 0
document_count: int = 0
created_at: datetime
updated_at: datetime
class StudyTopicListResponse(BaseModel):
items: list[StudyTopicResponse]
total: int
class StudyTopicSessionSummary(BaseModel):
"""상세 뷰의 세션 카드 페이로드 — 통합 뷰 렌더용 최소 필드."""
id: int
study_type: str
certification: str | None
language_code: str | None
learning_level: str | None
subject: str | None
topic: str | None
mode: str
repetition_count: int
review_state: str | None
created_at: datetime
updated_at: datetime
class StudyTopicDocumentSummary(BaseModel):
"""상세 뷰의 자료 카드 페이로드."""
id: int
title: str | None
file_format: str
file_type: str
category: str | None
ai_domain: str | None
importance: str | None
sort_order: int
linked_at: datetime
class StudyTopicSections(BaseModel):
"""확장 친화 dict — 향후 audio_assets / vocab_decks / question_sets 키 추가 가능."""
sessions: list[StudyTopicSessionSummary]
documents: list[StudyTopicDocumentSummary]
class StudyTopicStats(BaseModel):
"""자산 카운트 — 0 으로 미리 노출해서 후속 PR 에서 필드만 채움."""
session_count: int
document_count: int
audio_count: int = 0
vocab_count: int = 0
question_set_count: int = 0
class StudyTopicMeta(BaseModel):
id: int
name: str
description: str | None
color: str | None
study_type: str | None
sort_order: int
created_at: datetime
updated_at: datetime
class StudyTopicDetailResponse(BaseModel):
topic: StudyTopicMeta
sections: StudyTopicSections
stats: StudyTopicStats
class StudyTopicDocumentLinkRequest(BaseModel):
document_ids: list[int] = Field(min_length=1, max_length=100)
class StudyTopicDocumentLinkResponse(BaseModel):
linked: list[int]
skipped_existing: list[int]
skipped_not_found: list[int]
# ─── Helpers ───
def _verify_topic_ownership(topic: StudyTopic | None, user: User) -> StudyTopic:
"""소유자 검증 + soft-deleted 행 차단. mismatch 도 404 (정보 누설 방지)."""
if topic is None or topic.user_id != user.id or topic.deleted_at is not None:
raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다")
return topic
def _meta_from_topic(t: StudyTopic) -> StudyTopicMeta:
return StudyTopicMeta(
id=t.id,
name=t.name,
description=t.description,
color=t.color,
study_type=t.study_type,
sort_order=t.sort_order,
created_at=t.created_at,
updated_at=t.updated_at,
)
# ─── 엔드포인트 ───
@router.get("/", response_model=StudyTopicListResponse)
async def list_study_topics(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""사용자의 active 주제 목록. 세션 수·자료 수 집계 포함."""
# active 주제 + 세션 수 (LEFT JOIN study_sessions)
sess_count_sub = (
select(
StudySession.study_topic_id.label("topic_id"),
func.count().label("c"),
)
.where(StudySession.user_id == user.id)
.where(StudySession.study_topic_id.is_not(None))
.group_by(StudySession.study_topic_id)
.subquery()
)
doc_count_sub = (
select(
StudyTopicDocument.study_topic_id.label("topic_id"),
func.count().label("c"),
)
.where(StudyTopicDocument.user_id == user.id)
.group_by(StudyTopicDocument.study_topic_id)
.subquery()
)
base = (
select(
StudyTopic,
func.coalesce(sess_count_sub.c.c, 0).label("session_count"),
func.coalesce(doc_count_sub.c.c, 0).label("document_count"),
)
.outerjoin(sess_count_sub, sess_count_sub.c.topic_id == StudyTopic.id)
.outerjoin(doc_count_sub, doc_count_sub.c.topic_id == StudyTopic.id)
.where(StudyTopic.user_id == user.id, StudyTopic.deleted_at.is_(None))
.order_by(StudyTopic.sort_order.asc(), StudyTopic.id.desc())
)
total_query = select(func.count()).select_from(
select(StudyTopic.id)
.where(StudyTopic.user_id == user.id, StudyTopic.deleted_at.is_(None))
.subquery()
)
total = (await session.execute(total_query)).scalar() or 0
rows = (await session.execute(base.offset(offset).limit(limit))).all()
items = [
StudyTopicResponse(
id=t.id,
name=t.name,
description=t.description,
color=t.color,
study_type=t.study_type,
sort_order=t.sort_order,
session_count=int(sc),
document_count=int(dc),
created_at=t.created_at,
updated_at=t.updated_at,
)
for t, sc, dc in rows
]
return StudyTopicListResponse(items=items, total=total)
@router.get("/by-document/{document_id}", response_model=list[StudyTopicMeta])
async def list_topics_for_document(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""이 자료가 매핑된 active 주제 목록.
/study/sources 카드의 "이 자료가 속한 주제" 배지·컨텍스트 메뉴용.
"""
rows = (
await session.execute(
select(StudyTopic)
.join(StudyTopicDocument, StudyTopicDocument.study_topic_id == StudyTopic.id)
.where(
StudyTopicDocument.document_id == document_id,
StudyTopicDocument.user_id == user.id,
StudyTopic.deleted_at.is_(None),
)
.order_by(StudyTopic.sort_order.asc(), StudyTopic.id.desc())
)
).scalars().all()
return [_meta_from_topic(t) for t in rows]
@router.post("/", response_model=StudyTopicResponse, status_code=201)
async def create_study_topic(
body: StudyTopicCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""주제 생성. 같은 이름의 active 주제가 이미 있으면 409.
soft-deleted 동명 주제는 partial unique index 에서 빠지므로 신규 생성 가능.
"""
topic = StudyTopic(
user_id=user.id,
name=body.name.strip(),
description=body.description,
color=body.color,
study_type=body.study_type,
sort_order=body.sort_order,
)
session.add(topic)
try:
await session.flush()
await session.commit()
except IntegrityError:
await session.rollback()
raise HTTPException(status_code=409, detail="이미 같은 이름의 학습 주제가 있습니다")
return StudyTopicResponse(
id=topic.id,
name=topic.name,
description=topic.description,
color=topic.color,
study_type=topic.study_type,
sort_order=topic.sort_order,
session_count=0,
document_count=0,
created_at=topic.created_at,
updated_at=topic.updated_at,
)
@router.get("/{topic_id}", response_model=StudyTopicDetailResponse)
async def get_study_topic(
topic_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""통합 뷰: 주제 메타 + 세션 목록 + 자료 목록 + stats.
응답 형태는 후속 PR(오디오/단어장/문제세트 추가) 에서 sections / stats 키만 늘리면 되도록 dict 구조.
"""
topic = await session.get(StudyTopic, topic_id)
topic = _verify_topic_ownership(topic, user)
# 세션 목록 — 최근순
sess_rows = (
await session.execute(
select(StudySession)
.where(
StudySession.user_id == user.id,
StudySession.study_topic_id == topic_id,
)
.order_by(StudySession.created_at.desc(), StudySession.id.desc())
)
).scalars().all()
sessions_payload = [
StudyTopicSessionSummary(
id=s.id,
study_type=s.study_type,
certification=s.certification,
language_code=s.language_code,
learning_level=s.learning_level,
subject=s.subject,
topic=s.topic,
mode=s.mode,
repetition_count=s.repetition_count,
review_state=s.review_state,
created_at=s.created_at,
updated_at=s.updated_at,
)
for s in sess_rows
]
# 자료 목록 — 매핑 sort_order → created_at desc
doc_rows = (
await session.execute(
select(Document, StudyTopicDocument)
.join(
StudyTopicDocument,
and_(
StudyTopicDocument.document_id == Document.id,
StudyTopicDocument.study_topic_id == topic_id,
StudyTopicDocument.user_id == user.id,
),
)
.where(Document.deleted_at.is_(None))
.order_by(
StudyTopicDocument.sort_order.asc(),
StudyTopicDocument.created_at.desc(),
)
)
).all()
documents_payload = [
StudyTopicDocumentSummary(
id=d.id,
title=d.title,
file_format=d.file_format,
file_type=str(d.file_type) if d.file_type is not None else "immutable",
category=str(d.category) if d.category is not None else None,
ai_domain=d.ai_domain,
importance=d.importance,
sort_order=link.sort_order,
linked_at=link.created_at,
)
for d, link in doc_rows
]
return StudyTopicDetailResponse(
topic=_meta_from_topic(topic),
sections=StudyTopicSections(
sessions=sessions_payload,
documents=documents_payload,
),
stats=StudyTopicStats(
session_count=len(sessions_payload),
document_count=len(documents_payload),
# 후속 PR 에서 채움
audio_count=0,
vocab_count=0,
question_set_count=0,
),
)
@router.patch("/{topic_id}", response_model=StudyTopicResponse)
async def update_study_topic(
topic_id: int,
body: StudyTopicUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
topic = await session.get(StudyTopic, topic_id)
topic = _verify_topic_ownership(topic, user)
fields_set = body.model_fields_set
if "name" in fields_set and body.name is not None:
topic.name = body.name.strip()
for fname in {"description", "color", "study_type", "sort_order"} & fields_set:
setattr(topic, fname, getattr(body, fname))
topic.updated_at = datetime.now(timezone.utc)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise HTTPException(status_code=409, detail="이미 같은 이름의 학습 주제가 있습니다")
# 응답용 카운트 별도 조회
sc = (await session.execute(
select(func.count())
.select_from(StudySession)
.where(StudySession.user_id == user.id, StudySession.study_topic_id == topic.id)
)).scalar() or 0
dc = (await session.execute(
select(func.count())
.select_from(StudyTopicDocument)
.where(StudyTopicDocument.study_topic_id == topic.id)
)).scalar() or 0
return StudyTopicResponse(
id=topic.id,
name=topic.name,
description=topic.description,
color=topic.color,
study_type=topic.study_type,
sort_order=topic.sort_order,
session_count=int(sc),
document_count=int(dc),
created_at=topic.created_at,
updated_at=topic.updated_at,
)
@router.delete("/{topic_id}", status_code=204)
async def delete_study_topic(
topic_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""주제 soft delete. 세션의 study_topic_id 는 SET NULL, 문서 매핑은 cascade 제거.
같은 이름 재생성을 위해 partial unique index 가 deleted_at IS NULL 인 행만 본다.
"""
topic = await session.get(StudyTopic, topic_id)
topic = _verify_topic_ownership(topic, user)
# 세션 FK SET NULL — 명시적으로 (DB ON DELETE SET NULL 은 hard delete 시에만 동작)
await session.execute(
update(StudySession)
.where(
StudySession.user_id == user.id,
StudySession.study_topic_id == topic_id,
)
.values(study_topic_id=None)
)
# 문서 매핑 명시 제거 (soft delete 라 FK CASCADE 안 탐)
await session.execute(
delete(StudyTopicDocument).where(
StudyTopicDocument.study_topic_id == topic_id,
)
)
topic.deleted_at = datetime.now(timezone.utc)
await session.commit()
# ─── 자료(문서) 매핑 ───
@router.post(
"/{topic_id}/documents",
response_model=StudyTopicDocumentLinkResponse,
status_code=201,
)
async def link_documents_to_topic(
topic_id: int,
body: StudyTopicDocumentLinkRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""자료 N개를 주제에 일괄 매핑. 이미 연결된 건 skipped_existing 으로 보고."""
topic = await session.get(StudyTopic, topic_id)
topic = _verify_topic_ownership(topic, user)
requested = list(dict.fromkeys(body.document_ids)) # 중복 제거 + 순서 보존
# 존재 + 미삭제 documents 만 통과 (single-user 시스템: documents.user_id 부재 가능)
valid_rows = (
await session.execute(
select(Document.id).where(
Document.id.in_(requested),
Document.deleted_at.is_(None),
)
)
).scalars().all()
valid_set = set(valid_rows)
not_found = [d for d in requested if d not in valid_set]
# 이미 매핑된 것
existing_rows = (
await session.execute(
select(StudyTopicDocument.document_id).where(
StudyTopicDocument.study_topic_id == topic_id,
StudyTopicDocument.document_id.in_(valid_set),
)
)
).scalars().all()
existing_set = set(existing_rows)
to_link = [d for d in requested if d in valid_set and d not in existing_set]
# 기존 max sort_order 다음부터
max_sort = (
await session.execute(
select(func.coalesce(func.max(StudyTopicDocument.sort_order), -1))
.where(StudyTopicDocument.study_topic_id == topic_id)
)
).scalar() or -1
for i, doc_id in enumerate(to_link, start=1):
session.add(StudyTopicDocument(
study_topic_id=topic_id,
document_id=doc_id,
user_id=user.id,
sort_order=int(max_sort) + i,
))
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise HTTPException(status_code=409, detail="자료 매핑 중 충돌이 발생했습니다")
return StudyTopicDocumentLinkResponse(
linked=to_link,
skipped_existing=sorted(existing_set),
skipped_not_found=not_found,
)
@router.delete("/{topic_id}/documents/{document_id}", status_code=204)
async def unlink_document_from_topic(
topic_id: int,
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
result = await session.execute(
delete(StudyTopicDocument).where(
StudyTopicDocument.study_topic_id == topic_id,
StudyTopicDocument.document_id == document_id,
StudyTopicDocument.user_id == user.id,
)
)
await session.commit()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="매핑을 찾을 수 없습니다")
# ─── 세션 매핑 ───
@router.post("/{topic_id}/sessions/{session_id}", status_code=204)
async def attach_session_to_topic(
topic_id: int,
session_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""기존 세션을 주제에 연결. 다른 주제에 이미 연결돼 있으면 새 주제로 갱신 (1:N)."""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
sess = await session.get(StudySession, session_id)
if sess is None or sess.user_id != user.id:
raise HTTPException(status_code=404, detail="학습 세션을 찾을 수 없습니다")
sess.study_topic_id = topic_id
sess.updated_at = datetime.now(timezone.utc)
await session.commit()
@router.delete("/{topic_id}/sessions/{session_id}", status_code=204)
async def detach_session_from_topic(
topic_id: int,
session_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""세션 분리. 현재 study_topic_id 가 topic_id 가 아니면 404."""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
sess = await session.get(StudySession, session_id)
if sess is None or sess.user_id != user.id:
raise HTTPException(status_code=404, detail="학습 세션을 찾을 수 없습니다")
if sess.study_topic_id != topic_id:
raise HTTPException(status_code=404, detail="해당 주제에 연결된 세션이 아닙니다")
sess.study_topic_id = None
sess.updated_at = datetime.now(timezone.utc)
await session.commit()
+2
View File
@@ -20,6 +20,7 @@ from api.news import router as news_router
from api.search import router as search_router
from api.setup import router as setup_router
from api.study_sessions import router as study_sessions_router
from api.study_topics import router as study_topics_router
from api.video import router as video_router
from core.config import settings
from core.database import async_session, engine, init_db
@@ -115,6 +116,7 @@ app.include_router(digest_router, prefix="/api/digest", tags=["digest"])
app.include_router(audio_router, prefix="/api/audio", tags=["audio"])
app.include_router(video_router, prefix="/api/video", tags=["video"])
app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"])
app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"])
# TODO: Phase 5에서 추가
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
+10
View File
@@ -87,6 +87,11 @@ class StudySession(Base):
correct_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
incorrect_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# 학습 워크스페이스(study_topic) 1:N. NULL 허용 — 미분류 세션이 정상 상태.
study_topic_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
@@ -101,6 +106,11 @@ class StudySession(Base):
order_by="StudySessionAsset.sort_order",
)
# 연관 학습 워크스페이스
study_topic: Mapped["StudyTopic | None"] = relationship(
"StudyTopic", back_populates="sessions", lazy="noload"
)
class StudySessionAsset(Base):
__tablename__ = "study_session_assets"
+80
View File
@@ -0,0 +1,80 @@
"""study_topics / study_topic_documents 테이블 ORM — 학습 워크스페이스 1차 컨테이너
목적: 필기 세션(StudySession) 과 자료(documents) 를 한 학습 주제(예: 가스기사)
아래로 묶는 컨테이너. 향후 단어장/오디오/문제세트 같은 학습 자산이 같은
컨테이너 아래로 들어올 수 있도록 설계.
설계 원칙:
- documents.category(자료실 UI 축) 와 직교한 별도 분류 축. 자료실 facet/카테고리 미터치.
- StudySession.certification/subject/topic 컬럼은 보존, 본 컨테이너 와 직교 세부 메타.
- study_type 은 느슨한 분류. DB/Pydantic 강한 enum 미사용. 권장값: certification /
language / school / work / general (UI 드롭다운에서만 안내).
- soft delete (deleted_at). 동일 user_id+name 의 active 행만 partial unique index 로
중복 방지 — 삭제된 주제명 재생성 가능.
- 자산 다대다 매핑: 본 PR 은 documents 만 (study_topic_documents). 향후 자산 타입별
조인 테이블 추가 (study_topic_audio_assets 등). polymorphic 단일 테이블 금지.
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class StudyTopic(Base):
__tablename__ = "study_topics"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
color: Mapped[str | None] = mapped_column(String(20))
# 느슨한 분류 (certification/language/school/work/general 권장)
study_type: Mapped[str | None] = mapped_column(String(40))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 연관 — 세션 (1:N), 자료 매핑 (N:M)
sessions: Mapped[list["StudySession"]] = relationship( # type: ignore[name-defined] # noqa: F821
"StudySession", back_populates="study_topic", lazy="noload"
)
document_links: Mapped[list["StudyTopicDocument"]] = relationship(
back_populates="topic",
cascade="all, delete-orphan",
order_by="StudyTopicDocument.sort_order",
lazy="noload",
)
class StudyTopicDocument(Base):
__tablename__ = "study_topic_documents"
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), primary_key=True
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
topic: Mapped["StudyTopic"] = relationship(back_populates="document_links")
+13 -1
View File
@@ -1,7 +1,8 @@
<script>
// /study — 학습 hub.
// 자료 학습 (자료실 자료 + 회독 추적) / 필사 세션 (Apple Pencil) / Phase 2~ 퀴즈/SRS.
import { BookOpen, PenLine, GraduationCap } from 'lucide-svelte';
// 학습 워크스페이스(주제) — 필기·자료를 묶어 보는 1차 컨테이너.
import { BookOpen, PenLine, GraduationCap, FolderKanban } from 'lucide-svelte';
</script>
<div class="p-4 md:p-6 max-w-5xl mx-auto">
@@ -12,6 +13,17 @@
<p class="text-sm text-dim mt-1">학습 자료 회독 / 손글씨 필사 세션 / (예정) 퀴즈·복습.</p>
</header>
<a
href="/study/topics"
class="block mb-3 p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
>
<div class="flex items-center gap-2 mb-2">
<FolderKanban size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">주제로 보기</h2>
</div>
<p class="text-xs text-dim">"가스기사" 같은 학습 주제 아래에 필기 세션과 자료를 함께 묶어 본다. 한 주제 안에서 필기·자료를 한눈에.</p>
</a>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href="/study/sources"
@@ -0,0 +1,272 @@
<script>
/**
* /study/topics — 학습 워크스페이스(주제) 목록.
*
* 한 주제(예: 가스기사) 아래에 필기 세션과 자료(library document)를 묶어 본다.
* 1차 컨테이너로 향후 단어장/오디오/문제세트 같은 학습 자산이 같은 묶음으로 들어올 예정.
*
* - 카드 그리드 (세션 수 / 자료 수 표시)
* - 새 주제 생성 인라인 폼
* - 주제 클릭 시 /study/topics/[id] 통합 뷰로 이동
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { Plus, ArrowLeft, FolderKanban, Trash2, Pencil } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
const STUDY_TYPE_OPTIONS = [
{ value: '', label: '미지정' },
{ value: 'certification', label: '자격증 (certification)' },
{ value: 'language', label: '어학 (language)' },
{ value: 'school', label: '학교 (school)' },
{ value: 'work', label: '업무 (work)' },
{ value: 'general', label: '일반 (general)' },
];
let topics = $state([]);
let total = $state(0);
let loading = $state(true);
// 생성 폼
let formOpen = $state(false);
let f_name = $state('');
let f_description = $state('');
let f_color = $state('');
let f_study_type = $state('');
let creating = $state(false);
// 편집 모달
let editing = $state(null); // {id, name, description, color, study_type}
let savingEdit = $state(false);
async function load() {
loading = true;
try {
const res = await api('/study-topics/?limit=200');
topics = res.items;
total = res.total;
} catch (err) {
addToast('error', err.detail || '학습 주제 로딩 실패');
} finally {
loading = false;
}
}
onMount(load);
async function createTopic() {
if (!f_name.trim()) {
addToast('error', '주제 이름을 입력하세요');
return;
}
creating = true;
try {
const body = {
name: f_name.trim(),
description: f_description || null,
color: f_color || null,
study_type: f_study_type || null,
};
const t = await api('/study-topics/', {
method: 'POST',
body: JSON.stringify(body),
});
topics = [t, ...topics];
total += 1;
addToast('success', '주제 생성됨');
formOpen = false;
f_name = '';
f_description = '';
f_color = '';
f_study_type = '';
} catch (err) {
addToast('error', err.detail || '주제 생성 실패');
} finally {
creating = false;
}
}
function startEdit(t) {
editing = {
id: t.id,
name: t.name,
description: t.description ?? '',
color: t.color ?? '',
study_type: t.study_type ?? '',
};
}
async function saveEdit() {
if (!editing) return;
if (!editing.name.trim()) {
addToast('error', '주제 이름을 입력하세요');
return;
}
savingEdit = true;
try {
const updated = await api(`/study-topics/${editing.id}`, {
method: 'PATCH',
body: JSON.stringify({
name: editing.name.trim(),
description: editing.description || null,
color: editing.color || null,
study_type: editing.study_type || null,
}),
});
topics = topics.map((x) => (x.id === updated.id ? updated : x));
addToast('success', '저장됨');
editing = null;
} catch (err) {
addToast('error', err.detail || '저장 실패');
} finally {
savingEdit = false;
}
}
async function removeTopic(t) {
if (!confirm(`"${t.name}" 주제를 삭제합니다.\n연결된 자료 매핑은 해제되고 세션은 미분류 상태로 남습니다.`)) return;
try {
await api(`/study-topics/${t.id}`, { method: 'DELETE' });
topics = topics.filter((x) => x.id !== t.id);
total -= 1;
addToast('success', '삭제됨');
} catch (err) {
addToast('error', err.detail || '삭제 실패');
}
}
function fmtDate(s) {
return new Date(s).toLocaleDateString('ko-KR', { dateStyle: 'medium' });
}
</script>
<svelte:head><title>주제로 보기 — 공부</title></svelte:head>
<div class="p-4 md:p-6 max-w-5xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text flex items-center gap-1">
<ArrowLeft size={14} /> 공부
</a>
<span class="text-faint">/</span>
<span class="text-text font-medium flex items-center gap-1.5">
<FolderKanban size={14} class="text-accent" /> 주제로 보기
</span>
</div>
<header class="mb-4">
<h1 class="text-lg font-semibold text-text">학습 주제</h1>
<p class="text-xs text-dim mt-1">한 주제 아래에 필기 세션과 자료를 묶어 보고 진도 관리. 향후 단어장·오디오·문제세트도 같은 묶음으로 연결됩니다.</p>
</header>
<!-- 새 주제 -->
<Card class="mb-4">
{#snippet children()}
<div class="p-3">
{#if !formOpen}
<Button onclick={() => (formOpen = true)} icon={Plus} size="sm">새 주제</Button>
{:else}
<div class="flex flex-col gap-3">
<div class="text-sm font-semibold text-text">새 학습 주제</div>
<TextInput label="주제 이름" bind:value={f_name} placeholder="예: 가스기사" />
<Textarea label="설명 (선택)" bind:value={f_description} rows={2} placeholder="이 주제의 학습 목표, 시험 일정 등" />
<Select label="분류 (선택)" bind:value={f_study_type} options={STUDY_TYPE_OPTIONS} />
<TextInput label="색상 (선택, hex)" bind:value={f_color} placeholder="#3B82F6" />
<div class="flex gap-2 justify-end">
<Button variant="ghost" onclick={() => (formOpen = false)} disabled={creating}>취소</Button>
<Button onclick={createTopic} loading={creating}>생성</Button>
</div>
</div>
{/if}
</div>
{/snippet}
</Card>
<!-- 목록 -->
{#if loading}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each Array(4) as _}<Skeleton h="h-24" rounded="lg" />{/each}
</div>
{:else if topics.length === 0}
<EmptyState
icon={FolderKanban}
title="학습 주제가 없습니다"
description="위 버튼으로 첫 주제를 만들고 필기·자료를 묶어보세요."
/>
{:else}
<div class="text-xs text-dim mb-2">{total}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each topics as t (t.id)}
<div class="relative p-4 rounded-lg border border-default bg-surface hover:border-accent transition-colors">
<a href={`/study/topics/${t.id}`} class="block">
<div class="flex items-center gap-2 mb-1">
{#if t.color}
<span class="inline-block w-3 h-3 rounded-full shrink-0" style="background-color: {t.color}"></span>
{:else}
<FolderKanban size={14} class="text-dim shrink-0" />
{/if}
<span class="text-sm font-semibold text-text truncate">{t.name}</span>
{#if t.study_type}
<span class="text-[10px] text-dim border border-default rounded px-1.5 py-0.5 shrink-0">{t.study_type}</span>
{/if}
</div>
{#if t.description}
<p class="text-xs text-dim line-clamp-2 mb-2">{t.description}</p>
{/if}
<div class="text-[10px] text-dim flex items-center gap-3">
<span>필기 <span class="text-text">{t.session_count}</span></span>
<span>자료 <span class="text-text">{t.document_count}</span></span>
<span class="ml-auto">{fmtDate(t.created_at)}</span>
</div>
</a>
<div class="absolute top-2 right-2 flex items-center gap-1">
<button
type="button"
onclick={() => startEdit(t)}
class="p-1.5 rounded hover:bg-bg text-dim hover:text-text"
aria-label="편집"
><Pencil size={12} /></button>
<button
type="button"
onclick={() => removeTopic(t)}
class="p-1.5 rounded hover:bg-error/10 text-dim hover:text-error"
aria-label="삭제"
><Trash2 size={12} /></button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- 편집 모달 -->
{#if editing}
<button
type="button"
aria-label="모달 닫기"
onclick={() => (editing = null)}
class="fixed inset-0 z-40 bg-black/40"
></button>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-md bg-surface rounded-lg border border-default shadow-xl p-4 pointer-events-auto">
<div class="text-sm font-semibold text-text mb-3">주제 편집</div>
<div class="flex flex-col gap-3">
<TextInput label="주제 이름" bind:value={editing.name} />
<Textarea label="설명" bind:value={editing.description} rows={2} />
<Select label="분류" bind:value={editing.study_type} options={STUDY_TYPE_OPTIONS} />
<TextInput label="색상 (hex)" bind:value={editing.color} placeholder="#3B82F6" />
</div>
<div class="flex gap-2 justify-end mt-4">
<Button variant="ghost" onclick={() => (editing = null)} disabled={savingEdit}>취소</Button>
<Button onclick={saveEdit} loading={savingEdit}>저장</Button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,361 @@
<script>
/**
* /study/topics/[id] — 학습 워크스페이스 통합 뷰.
*
* 한 주제(예: 가스기사) 아래에 묶인 필기 세션 + 자료 문서를 한 화면에서.
* 응답은 sections + stats dict 구조 — 후속 PR 에서 audio_assets/vocab_decks/question_sets 키 추가 가능.
*/
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, FolderKanban, PenLine, BookOpen, Plus, Trash2, ArrowRight, Languages,
} from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
let topicId = $derived(Number($page.params.id));
let detail = $state(null); // { topic, sections, stats }
let loading = $state(true);
// 자료 추가 모달
let docModalOpen = $state(false);
let docSearch = $state('');
let docCandidates = $state([]); // {id, title, file_format, ...}
let docSearching = $state(false);
let docSelected = $state(new Set()); // Set<number>
let docLinking = $state(false);
async function load() {
loading = true;
try {
detail = await api(`/study-topics/${topicId}`);
} catch (err) {
addToast('error', err.detail || '학습 주제 로딩 실패');
detail = null;
} finally {
loading = false;
}
}
$effect(() => {
void topicId;
if (Number.isFinite(topicId)) {
untrack(() => load());
}
});
async function unlinkDocument(docId) {
if (!confirm('이 자료를 주제에서 분리합니다. 자료 본체는 유지됩니다.')) return;
try {
await api(`/study-topics/${topicId}/documents/${docId}`, { method: 'DELETE' });
addToast('success', '자료 분리됨');
await load();
} catch (err) {
addToast('error', err.detail || '분리 실패');
}
}
async function detachSession(sessId) {
if (!confirm('이 필기 세션을 주제에서 분리합니다. 세션은 미분류로 남고 데이터는 유지됩니다.')) return;
try {
await api(`/study-topics/${topicId}/sessions/${sessId}`, { method: 'DELETE' });
addToast('success', '세션 분리됨');
await load();
} catch (err) {
addToast('error', err.detail || '분리 실패');
}
}
async function searchDocs() {
docSearching = true;
try {
const params = new URLSearchParams();
params.set('page', '1');
params.set('page_size', '30');
if (docSearch.trim()) params.set('q', docSearch.trim());
const res = await api(`/documents/library?${params}`);
// 이미 매핑된 자료는 candidates 에서 빼기 위해 detail.sections.documents 의 id set 비교
const existing = new Set((detail?.sections?.documents ?? []).map((d) => d.id));
docCandidates = (res.items ?? []).filter((d) => !existing.has(d.id));
} catch (err) {
addToast('error', err.detail || '자료 검색 실패');
docCandidates = [];
} finally {
docSearching = false;
}
}
function openDocModal() {
docModalOpen = true;
docSearch = '';
docCandidates = [];
docSelected = new Set();
searchDocs();
}
function toggleDocSelect(id) {
const next = new Set(docSelected);
if (next.has(id)) next.delete(id); else next.add(id);
docSelected = next;
}
async function linkSelectedDocs() {
if (docSelected.size === 0) {
addToast('error', '추가할 자료를 선택하세요');
return;
}
docLinking = true;
try {
const result = await api(`/study-topics/${topicId}/documents`, {
method: 'POST',
body: JSON.stringify({ document_ids: Array.from(docSelected) }),
});
addToast(
'success',
`${result.linked.length}건 연결됨` + (result.skipped_existing.length ? ` · ${result.skipped_existing.length}건 이미 연결` : '')
);
docModalOpen = false;
await load();
} catch (err) {
addToast('error', err.detail || '자료 연결 실패');
} finally {
docLinking = false;
}
}
function fmtDate(s) {
return new Date(s).toLocaleDateString('ko-KR', { dateStyle: 'medium' });
}
</script>
<svelte:head>
<title>{detail?.topic?.name ?? '주제'} — 공부</title>
</svelte:head>
<div class="p-4 md:p-6 max-w-5xl mx-auto">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제로 보기</a>
<span class="text-faint">/</span>
<span class="text-text font-medium truncate">{detail?.topic?.name ?? '...'}</span>
</div>
{#if loading}
<div class="space-y-3">
<Skeleton h="h-12" rounded="lg" />
<Skeleton h="h-32" rounded="lg" />
</div>
{:else if !detail}
<EmptyState
icon={FolderKanban}
title="주제를 찾을 없습니다"
description="삭제되었거나 권한이 없는 주제입니다."
/>
<div class="mt-3">
<Button href="/study/topics" icon={ArrowLeft}>주제 목록으로</Button>
</div>
{:else}
<!-- 주제 헤더 -->
<div class="mb-4 p-4 rounded-lg border border-default bg-surface">
<div class="flex items-center gap-2 mb-1">
{#if detail.topic.color}
<span class="inline-block w-3 h-3 rounded-full shrink-0" style="background-color: {detail.topic.color}"></span>
{:else}
<FolderKanban size={16} class="text-accent shrink-0" />
{/if}
<h1 class="text-lg font-semibold text-text truncate">{detail.topic.name}</h1>
{#if detail.topic.study_type}
<span class="text-[10px] text-dim border border-default rounded px-1.5 py-0.5 shrink-0">{detail.topic.study_type}</span>
{/if}
</div>
{#if detail.topic.description}
<p class="text-xs text-dim mt-1 whitespace-pre-line">{detail.topic.description}</p>
{/if}
<div class="text-[11px] text-dim mt-3 flex items-center gap-3 flex-wrap">
<span>필기 <span class="text-text">{detail.stats.session_count}</span></span>
<span>자료 <span class="text-text">{detail.stats.document_count}</span></span>
<!-- 후속 PR 자산 카운트 placeholder -->
{#if detail.stats.audio_count > 0}<span>오디오 <span class="text-text">{detail.stats.audio_count}</span></span>{/if}
{#if detail.stats.vocab_count > 0}<span>단어장 <span class="text-text">{detail.stats.vocab_count}</span></span>{/if}
{#if detail.stats.question_set_count > 0}<span>문제 <span class="text-text">{detail.stats.question_set_count}</span></span>{/if}
</div>
</div>
<!-- 필기 세션 -->
<section class="mb-5">
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold text-text flex items-center gap-2">
<PenLine size={14} class="text-accent" /> 필기 세션
<span class="text-[10px] text-dim">{detail.sections.sessions.length}</span>
</h2>
<Button href="/study/write" size="sm" variant="ghost" icon={Plus}> 세션</Button>
</div>
{#if detail.sections.sessions.length === 0}
<div class="text-xs text-dim p-3 border border-dashed border-default/60 rounded-lg">
이 주제에 연결된 필기 세션이 없습니다. /study/write 에서 새 세션을 만들 때 주제를 선택하거나, 기존 세션 편집에서 본 주제로 이동시키세요.
</div>
{:else}
<div class="flex flex-col gap-2">
{#each detail.sections.sessions as s (s.id)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-dim">
{#if s.study_type === 'language'}
<Languages size={12} />
<span>{s.language_code || '?'} · {s.learning_level || ''}</span>
{:else}
<BookOpen size={12} />
<span>{s.certification || '미지정'}</span>
{/if}
<span>·</span>
<span>{s.mode}</span>
</div>
<div class="text-sm text-text mt-1 truncate">
{s.subject || '(과목 미지정)'}{s.topic || '(주제 미지정)'}
</div>
<div class="text-[10px] text-dim mt-1">{fmtDate(s.created_at)}</div>
</div>
<Button href={`/study/write/${s.id}`} size="sm" variant="ghost" icon={ArrowRight} iconPosition="right">열기</Button>
<button
type="button"
onclick={() => detachSession(s.id)}
class="p-1.5 rounded hover:bg-error/10 text-dim hover:text-error"
aria-label="주제에서 분리"
><Trash2 size={12} /></button>
</div>
{/each}
</div>
{/if}
</section>
<!-- 자료 -->
<section class="mb-5">
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold text-text flex items-center gap-2">
<BookOpen size={14} class="text-accent" /> 자료
<span class="text-[10px] text-dim">{detail.sections.documents.length}</span>
</h2>
<Button onclick={openDocModal} size="sm" variant="ghost" icon={Plus}>자료 추가</Button>
</div>
{#if detail.sections.documents.length === 0}
<div class="text-xs text-dim p-3 border border-dashed border-default/60 rounded-lg">
이 주제에 연결된 자료가 없습니다. "자료 추가" 로 자료실 자료를 묶어보세요.
</div>
{:else}
<div class="flex flex-col gap-2">
{#each detail.sections.documents as d (d.id)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
<div class="flex-1 min-w-0">
<div class="text-sm text-text truncate">
{d.title || `(제목 없음 · #${d.id})`}
</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
<span>{d.file_format}</span>
{#if d.category}<span>· {d.category}</span>{/if}
{#if d.ai_domain}<span>· {d.ai_domain}</span>{/if}
{#if d.importance && d.importance !== 'medium'}<span>· {d.importance}</span>{/if}
<span class="ml-auto">{fmtDate(d.linked_at)}</span>
</div>
</div>
<Button href={`/documents/${d.id}`} size="sm" variant="ghost" icon={ArrowRight} iconPosition="right">열기</Button>
<button
type="button"
onclick={() => unlinkDocument(d.id)}
class="p-1.5 rounded hover:bg-error/10 text-dim hover:text-error"
aria-label="주제에서 분리"
><Trash2 size={12} /></button>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</div>
<!-- 자료 추가 모달 -->
{#if docModalOpen}
<button
type="button"
aria-label="모달 닫기"
onclick={() => (docModalOpen = false)}
class="fixed inset-0 z-40 bg-black/40"
></button>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-2xl bg-surface rounded-lg border border-default shadow-xl pointer-events-auto flex flex-col max-h-[80vh]">
<header class="flex items-center justify-between px-4 py-3 border-b border-default shrink-0">
<div class="text-sm font-semibold text-text">자료 추가</div>
<button
type="button"
onclick={() => (docModalOpen = false)}
class="p-1 rounded hover:bg-bg text-dim"
aria-label="닫기"
>×</button>
</header>
<div class="px-4 py-3 border-b border-default shrink-0 flex gap-2">
<input
type="text"
bind:value={docSearch}
placeholder="자료 제목 검색"
class="flex-1 px-3 py-1.5 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent"
onkeydown={(e) => { if (e.key === 'Enter') searchDocs(); }}
/>
<Button size="sm" onclick={searchDocs} loading={docSearching}>검색</Button>
</div>
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-3">
{#if docSearching}
<div class="space-y-2">
{#each Array(4) as _}<Skeleton h="h-12" rounded="md" />{/each}
</div>
{:else if docCandidates.length === 0}
<p class="text-xs text-dim text-center py-4">검색 결과가 없습니다.</p>
{:else}
<ul class="flex flex-col gap-1">
{#each docCandidates as d (d.id)}
<li>
<label class="flex items-center gap-3 p-2 rounded hover:bg-bg cursor-pointer">
<input
type="checkbox"
checked={docSelected.has(d.id)}
onchange={() => toggleDocSelect(d.id)}
class="shrink-0"
/>
<div class="flex-1 min-w-0">
<div class="text-sm text-text truncate">{d.title || `#${d.id}`}</div>
<div class="text-[11px] text-dim flex items-center gap-2">
<span>{d.file_format}</span>
{#if d.ai_domain}<span>· {d.ai_domain}</span>{/if}
{#if d.user_tags?.length}
{#each d.user_tags.slice(0, 3) as t}
<span>· {t}</span>
{/each}
{/if}
</div>
</div>
</label>
</li>
{/each}
</ul>
{/if}
</div>
<footer class="flex items-center justify-between px-4 py-3 border-t border-default shrink-0">
<div class="text-xs text-dim">{docSelected.size}건 선택됨</div>
<div class="flex gap-2">
<Button variant="ghost" onclick={() => (docModalOpen = false)} disabled={docLinking}>취소</Button>
<Button onclick={linkSelectedDocs} loading={docLinking} disabled={docSelected.size === 0}>
{docSelected.size}건 추가
</Button>
</div>
</footer>
</div>
</div>
{/if}
+22 -1
View File
@@ -22,6 +22,9 @@
let total = $state(0);
let loading = $state(true);
// 학습 워크스페이스(주제) 선택용 옵션
let topicOptions = $state([{ value: '', label: '미지정' }]);
// 빠른 시작 폼
let formOpen = $state(false);
let f_certification = $state('');
@@ -31,6 +34,7 @@
let f_topic = $state('');
let f_source_text = $state('');
let f_mode = $state('copy');
let f_study_topic_id = $state('');
let creating = $state(false);
const MODE_OPTIONS = [
@@ -60,12 +64,27 @@
}
}
async function loadTopics() {
try {
const res = await api('/study-topics/?limit=200');
topicOptions = [
{ value: '', label: '미지정' },
...res.items.map((t) => ({ value: String(t.id), label: t.name })),
];
} catch {
// 주제 로딩 실패는 비치명 — 미지정으로 진행 가능
}
}
$effect(() => {
void studyType;
load();
});
onMount(load);
onMount(() => {
load();
loadTopics();
});
async function createSession() {
creating = true;
@@ -77,6 +96,7 @@
topic: f_topic || null,
source_text: f_source_text || null,
mode: f_mode,
study_topic_id: f_study_topic_id ? Number(f_study_topic_id) : null,
};
if (studyType === 'certification') {
body.certification = f_certification || null;
@@ -160,6 +180,7 @@
{/if}
<TextInput label="과목" bind:value={f_subject} placeholder={studyType === 'language' ? '예: 漢字' : '예: 산업안전보건법'} />
<TextInput label="주제" bind:value={f_topic} placeholder={studyType === 'language' ? '예: 安全' : '예: 안전보건관리책임자의 직무'} />
<Select label="학습 워크스페이스 (선택)" bind:value={f_study_topic_id} options={topicOptions} />
<Textarea label="원문 텍스트" bind:value={f_source_text} rows={3} placeholder="발췌 원문 또는 학습 단어/문장" />
<div class="flex gap-2 justify-end">
<Button variant="ghost" onclick={() => (formOpen = false)} disabled={creating}>취소</Button>
+28
View File
@@ -0,0 +1,28 @@
-- 179_study_topics.sql (1/6)
-- 학습 주제(study_topic) — 학습 워크스페이스 1차 컨테이너.
-- 필기 세션 + 자료(library document)를 한 주제(예: 가스기사) 아래로 묶는다.
--
-- 핵심 개념:
-- - 단순 폴더/태그가 아닌 학습 워크스페이스 컨테이너 (향후 단어장/오디오/문제세트 등 자산이 같은 컨테이너로 들어옴).
-- - documents.category(자료실 UI 축) 와 직교. 자료실 facet/카테고리는 건드리지 않는다.
-- - study_sessions.certification/subject/topic 컬럼은 보존, 본 컨테이너 와 직교한 세부 메타로 계속 사용.
--
-- study_type 권장값: certification / language / school / work / general.
-- 백엔드는 강한 enum 으로 강제하지 않음 (DB 제약 / Pydantic Literal 모두 사용 금지).
-- 어학 세부(jlpt_n3 등) 같은 분류 확장 여지 확보.
--
-- soft delete (deleted_at): 묶음 해제 시 매핑·세션 보호.
-- partial unique index (180) 가 active 행끼리만 중복 방지 → 삭제된 주제명 재사용 가능.
CREATE TABLE IF NOT EXISTS study_topics (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(120) NOT NULL,
description TEXT,
color VARCHAR(20),
study_type VARCHAR(40),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
@@ -0,0 +1,8 @@
-- 180_study_topics_uq_active.sql (2/6)
-- active 주제끼리만 (user_id, name) 중복 방지.
-- soft-deleted 행은 unique 제약에서 빠지므로 삭제된 주제명을 다시 만들 수 있다.
-- 테이블 레벨 UNIQUE (user_id, name) 은 의도적으로 사용하지 않음 (soft delete 와 충돌).
CREATE UNIQUE INDEX IF NOT EXISTS uq_study_topics_user_name_active
ON study_topics (user_id, name)
WHERE deleted_at IS NULL;
+6
View File
@@ -0,0 +1,6 @@
-- 181_study_topics_idx_user.sql (3/6)
-- 사용자별 active 주제 목록 조회용 partial index.
CREATE INDEX IF NOT EXISTS idx_study_topics_user
ON study_topics (user_id, sort_order, id)
WHERE deleted_at IS NULL;
@@ -0,0 +1,8 @@
-- 182_study_sessions_topic_fk.sql (4/6)
-- study_sessions 에 study_topic_id 컬럼 추가 (1:N).
-- 기존 세션은 NULL 로 남고, 사용자가 점진적으로 묶음에 연결.
-- 주제가 soft-delete 되면 ON DELETE SET NULL 대비 (현재는 soft delete 라 hard delete 발생 시 보호).
ALTER TABLE study_sessions
ADD COLUMN IF NOT EXISTS study_topic_id BIGINT
REFERENCES study_topics(id) ON DELETE SET NULL;
@@ -0,0 +1,6 @@
-- 183_study_sessions_topic_idx.sql (5/6)
-- 주제별 세션 조회용 partial index.
CREATE INDEX IF NOT EXISTS idx_study_sessions_topic
ON study_sessions (study_topic_id, created_at DESC)
WHERE study_topic_id IS NOT NULL;
+19
View File
@@ -0,0 +1,19 @@
-- 184_study_topic_documents.sql (6/6)
-- 자료(library document) ↔ 주제 다대다 매핑.
-- 한 자료가 여러 주제에 속할 수 있다 (예: "안전관리론" 교재가 가스기사와 산업안전기사 양쪽에 속함).
--
-- user_id 는 권한 체크용 반정규화 — 다른 사용자의 자료를 묶지 못하도록 강제.
-- documents 본체는 손대지 않고 본 조인 테이블만 사용.
--
-- 향후 자산 타입(audio/vocab/question_set 등)이 늘어나면 polymorphic 테이블 대신
-- study_topic_audio_assets / study_topic_vocab_decks 등 타입별 조인 테이블을 추가한다.
-- (study_topic_items 같은 단일 polymorphic 테이블은 만들지 않는다.)
CREATE TABLE IF NOT EXISTS study_topic_documents (
study_topic_id BIGINT NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (study_topic_id, document_id)
);
@@ -0,0 +1,7 @@
-- 185_study_topic_documents_idx_doc.sql
-- document_id 기준 역방향 lookup 용 보조 인덱스.
-- (study_topic_id, document_id) PK 의 leading column 이 study_topic_id 라
-- "이 자료가 어느 주제들에 속해있나" 조회를 위해 별도 인덱스 필요.
CREATE INDEX IF NOT EXISTS idx_study_topic_documents_doc
ON study_topic_documents (document_id);