feat: add reset deck progress feature for deck detail page

This commit is contained in:
2026-03-13 22:02:55 +08:00
parent 279eee2953
commit af684a15ce
17 changed files with 575 additions and 82 deletions

View File

@@ -162,3 +162,17 @@ export type ActionOutputGetCardById = {
message: string;
data?: ActionOutputCardWithNote;
};
export const schemaActionInputResetDeckCards = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputResetDeckCards = z.infer<typeof schemaActionInputResetDeckCards>;
export const validateActionInputResetDeckCards = generateValidator(schemaActionInputResetDeckCards);
export type ActionOutputResetDeckCards = {
success: boolean;
message: string;
data?: {
count: number;
};
};

View File

@@ -13,6 +13,7 @@ import {
ActionInputGetCardStats,
ActionInputDeleteCard,
ActionInputGetCardById,
ActionInputResetDeckCards,
ActionOutputCreateCard,
ActionOutputAnswerCard,
ActionOutputGetCards,
@@ -23,6 +24,7 @@ import {
ActionOutputCard,
ActionOutputCardWithNote,
ActionOutputScheduledCard,
ActionOutputResetDeckCards,
validateActionInputCreateCard,
validateActionInputAnswerCard,
validateActionInputGetCardsForReview,
@@ -31,6 +33,7 @@ import {
validateActionInputGetCardStats,
validateActionInputDeleteCard,
validateActionInputGetCardById,
validateActionInputResetDeckCards,
} from "./card-action-dto";
import {
serviceCreateCard,
@@ -43,6 +46,7 @@ import {
serviceDeleteCard,
serviceGetCardByIdWithNote,
serviceCheckCardOwnership,
serviceResetDeckCards,
} from "./card-service";
import { CardQueue } from "../../../generated/prisma/enums";
@@ -425,3 +429,36 @@ export async function actionGetCardById(
return { success: false, message: "An error occurred while fetching the card" };
}
}
export async function actionResetDeckCards(
input: unknown,
): Promise<ActionOutputResetDeckCards> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputResetDeckCards(input);
const result = await serviceResetDeckCards({
deckId: validated.deckId,
userId,
});
if (!result.success) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: { count: result.count },
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to reset deck cards", { error: e });
return { success: false, message: "An error occurred while resetting deck cards" };
}
}

View File

@@ -102,3 +102,11 @@ export type RepoOutputCardStats = {
review: number;
due: number;
};
export interface RepoInputResetDeckCards {
deckId: number;
}
export type RepoOutputResetDeckCards = {
count: number;
};

View File

@@ -7,9 +7,11 @@ import {
RepoInputGetCardsForReview,
RepoInputGetNewCards,
RepoInputBulkUpdateCards,
RepoInputResetDeckCards,
RepoOutputCard,
RepoOutputCardWithNote,
RepoOutputCardStats,
RepoOutputResetDeckCards,
} from "./card-repository-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
@@ -307,3 +309,29 @@ export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCa
});
return cards;
}
export async function repoResetDeckCards(
input: RepoInputResetDeckCards,
): Promise<RepoOutputResetDeckCards> {
log.debug("Resetting deck cards", { deckId: input.deckId });
const result = await prisma.card.updateMany({
where: { deckId: input.deckId },
data: {
type: CardType.NEW,
queue: CardQueue.NEW,
due: 0,
ivl: 0,
factor: 2500,
reps: 0,
lapses: 0,
left: 0,
odue: 0,
odid: 0,
mod: Math.floor(Date.now() / 1000),
},
});
log.info("Deck cards reset", { deckId: input.deckId, count: result.count });
return { count: result.count };
}

View File

@@ -99,6 +99,24 @@ export type ServiceOutputReviewResult = {
scheduled: ServiceOutputScheduledCard;
};
export interface ServiceInputResetDeckCards {
deckId: number;
userId: string;
}
export interface ServiceInputCheckDeckOwnership {
deckId: number;
userId: string;
}
export type ServiceOutputCheckDeckOwnership = boolean;
export type ServiceOutputResetDeckCards = {
success: boolean;
count: number;
message: string;
};
export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10],
RELEARNING_STEPS: [10],

View File

@@ -12,7 +12,9 @@ import {
repoDeleteCard,
repoGetCardsByNoteId,
repoGetCardDeckOwnerId,
repoResetDeckCards,
} from "./card-repository";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
import {
RepoInputUpdateCard,
RepoOutputCard,
@@ -25,12 +27,15 @@ import {
ServiceInputGetCardsByDeckId,
ServiceInputGetCardStats,
ServiceInputCheckCardOwnership,
ServiceInputResetDeckCards,
ServiceInputCheckDeckOwnership,
ServiceOutputCard,
ServiceOutputCardWithNote,
ServiceOutputCardStats,
ServiceOutputScheduledCard,
ServiceOutputReviewResult,
ServiceOutputCheckCardOwnership,
ServiceOutputResetDeckCards,
ReviewEase,
SM2_CONFIG,
} from "./card-service-dto";
@@ -495,3 +500,27 @@ export async function serviceCheckCardOwnership(
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
return ownerId === input.userId;
}
export async function serviceCheckDeckOwnership(
input: ServiceInputCheckDeckOwnership,
): Promise<ServiceOutputCheckCardOwnership> {
log.debug("Checking deck ownership", { deckId: input.deckId });
const ownerId = await repoGetUserIdByDeckId(input.deckId);
return ownerId === input.userId;
}
export async function serviceResetDeckCards(
input: ServiceInputResetDeckCards,
): Promise<ServiceOutputResetDeckCards> {
log.info("Resetting deck cards", { deckId: input.deckId, userId: input.userId });
const isOwner = await serviceCheckDeckOwnership({ deckId: input.deckId, userId: input.userId });
if (!isOwner) {
return { success: false, count: 0, message: "You do not have permission to reset this deck" };
}
const result = await repoResetDeckCards({ deckId: input.deckId });
log.info("Deck cards reset successfully", { deckId: input.deckId, count: result.count });
return { success: true, count: result.count, message: "Deck cards reset successfully" };
}

View File

@@ -32,7 +32,8 @@ export function repoGenerateGuid(): string {
export function repoCalculateCsum(text: string): number {
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16);
// Use 7 hex chars to stay within INTEGER range (max 268,435,455 < 2,147,483,647)
return parseInt(hash.substring(0, 7), 16);
}
export function repoJoinFields(fields: string[]): string {