refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移
This commit is contained in:
428
src/modules/card/card-action.ts
Normal file
428
src/modules/card/card-action.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import {
|
||||
ActionInputCreateCard,
|
||||
ActionInputAnswerCard,
|
||||
ActionInputGetCardsForReview,
|
||||
ActionInputGetNewCards,
|
||||
ActionInputGetCardsByDeckId,
|
||||
ActionInputGetCardStats,
|
||||
ActionInputDeleteCard,
|
||||
ActionInputGetCardById,
|
||||
ActionOutputCreateCard,
|
||||
ActionOutputAnswerCard,
|
||||
ActionOutputGetCards,
|
||||
ActionOutputGetCardsWithNote,
|
||||
ActionOutputGetCardStats,
|
||||
ActionOutputDeleteCard,
|
||||
ActionOutputGetCardById,
|
||||
ActionOutputCard,
|
||||
ActionOutputCardWithNote,
|
||||
ActionOutputScheduledCard,
|
||||
validateActionInputCreateCard,
|
||||
validateActionInputAnswerCard,
|
||||
validateActionInputGetCardsForReview,
|
||||
validateActionInputGetNewCards,
|
||||
validateActionInputGetCardsByDeckId,
|
||||
validateActionInputGetCardStats,
|
||||
validateActionInputDeleteCard,
|
||||
validateActionInputGetCardById,
|
||||
} from "./card-action-dto";
|
||||
import {
|
||||
serviceCreateCard,
|
||||
serviceAnswerCard,
|
||||
serviceGetCardsForReview,
|
||||
serviceGetNewCards,
|
||||
serviceGetCardsByDeckId,
|
||||
serviceGetCardsByDeckIdWithNotes,
|
||||
serviceGetCardStats,
|
||||
serviceDeleteCard,
|
||||
serviceGetCardByIdWithNote,
|
||||
} from "./card-service";
|
||||
import { repoGetCardDeckOwnerId } from "./card-repository";
|
||||
import { CardQueue } from "../../../generated/prisma/enums";
|
||||
|
||||
const log = createLogger("card-action");
|
||||
|
||||
function mapCardToOutput(card: {
|
||||
id: bigint;
|
||||
noteId: bigint;
|
||||
deckId: number;
|
||||
ord: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
type: string;
|
||||
queue: string;
|
||||
due: number;
|
||||
ivl: number;
|
||||
factor: number;
|
||||
reps: number;
|
||||
lapses: number;
|
||||
left: number;
|
||||
odue: number;
|
||||
odid: number;
|
||||
flags: number;
|
||||
data: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): ActionOutputCard {
|
||||
return {
|
||||
id: card.id.toString(),
|
||||
noteId: card.noteId.toString(),
|
||||
deckId: card.deckId,
|
||||
ord: card.ord,
|
||||
mod: card.mod,
|
||||
usn: card.usn,
|
||||
type: card.type as ActionOutputCard["type"],
|
||||
queue: card.queue as ActionOutputCard["queue"],
|
||||
due: card.due,
|
||||
ivl: card.ivl,
|
||||
factor: card.factor,
|
||||
reps: card.reps,
|
||||
lapses: card.lapses,
|
||||
left: card.left,
|
||||
odue: card.odue,
|
||||
odid: card.odid,
|
||||
flags: card.flags,
|
||||
data: card.data,
|
||||
createdAt: card.createdAt,
|
||||
updatedAt: card.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCardWithNoteToOutput(card: {
|
||||
id: bigint;
|
||||
noteId: bigint;
|
||||
deckId: number;
|
||||
ord: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
type: string;
|
||||
queue: string;
|
||||
due: number;
|
||||
ivl: number;
|
||||
factor: number;
|
||||
reps: number;
|
||||
lapses: number;
|
||||
left: number;
|
||||
odue: number;
|
||||
odid: number;
|
||||
flags: number;
|
||||
data: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
note: {
|
||||
id: bigint;
|
||||
flds: string;
|
||||
sfld: string;
|
||||
tags: string;
|
||||
};
|
||||
}): ActionOutputCardWithNote {
|
||||
return {
|
||||
...mapCardToOutput(card),
|
||||
note: {
|
||||
id: card.note.id.toString(),
|
||||
flds: card.note.flds,
|
||||
sfld: card.note.sfld,
|
||||
tags: card.note.tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapScheduledToOutput(scheduled: {
|
||||
cardId: bigint;
|
||||
newType: string;
|
||||
newQueue: string;
|
||||
newDue: number;
|
||||
newIvl: number;
|
||||
newFactor: number;
|
||||
newReps: number;
|
||||
newLapses: number;
|
||||
nextReviewDate: Date;
|
||||
}): ActionOutputScheduledCard {
|
||||
return {
|
||||
cardId: scheduled.cardId.toString(),
|
||||
newType: scheduled.newType as ActionOutputScheduledCard["newType"],
|
||||
newQueue: scheduled.newQueue as ActionOutputScheduledCard["newQueue"],
|
||||
newDue: scheduled.newDue,
|
||||
newIvl: scheduled.newIvl,
|
||||
newFactor: scheduled.newFactor,
|
||||
newReps: scheduled.newReps,
|
||||
newLapses: scheduled.newLapses,
|
||||
nextReviewDate: scheduled.nextReviewDate,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkCardOwnership(cardId: bigint): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
|
||||
const ownerId = await repoGetCardDeckOwnerId(cardId);
|
||||
return ownerId === session.user.id;
|
||||
}
|
||||
|
||||
async function getCurrentUserId(): Promise<string | null> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
return session?.user?.id ?? null;
|
||||
}
|
||||
|
||||
export async function actionCreateCard(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputCreateCard> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputCreateCard(input);
|
||||
const cardId = await serviceCreateCard(validated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Card created successfully",
|
||||
data: { cardId: cardId.toString() },
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to create card", { error: e });
|
||||
return { success: false, message: "An error occurred while creating the card" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionAnswerCard(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputAnswerCard> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputAnswerCard(input);
|
||||
|
||||
const isOwner = await checkCardOwnership(validated.cardId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to answer this card" };
|
||||
}
|
||||
|
||||
const result = await serviceAnswerCard(validated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Card answered successfully",
|
||||
data: {
|
||||
card: mapCardToOutput(result.card),
|
||||
scheduled: mapScheduledToOutput(result.scheduled),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to answer card", { error: e });
|
||||
return { success: false, message: "An error occurred while answering the card" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetCardsForReview(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetCardsWithNote> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputGetCardsForReview(input);
|
||||
const cards = await serviceGetCardsForReview(validated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Cards fetched successfully",
|
||||
data: cards.map(mapCardWithNoteToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get cards for review", { error: e });
|
||||
return { success: false, message: "An error occurred while fetching cards" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetNewCards(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetCardsWithNote> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputGetNewCards(input);
|
||||
const cards = await serviceGetNewCards(validated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "New cards fetched successfully",
|
||||
data: cards.map(mapCardWithNoteToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get new cards", { error: e });
|
||||
return { success: false, message: "An error occurred while fetching new cards" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetCardsByDeckId(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetCards> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputGetCardsByDeckId(input);
|
||||
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
|
||||
const cards = await serviceGetCardsByDeckId({
|
||||
...validated,
|
||||
queue,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Cards fetched successfully",
|
||||
data: cards.map(mapCardToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get cards by deck", { error: e });
|
||||
return { success: false, message: "An error occurred while fetching cards" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetCardsByDeckIdWithNotes(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetCardsWithNote> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputGetCardsByDeckId(input);
|
||||
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
|
||||
const cards = await serviceGetCardsByDeckIdWithNotes({
|
||||
...validated,
|
||||
queue,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Cards fetched successfully",
|
||||
data: cards.map(mapCardWithNoteToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get cards by deck with notes", { error: e });
|
||||
return { success: false, message: "An error occurred while fetching cards" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetCardStats(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetCardStats> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputGetCardStats(input);
|
||||
const stats = await serviceGetCardStats(validated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Card stats fetched successfully",
|
||||
data: stats,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get card stats", { error: e });
|
||||
return { success: false, message: "An error occurred while fetching card stats" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteCard(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputDeleteCard> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputDeleteCard(input);
|
||||
|
||||
const isOwner = await checkCardOwnership(validated.cardId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to delete this card" };
|
||||
}
|
||||
|
||||
await serviceDeleteCard(validated.cardId);
|
||||
|
||||
return { success: true, message: "Card deleted successfully" };
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to delete card", { error: e });
|
||||
return { success: false, message: "An error occurred while deleting the card" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetCardById(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetCardById> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validated = validateActionInputGetCardById(input);
|
||||
const card = await serviceGetCardByIdWithNote(validated.cardId);
|
||||
|
||||
if (!card) {
|
||||
return { success: false, message: "Card not found" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Card fetched successfully",
|
||||
data: mapCardWithNoteToOutput(card),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get card by id", { error: e });
|
||||
return { success: false, message: "An error occurred while fetching the card" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user