refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移
This commit is contained in:
157
src/modules/deck/deck-action-dto.ts
Normal file
157
src/modules/deck/deck-action-dto.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { generateValidator } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
|
||||
export const schemaActionInputCreateDeck = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
desc: z.string().max(500).optional(),
|
||||
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
|
||||
});
|
||||
export type ActionInputCreateDeck = z.infer<typeof schemaActionInputCreateDeck>;
|
||||
export const validateActionInputCreateDeck = generateValidator(schemaActionInputCreateDeck);
|
||||
|
||||
export const schemaActionInputUpdateDeck = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
desc: z.string().max(500).optional(),
|
||||
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
|
||||
collapsed: z.boolean().optional(),
|
||||
});
|
||||
export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>;
|
||||
export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
|
||||
|
||||
export const schemaActionInputDeleteDeck = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputDeleteDeck = z.infer<typeof schemaActionInputDeleteDeck>;
|
||||
export const validateActionInputDeleteDeck = generateValidator(schemaActionInputDeleteDeck);
|
||||
|
||||
export const schemaActionInputGetDeckById = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputGetDeckById = z.infer<typeof schemaActionInputGetDeckById>;
|
||||
export const validateActionInputGetDeckById = generateValidator(schemaActionInputGetDeckById);
|
||||
|
||||
export const schemaActionInputGetPublicDecks = z.object({
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type ActionInputGetPublicDecks = z.infer<typeof schemaActionInputGetPublicDecks>;
|
||||
export const validateActionInputGetPublicDecks = generateValidator(schemaActionInputGetPublicDecks);
|
||||
|
||||
export type ActionOutputDeck = {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
userId: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
collapsed: boolean;
|
||||
conf: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
cardCount?: number;
|
||||
};
|
||||
|
||||
export type ActionOutputPublicDeck = ActionOutputDeck & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ActionOutputCreateDeck = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
deckId?: number;
|
||||
};
|
||||
|
||||
export type ActionOutputUpdateDeck = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputDeleteDeck = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputGetDeckById = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeck;
|
||||
};
|
||||
|
||||
export type ActionOutputGetDecksByUserId = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeck[];
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicDecks = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicDeck[];
|
||||
};
|
||||
|
||||
export const schemaActionInputSearchPublicDecks = z.object({
|
||||
query: z.string().min(1),
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type ActionInputSearchPublicDecks = z.infer<typeof schemaActionInputSearchPublicDecks>;
|
||||
export const validateActionInputSearchPublicDecks = generateValidator(schemaActionInputSearchPublicDecks);
|
||||
|
||||
export const schemaActionInputGetPublicDeckById = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputGetPublicDeckById = z.infer<typeof schemaActionInputGetPublicDeckById>;
|
||||
export const validateActionInputGetPublicDeckById = generateValidator(schemaActionInputGetPublicDeckById);
|
||||
|
||||
export const schemaActionInputToggleDeckFavorite = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputToggleDeckFavorite = z.infer<typeof schemaActionInputToggleDeckFavorite>;
|
||||
export const validateActionInputToggleDeckFavorite = generateValidator(schemaActionInputToggleDeckFavorite);
|
||||
|
||||
export const schemaActionInputCheckDeckFavorite = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputCheckDeckFavorite = z.infer<typeof schemaActionInputCheckDeckFavorite>;
|
||||
export const validateActionInputCheckDeckFavorite = generateValidator(schemaActionInputCheckDeckFavorite);
|
||||
|
||||
export type ActionOutputDeckFavorite = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ActionOutputSearchPublicDecks = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicDeck[];
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicDeckById = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicDeck;
|
||||
};
|
||||
|
||||
export type ActionOutputToggleDeckFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeckFavorite;
|
||||
};
|
||||
|
||||
export type ActionOutputCheckDeckFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeckFavorite;
|
||||
};
|
||||
|
||||
export type ActionOutputUserFavoriteDeck = ActionOutputPublicDeck & {
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
export type ActionOutputGetUserFavoriteDecks = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputUserFavoriteDeck[];
|
||||
};
|
||||
327
src/modules/deck/deck-action.ts
Normal file
327
src/modules/deck/deck-action.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
import {
|
||||
ActionInputCreateDeck,
|
||||
ActionInputUpdateDeck,
|
||||
ActionInputDeleteDeck,
|
||||
ActionInputGetDeckById,
|
||||
ActionInputGetPublicDecks,
|
||||
ActionInputSearchPublicDecks,
|
||||
ActionInputGetPublicDeckById,
|
||||
ActionInputToggleDeckFavorite,
|
||||
ActionInputCheckDeckFavorite,
|
||||
ActionOutputCreateDeck,
|
||||
ActionOutputUpdateDeck,
|
||||
ActionOutputDeleteDeck,
|
||||
ActionOutputGetDeckById,
|
||||
ActionOutputGetDecksByUserId,
|
||||
ActionOutputGetPublicDecks,
|
||||
ActionOutputDeck,
|
||||
ActionOutputPublicDeck,
|
||||
ActionOutputSearchPublicDecks,
|
||||
ActionOutputGetPublicDeckById,
|
||||
ActionOutputToggleDeckFavorite,
|
||||
ActionOutputCheckDeckFavorite,
|
||||
ActionOutputGetUserFavoriteDecks,
|
||||
validateActionInputCreateDeck,
|
||||
validateActionInputUpdateDeck,
|
||||
validateActionInputDeleteDeck,
|
||||
validateActionInputGetDeckById,
|
||||
validateActionInputGetPublicDecks,
|
||||
validateActionInputSearchPublicDecks,
|
||||
validateActionInputGetPublicDeckById,
|
||||
validateActionInputToggleDeckFavorite,
|
||||
validateActionInputCheckDeckFavorite,
|
||||
} from "./deck-action-dto";
|
||||
import {
|
||||
serviceCreateDeck,
|
||||
serviceUpdateDeck,
|
||||
serviceDeleteDeck,
|
||||
serviceGetDeckById,
|
||||
serviceGetDecksByUserId,
|
||||
serviceGetPublicDecks,
|
||||
serviceCheckOwnership,
|
||||
serviceSearchPublicDecks,
|
||||
serviceGetPublicDeckById,
|
||||
serviceToggleDeckFavorite,
|
||||
serviceCheckDeckFavorite,
|
||||
serviceGetUserFavoriteDecks,
|
||||
} from "./deck-service";
|
||||
|
||||
const log = createLogger("deck-action");
|
||||
|
||||
async function checkDeckOwnership(deckId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
return serviceCheckOwnership({ deckId, userId: session.user.id });
|
||||
}
|
||||
|
||||
export async function actionCreateDeck(input: ActionInputCreateDeck): Promise<ActionOutputCreateDeck> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validatedInput = validateActionInputCreateDeck(input);
|
||||
const result = await serviceCreateDeck({
|
||||
name: validatedInput.name,
|
||||
desc: validatedInput.desc,
|
||||
userId: session.user.id,
|
||||
visibility: validatedInput.visibility as Visibility | undefined,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to create deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdateDeck(input: ActionInputUpdateDeck): Promise<ActionOutputUpdateDeck> {
|
||||
try {
|
||||
const validatedInput = validateActionInputUpdateDeck(input);
|
||||
|
||||
const isOwner = await checkDeckOwnership(validatedInput.deckId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to update this deck" };
|
||||
}
|
||||
|
||||
return serviceUpdateDeck({
|
||||
deckId: validatedInput.deckId,
|
||||
name: validatedInput.name,
|
||||
desc: validatedInput.desc,
|
||||
visibility: validatedInput.visibility as Visibility | undefined,
|
||||
collapsed: validatedInput.collapsed,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to update deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteDeck(input: ActionInputDeleteDeck): Promise<ActionOutputDeleteDeck> {
|
||||
try {
|
||||
const validatedInput = validateActionInputDeleteDeck(input);
|
||||
|
||||
const isOwner = await checkDeckOwnership(validatedInput.deckId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to delete this deck" };
|
||||
}
|
||||
|
||||
return serviceDeleteDeck({ deckId: validatedInput.deckId });
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to delete deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetDeckById(input: ActionInputGetDeckById): Promise<ActionOutputGetDeckById> {
|
||||
try {
|
||||
const validatedInput = validateActionInputGetDeckById(input);
|
||||
const result = await serviceGetDeckById({ deckId: validatedInput.deckId });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
...result.data,
|
||||
visibility: result.data.visibility as "PRIVATE" | "PUBLIC",
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to get deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetDecksByUserId(userId: string): Promise<ActionOutputGetDecksByUserId> {
|
||||
try {
|
||||
const result = await serviceGetDecksByUserId({ userId });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to get decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicDecks(input: ActionInputGetPublicDecks = {}): Promise<ActionOutputGetPublicDecks> {
|
||||
try {
|
||||
const validatedInput = validateActionInputGetPublicDecks(input);
|
||||
const result = await serviceGetPublicDecks(validatedInput);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get public decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicDeckById(input: ActionInputGetPublicDeckById): Promise<ActionOutputGetPublicDeckById> {
|
||||
try {
|
||||
const validatedInput = validateActionInputGetPublicDeckById(input);
|
||||
const result = await serviceGetPublicDeckById(validatedInput);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
...result.data,
|
||||
visibility: result.data.visibility as "PRIVATE" | "PUBLIC",
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get public deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionSearchPublicDecks(input: ActionInputSearchPublicDecks): Promise<ActionOutputSearchPublicDecks> {
|
||||
try {
|
||||
const validatedInput = validateActionInputSearchPublicDecks(input);
|
||||
const result = await serviceSearchPublicDecks(validatedInput);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to search public decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionToggleDeckFavorite(input: ActionInputToggleDeckFavorite): Promise<ActionOutputToggleDeckFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validatedInput = validateActionInputToggleDeckFavorite(input);
|
||||
const result = await serviceToggleDeckFavorite({
|
||||
deckId: validatedInput.deckId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to toggle deck favorite", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCheckDeckFavorite(input: ActionInputCheckDeckFavorite): Promise<ActionOutputCheckDeckFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: true, message: "Not logged in", data: { isFavorited: false, favoriteCount: 0 } };
|
||||
}
|
||||
|
||||
const validatedInput = validateActionInputCheckDeckFavorite(input);
|
||||
const result = await serviceCheckDeckFavorite({
|
||||
deckId: validatedInput.deckId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to check deck favorite", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserFavoriteDecks(): Promise<ActionOutputGetUserFavoriteDecks> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const result = await serviceGetUserFavoriteDecks(session.user.id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to get user favorite decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
90
src/modules/deck/deck-repository-dto.ts
Normal file
90
src/modules/deck/deck-repository-dto.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export interface RepoInputCreateDeck {
|
||||
name: string;
|
||||
desc?: string;
|
||||
userId: string;
|
||||
visibility?: Visibility;
|
||||
}
|
||||
|
||||
export interface RepoInputUpdateDeck {
|
||||
id: number;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
visibility?: Visibility;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface RepoInputGetDeckById {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetDecksByUserId {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputGetPublicDecks {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: "createdAt" | "name";
|
||||
}
|
||||
|
||||
export interface RepoInputDeleteDeck {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export type RepoOutputDeck = {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
userId: string;
|
||||
visibility: Visibility;
|
||||
collapsed: boolean;
|
||||
conf: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
cardCount?: number;
|
||||
};
|
||||
|
||||
export type RepoOutputPublicDeck = RepoOutputDeck & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type RepoOutputDeckOwnership = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export interface RepoInputToggleDeckFavorite {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputCheckDeckFavorite {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputSearchPublicDecks {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetPublicDeckById {
|
||||
deckId: number;
|
||||
}
|
||||
|
||||
export type RepoOutputDeckFavorite = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export interface RepoInputGetUserFavoriteDecks {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type RepoOutputUserFavoriteDeck = RepoOutputPublicDeck & {
|
||||
favoritedAt: Date;
|
||||
};
|
||||
327
src/modules/deck/deck-repository.ts
Normal file
327
src/modules/deck/deck-repository.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
RepoInputCreateDeck,
|
||||
RepoInputUpdateDeck,
|
||||
RepoInputGetDeckById,
|
||||
RepoInputGetDecksByUserId,
|
||||
RepoInputGetPublicDecks,
|
||||
RepoInputDeleteDeck,
|
||||
RepoOutputDeck,
|
||||
RepoOutputPublicDeck,
|
||||
RepoOutputDeckOwnership,
|
||||
RepoInputToggleDeckFavorite,
|
||||
RepoInputCheckDeckFavorite,
|
||||
RepoInputSearchPublicDecks,
|
||||
RepoInputGetPublicDeckById,
|
||||
RepoOutputDeckFavorite,
|
||||
RepoInputGetUserFavoriteDecks,
|
||||
RepoOutputUserFavoriteDeck,
|
||||
} from "./deck-repository-dto";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export async function repoCreateDeck(data: RepoInputCreateDeck): Promise<number> {
|
||||
const deck = await prisma.deck.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
desc: data.desc ?? "",
|
||||
userId: data.userId,
|
||||
visibility: data.visibility ?? Visibility.PRIVATE,
|
||||
},
|
||||
});
|
||||
return deck.id;
|
||||
}
|
||||
|
||||
export async function repoUpdateDeck(input: RepoInputUpdateDeck): Promise<void> {
|
||||
const { id, ...updateData } = input;
|
||||
await prisma.deck.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetDeckById(input: RepoInputGetDeckById): Promise<RepoOutputDeck | null> {
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deck) return null;
|
||||
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
collapsed: deck.collapsed,
|
||||
conf: deck.conf,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetDecksByUserId(input: RepoInputGetDecksByUserId): Promise<RepoOutputDeck[]> {
|
||||
const decks = await prisma.deck.findMany({
|
||||
where: { userId: input.userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return decks.map((deck) => ({
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
collapsed: deck.collapsed,
|
||||
conf: deck.conf,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetPublicDecks(input: RepoInputGetPublicDecks = {}): Promise<RepoOutputPublicDeck[]> {
|
||||
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
|
||||
|
||||
const decks = await prisma.deck.findMany({
|
||||
where: { visibility: Visibility.PUBLIC },
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
orderBy: { [orderBy]: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return decks.map((deck) => ({
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
collapsed: deck.collapsed,
|
||||
conf: deck.conf,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
userName: deck.user?.name ?? null,
|
||||
userUsername: deck.user?.username ?? null,
|
||||
favoriteCount: deck._count?.favorites ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoDeleteDeck(input: RepoInputDeleteDeck): Promise<void> {
|
||||
await prisma.deck.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetUserIdByDeckId(deckId: number): Promise<string | null> {
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: deckId },
|
||||
select: { userId: true },
|
||||
});
|
||||
return deck?.userId ?? null;
|
||||
}
|
||||
|
||||
export async function repoGetDeckOwnership(deckId: number): Promise<RepoOutputDeckOwnership | null> {
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: deckId },
|
||||
select: { userId: true },
|
||||
});
|
||||
return deck;
|
||||
}
|
||||
|
||||
export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById): Promise<RepoOutputPublicDeck | null> {
|
||||
const deck = await prisma.deck.findFirst({
|
||||
where: {
|
||||
id: input.deckId,
|
||||
visibility: Visibility.PUBLIC,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deck) return null;
|
||||
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
collapsed: deck.collapsed,
|
||||
conf: deck.conf,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
userName: deck.user?.name ?? null,
|
||||
userUsername: deck.user?.username ?? null,
|
||||
favoriteCount: deck._count?.favorites ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoToggleDeckFavorite(input: RepoInputToggleDeckFavorite): Promise<RepoOutputDeckFavorite> {
|
||||
const existing = await prisma.deckFavorite.findUnique({
|
||||
where: {
|
||||
userId_deckId: {
|
||||
userId: input.userId,
|
||||
deckId: input.deckId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.deckFavorite.delete({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
} else {
|
||||
await prisma.deckFavorite.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
deckId: input.deckId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: input.deckId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { favorites: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isFavorited: !existing,
|
||||
favoriteCount: deck?._count?.favorites ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoCheckDeckFavorite(input: RepoInputCheckDeckFavorite): Promise<RepoOutputDeckFavorite> {
|
||||
const favorite = await prisma.deckFavorite.findUnique({
|
||||
where: {
|
||||
userId_deckId: {
|
||||
userId: input.userId,
|
||||
deckId: input.deckId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: input.deckId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { favorites: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isFavorited: !!favorite,
|
||||
favoriteCount: deck?._count?.favorites ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoSearchPublicDecks(input: RepoInputSearchPublicDecks): Promise<RepoOutputPublicDeck[]> {
|
||||
const { query, limit = 50, offset = 0 } = input;
|
||||
|
||||
const decks = await prisma.deck.findMany({
|
||||
where: {
|
||||
visibility: Visibility.PUBLIC,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ desc: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return decks.map((deck) => ({
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
collapsed: deck.collapsed,
|
||||
conf: deck.conf,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
userName: deck.user?.name ?? null,
|
||||
userUsername: deck.user?.username ?? null,
|
||||
favoriteCount: deck._count?.favorites ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetUserFavoriteDecks(
|
||||
input: RepoInputGetUserFavoriteDecks,
|
||||
): Promise<RepoOutputUserFavoriteDeck[]> {
|
||||
const favorites = await prisma.deckFavorite.findMany({
|
||||
where: { userId: input.userId },
|
||||
include: {
|
||||
deck: {
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return favorites.map((fav) => ({
|
||||
id: fav.deck.id,
|
||||
name: fav.deck.name,
|
||||
desc: fav.deck.desc,
|
||||
userId: fav.deck.userId,
|
||||
visibility: fav.deck.visibility,
|
||||
collapsed: fav.deck.collapsed,
|
||||
conf: fav.deck.conf,
|
||||
createdAt: fav.deck.createdAt,
|
||||
updatedAt: fav.deck.updatedAt,
|
||||
cardCount: fav.deck._count?.cards ?? 0,
|
||||
userName: fav.deck.user?.name ?? null,
|
||||
userUsername: fav.deck.user?.username ?? null,
|
||||
favoriteCount: fav.deck._count?.favorites ?? 0,
|
||||
favoritedAt: fav.createdAt,
|
||||
}));
|
||||
}
|
||||
86
src/modules/deck/deck-service-dto.ts
Normal file
86
src/modules/deck/deck-service-dto.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export type ServiceInputCreateDeck = {
|
||||
name: string;
|
||||
desc?: string;
|
||||
userId: string;
|
||||
visibility?: Visibility;
|
||||
};
|
||||
|
||||
export type ServiceInputUpdateDeck = {
|
||||
deckId: number;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
visibility?: Visibility;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
export type ServiceInputDeleteDeck = {
|
||||
deckId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetDeckById = {
|
||||
deckId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetDecksByUserId = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputGetPublicDecks = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckOwnership = {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputDeck = {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
userId: string;
|
||||
visibility: Visibility;
|
||||
collapsed: boolean;
|
||||
conf: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
cardCount?: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputPublicDeck = ServiceOutputDeck & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceInputToggleDeckFavorite = {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckDeckFavorite = {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputSearchPublicDecks = {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetPublicDeckById = {
|
||||
deckId: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputDeckFavorite = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputUserFavoriteDeck = ServiceOutputPublicDeck & {
|
||||
favoritedAt: Date;
|
||||
};
|
||||
169
src/modules/deck/deck-service.ts
Normal file
169
src/modules/deck/deck-service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
"use server";
|
||||
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
ServiceInputCreateDeck,
|
||||
ServiceInputUpdateDeck,
|
||||
ServiceInputDeleteDeck,
|
||||
ServiceInputGetDeckById,
|
||||
ServiceInputGetDecksByUserId,
|
||||
ServiceInputGetPublicDecks,
|
||||
ServiceInputCheckOwnership,
|
||||
ServiceOutputDeck,
|
||||
ServiceOutputPublicDeck,
|
||||
ServiceInputToggleDeckFavorite,
|
||||
ServiceInputCheckDeckFavorite,
|
||||
ServiceInputSearchPublicDecks,
|
||||
ServiceInputGetPublicDeckById,
|
||||
ServiceOutputDeckFavorite,
|
||||
ServiceOutputUserFavoriteDeck,
|
||||
} from "./deck-service-dto";
|
||||
import {
|
||||
repoCreateDeck,
|
||||
repoUpdateDeck,
|
||||
repoGetDeckById,
|
||||
repoGetDecksByUserId,
|
||||
repoGetPublicDecks,
|
||||
repoDeleteDeck,
|
||||
repoGetUserIdByDeckId,
|
||||
repoToggleDeckFavorite,
|
||||
repoCheckDeckFavorite,
|
||||
repoSearchPublicDecks,
|
||||
repoGetPublicDeckById,
|
||||
repoGetUserFavoriteDecks,
|
||||
} from "./deck-repository";
|
||||
|
||||
const log = createLogger("deck-service");
|
||||
|
||||
export async function serviceCheckOwnership(input: ServiceInputCheckOwnership): Promise<boolean> {
|
||||
const ownerId = await repoGetUserIdByDeckId(input.deckId);
|
||||
return ownerId === input.userId;
|
||||
}
|
||||
|
||||
export async function serviceCreateDeck(input: ServiceInputCreateDeck): Promise<{ success: boolean; deckId?: number; message: string }> {
|
||||
try {
|
||||
log.info("Creating deck", { name: input.name, userId: input.userId });
|
||||
const deckId = await repoCreateDeck(input);
|
||||
log.info("Deck created successfully", { deckId });
|
||||
return { success: true, deckId, message: "Deck created successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to create deck", { error });
|
||||
return { success: false, message: "Failed to create deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceUpdateDeck(input: ServiceInputUpdateDeck): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
log.info("Updating deck", { deckId: input.deckId });
|
||||
await repoUpdateDeck({
|
||||
id: input.deckId,
|
||||
name: input.name,
|
||||
desc: input.desc,
|
||||
visibility: input.visibility,
|
||||
collapsed: input.collapsed,
|
||||
});
|
||||
log.info("Deck updated successfully", { deckId: input.deckId });
|
||||
return { success: true, message: "Deck updated successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to update deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to update deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceDeleteDeck(input: ServiceInputDeleteDeck): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
log.info("Deleting deck", { deckId: input.deckId });
|
||||
await repoDeleteDeck({ id: input.deckId });
|
||||
log.info("Deck deleted successfully", { deckId: input.deckId });
|
||||
return { success: true, message: "Deck deleted successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to delete deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to delete deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetDeckById(input: ServiceInputGetDeckById): Promise<{ success: boolean; data?: ServiceOutputDeck; message: string }> {
|
||||
try {
|
||||
const deck = await repoGetDeckById({ id: input.deckId });
|
||||
if (!deck) {
|
||||
return { success: false, message: "Deck not found" };
|
||||
}
|
||||
return { success: true, data: deck, message: "Deck retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to get deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetDecksByUserId(input: ServiceInputGetDecksByUserId): Promise<{ success: boolean; data?: ServiceOutputDeck[]; message: string }> {
|
||||
try {
|
||||
const decks = await repoGetDecksByUserId(input);
|
||||
return { success: true, data: decks, message: "Decks retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get decks", { error, userId: input.userId });
|
||||
return { success: false, message: "Failed to get decks" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetPublicDecks(input: ServiceInputGetPublicDecks = {}): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
|
||||
try {
|
||||
const decks = await repoGetPublicDecks(input);
|
||||
return { success: true, data: decks, message: "Public decks retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get public decks", { error });
|
||||
return { success: false, message: "Failed to get public decks" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetPublicDeckById(input: ServiceInputGetPublicDeckById): Promise<{ success: boolean; data?: ServiceOutputPublicDeck; message: string }> {
|
||||
try {
|
||||
const deck = await repoGetPublicDeckById(input);
|
||||
if (!deck) {
|
||||
return { success: false, message: "Deck not found or not public" };
|
||||
}
|
||||
return { success: true, data: deck, message: "Deck retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get public deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to get deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceToggleDeckFavorite(input: ServiceInputToggleDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
|
||||
try {
|
||||
const result = await repoToggleDeckFavorite(input);
|
||||
return { success: true, data: result, message: "Favorite toggled successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to toggle deck favorite", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to toggle favorite" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceCheckDeckFavorite(input: ServiceInputCheckDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
|
||||
try {
|
||||
const result = await repoCheckDeckFavorite(input);
|
||||
return { success: true, data: result, message: "Favorite status retrieved" };
|
||||
} catch (error) {
|
||||
log.error("Failed to check deck favorite", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to check favorite status" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceSearchPublicDecks(input: ServiceInputSearchPublicDecks): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
|
||||
try {
|
||||
const decks = await repoSearchPublicDecks(input);
|
||||
return { success: true, data: decks, message: "Search completed successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to search public decks", { error, query: input.query });
|
||||
return { success: false, message: "Search failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetUserFavoriteDecks(userId: string): Promise<{ success: boolean; data?: ServiceOutputUserFavoriteDeck[]; message: string }> {
|
||||
try {
|
||||
const favorites = await repoGetUserFavoriteDecks({ userId });
|
||||
return { success: true, data: favorites, message: "Favorite decks retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get user favorite decks", { error, userId });
|
||||
return { success: false, message: "Failed to get favorite decks" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user