feat: 과목 삭제 기능 — 연관 데이터 트랜잭션 정리

- backend: subjects.service.remove()를 트랜잭션으로 변경
  (reviewSchedule → studyLog → skillSnapshot → subject 순서 삭제)
- frontend: 과목 카드에 삭제 버튼 + 확인 다이얼로그 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-19 16:19:35 +09:00
parent 629f2465e0
commit 59da6a3578
2 changed files with 106 additions and 2 deletions

View File

@@ -37,7 +37,48 @@ export class SubjectsService {
async remove(userId: number, id: number) {
const owned = await this.prisma.subject.findFirst({ where: { id, userId } });
if (!owned) throw new NotFoundException();
await this.prisma.subject.delete({ where: { id } });
return { ok: true };
return this.prisma.$transaction(async (tx) => {
// 1. 이 과목에 속한 studyLog ID 목록
const logs = await tx.studyLog.findMany({
where: { userId, subjectId: id },
select: { id: true },
});
const logIds = logs.map((l) => l.id);
// 2. 연관 reviewSchedule 삭제
if (logIds.length > 0) {
await tx.reviewSchedule.deleteMany({
where: { studyLogId: { in: logIds } },
});
}
// 3. studyLog 삭제
await tx.studyLog.deleteMany({
where: { userId, subjectId: id },
});
// 4. skillSnapshot 삭제 (태그 경유)
const tagIds = await tx.tag.findMany({
where: { subjectId: id },
select: { id: true },
});
if (tagIds.length > 0) {
await tx.skillSnapshot.deleteMany({
where: { userId, tagId: { in: tagIds.map((t) => t.id) } },
});
}
// 5. 과목 삭제 (tags는 onDelete: Cascade로 자동 삭제)
await tx.subject.delete({ where: { id } });
return {
ok: true,
deleted: {
studyLogs: logIds.length,
tags: tagIds.length,
},
};
});
}
}

View File

@@ -101,6 +101,9 @@ function SubjectsBody() {
subjectId: number;
} | null>(null);
const [deleteSubjectCandidate, setDeleteSubjectCandidate] =
useState<SubjectWithTags | null>(null);
const loadSubjects = useCallback(async () => {
setLoading(true);
setLoadError(null);
@@ -340,6 +343,27 @@ function SubjectsBody() {
}
};
const confirmDeleteSubject = async () => {
if (!deleteSubjectCandidate) return;
const subject = deleteSubjectCandidate;
setDeleteSubjectCandidate(null);
try {
await api.delete(`/subjects/${subject.id}`);
setSubjects((prev) => prev?.filter((s) => s.id !== subject.id) ?? null);
setSelectedTab('all');
showToast({
variant: 'success',
message: `'${subject.name}' 과목을 삭제했어요.`,
});
} catch {
showToast({
variant: 'danger',
message: '과목 삭제에 실패했어요.',
});
}
};
if (loading) {
return (
<PageWrap>
@@ -374,6 +398,17 @@ function SubjectsBody() {
onCancel={() => setDeleteCandidate(null)}
/>
<ConfirmDialog
open={deleteSubjectCandidate !== null}
title="과목 삭제"
body={`'${deleteSubjectCandidate?.name}' 과목과 하위 태그 ${deleteSubjectCandidate?.tags.length ?? 0}개, 관련 학습 기록이 모두 삭제돼요. 되돌릴 수 없어요.`}
confirmLabel="과목 삭제"
cancelLabel="취소"
tone="danger"
onConfirm={() => void confirmDeleteSubject()}
onCancel={() => setDeleteSubjectCandidate(null)}
/>
<SubjectModal
open={subjectModalOpen}
mode={subjectDraft.id === null ? 'create' : 'edit'}
@@ -473,6 +508,13 @@ function SubjectsBody() {
<Icon name="plus" size={16} weight="bold" />
</HeaderActionButton>
<DangerActionButton
type="button"
onClick={() => setDeleteSubjectCandidate(subject)}
>
<Icon name="x" size={16} />
</DangerActionButton>
</SubjectHeaderActions>
</SubjectCardHeader>
@@ -1197,6 +1239,27 @@ const HeaderActionButton = styled.button`
}
`;
const DangerActionButton = styled.button`
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: ${theme.radius.md};
background: transparent;
color: ${theme.color.textMute};
font-size: 13px;
font-weight: 600;
transition: all 0.16s ease;
&:hover {
color: ${theme.color.danger};
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
`;
const TagComposer = styled.div`
display: grid;
grid-template-columns: minmax(0, 1fr) auto;