refactor: 完全重构为 Anki 兼容数据结构

- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
This commit is contained in:
2026-03-10 19:20:46 +08:00
parent 9b78fd5215
commit 57ad1b8699
72 changed files with 7107 additions and 2430 deletions

View 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[];
};

View 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" };
}
}

View 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;
};

View 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,
}));
}

View 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;
};

View 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" };
}
}