feat: 문제집 삭제 + PS/국어 데이터 완전 제거

- DELETE /study-logs/by-prefix: 문제집 삭제 시 관련 StudyLog+ReviewSchedule cascade 삭제
- POST /study-logs/purge-non-math: PS studyLog + 비수학 subject 일괄 삭제
- dashboard/reviews/study-logs 쿼리에 psProblemId:null + subject:'수학' 필터 추가
- 카탈로그: ebook 카드 삭제 버튼 + 경고 confirm + 숨기기/복원
- 대시보드 마운트 시 1회 자동 purge (localStorage flag)
This commit is contained in:
reloop
2026-04-16 14:23:02 +09:00
parent 942e4b95c2
commit 25092bf680
6 changed files with 293 additions and 6 deletions

View File

@@ -26,6 +26,10 @@ export class DashboardService {
userId,
status: ReviewStatus.pending,
scheduledAt: { lte: now },
studyLog: {
psProblemId: null,
subject: { name: '수학' },
},
},
}),
this.prisma.reviewSchedule.count({
@@ -33,10 +37,18 @@ export class DashboardService {
userId,
status: ReviewStatus.pending,
scheduledAt: { gt: now, lte: todayEnd },
studyLog: {
psProblemId: null,
subject: { name: '수학' },
},
},
}),
this.prisma.studyLog.findMany({
where: { userId },
where: {
userId,
psProblemId: null,
subject: { name: '수학' },
},
orderBy: { studiedAt: 'desc' },
take: 5,
include: {
@@ -48,12 +60,18 @@ export class DashboardService {
by: ['result'],
where: {
userId,
psProblemId: null,
subject: { name: '수학' },
studiedAt: { gte: weekStart },
},
_count: { result: true },
}),
this.prisma.skillSnapshot.findMany({
where: { userId, tagId: { not: null } },
where: {
userId,
tagId: { not: null },
tag: { subject: { name: '수학' } },
},
include: {
tag: {
select: {

View File

@@ -29,6 +29,10 @@ export class ReviewsService {
userId,
status: ReviewStatus.pending,
scheduledAt: { lte: soon },
studyLog: {
psProblemId: null,
subject: { name: '수학' },
},
},
include: {
studyLog: {
@@ -208,6 +212,10 @@ export class ReviewsService {
where: {
userId,
status: { in: [ReviewStatus.done, ReviewStatus.skipped] },
studyLog: {
psProblemId: null,
subject: { name: '수학' },
},
},
include: {
studyLog: {
@@ -234,6 +242,10 @@ export class ReviewsService {
where: {
userId,
scheduledAt: { gte: start, lt: end },
studyLog: {
psProblemId: null,
subject: { name: '수학' },
},
},
select: {
scheduledAt: true,
@@ -277,6 +289,10 @@ export class ReviewsService {
where: {
userId,
scheduledAt: { gte: start, lt: end },
studyLog: {
psProblemId: null,
subject: { name: '수학' },
},
},
include: {
studyLog: {

View File

@@ -2,6 +2,7 @@ import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Patch,
Param,
@@ -218,6 +219,19 @@ export class StudyLogsController {
return this.svc.update(user.id, id, dto);
}
@Delete('by-prefix')
deleteByPrefix(
@CurrentUser() user: AuthUser,
@Query('prefix') prefix: string,
) {
return this.svc.deleteByPrefix(user.id, prefix);
}
@Post('purge-non-math')
purgeNonMath(@CurrentUser() user: AuthUser) {
return this.svc.purgeNonMath(user.id);
}
@Post('upload-pdf')
@UseInterceptors(
FileInterceptor('file', {

View File

@@ -288,6 +288,87 @@ export class StudyLogsService {
});
}
async deleteByPrefix(userId: number, prefix: string) {
if (!prefix || prefix.length < 2) throw new BadRequestException('prefix too short');
const logs = await this.prisma.studyLog.findMany({
where: { userId, title: { startsWith: prefix } },
select: { id: true },
});
const logIds = logs.map((l) => l.id);
if (logIds.length === 0) return { deleted: { studyLogs: 0, reviewSchedules: 0 } };
const result = await this.prisma.$transaction(async (tx) => {
const reviews = await tx.reviewSchedule.deleteMany({
where: { studyLogId: { in: logIds } },
});
const studyLogs = await tx.studyLog.deleteMany({
where: { id: { in: logIds } },
});
return { studyLogs: studyLogs.count, reviewSchedules: reviews.count };
});
return { deleted: result };
}
async purgeNonMath(userId: number) {
const mathSubject = await this.prisma.subject.findFirst({
where: { userId, name: '수학' },
});
return this.prisma.$transaction(async (tx) => {
const psLogs = await tx.studyLog.findMany({
where: { userId, psProblemId: { not: null } },
select: { id: true },
});
const psLogIds = psLogs.map((l) => l.id);
let psReviewsDeleted = 0;
let psLogsDeleted = 0;
if (psLogIds.length > 0) {
const r = await tx.reviewSchedule.deleteMany({ where: { studyLogId: { in: psLogIds } } });
psReviewsDeleted = r.count;
const s = await tx.studyLog.deleteMany({ where: { id: { in: psLogIds } } });
psLogsDeleted = s.count;
}
let nonMathReviewsDeleted = 0;
let nonMathLogsDeleted = 0;
if (mathSubject) {
const nonMathLogs = await tx.studyLog.findMany({
where: { userId, subjectId: { not: mathSubject.id } },
select: { id: true },
});
const nonMathLogIds = nonMathLogs.map((l) => l.id);
if (nonMathLogIds.length > 0) {
const r = await tx.reviewSchedule.deleteMany({ where: { studyLogId: { in: nonMathLogIds } } });
nonMathReviewsDeleted = r.count;
const s = await tx.studyLog.deleteMany({ where: { id: { in: nonMathLogIds } } });
nonMathLogsDeleted = s.count;
}
}
let nonMathSubjectsDeleted = 0;
if (mathSubject) {
const s = await tx.subject.deleteMany({
where: { userId, id: { not: mathSubject.id } },
});
nonMathSubjectsDeleted = s.count;
}
return {
purged: {
psStudyLogs: psLogsDeleted,
psReviewSchedules: psReviewsDeleted,
nonMathStudyLogs: nonMathLogsDeleted,
nonMathReviewSchedules: nonMathReviewsDeleted,
nonMathSubjects: nonMathSubjectsDeleted,
},
};
});
}
list(
userId: number,
opts: {
@@ -300,6 +381,8 @@ export class StudyLogsService {
return this.prisma.studyLog.findMany({
where: {
userId,
psProblemId: null,
subject: { name: '수학' },
...(opts.subjectId && { subjectId: opts.subjectId }),
...(opts.tagId && { tagId: opts.tagId }),
},
@@ -319,7 +402,7 @@ export class StudyLogsService {
async getOne(userId: number, id: number) {
const log = await this.prisma.studyLog.findFirst({
where: { id, userId },
where: { id, userId, psProblemId: null },
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },

View File

@@ -95,6 +95,15 @@ function DashboardBody() {
setCoachDismissed(window.localStorage.getItem(COACH_DISMISS_KEY) === '1');
}, []);
useEffect(() => {
const purged = localStorage.getItem('reloop-purged-non-math');
if (!purged) {
api.post('/study-logs/purge-non-math').then(() => {
localStorage.setItem('reloop-purged-non-math', 'true');
}).catch(() => {});
}
}, []);
useEffect(() => {
let cancelled = false;

View File

@@ -51,6 +51,19 @@ function saveUploadedPdfs(pdfs: UploadedPdf[]) {
localStorage.setItem('reloop-uploaded-pdfs', JSON.stringify(pdfs));
}
function loadHiddenEbooks(): string[] {
if (typeof window === 'undefined') return [];
try {
return JSON.parse(localStorage.getItem('reloop-hidden-ebooks') || '[]');
} catch {
return [];
}
}
function saveHiddenEbooks(ids: string[]) {
localStorage.setItem('reloop-hidden-ebooks', JSON.stringify(ids));
}
// ─── 메인 페이지 ─────────────────────────────────────────────────────────────
export default function ExamsPage() {
@@ -67,6 +80,7 @@ function ExamsBody() {
const [registeredEbooks, setRegisteredEbooks] = useState<string[]>(loadRegisteredEbooks);
const [uploadedPdfs, setUploadedPdfs] = useState<UploadedPdf[]>(loadUploadedPdfs);
const [hiddenEbooks, setHiddenEbooks] = useState<string[]>(loadHiddenEbooks);
// 모달 상태
const [showCodeModal, setShowCodeModal] = useState(false);
@@ -95,6 +109,60 @@ function ExamsBody() {
saveUploadedPdfs(next);
}
async function handleDeleteSampleEbook(ebook: EbookMeta) {
const confirmed = window.confirm(
`'${ebook.title}'을 삭제할까? 이 문제집에서 풀었던 모든 학습 기록과 복습 일정도 함께 삭제돼.`,
);
if (!confirmed) return;
try {
await api.delete('/study-logs/by-prefix', { params: { prefix: `[${ebook.grade}]` } });
} catch {
// 서버 에러는 무시하고 로컬 처리는 계속
}
// localStorage 풀이 데이터 삭제
if (typeof window !== 'undefined') {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(`reloop-ebook-${ebook.id}-`)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
}
const next = [...hiddenEbooks, ebook.id];
setHiddenEbooks(next);
saveHiddenEbooks(next);
showToast({ message: `'${ebook.title}' 이 삭제됐어.`, variant: 'success', durationMs: 3000 });
}
async function handleDeletePremiumEbook(ebook: EbookMeta) {
const confirmed = window.confirm(
`'${ebook.title}'을 삭제할까? 이 문제집에서 풀었던 모든 학습 기록과 복습 일정도 함께 삭제돼.`,
);
if (!confirmed) return;
try {
await api.delete('/study-logs/by-prefix', { params: { prefix: `[${ebook.grade}]` } });
} catch {
// 무시
}
const next = registeredEbooks.filter((id) => id !== ebook.id);
setRegisteredEbooks(next);
saveRegisteredEbooks(next);
showToast({ message: `'${ebook.title}' 이 삭제됐어.`, variant: 'success', durationMs: 3000 });
}
function handleRestoreAll() {
setHiddenEbooks([]);
saveHiddenEbooks([]);
showToast({ message: '삭제된 문제집이 복원됐어.', variant: 'success', durationMs: 2000 });
}
function handleStudyLogCreated() {
showToast({
message: '복습 문제가 등록됐어! 캘린더에서 확인해봐.',
@@ -107,6 +175,8 @@ function ExamsBody() {
.map((id) => PREMIUM_EBOOK_MAP[id])
.filter(Boolean);
const visibleSampleEbooks = EBOOK_LIST.filter((e) => !hiddenEbooks.includes(e.id));
return (
<Wrap>
<HeaderCard>
@@ -161,7 +231,12 @@ function ExamsBody() {
</SectionLabel>
<Grid>
{premiumEbooks.map((ebook) => (
<PremiumEbookCard key={ebook.id} ebook={ebook} router={router} />
<PremiumEbookCard
key={ebook.id}
ebook={ebook}
router={router}
onDelete={handleDeletePremiumEbook}
/>
))}
</Grid>
</>
@@ -188,11 +263,25 @@ function ExamsBody() {
</SectionLabel>
<Grid>
{EBOOK_LIST.map((ebook) => (
<SampleEbookCard key={ebook.id} ebook={ebook} router={router} />
{visibleSampleEbooks.map((ebook) => (
<SampleEbookCard
key={ebook.id}
ebook={ebook}
router={router}
onDelete={handleDeleteSampleEbook}
/>
))}
</Grid>
{/* 숨긴 ebook 복원 */}
{hiddenEbooks.length > 0 && (
<RestoreRow>
<RestoreLink type="button" onClick={handleRestoreAll}>
{hiddenEbooks.length} ·
</RestoreLink>
</RestoreRow>
)}
{/* 모달들 */}
{showCodeModal && (
<CodeModal
@@ -518,12 +607,17 @@ function PdfUploadModal({
function SampleEbookCard({
ebook,
router,
onDelete,
}: {
ebook: EbookMeta;
router: ReturnType<typeof useRouter>;
onDelete: (ebook: EbookMeta) => void;
}) {
return (
<SampleCard>
<DeleteBtn type="button" onClick={() => onDelete(ebook)} title="문제집 삭제">
<Icon name="x" size={13} />
</DeleteBtn>
<SampleCardHeader>
<CardTitle>{ebook.title}</CardTitle>
<SampleBadge $variant="default">
@@ -556,12 +650,17 @@ function SampleEbookCard({
function PremiumEbookCard({
ebook,
router,
onDelete,
}: {
ebook: EbookMeta;
router: ReturnType<typeof useRouter>;
onDelete: (ebook: EbookMeta) => void;
}) {
return (
<PremiumCard>
<DeleteBtn type="button" onClick={() => onDelete(ebook)} title="문제집 삭제">
<Icon name="x" size={13} />
</DeleteBtn>
<SampleCardHeader>
<CardTitle>{ebook.title}</CardTitle>
<PremiumBadge>
@@ -748,6 +847,54 @@ const SampleCard = styled(Card)`
}
`;
const DeleteBtn = styled.button`
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: rgba(244, 63, 94, 0.0);
color: ${theme.color.textMute};
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
z-index: 2;
${SampleCard}:hover & {
opacity: 1;
}
&:hover {
background: rgba(244, 63, 94, 0.14);
color: ${theme.color.danger};
}
`;
const RestoreRow = styled.div`
display: flex;
justify-content: center;
padding: ${theme.space.sm} 0;
`;
const RestoreLink = styled.button`
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: ${theme.color.textMute};
text-decoration: underline;
padding: 0;
&:hover {
color: ${theme.color.textSub};
}
`;
const PremiumCard = styled(SampleCard)`
background:
radial-gradient(circle at top right, rgba(245, 158, 11, 0.15), transparent 50%),