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