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:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user