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