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,164 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
export const schemaActionInputCreateCard = z.object({
noteId: z.bigint(),
deckId: z.number().int().positive(),
ord: z.number().int().min(0).optional(),
});
export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>;
export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
export const schemaActionInputAnswerCard = z.object({
cardId: z.bigint(),
ease: z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
]),
});
export type ActionInputAnswerCard = z.infer<typeof schemaActionInputAnswerCard>;
export const validateActionInputAnswerCard = generateValidator(schemaActionInputAnswerCard);
export const schemaActionInputGetCardsForReview = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
});
export type ActionInputGetCardsForReview = z.infer<typeof schemaActionInputGetCardsForReview>;
export const validateActionInputGetCardsForReview = generateValidator(schemaActionInputGetCardsForReview);
export const schemaActionInputGetNewCards = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
});
export type ActionInputGetNewCards = z.infer<typeof schemaActionInputGetNewCards>;
export const validateActionInputGetNewCards = generateValidator(schemaActionInputGetNewCards);
export const schemaActionInputGetCardsByDeckId = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
queue: z.union([
z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"]),
z.array(z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"])),
]).optional(),
});
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
export const schemaActionInputGetCardStats = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputGetCardStats = z.infer<typeof schemaActionInputGetCardStats>;
export const validateActionInputGetCardStats = generateValidator(schemaActionInputGetCardStats);
export const schemaActionInputDeleteCard = z.object({
cardId: z.bigint(),
});
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
export const schemaActionInputGetCardById = z.object({
cardId: z.bigint(),
});
export type ActionInputGetCardById = z.infer<typeof schemaActionInputGetCardById>;
export const validateActionInputGetCardById = generateValidator(schemaActionInputGetCardById);
export type ActionOutputCard = {
id: string;
noteId: string;
deckId: number;
ord: number;
mod: number;
usn: number;
type: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
queue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCardWithNote = ActionOutputCard & {
note: {
id: string;
flds: string;
sfld: string;
tags: string;
};
};
export type ActionOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ActionOutputScheduledCard = {
cardId: string;
newType: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
newQueue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ActionOutputCreateCard = {
success: boolean;
message: string;
data?: {
cardId: string;
};
};
export type ActionOutputAnswerCard = {
success: boolean;
message: string;
data?: {
card: ActionOutputCard;
scheduled: ActionOutputScheduledCard;
};
};
export type ActionOutputGetCards = {
success: boolean;
message: string;
data?: ActionOutputCard[];
};
export type ActionOutputGetCardsWithNote = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote[];
};
export type ActionOutputGetCardStats = {
success: boolean;
message: string;
data?: ActionOutputCardStats;
};
export type ActionOutputDeleteCard = {
success: boolean;
message: string;
};
export type ActionOutputGetCardById = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote;
};

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

View File

@@ -0,0 +1,104 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums";
export interface RepoInputCreateCard {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
due: number;
type?: CardType;
queue?: CardQueue;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
}
export interface RepoInputUpdateCard {
ord?: number;
mod?: number;
usn?: number;
type?: CardType;
queue?: CardQueue;
due?: number;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
}
export interface RepoInputGetCardsByDeckId {
deckId: number;
limit?: number;
offset?: number;
queue?: CardQueue | CardQueue[];
}
export interface RepoInputGetCardsForReview {
deckId: number;
limit?: number;
}
export interface RepoInputGetNewCards {
deckId: number;
limit?: number;
}
export interface RepoInputBulkUpdateCard {
id: bigint;
data: RepoInputUpdateCard;
}
export interface RepoInputBulkUpdateCards {
cards: RepoInputBulkUpdateCard[];
}
export type RepoOutputCard = {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputCardWithNote = RepoOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type RepoOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};

View File

@@ -0,0 +1,309 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateCard,
RepoInputUpdateCard,
RepoInputGetCardsByDeckId,
RepoInputGetCardsForReview,
RepoInputGetNewCards,
RepoInputBulkUpdateCards,
RepoOutputCard,
RepoOutputCardWithNote,
RepoOutputCardStats,
} from "./card-repository-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-repository");
export async function repoCreateCard(
input: RepoInputCreateCard,
): Promise<bigint> {
log.debug("Creating card", { noteId: input.noteId.toString(), deckId: input.deckId });
const card = await prisma.card.create({
data: {
id: input.id,
noteId: input.noteId,
deckId: input.deckId,
ord: input.ord,
due: input.due,
mod: Math.floor(Date.now() / 1000),
type: input.type ?? CardType.NEW,
queue: input.queue ?? CardQueue.NEW,
ivl: input.ivl ?? 0,
factor: input.factor ?? 2500,
reps: input.reps ?? 0,
lapses: input.lapses ?? 0,
left: input.left ?? 0,
odue: input.odue ?? 0,
odid: input.odid ?? 0,
flags: input.flags ?? 0,
data: input.data ?? "",
},
});
log.info("Card created", { cardId: card.id.toString() });
return card.id;
}
export async function repoUpdateCard(
id: bigint,
input: RepoInputUpdateCard,
): Promise<void> {
log.debug("Updating card", { cardId: id.toString() });
await prisma.card.update({
where: { id },
data: {
...input,
updatedAt: new Date(),
},
});
log.info("Card updated", { cardId: id.toString() });
}
export async function repoGetCardById(id: bigint): Promise<RepoOutputCard | null> {
const card = await prisma.card.findUnique({
where: { id },
});
return card;
}
export async function repoGetCardByIdWithNote(
id: bigint,
): Promise<RepoOutputCardWithNote | null> {
const card = await prisma.card.findUnique({
where: { id },
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
});
return card;
}
export async function repoGetCardsByDeckId(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCard[]> {
const { deckId, limit = 50, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
orderBy: { due: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsByDeckIdWithNotes(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 100, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { id: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck with notes", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsForReview(
input: RepoInputGetCardsForReview,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const cards = await prisma.card.findMany({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
take: limit,
});
log.debug("Fetched cards for review", { deckId, count: cards.length });
return cards;
}
export async function repoGetNewCards(
input: RepoInputGetNewCards,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const cards = await prisma.card.findMany({
where: {
deckId,
type: CardType.NEW,
queue: CardQueue.NEW,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { due: "asc" },
take: limit,
});
log.debug("Fetched new cards", { deckId, count: cards.length });
return cards;
}
export async function repoDeleteCard(id: bigint): Promise<void> {
log.debug("Deleting card", { cardId: id.toString() });
await prisma.card.delete({
where: { id },
});
log.info("Card deleted", { cardId: id.toString() });
}
export async function repoBulkUpdateCards(
input: RepoInputBulkUpdateCards,
): Promise<void> {
log.debug("Bulk updating cards", { count: input.cards.length });
await prisma.$transaction(
input.cards.map((item) =>
prisma.card.update({
where: { id: item.id },
data: {
...item.data,
updatedAt: new Date(),
},
}),
),
);
log.info("Bulk update completed", { count: input.cards.length });
}
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const [total, newCards, learning, review, due] = await Promise.all([
prisma.card.count({ where: { deckId } }),
prisma.card.count({ where: { deckId, type: CardType.NEW } }),
prisma.card.count({ where: { deckId, type: CardType.LEARNING } }),
prisma.card.count({ where: { deckId, type: CardType.REVIEW } }),
prisma.card.count({
where: {
deckId,
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
}),
]);
return { total, new: newCards, learning, review, due };
}
export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | null> {
const card = await prisma.card.findUnique({
where: { id: cardId },
include: {
deck: {
select: { userId: true },
},
},
});
return card?.deck.userId ?? null;
}
export async function repoGetNextDueCard(deckId: number): Promise<RepoOutputCard | null> {
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const card = await prisma.card.findFirst({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
});
return card;
}
export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCard[]> {
const cards = await prisma.card.findMany({
where: { noteId },
orderBy: { ord: "asc" },
});
return cards;
}

View File

@@ -0,0 +1,113 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums";
export type ReviewEase = 1 | 2 | 3 | 4;
export interface ServiceInputCreateCard {
noteId: bigint;
deckId: number;
ord?: number;
}
export interface ServiceInputAnswerCard {
cardId: bigint;
ease: ReviewEase;
}
export interface ServiceInputGetCardsForReview {
deckId: number;
limit?: number;
}
export interface ServiceInputGetNewCards {
deckId: number;
limit?: number;
}
export interface ServiceInputGetCardsByDeckId {
deckId: number;
limit?: number;
offset?: number;
queue?: CardQueue | CardQueue[];
}
export interface ServiceInputGetCardStats {
deckId: number;
}
export type ServiceOutputCard = {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputCardWithNote = ServiceOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type ServiceOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ServiceOutputScheduledCard = {
cardId: bigint;
newType: CardType;
newQueue: CardQueue;
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ServiceOutputReviewResult = {
success: boolean;
card: ServiceOutputCard;
scheduled: ServiceOutputScheduledCard;
};
export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10],
GRADUATING_INTERVAL_GOOD: 1,
GRADUATING_INTERVAL_EASY: 4,
EASY_INTERVAL: 4,
MINIMUM_FACTOR: 1300,
DEFAULT_FACTOR: 2500,
FACTOR_ADJUSTMENTS: {
1: -200,
2: -150,
3: 0,
4: 150,
},
INITIAL_INTERVALS: {
2: 1,
3: 3,
4: 4,
},
} as const;

View File

@@ -0,0 +1,384 @@
import { createLogger } from "@/lib/logger";
import {
repoCreateCard,
repoUpdateCard,
repoGetCardById,
repoGetCardByIdWithNote,
repoGetCardsByDeckId,
repoGetCardsByDeckIdWithNotes,
repoGetCardsForReview,
repoGetNewCards,
repoGetCardStats,
repoDeleteCard,
repoGetCardsByNoteId,
} from "./card-repository";
import {
RepoInputUpdateCard,
RepoOutputCard,
} from "./card-repository-dto";
import {
ServiceInputCreateCard,
ServiceInputAnswerCard,
ServiceInputGetCardsForReview,
ServiceInputGetNewCards,
ServiceInputGetCardsByDeckId,
ServiceInputGetCardStats,
ServiceOutputCard,
ServiceOutputCardWithNote,
ServiceOutputCardStats,
ServiceOutputScheduledCard,
ServiceOutputReviewResult,
ReviewEase,
SM2_CONFIG,
} from "./card-service-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-service");
function generateCardId(): bigint {
return BigInt(Date.now());
}
function calculateDueDate(intervalDays: number): number {
const now = Math.floor(Date.now() / 1000);
const todayStart = Math.floor(now / 86400) * 86400;
return Math.floor(todayStart / 86400) + intervalDays;
}
function calculateNextReviewTime(intervalDays: number): Date {
const now = Date.now();
return new Date(now + intervalDays * 86400 * 1000);
}
function scheduleNewCard(ease: ReviewEase, factor: number): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
} {
if (ease === 1) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
};
}
const ivl = SM2_CONFIG.INITIAL_INTERVALS[ease];
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
};
}
function scheduleLearningCard(ease: ReviewEase, factor: number, left: number): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLeft: number;
} {
if (ease === 1) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
newLeft: SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length,
};
}
const stepIndex = Math.floor(left % 1000);
if (ease === 2 && stepIndex < SM2_CONFIG.LEARNING_STEPS.length - 1) {
const nextStep = stepIndex + 1;
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[nextStep] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]),
newLeft: nextStep * 1000 + (SM2_CONFIG.LEARNING_STEPS.length - nextStep),
};
}
const ivl = ease === 4 ? SM2_CONFIG.GRADUATING_INTERVAL_EASY : SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
newLeft: 0,
};
}
function scheduleReviewCard(
ease: ReviewEase,
ivl: number,
factor: number,
lapses: number,
): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLapses: number;
} {
if (ease === 1) {
return {
type: CardType.RELEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
newLapses: lapses + 1,
};
}
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]);
const factorMultiplier = newFactor / 1000;
let newIvl = Math.floor(ivl * factorMultiplier);
if (ease === 2) {
newIvl = Math.max(1, Math.floor(newIvl * 1.2));
} else if (ease === 4) {
newIvl = Math.floor(newIvl * 1.3);
}
newIvl = Math.max(1, newIvl);
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl: newIvl,
due: calculateDueDate(newIvl),
newFactor,
newLapses: lapses,
};
}
function mapToServiceOutput(card: RepoOutputCard): ServiceOutputCard {
return {
id: card.id,
noteId: card.noteId,
deckId: card.deckId,
ord: card.ord,
mod: card.mod,
usn: card.usn,
type: card.type,
queue: card.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,
};
}
export async function serviceCreateCard(
input: ServiceInputCreateCard,
): Promise<bigint> {
log.info("Creating card from note", { noteId: input.noteId.toString(), deckId: input.deckId });
const existingCards = await repoGetCardsByNoteId(input.noteId);
const maxOrd = existingCards.reduce((max, c) => Math.max(max, c.ord), -1);
const ord = input.ord ?? maxOrd + 1;
const cardId = await repoCreateCard({
id: generateCardId(),
noteId: input.noteId,
deckId: input.deckId,
ord,
due: ord,
type: CardType.NEW,
queue: CardQueue.NEW,
});
log.info("Card created", { cardId: cardId.toString() });
return cardId;
}
export async function serviceAnswerCard(
input: ServiceInputAnswerCard,
): Promise<ServiceOutputReviewResult> {
log.info("Answering card", { cardId: input.cardId.toString(), ease: input.ease });
const card = await repoGetCardById(input.cardId);
if (!card) {
throw new Error(`Card not found: ${input.cardId.toString()}`);
}
const { ease } = input;
let updateData: RepoInputUpdateCard;
let scheduled: ServiceOutputScheduledCard;
if (card.type === CardType.NEW) {
const result = scheduleNewCard(ease, card.factor);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.type === CardType.LEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else if (card.type === CardType.LEARNING || card.type === CardType.RELEARNING) {
const result = scheduleLearningCard(ease, card.factor, card.left);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.newLeft,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else {
const result = scheduleReviewCard(ease, card.ivl, card.factor, card.lapses);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
lapses: result.newLapses,
left: result.type === CardType.RELEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: result.newLapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
}
await repoUpdateCard(input.cardId, updateData);
const updatedCard = await repoGetCardById(input.cardId);
if (!updatedCard) {
throw new Error(`Card not found after update: ${input.cardId.toString()}`);
}
log.info("Card answered and scheduled", {
cardId: input.cardId.toString(),
newType: scheduled.newType,
newIvl: scheduled.newIvl,
nextReview: scheduled.nextReviewDate.toISOString(),
});
return {
success: true,
card: mapToServiceOutput(updatedCard),
scheduled,
};
}
export async function serviceGetNextCardForReview(
deckId: number,
): Promise<ServiceOutputCardWithNote | null> {
log.debug("Getting next card for review", { deckId });
const cards = await repoGetCardsForReview({ deckId, limit: 1 });
return cards[0] ?? null;
}
export async function serviceGetCardsForReview(
input: ServiceInputGetCardsForReview,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting cards for review", { deckId: input.deckId });
return repoGetCardsForReview(input);
}
export async function serviceGetNewCards(
input: ServiceInputGetNewCards,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting new cards", { deckId: input.deckId });
return repoGetNewCards(input);
}
export async function serviceGetCardsByDeckId(
input: ServiceInputGetCardsByDeckId,
): Promise<ServiceOutputCard[]> {
log.debug("Getting cards by deck", { deckId: input.deckId });
const cards = await repoGetCardsByDeckId(input);
return cards.map(mapToServiceOutput);
}
export async function serviceGetCardsByDeckIdWithNotes(
input: ServiceInputGetCardsByDeckId,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting cards by deck with notes", { deckId: input.deckId });
return repoGetCardsByDeckIdWithNotes(input);
}
export async function serviceGetCardById(
cardId: bigint,
): Promise<ServiceOutputCard | null> {
const card = await repoGetCardById(cardId);
return card ? mapToServiceOutput(card) : null;
}
export async function serviceGetCardByIdWithNote(
cardId: bigint,
): Promise<ServiceOutputCardWithNote | null> {
return repoGetCardByIdWithNote(cardId);
}
export async function serviceGetCardStats(
input: ServiceInputGetCardStats,
): Promise<ServiceOutputCardStats> {
log.debug("Getting card stats", { deckId: input.deckId });
return repoGetCardStats(input.deckId);
}
export async function serviceDeleteCard(cardId: bigint): Promise<void> {
log.info("Deleting card", { cardId: cardId.toString() });
await repoDeleteCard(cardId);
}

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

View File

@@ -1,110 +0,0 @@
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MAX_IPA, LENGTH_MAX_LANGUAGE, LENGTH_MAX_PAIR_TEXT, LENGTH_MIN_FOLDER_NAME, LENGTH_MIN_IPA, LENGTH_MIN_LANGUAGE, LENGTH_MIN_PAIR_TEXT } from "@/shared/constant";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { generateValidator } from "@/utils/validate";
import z from "zod";
export const schemaActionInputCreatePair = z.object({
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
folderId: z.int()
});
export type ActionInputCreatePair = z.infer<typeof schemaActionInputCreatePair>;
export const validateActionInputCreatePair = generateValidator(schemaActionInputCreatePair);
export const schemaActionInputUpdatePairById = z.object({
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
folderId: z.int().optional()
});
export type ActionInputUpdatePairById = z.infer<typeof schemaActionInputUpdatePairById>;
export const validateActionInputUpdatePairById = generateValidator(schemaActionInputUpdatePairById);
export type ActionOutputGetFoldersWithTotalPairsByUserId = {
message: string,
success: boolean,
data?: TSharedFolderWithTotalPairs[];
};
export const schemaActionInputSetFolderVisibility = z.object({
folderId: z.number().int().positive(),
visibility: z.enum(["PRIVATE", "PUBLIC"]),
});
export type ActionInputSetFolderVisibility = z.infer<typeof schemaActionInputSetFolderVisibility>;
export const schemaActionInputSearchPublicFolders = z.object({
query: z.string().min(1).max(100),
});
export type ActionInputSearchPublicFolders = z.infer<typeof schemaActionInputSearchPublicFolders>;
export type ActionOutputPublicFolder = {
id: number;
name: string;
visibility: "PRIVATE" | "PUBLIC";
createdAt: Date;
userId: string;
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type ActionOutputGetPublicFolders = {
message: string;
success: boolean;
data?: ActionOutputPublicFolder[];
};
export type ActionOutputGetPublicFolderById = {
message: string;
success: boolean;
data?: ActionOutputPublicFolder;
};
export type ActionOutputSetFolderVisibility = {
message: string;
success: boolean;
};
export type ActionOutputToggleFavorite = {
message: string;
success: boolean;
data?: {
isFavorited: boolean;
favoriteCount: number;
};
};
export type ActionOutputCheckFavorite = {
message: string;
success: boolean;
data?: {
isFavorited: boolean;
favoriteCount: number;
};
};
export type ActionOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
export type ActionOutputGetUserFavorites = {
message: string;
success: boolean;
data?: ActionOutputUserFavorite[];
};

View File

@@ -1,527 +0,0 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
const log = createLogger("folder-action");
import {
ActionInputCreatePair,
ActionInputUpdatePairById,
ActionOutputGetFoldersWithTotalPairsByUserId,
ActionOutputGetPublicFolders,
ActionOutputGetPublicFolderById,
ActionOutputSetFolderVisibility,
ActionOutputToggleFavorite,
ActionOutputCheckFavorite,
ActionOutputGetUserFavorites,
ActionOutputUserFavorite,
validateActionInputCreatePair,
validateActionInputUpdatePairById,
} from "./folder-action-dto";
import {
repoCreateFolder,
repoCreatePair,
repoDeleteFolderById,
repoDeletePairById,
repoGetFolderIdByPairId,
repoGetFolderVisibility,
repoGetFoldersByUserId,
repoGetFoldersWithTotalPairsByUserId,
repoGetPairsByFolderId,
repoGetPublicFolders,
repoGetPublicFolderById,
repoGetUserIdByFolderId,
repoRenameFolderById,
repoSearchPublicFolders,
repoUpdateFolderVisibility,
repoUpdatePairById,
repoToggleFavorite,
repoCheckFavorite,
repoGetUserFavorites,
} from "./folder-repository";
import { validate } from "@/utils/validate";
import z from "zod";
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
import { Visibility } from "../../../generated/prisma/enums";
async function checkFolderOwnership(folderId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
return folderOwnerId === session.user.id;
}
async function checkPairOwnership(pairId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const folderId = await repoGetFolderIdByPairId(pairId);
if (!folderId) return false;
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
return folderOwnerId === session.user.id;
}
export async function actionGetPairsByFolderId(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetPairsByFolderId(folderId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
try {
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to update this item.',
};
}
const validatedDto = validateActionInputUpdatePairById(dto);
await repoUpdatePairById(id, validatedDto);
return {
success: true,
message: 'success',
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetUserIdByFolderId(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetUserIdByFolderId(folderId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFolderVisibility(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetFolderVisibility(folderId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionDeleteFolderById(folderId: number) {
try {
const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to delete this folder.',
};
}
await repoDeleteFolderById(folderId);
return {
success: true,
message: 'success',
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionDeletePairById(id: number) {
try {
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to delete this item.',
};
}
await repoDeletePairById(id);
return {
success: true,
message: 'success'
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promise<ActionOutputGetFoldersWithTotalPairsByUserId> {
try {
return {
success: true,
message: 'success',
data: await repoGetFoldersWithTotalPairsByUserId(id)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFoldersByUserId(userId: string) {
try {
return {
success: true,
message: 'success',
data: await repoGetFoldersByUserId(userId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionCreatePair(dto: ActionInputCreatePair) {
try {
const isOwner = await checkFolderOwnership(dto.folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to add items to this folder.',
};
}
const validatedDto = validateActionInputCreatePair(dto);
await repoCreatePair(validatedDto);
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionCreateFolder(userId: string, folderName: string) {
try {
const validatedFolderName = validate(folderName,
z.string()
.trim()
.min(LENGTH_MIN_FOLDER_NAME)
.max(LENGTH_MAX_FOLDER_NAME));
await repoCreateFolder({
name: validatedFolderName,
userId: userId
});
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionRenameFolderById(id: number, newName: string) {
try {
const isOwner = await checkFolderOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to rename this folder.',
};
}
const validatedNewName = validate(
newName,
z.string()
.min(LENGTH_MIN_FOLDER_NAME)
.max(LENGTH_MAX_FOLDER_NAME)
.trim());
await repoRenameFolderById(id, validatedNewName);
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionSetFolderVisibility(
folderId: number,
visibility: "PRIVATE" | "PUBLIC",
): Promise<ActionOutputSetFolderVisibility> {
try {
const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to change this folder visibility.',
};
}
await repoUpdateFolderVisibility({
folderId,
visibility: visibility as Visibility,
});
return {
success: true,
message: 'success',
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetPublicFolders(): Promise<ActionOutputGetPublicFolders> {
try {
const data = await repoGetPublicFolders({});
return {
success: true,
message: 'success',
data: data.map((folder) => ({
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionSearchPublicFolders(query: string): Promise<ActionOutputGetPublicFolders> {
try {
const data = await repoSearchPublicFolders({ query, limit: 50 });
return {
success: true,
message: 'success',
data: data.map((folder) => ({
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetPublicFolderById(folderId: number): Promise<ActionOutputGetPublicFolderById> {
try {
const folder = await repoGetPublicFolderById(folderId);
if (!folder) {
return {
success: false,
message: 'Folder not found.',
};
}
return {
success: true,
message: 'success',
data: {
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
},
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionToggleFavorite(
folderId: number,
): Promise<ActionOutputToggleFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: 'Unauthorized',
};
}
const isFavorited = await repoToggleFavorite({
folderId,
userId: session.user.id,
});
const { favoriteCount } = await repoCheckFavorite({
folderId,
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: {
isFavorited,
favoriteCount,
},
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionCheckFavorite(
folderId: number,
): Promise<ActionOutputCheckFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: true,
message: 'success',
data: {
isFavorited: false,
favoriteCount: 0,
},
};
}
const { isFavorited, favoriteCount } = await repoCheckFavorite({
folderId,
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: {
isFavorited,
favoriteCount,
},
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavorites> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: 'Unauthorized',
};
}
const favorites = await repoGetUserFavorites({
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: favorites.map((fav) => ({
id: fav.id,
folderId: fav.folderId,
folderName: fav.folderName,
folderCreatedAt: fav.folderCreatedAt,
folderTotalPairs: fav.folderTotalPairs,
folderOwnerId: fav.folderOwnerId,
folderOwnerName: fav.folderOwnerName,
folderOwnerUsername: fav.folderOwnerUsername,
favoritedAt: fav.favoritedAt,
})),
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}

View File

@@ -1,91 +0,0 @@
import { Visibility } from "../../../generated/prisma/enums";
export interface RepoInputCreateFolder {
name: string;
userId: string;
}
export interface RepoInputCreatePair {
text1: string;
text2: string;
language1: string;
language2: string;
ipa1?: string;
ipa2?: string;
folderId: number;
}
export interface RepoInputUpdatePair {
text1?: string;
text2?: string;
language1?: string;
language2?: string;
ipa1?: string;
ipa2?: string;
}
export interface RepoInputUpdateFolderVisibility {
folderId: number;
visibility: Visibility;
}
export interface RepoInputSearchPublicFolders {
query: string;
limit?: number;
}
export interface RepoInputGetPublicFolders {
limit?: number;
offset?: number;
orderBy?: "createdAt" | "name";
}
export type RepoOutputPublicFolder = {
id: number;
name: string;
visibility: Visibility;
createdAt: Date;
userId: string;
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type RepoOutputFolderVisibility = {
visibility: Visibility;
userId: string;
};
export interface RepoInputToggleFavorite {
folderId: number;
userId: string;
}
export interface RepoInputCheckFavorite {
folderId: number;
userId: string;
}
export type RepoOutputFavoriteStatus = {
isFavorited: boolean;
favoriteCount: number;
};
export interface RepoInputGetUserFavorites {
userId: string;
limit?: number;
offset?: number;
}
export type RepoOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};

View File

@@ -1,333 +0,0 @@
import { prisma } from "@/lib/db";
import {
RepoInputCreateFolder,
RepoInputCreatePair,
RepoInputUpdatePair,
RepoInputUpdateFolderVisibility,
RepoInputSearchPublicFolders,
RepoInputGetPublicFolders,
RepoOutputPublicFolder,
RepoOutputFolderVisibility,
RepoInputToggleFavorite,
RepoInputCheckFavorite,
RepoOutputFavoriteStatus,
RepoInputGetUserFavorites,
RepoOutputUserFavorite,
} from "./folder-repository-dto";
import { Visibility } from "../../../generated/prisma/enums";
export async function repoCreatePair(data: RepoInputCreatePair) {
return (await prisma.pair.create({
data: data,
})).id;
}
export async function repoDeletePairById(id: number) {
await prisma.pair.delete({
where: {
id: id,
},
});
}
export async function repoUpdatePairById(
id: number,
data: RepoInputUpdatePair,
) {
await prisma.pair.update({
where: {
id: id,
},
data: data,
});
}
export async function repoGetPairCountByFolderId(folderId: number) {
return prisma.pair.count({
where: {
folderId: folderId,
},
});
}
export async function repoGetPairsByFolderId(folderId: number) {
return (await prisma.pair.findMany({
where: {
folderId: folderId,
},
})).map(pair => {
return {
text1:pair.text1,
text2: pair.text2,
language1: pair.language1,
language2: pair.language2,
ipa1: pair.ipa1,
ipa2: pair.ipa2,
id: pair.id,
folderId: pair.folderId
}
});
}
export async function repoGetFoldersByUserId(userId: string) {
return (await prisma.folder.findMany({
where: {
userId: userId,
},
}))?.map(v => {
return {
id: v.id,
name: v.name,
userId: v.userId,
visibility: v.visibility,
};
});
}
export async function repoRenameFolderById(id: number, newName: string) {
await prisma.folder.update({
where: {
id: id,
},
data: {
name: newName,
},
});
}
export async function repoGetFoldersWithTotalPairsByUserId(userId: string) {
const folders = await prisma.folder.findMany({
where: { userId },
include: {
_count: {
select: { pairs: true },
},
},
orderBy: {
createdAt: 'desc',
},
});
return folders.map(folder => ({
id: folder.id,
name: folder.name,
userId: folder.userId,
visibility: folder.visibility,
total: folder._count?.pairs ?? 0,
createdAt: folder.createdAt,
}));
}
export async function repoCreateFolder(folder: RepoInputCreateFolder) {
await prisma.folder.create({
data: folder,
});
}
export async function repoDeleteFolderById(id: number) {
await prisma.folder.delete({
where: {
id: id,
},
});
}
export async function repoGetUserIdByFolderId(id: number) {
const folder = await prisma.folder.findUnique({
where: {
id: id,
},
});
return folder?.userId;
}
export async function repoGetFolderIdByPairId(pairId: number) {
const pair = await prisma.pair.findUnique({
where: {
id: pairId,
},
select: {
folderId: true,
},
});
return pair?.folderId;
}
export async function repoUpdateFolderVisibility(
input: RepoInputUpdateFolderVisibility,
): Promise<void> {
await prisma.folder.update({
where: { id: input.folderId },
data: { visibility: input.visibility },
});
}
export async function repoGetFolderVisibility(
folderId: number,
): Promise<RepoOutputFolderVisibility | null> {
const folder = await prisma.folder.findUnique({
where: { id: folderId },
select: { visibility: true, userId: true },
});
return folder;
}
export async function repoGetPublicFolderById(
folderId: number,
): Promise<RepoOutputPublicFolder | null> {
const folder = await prisma.folder.findUnique({
where: { id: folderId, visibility: Visibility.PUBLIC },
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
});
if (!folder) return null;
return {
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user?.name ?? "Unknown",
userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
};
}
export async function repoGetPublicFolders(
input: RepoInputGetPublicFolders = {},
): Promise<RepoOutputPublicFolder[]> {
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
const folders = await prisma.folder.findMany({
where: { visibility: Visibility.PUBLIC },
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
orderBy: { [orderBy]: "desc" },
take: limit,
skip: offset,
});
return folders.map((folder) => ({
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user?.name ?? "Unknown",
userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
}));
}
export async function repoSearchPublicFolders(
input: RepoInputSearchPublicFolders,
): Promise<RepoOutputPublicFolder[]> {
const { query, limit = 50 } = input;
const folders = await prisma.folder.findMany({
where: {
visibility: Visibility.PUBLIC,
name: { contains: query, mode: "insensitive" },
},
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
return folders.map((folder) => ({
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user?.name ?? "Unknown",
userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
}));
}
export async function repoToggleFavorite(
input: RepoInputToggleFavorite,
): Promise<boolean> {
const existing = await prisma.folderFavorite.findUnique({
where: {
userId_folderId: {
userId: input.userId,
folderId: input.folderId,
},
},
});
if (existing) {
await prisma.folderFavorite.delete({
where: { id: existing.id },
});
return false;
} else {
await prisma.folderFavorite.create({
data: {
userId: input.userId,
folderId: input.folderId,
},
});
return true;
}
}
export async function repoCheckFavorite(
input: RepoInputCheckFavorite,
): Promise<RepoOutputFavoriteStatus> {
const favorite = await prisma.folderFavorite.findUnique({
where: {
userId_folderId: {
userId: input.userId,
folderId: input.folderId,
},
},
});
const count = await prisma.folderFavorite.count({
where: { folderId: input.folderId },
});
return {
isFavorited: !!favorite,
favoriteCount: count,
};
}
export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
const { userId, limit = 50, offset = 0 } = input;
const favorites = await prisma.folderFavorite.findMany({
where: { userId },
include: {
folder: {
include: {
_count: { select: { pairs: true } },
user: { select: { name: true, username: true } },
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return favorites.map((fav) => ({
id: fav.id,
folderId: fav.folderId,
folderName: fav.folder.name,
folderCreatedAt: fav.folder.createdAt,
folderTotalPairs: fav.folder._count.pairs,
folderOwnerId: fav.folder.userId,
folderOwnerName: fav.folder.user?.name ?? "Unknown",
folderOwnerUsername: fav.folder.user?.username ?? "unknown",
favoritedAt: fav.createdAt,
}));
}

View File

@@ -1,108 +0,0 @@
import { Visibility } from "../../../generated/prisma/enums";
export type ServiceInputCreateFolder = {
name: string;
userId: string;
};
export type ServiceInputRenameFolder = {
folderId: number;
newName: string;
};
export type ServiceInputDeleteFolder = {
folderId: number;
};
export type ServiceInputSetVisibility = {
folderId: number;
visibility: Visibility;
};
export type ServiceInputCheckOwnership = {
folderId: number;
userId: string;
};
export type ServiceInputCheckPairOwnership = {
pairId: number;
userId: string;
};
export type ServiceInputCreatePair = {
folderId: number;
text1: string;
text2: string;
language1: string;
language2: string;
};
export type ServiceInputUpdatePair = {
pairId: number;
text1?: string;
text2?: string;
language1?: string;
language2?: string;
};
export type ServiceInputDeletePair = {
pairId: number;
};
export type ServiceInputGetPublicFolders = {
limit?: number;
offset?: number;
};
export type ServiceInputSearchPublicFolders = {
query: string;
limit?: number;
};
export type ServiceInputToggleFavorite = {
folderId: number;
userId: string;
};
export type ServiceInputCheckFavorite = {
folderId: number;
userId: string;
};
export type ServiceInputGetUserFavorites = {
userId: string;
limit?: number;
offset?: number;
};
export type ServiceOutputFolder = {
id: number;
name: string;
visibility: Visibility;
createdAt: Date;
userId: string;
};
export type ServiceOutputFolderWithDetails = ServiceOutputFolder & {
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type ServiceOutputFavoriteStatus = {
isFavorited: boolean;
favoriteCount: number;
};
export type ServiceOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};

View File

@@ -0,0 +1,106 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
import { NoteKind } from "../../../generated/prisma/enums";
import {
schemaNoteTypeField,
schemaNoteTypeTemplate,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
export const LENGTH_MIN_NOTE_TYPE_NAME = 1;
export const LENGTH_MAX_NOTE_TYPE_NAME = 100;
export const LENGTH_MAX_CSS = 50000;
const schemaNoteTypeFieldAction = z.object({
name: z.string().min(1).max(schemaNoteTypeField.name.maxLength),
ord: z.number().int(),
sticky: z.boolean(),
rtl: z.boolean(),
font: z.string().max(schemaNoteTypeField.font.maxLength).optional(),
size: z.number().int().min(schemaNoteTypeField.size.min).max(schemaNoteTypeField.size.max).optional(),
media: z.array(z.string()).optional(),
});
const schemaNoteTypeTemplateAction = z.object({
name: z.string().min(1).max(schemaNoteTypeTemplate.name.maxLength),
ord: z.number().int(),
qfmt: z.string().min(1).max(schemaNoteTypeTemplate.qfmt.maxLength),
afmt: z.string().min(1).max(schemaNoteTypeTemplate.afmt.maxLength),
bqfmt: z.string().max(schemaNoteTypeTemplate.bqfmt.maxLength).optional(),
bafmt: z.string().max(schemaNoteTypeTemplate.bafmt.maxLength).optional(),
did: z.number().int().optional(),
});
export const schemaActionInputCreateNoteType = z.object({
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1),
templates: z.array(schemaNoteTypeTemplateAction).min(1),
});
export type ActionInputCreateNoteType = z.infer<typeof schemaActionInputCreateNoteType>;
export const validateActionInputCreateNoteType = generateValidator(schemaActionInputCreateNoteType);
export const schemaActionInputUpdateNoteType = z.object({
id: z.number().int().positive(),
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME).optional(),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1).optional(),
templates: z.array(schemaNoteTypeTemplateAction).min(1).optional(),
});
export type ActionInputUpdateNoteType = z.infer<typeof schemaActionInputUpdateNoteType>;
export const validateActionInputUpdateNoteType = generateValidator(schemaActionInputUpdateNoteType);
export const schemaActionInputGetNoteTypeById = z.object({
id: z.number().int().positive(),
});
export type ActionInputGetNoteTypeById = z.infer<typeof schemaActionInputGetNoteTypeById>;
export const validateActionInputGetNoteTypeById = generateValidator(schemaActionInputGetNoteTypeById);
export const schemaActionInputDeleteNoteType = z.object({
id: z.number().int().positive(),
});
export type ActionInputDeleteNoteType = z.infer<typeof schemaActionInputDeleteNoteType>;
export const validateActionInputDeleteNoteType = generateValidator(schemaActionInputDeleteNoteType);
export type ActionOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCreateNoteType = {
success: boolean;
message: string;
data?: { id: number };
};
export type ActionOutputUpdateNoteType = {
success: boolean;
message: string;
};
export type ActionOutputGetNoteTypeById = {
success: boolean;
message: string;
data?: ActionOutputNoteType;
};
export type ActionOutputGetNoteTypesByUserId = {
success: boolean;
message: string;
data?: ActionOutputNoteType[];
};
export type ActionOutputDeleteNoteType = {
success: boolean;
message: string;
};

View File

@@ -0,0 +1,255 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateNoteType,
ActionInputUpdateNoteType,
ActionInputDeleteNoteType,
ActionOutputCreateNoteType,
ActionOutputUpdateNoteType,
ActionOutputGetNoteTypeById,
ActionOutputGetNoteTypesByUserId,
ActionOutputDeleteNoteType,
validateActionInputCreateNoteType,
validateActionInputUpdateNoteType,
validateActionInputDeleteNoteType,
} from "./note-type-action-dto";
import {
serviceCreateNoteType,
serviceUpdateNoteType,
serviceGetNoteTypeById,
serviceGetNoteTypesByUserId,
serviceDeleteNoteType,
} from "./note-type-service";
import {
DEFAULT_BASIC_NOTE_TYPE_FIELDS,
DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
DEFAULT_BASIC_NOTE_TYPE_CSS,
DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
DEFAULT_CLOZE_NOTE_TYPE_CSS,
} from "./note-type-repository-dto";
const log = createLogger("note-type-action");
export async function actionCreateNoteType(
input: ActionInputCreateNoteType,
): Promise<ActionOutputCreateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputCreateNoteType(input);
const id = await serviceCreateNoteType({
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type created successfully",
data: { id },
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Create note type failed", { error: e });
return {
success: false,
message: "Failed to create note type",
};
}
}
export async function actionUpdateNoteType(
input: ActionInputUpdateNoteType,
): Promise<ActionOutputUpdateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputUpdateNoteType(input);
await serviceUpdateNoteType({
id: validated.id,
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type updated successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Update note type failed", { error: e });
return {
success: false,
message: "Failed to update note type",
};
}
}
export async function actionGetNoteTypeById(
id: number,
): Promise<ActionOutputGetNoteTypeById> {
try {
const noteType = await serviceGetNoteTypeById({ id });
if (!noteType) {
return {
success: false,
message: "Note type not found",
};
}
return {
success: true,
message: "Note type retrieved successfully",
data: noteType,
};
} catch (e) {
log.error("Get note type failed", { error: e });
return {
success: false,
message: "Failed to retrieve note type",
};
}
}
export async function actionGetNoteTypesByUserId(): Promise<ActionOutputGetNoteTypesByUserId> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const noteTypes = await serviceGetNoteTypesByUserId({
userId: session.user.id,
});
return {
success: true,
message: "Note types retrieved successfully",
data: noteTypes,
};
} catch (e) {
log.error("Get note types failed", { error: e });
return {
success: false,
message: "Failed to retrieve note types",
};
}
}
export async function actionDeleteNoteType(
input: ActionInputDeleteNoteType,
): Promise<ActionOutputDeleteNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputDeleteNoteType(input);
await serviceDeleteNoteType({
id: validated.id,
userId: session.user.id,
});
return {
success: true,
message: "Note type deleted successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Delete note type failed", { error: e });
return {
success: false,
message: "Failed to delete note type",
};
}
}
export async function actionCreateDefaultBasicNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Basic Vocabulary",
kind: "STANDARD",
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
});
}
export async function actionCreateDefaultClozeNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Cloze",
kind: "CLOZE",
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
});
}
export async function actionGetDefaultBasicNoteTypeTemplate() {
return {
name: "Basic Vocabulary",
kind: "STANDARD" as const,
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
};
}
export async function actionGetDefaultClozeNoteTypeTemplate() {
return {
name: "Cloze",
kind: "CLOZE" as const,
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
};
}

View File

@@ -0,0 +1,181 @@
import { NoteKind } from "../../../generated/prisma/enums";
// ============================================
// Field Schema (Anki flds structure)
// ============================================
export interface NoteTypeField {
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font?: string;
size?: number;
media?: string[];
}
export const schemaNoteTypeField = {
name: { minLength: 1, maxLength: 50 },
font: { maxLength: 100 },
size: { min: 8, max: 72 },
};
// ============================================
// Template Schema (Anki tmpls structure)
// ============================================
export interface NoteTypeTemplate {
name: string;
ord: number;
qfmt: string;
afmt: string;
bqfmt?: string;
bafmt?: string;
did?: number;
}
export const schemaNoteTypeTemplate = {
name: { minLength: 1, maxLength: 100 },
qfmt: { maxLength: 10000 },
afmt: { maxLength: 10000 },
bqfmt: { maxLength: 10000 },
bafmt: { maxLength: 10000 },
};
// ============================================
// Repository Input Types
// ============================================
export interface RepoInputCreateNoteType {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
}
export interface RepoInputUpdateNoteType {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
}
export interface RepoInputGetNoteTypeById {
id: number;
}
export interface RepoInputGetNoteTypesByUserId {
userId: string;
}
export interface RepoInputDeleteNoteType {
id: number;
}
export interface RepoInputCheckNotesExist {
noteTypeId: number;
}
// ============================================
// Repository Output Types
// ============================================
export type RepoOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputNoteTypeOwnership = {
userId: string;
};
export type RepoOutputNotesExistCheck = {
exists: boolean;
count: number;
};
// ============================================
// Default Note Types
// ============================================
export const DEFAULT_BASIC_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "IPA", ord: 2, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Example", ord: 3, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_BASIC_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}<br>{{IPA}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}<br><br>{{Example}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}<br>{{IPA}}",
},
];
export const DEFAULT_BASIC_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.card1 {
background-color: #e8f4f8;
}
.card2 {
background-color: #f8f4e8;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 20px 0;
}`;
export const DEFAULT_CLOZE_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Text", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Extra", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Cloze",
ord: 0,
qfmt: "{{cloze:Text}}",
afmt: "{{cloze:Text}}<br><br>{{Extra}}",
},
];
export const DEFAULT_CLOZE_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.cloze {
font-weight: bold;
color: #0066cc;
}`;

View File

@@ -0,0 +1,151 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateNoteType,
RepoInputUpdateNoteType,
RepoInputGetNoteTypeById,
RepoInputGetNoteTypesByUserId,
RepoInputDeleteNoteType,
RepoInputCheckNotesExist,
RepoOutputNoteType,
RepoOutputNoteTypeOwnership,
RepoOutputNotesExistCheck,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("note-type-repository");
export async function repoCreateNoteType(
input: RepoInputCreateNoteType,
): Promise<number> {
const noteType = await prisma.noteType.create({
data: {
name: input.name,
kind: input.kind ?? NoteKind.STANDARD,
css: input.css ?? "",
fields: input.fields as unknown as object,
templates: input.templates as unknown as object,
userId: input.userId,
},
});
log.info("Created note type", { id: noteType.id, name: noteType.name });
return noteType.id;
}
export async function repoUpdateNoteType(
input: RepoInputUpdateNoteType,
): Promise<void> {
const updateData: {
name?: string;
kind?: NoteKind;
css?: string;
fields?: object;
templates?: object;
} = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.kind !== undefined) updateData.kind = input.kind;
if (input.css !== undefined) updateData.css = input.css;
if (input.fields !== undefined)
updateData.fields = input.fields as unknown as object;
if (input.templates !== undefined)
updateData.templates = input.templates as unknown as object;
await prisma.noteType.update({
where: { id: input.id },
data: updateData,
});
log.info("Updated note type", { id: input.id });
}
export async function repoGetNoteTypeById(
input: RepoInputGetNoteTypeById,
): Promise<RepoOutputNoteType | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: input.id },
});
if (!noteType) return null;
return {
id: noteType.id,
name: noteType.name,
kind: noteType.kind,
css: noteType.css,
fields: noteType.fields as unknown as NoteTypeField[],
templates: noteType.templates as unknown as NoteTypeTemplate[],
userId: noteType.userId,
createdAt: noteType.createdAt,
updatedAt: noteType.updatedAt,
};
}
export async function repoGetNoteTypesByUserId(
input: RepoInputGetNoteTypesByUserId,
): Promise<RepoOutputNoteType[]> {
const noteTypes = await prisma.noteType.findMany({
where: { userId: input.userId },
orderBy: { createdAt: "desc" },
});
return noteTypes.map((nt) => ({
id: nt.id,
name: nt.name,
kind: nt.kind,
css: nt.css,
fields: nt.fields as unknown as NoteTypeField[],
templates: nt.templates as unknown as NoteTypeTemplate[],
userId: nt.userId,
createdAt: nt.createdAt,
updatedAt: nt.updatedAt,
}));
}
export async function repoGetNoteTypeOwnership(
noteTypeId: number,
): Promise<RepoOutputNoteTypeOwnership | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { userId: true },
});
return noteType;
}
export async function repoDeleteNoteType(
input: RepoInputDeleteNoteType,
): Promise<void> {
await prisma.noteType.delete({
where: { id: input.id },
});
log.info("Deleted note type", { id: input.id });
}
export async function repoCheckNotesExist(
input: RepoInputCheckNotesExist,
): Promise<RepoOutputNotesExistCheck> {
const count = await prisma.note.count({
where: { noteTypeId: input.noteTypeId },
});
return {
exists: count > 0,
count,
};
}
export async function repoGetNoteTypeNameById(
noteTypeId: number,
): Promise<string | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { name: true },
});
return noteType?.name ?? null;
}

View File

@@ -0,0 +1,60 @@
import { NoteKind } from "../../../generated/prisma/enums";
import { NoteTypeField, NoteTypeTemplate } from "./note-type-repository-dto";
export type ServiceInputCreateNoteType = {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputUpdateNoteType = {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputGetNoteTypeById = {
id: number;
};
export type ServiceInputGetNoteTypesByUserId = {
userId: string;
};
export type ServiceInputDeleteNoteType = {
id: number;
userId: string;
};
export type ServiceInputValidateFields = {
fields: NoteTypeField[];
};
export type ServiceInputValidateTemplates = {
templates: NoteTypeTemplate[];
fields: NoteTypeField[];
};
export type ServiceOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputValidation = {
success: boolean;
errors: string[];
};

View File

@@ -0,0 +1,272 @@
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
repoCreateNoteType,
repoGetNoteTypeById,
repoGetNoteTypesByUserId,
repoUpdateNoteType,
repoDeleteNoteType,
repoGetNoteTypeOwnership,
repoCheckNotesExist,
} from "./note-type-repository";
import {
ServiceInputCreateNoteType,
ServiceInputUpdateNoteType,
ServiceInputGetNoteTypeById,
ServiceInputGetNoteTypesByUserId,
ServiceInputDeleteNoteType,
ServiceInputValidateFields,
ServiceInputValidateTemplates,
ServiceOutputNoteType,
ServiceOutputValidation,
} from "./note-type-service-dto";
import { schemaNoteTypeField, schemaNoteTypeTemplate } from "./note-type-repository-dto";
const log = createLogger("note-type-service");
export function serviceValidateFields(
input: ServiceInputValidateFields,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.fields) || input.fields.length === 0) {
errors.push("Fields must be a non-empty array");
return { success: false, errors };
}
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
for (let i = 0; i < input.fields.length; i++) {
const field = input.fields[i];
if (!field.name || field.name.trim().length === 0) {
errors.push(`Field ${i}: name is required`);
} else if (field.name.length > schemaNoteTypeField.name.maxLength) {
errors.push(`Field ${i}: name exceeds maximum length of ${schemaNoteTypeField.name.maxLength}`);
}
if (seenNames.has(field.name)) {
errors.push(`Field ${i}: duplicate field name "${field.name}"`);
}
seenNames.add(field.name);
if (typeof field.ord !== "number") {
errors.push(`Field ${i}: ord must be a number`);
} else if (seenOrds.has(field.ord)) {
errors.push(`Field ${i}: duplicate ordinal ${field.ord}`);
}
seenOrds.add(field.ord);
if (typeof field.sticky !== "boolean") {
errors.push(`Field ${i}: sticky must be a boolean`);
}
if (typeof field.rtl !== "boolean") {
errors.push(`Field ${i}: rtl must be a boolean`);
}
if (field.font && field.font.length > schemaNoteTypeField.font.maxLength) {
errors.push(`Field ${i}: font exceeds maximum length`);
}
if (field.size !== undefined && (field.size < schemaNoteTypeField.size.min || field.size > schemaNoteTypeField.size.max)) {
errors.push(`Field ${i}: size must be between ${schemaNoteTypeField.size.min} and ${schemaNoteTypeField.size.max}`);
}
}
return { success: errors.length === 0, errors };
}
export function serviceValidateTemplates(
input: ServiceInputValidateTemplates,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.templates) || input.templates.length === 0) {
errors.push("Templates must be a non-empty array");
return { success: false, errors };
}
const fieldNames = new Set(input.fields.map((f) => f.name));
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
const mustachePattern = /\{\{([^}]+)\}\}/g;
for (let i = 0; i < input.templates.length; i++) {
const template = input.templates[i];
if (!template.name || template.name.trim().length === 0) {
errors.push(`Template ${i}: name is required`);
} else if (template.name.length > schemaNoteTypeTemplate.name.maxLength) {
errors.push(`Template ${i}: name exceeds maximum length`);
}
if (seenNames.has(template.name)) {
errors.push(`Template ${i}: duplicate template name "${template.name}"`);
}
seenNames.add(template.name);
if (typeof template.ord !== "number") {
errors.push(`Template ${i}: ord must be a number`);
} else if (seenOrds.has(template.ord)) {
errors.push(`Template ${i}: duplicate ordinal ${template.ord}`);
}
seenOrds.add(template.ord);
if (!template.qfmt || template.qfmt.trim().length === 0) {
errors.push(`Template ${i}: qfmt (question format) is required`);
} else if (template.qfmt.length > schemaNoteTypeTemplate.qfmt.maxLength) {
errors.push(`Template ${i}: qfmt exceeds maximum length`);
}
if (!template.afmt || template.afmt.trim().length === 0) {
errors.push(`Template ${i}: afmt (answer format) is required`);
} else if (template.afmt.length > schemaNoteTypeTemplate.afmt.maxLength) {
errors.push(`Template ${i}: afmt exceeds maximum length`);
}
const qfmtMatches = template.qfmt.match(mustachePattern) || [];
const afmtMatches = template.afmt.match(mustachePattern) || [];
const allMatches = [...qfmtMatches, ...afmtMatches];
for (const match of allMatches) {
const content = match.slice(2, -2).trim();
if (content.startsWith("cloze:")) {
continue;
}
if (content === "FrontSide") {
continue;
}
if (content.startsWith("type:")) {
continue;
}
if (!fieldNames.has(content)) {
log.warn(`Template ${i}: unknown field reference "{{${content}}}"`);
}
}
}
return { success: errors.length === 0, errors };
}
export async function serviceCreateNoteType(
input: ServiceInputCreateNoteType,
): Promise<number> {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
log.info("Creating note type", { name: input.name, userId: input.userId });
return repoCreateNoteType({
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
userId: input.userId,
});
}
export async function serviceUpdateNoteType(
input: ServiceInputUpdateNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to update this note type");
}
if (input.fields) {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
}
if (input.templates && input.fields) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
} else if (input.templates) {
const existing = await repoGetNoteTypeById({ id: input.id });
if (existing) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: existing.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
}
}
log.info("Updating note type", { id: input.id });
await repoUpdateNoteType({
id: input.id,
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
});
}
export async function serviceGetNoteTypeById(
input: ServiceInputGetNoteTypeById,
): Promise<ServiceOutputNoteType | null> {
return repoGetNoteTypeById(input);
}
export async function serviceGetNoteTypesByUserId(
input: ServiceInputGetNoteTypesByUserId,
): Promise<ServiceOutputNoteType[]> {
return repoGetNoteTypesByUserId(input);
}
export async function serviceDeleteNoteType(
input: ServiceInputDeleteNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to delete this note type");
}
const notesCheck = await repoCheckNotesExist({ noteTypeId: input.id });
if (notesCheck.exists) {
throw new ValidateError(
`Cannot delete note type: ${notesCheck.count} notes are using this type`,
);
}
log.info("Deleting note type", { id: input.id });
await repoDeleteNoteType({ id: input.id });
}

View File

@@ -0,0 +1,131 @@
import { generateValidator } from "@/utils/validate";
import z from "zod";
export const LENGTH_MAX_NOTE_FIELD = 65535;
export const LENGTH_MIN_NOTE_FIELD = 0;
export const LENGTH_MAX_TAG = 100;
export const MAX_FIELDS = 100;
export const MAX_TAGS = 100;
export const schemaActionInputCreateNote = z.object({
noteTypeId: z.number().int().positive(),
fields: z
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
.min(1)
.max(MAX_FIELDS),
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
});
export type ActionInputCreateNote = z.infer<typeof schemaActionInputCreateNote>;
export const validateActionInputCreateNote = generateValidator(
schemaActionInputCreateNote,
);
export const schemaActionInputUpdateNote = z.object({
noteId: z.bigint(),
fields: z
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
.min(1)
.max(MAX_FIELDS)
.optional(),
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
});
export type ActionInputUpdateNote = z.infer<typeof schemaActionInputUpdateNote>;
export const validateActionInputUpdateNote = generateValidator(
schemaActionInputUpdateNote,
);
export const schemaActionInputDeleteNote = z.object({
noteId: z.bigint(),
});
export type ActionInputDeleteNote = z.infer<typeof schemaActionInputDeleteNote>;
export const validateActionInputDeleteNote = generateValidator(
schemaActionInputDeleteNote,
);
export const schemaActionInputGetNoteById = z.object({
noteId: z.bigint(),
});
export type ActionInputGetNoteById = z.infer<typeof schemaActionInputGetNoteById>;
export const validateActionInputGetNoteById = generateValidator(
schemaActionInputGetNoteById,
);
export const schemaActionInputGetNotesByNoteTypeId = z.object({
noteTypeId: z.number().int().positive(),
limit: z.number().int().positive().max(1000).optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputGetNotesByNoteTypeId = z.infer<
typeof schemaActionInputGetNotesByNoteTypeId
>;
export const validateActionInputGetNotesByNoteTypeId = generateValidator(
schemaActionInputGetNotesByNoteTypeId,
);
export const schemaActionInputGetNotesByUserId = z.object({
userId: z.string().min(1),
limit: z.number().int().positive().max(1000).optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputGetNotesByUserId = z.infer<
typeof schemaActionInputGetNotesByUserId
>;
export const validateActionInputGetNotesByUserId = generateValidator(
schemaActionInputGetNotesByUserId,
);
export type ActionOutputNote = {
id: string;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCreateNote = {
message: string;
success: boolean;
data?: {
id: string;
guid: string;
};
};
export type ActionOutputUpdateNote = {
message: string;
success: boolean;
};
export type ActionOutputDeleteNote = {
message: string;
success: boolean;
};
export type ActionOutputGetNoteById = {
message: string;
success: boolean;
data?: ActionOutputNote;
};
export type ActionOutputGetNotes = {
message: string;
success: boolean;
data?: ActionOutputNote[];
};
export type ActionOutputNoteCount = {
message: string;
success: boolean;
data?: {
count: number;
};
};

View File

@@ -0,0 +1,344 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateNote,
ActionInputUpdateNote,
ActionInputDeleteNote,
ActionInputGetNoteById,
ActionInputGetNotesByNoteTypeId,
ActionInputGetNotesByUserId,
ActionOutputCreateNote,
ActionOutputUpdateNote,
ActionOutputDeleteNote,
ActionOutputGetNoteById,
ActionOutputGetNotes,
ActionOutputNoteCount,
ActionOutputNote,
validateActionInputCreateNote,
validateActionInputUpdateNote,
validateActionInputDeleteNote,
validateActionInputGetNoteById,
validateActionInputGetNotesByNoteTypeId,
validateActionInputGetNotesByUserId,
} from "./note-action-dto";
import {
serviceCreateNote,
serviceUpdateNote,
serviceDeleteNote,
serviceGetNoteById,
serviceGetNotesByNoteTypeId,
serviceGetNotesByUserId,
serviceCountNotesByUserId,
serviceCountNotesByNoteTypeId,
NoteNotFoundError,
NoteOwnershipError,
} from "./note-service";
const log = createLogger("note-action");
function mapNoteToOutput(note: {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}): ActionOutputNote {
return {
id: note.id.toString(),
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
async function requireAuth(): Promise<string> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
return session.user.id;
}
export async function actionCreateNote(
input: unknown,
): Promise<ActionOutputCreateNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputCreateNote(input);
log.debug("Creating note", { userId, noteTypeId: validated.noteTypeId });
const result = await serviceCreateNote({
...validated,
userId,
});
log.info("Note created", { id: result.id.toString(), guid: result.guid });
return {
success: true,
message: "Note created successfully",
data: {
id: result.id.toString(),
guid: result.guid,
},
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to create note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionUpdateNote(
input: unknown,
): Promise<ActionOutputUpdateNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputUpdateNote(input);
log.debug("Updating note", { noteId: validated.noteId.toString() });
await serviceUpdateNote({
...validated,
userId,
});
log.info("Note updated", { noteId: validated.noteId.toString() });
return {
success: true,
message: "Note updated successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof NoteNotFoundError) {
return { success: false, message: "Note not found" };
}
if (e instanceof NoteOwnershipError) {
return { success: false, message: "You do not have permission to update this note" };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to update note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionDeleteNote(
input: unknown,
): Promise<ActionOutputDeleteNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputDeleteNote(input);
log.debug("Deleting note", { noteId: validated.noteId.toString() });
await serviceDeleteNote({
...validated,
userId,
});
log.info("Note deleted", { noteId: validated.noteId.toString() });
return {
success: true,
message: "Note deleted successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof NoteNotFoundError) {
return { success: false, message: "Note not found" };
}
if (e instanceof NoteOwnershipError) {
return { success: false, message: "You do not have permission to delete this note" };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to delete note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNoteById(
input: unknown,
): Promise<ActionOutputGetNoteById> {
try {
const validated = validateActionInputGetNoteById(input);
log.debug("Fetching note", { noteId: validated.noteId.toString() });
const note = await serviceGetNoteById(validated);
if (!note) {
return {
success: false,
message: "Note not found",
};
}
return {
success: true,
message: "Note retrieved successfully",
data: mapNoteToOutput(note),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNotesByNoteTypeId(
input: unknown,
): Promise<ActionOutputGetNotes> {
try {
const validated = validateActionInputGetNotesByNoteTypeId(input);
log.debug("Fetching notes by note type", { noteTypeId: validated.noteTypeId });
const notes = await serviceGetNotesByNoteTypeId(validated);
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get notes by note type", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNotesByUserId(
input: unknown,
): Promise<ActionOutputGetNotes> {
try {
const validated = validateActionInputGetNotesByUserId(input);
log.debug("Fetching notes by user", { userId: validated.userId });
const notes = await serviceGetNotesByUserId(validated);
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get notes by user", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetMyNotes(
limit?: number,
offset?: number,
): Promise<ActionOutputGetNotes> {
try {
const userId = await requireAuth();
log.debug("Fetching current user's notes", { userId, limit, offset });
const notes = await serviceGetNotesByUserId({
userId,
limit,
offset,
});
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to get user's notes", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetMyNoteCount(): Promise<ActionOutputNoteCount> {
try {
const userId = await requireAuth();
log.debug("Counting current user's notes", { userId });
const result = await serviceCountNotesByUserId(userId);
return {
success: true,
message: "Note count retrieved successfully",
data: { count: result.count },
};
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to count user's notes", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNoteCountByNoteType(
noteTypeId: number,
): Promise<ActionOutputNoteCount> {
try {
log.debug("Counting notes by note type", { noteTypeId });
const result = await serviceCountNotesByNoteTypeId(noteTypeId);
return {
success: true,
message: "Note count retrieved successfully",
data: { count: result.count },
};
} catch (e) {
log.error("Failed to count notes by note type", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}

View File

@@ -0,0 +1,72 @@
// Repository layer DTOs for Note module
// Follows Anki-compatible note structure with BigInt IDs
export interface RepoInputCreateNote {
noteTypeId: number;
fields: string[];
tags?: string[];
userId: string;
}
export interface RepoInputUpdateNote {
id: bigint;
fields?: string[];
tags?: string[];
}
export interface RepoInputGetNoteById {
id: bigint;
}
export interface RepoInputGetNotesByNoteTypeId {
noteTypeId: number;
limit?: number;
offset?: number;
}
export interface RepoInputGetNotesByUserId {
userId: string;
limit?: number;
offset?: number;
}
export interface RepoInputDeleteNote {
id: bigint;
}
export interface RepoInputCheckNoteOwnership {
noteId: bigint;
userId: string;
}
export type RepoOutputNote = {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string;
flds: string;
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputNoteWithFields = Omit<RepoOutputNote, "flds" | "tags"> & {
fields: string[];
tagsArray: string[];
};
export type RepoOutputNoteOwnership = {
userId: string;
};
// Helper function types
export type RepoHelperGenerateGuid = () => string;
export type RepoHelperCalculateCsum = (text: string) => number;
export type RepoHelperJoinFields = (fields: string[]) => string;
export type RepoHelperSplitFields = (flds: string) => string[];

View File

@@ -0,0 +1,283 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import { createHash } from "crypto";
import {
RepoInputCreateNote,
RepoInputUpdateNote,
RepoInputGetNoteById,
RepoInputGetNotesByNoteTypeId,
RepoInputGetNotesByUserId,
RepoInputDeleteNote,
RepoInputCheckNoteOwnership,
RepoOutputNote,
RepoOutputNoteWithFields,
RepoOutputNoteOwnership,
} from "./note-repository-dto";
const log = createLogger("note-repository");
const FIELD_SEPARATOR = "\x1f";
const BASE91_CHARS =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
export function repoGenerateGuid(): string {
let guid = "";
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
for (let i = 0; i < 10; i++) {
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
}
return guid;
}
export function repoCalculateCsum(text: string): number {
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16);
}
export function repoJoinFields(fields: string[]): string {
return fields.join(FIELD_SEPARATOR);
}
export function repoSplitFields(flds: string): string[] {
return flds.split(FIELD_SEPARATOR);
}
export async function repoCreateNote(
input: RepoInputCreateNote,
): Promise<bigint> {
const now = Date.now();
const id = BigInt(now);
const guid = repoGenerateGuid();
const flds = repoJoinFields(input.fields);
const sfld = input.fields[0] ?? "";
const csum = repoCalculateCsum(sfld);
const tags = input.tags?.join(" ") ?? " ";
log.debug("Creating note", { id: id.toString(), guid, noteTypeId: input.noteTypeId });
await prisma.note.create({
data: {
id,
guid,
noteTypeId: input.noteTypeId,
mod: Math.floor(now / 1000),
usn: -1,
tags,
flds,
sfld,
csum,
flags: 0,
data: "",
userId: input.userId,
},
});
log.info("Note created", { id: id.toString(), guid });
return id;
}
export async function repoUpdateNote(input: RepoInputUpdateNote): Promise<void> {
const now = Date.now();
const updateData: {
mod?: number;
usn?: number;
flds?: string;
sfld?: string;
csum?: number;
tags?: string;
} = {
mod: Math.floor(now / 1000),
usn: -1,
};
if (input.fields) {
updateData.flds = repoJoinFields(input.fields);
updateData.sfld = input.fields[0] ?? "";
updateData.csum = repoCalculateCsum(updateData.sfld);
}
if (input.tags) {
updateData.tags = input.tags.join(" ");
}
log.debug("Updating note", { id: input.id.toString() });
await prisma.note.update({
where: { id: input.id },
data: updateData,
});
log.info("Note updated", { id: input.id.toString() });
}
export async function repoGetNoteById(
input: RepoInputGetNoteById,
): Promise<RepoOutputNote | null> {
const note = await prisma.note.findUnique({
where: { id: input.id },
});
if (!note) {
log.debug("Note not found", { id: input.id.toString() });
return null;
}
return {
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
export async function repoGetNoteByIdWithFields(
input: RepoInputGetNoteById,
): Promise<RepoOutputNoteWithFields | null> {
const note = await repoGetNoteById(input);
if (!note) return null;
return {
...note,
fields: repoSplitFields(note.flds),
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
};
}
export async function repoGetNotesByNoteTypeId(
input: RepoInputGetNotesByNoteTypeId,
): Promise<RepoOutputNote[]> {
const { noteTypeId, limit = 50, offset = 0 } = input;
log.debug("Fetching notes by note type", { noteTypeId, limit, offset });
const notes = await prisma.note.findMany({
where: { noteTypeId },
orderBy: { id: "desc" },
take: limit,
skip: offset,
});
log.info("Fetched notes by note type", { noteTypeId, count: notes.length });
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function repoGetNotesByUserId(
input: RepoInputGetNotesByUserId,
): Promise<RepoOutputNote[]> {
const { userId, limit = 50, offset = 0 } = input;
log.debug("Fetching notes by user", { userId, limit, offset });
const notes = await prisma.note.findMany({
where: { userId },
orderBy: { id: "desc" },
take: limit,
skip: offset,
});
log.info("Fetched notes by user", { userId, count: notes.length });
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function repoGetNotesByUserIdWithFields(
input: RepoInputGetNotesByUserId,
): Promise<RepoOutputNoteWithFields[]> {
const notes = await repoGetNotesByUserId(input);
return notes.map((note) => ({
...note,
fields: repoSplitFields(note.flds),
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
}));
}
export async function repoDeleteNote(input: RepoInputDeleteNote): Promise<void> {
log.debug("Deleting note", { id: input.id.toString() });
await prisma.note.delete({
where: { id: input.id },
});
log.info("Note deleted", { id: input.id.toString() });
}
export async function repoCheckNoteOwnership(
input: RepoInputCheckNoteOwnership,
): Promise<boolean> {
const note = await prisma.note.findUnique({
where: { id: input.noteId },
select: { userId: true },
});
return note?.userId === input.userId;
}
export async function repoGetNoteOwnership(
input: RepoInputGetNoteById,
): Promise<RepoOutputNoteOwnership | null> {
const note = await prisma.note.findUnique({
where: { id: input.id },
select: { userId: true },
});
if (!note) return null;
return { userId: note.userId };
}
export async function repoCountNotesByUserId(userId: string): Promise<number> {
return prisma.note.count({
where: { userId },
});
}
export async function repoCountNotesByNoteTypeId(
noteTypeId: number,
): Promise<number> {
return prisma.note.count({
where: { noteTypeId },
});
}

View File

@@ -0,0 +1,60 @@
export type ServiceInputCreateNote = {
noteTypeId: number;
fields: string[];
tags?: string[];
userId: string;
};
export type ServiceInputUpdateNote = {
noteId: bigint;
fields?: string[];
tags?: string[];
userId: string;
};
export type ServiceInputDeleteNote = {
noteId: bigint;
userId: string;
};
export type ServiceInputGetNoteById = {
noteId: bigint;
};
export type ServiceInputGetNotesByNoteTypeId = {
noteTypeId: number;
limit?: number;
offset?: number;
};
export type ServiceInputGetNotesByUserId = {
userId: string;
limit?: number;
offset?: number;
};
export type ServiceOutputNote = {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputCreateNote = {
id: bigint;
guid: string;
};
export type ServiceOutputNoteCount = {
count: number;
};

View File

@@ -0,0 +1,200 @@
import { createLogger } from "@/lib/logger";
import {
repoCreateNote,
repoUpdateNote,
repoGetNoteByIdWithFields,
repoGetNotesByNoteTypeId,
repoGetNotesByUserIdWithFields,
repoDeleteNote,
repoCheckNoteOwnership,
repoCountNotesByUserId,
repoCountNotesByNoteTypeId,
} from "./note-repository";
import {
ServiceInputCreateNote,
ServiceInputUpdateNote,
ServiceInputDeleteNote,
ServiceInputGetNoteById,
ServiceInputGetNotesByNoteTypeId,
ServiceInputGetNotesByUserId,
ServiceOutputNote,
ServiceOutputCreateNote,
ServiceOutputNoteCount,
} from "./note-service-dto";
const log = createLogger("note-service");
export class NoteNotFoundError extends Error {
constructor(noteId: bigint) {
super(`Note not found: ${noteId.toString()}`);
this.name = "NoteNotFoundError";
}
}
export class NoteOwnershipError extends Error {
constructor() {
super("You do not have permission to access this note");
this.name = "NoteOwnershipError";
}
}
export async function serviceCreateNote(
input: ServiceInputCreateNote,
): Promise<ServiceOutputCreateNote> {
log.debug("Creating note", { userId: input.userId, noteTypeId: input.noteTypeId });
const id = await repoCreateNote({
noteTypeId: input.noteTypeId,
fields: input.fields,
tags: input.tags,
userId: input.userId,
});
const note = await repoGetNoteByIdWithFields({ id });
if (!note) {
throw new NoteNotFoundError(id);
}
log.info("Note created successfully", { id: id.toString(), guid: note.guid });
return {
id,
guid: note.guid,
};
}
export async function serviceUpdateNote(
input: ServiceInputUpdateNote,
): Promise<void> {
log.debug("Updating note", { noteId: input.noteId.toString() });
const isOwner = await repoCheckNoteOwnership({
noteId: input.noteId,
userId: input.userId,
});
if (!isOwner) {
throw new NoteOwnershipError();
}
await repoUpdateNote({
id: input.noteId,
fields: input.fields,
tags: input.tags,
});
log.info("Note updated successfully", { noteId: input.noteId.toString() });
}
export async function serviceDeleteNote(
input: ServiceInputDeleteNote,
): Promise<void> {
log.debug("Deleting note", { noteId: input.noteId.toString() });
const isOwner = await repoCheckNoteOwnership({
noteId: input.noteId,
userId: input.userId,
});
if (!isOwner) {
throw new NoteOwnershipError();
}
await repoDeleteNote({ id: input.noteId });
log.info("Note deleted successfully", { noteId: input.noteId.toString() });
}
export async function serviceGetNoteById(
input: ServiceInputGetNoteById,
): Promise<ServiceOutputNote | null> {
log.debug("Fetching note by id", { noteId: input.noteId.toString() });
const note = await repoGetNoteByIdWithFields({ id: input.noteId });
if (!note) {
log.debug("Note not found", { noteId: input.noteId.toString() });
return null;
}
return {
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tagsArray,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
export async function serviceGetNotesByNoteTypeId(
input: ServiceInputGetNotesByNoteTypeId,
): Promise<ServiceOutputNote[]> {
log.debug("Fetching notes by note type", { noteTypeId: input.noteTypeId });
const notes = await repoGetNotesByNoteTypeId(input);
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
fields: note.flds.split("\x1f"),
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function serviceGetNotesByUserId(
input: ServiceInputGetNotesByUserId,
): Promise<ServiceOutputNote[]> {
log.debug("Fetching notes by user", { userId: input.userId });
const notes = await repoGetNotesByUserIdWithFields(input);
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tagsArray,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function serviceCountNotesByUserId(
userId: string,
): Promise<ServiceOutputNoteCount> {
const count = await repoCountNotesByUserId(userId);
return { count };
}
export async function serviceCountNotesByNoteTypeId(
noteTypeId: number,
): Promise<ServiceOutputNoteCount> {
const count = await repoCountNotesByNoteTypeId(noteTypeId);
return { count };
}

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
export const schemaActionInputProcessOCR = z.object({
imageBase64: z.string().min(1, "Image is required"),
folderId: z.number().int().positive("Folder ID must be positive"),
deckId: z.number().int().positive("Deck ID must be positive"),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
});

View File

@@ -1 +1 @@
export type { RepoInputCreatePair } from "@/modules/folder/folder-repository-dto";
export {};

View File

@@ -1,5 +1 @@
import { repoCreatePair, repoGetUserIdByFolderId } from "@/modules/folder/folder-repository";
import type { RepoInputCreatePair } from "./ocr-repository-dto";
export { repoCreatePair, repoGetUserIdByFolderId };
export type { RepoInputCreatePair };
export {};

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
export const schemaServiceInputProcessOCR = z.object({
imageBase64: z.string().min(1, "Image is required"),
folderId: z.number().int().positive("Folder ID must be positive"),
deckId: z.number().int().positive("Deck ID must be positive"),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
});

View File

@@ -1,18 +1,69 @@
"use server";
import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator";
import { repoCreatePair, repoGetUserIdByFolderId } from "@/modules/folder/folder-repository";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
import { repoCreateNote, repoJoinFields } from "@/modules/note/note-repository";
import { repoCreateCard } from "@/modules/card/card-repository";
import { repoGetNoteTypesByUserId, repoCreateNoteType } from "@/modules/note-type/note-type-repository";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("ocr-service");
const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)";
async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
const existingTypes = await repoGetNoteTypesByUserId({ userId });
const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME);
if (existing) {
return existing.id;
}
const fields = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Source Language", ord: 2, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
{ name: "Target Language", ord: 3, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
];
const templates = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}",
},
];
const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }";
const noteTypeId = await repoCreateNoteType({
name: VOCABULARY_NOTE_TYPE_NAME,
kind: NoteKind.STANDARD,
css,
fields,
templates,
userId,
});
log.info("Created vocabulary note type", { noteTypeId, userId });
return noteTypeId;
}
export async function serviceProcessOCR(
input: ServiceInputProcessOCR
): Promise<ServiceOutputProcessOCR> {
log.info("Processing OCR request", { folderId: input.folderId });
log.info("Processing OCR request", { deckId: input.deckId });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
@@ -20,15 +71,15 @@ export async function serviceProcessOCR(
return { success: false, message: "Unauthorized" };
}
const folderOwner = await repoGetUserIdByFolderId(input.folderId);
if (folderOwner !== session.user.id) {
log.warn("Folder ownership mismatch", {
folderId: input.folderId,
const deckOwner = await repoGetUserIdByDeckId(input.deckId);
if (deckOwner !== session.user.id) {
log.warn("Deck ownership mismatch", {
deckId: input.deckId,
userId: session.user.id
});
return {
success: false,
message: "You don't have permission to modify this folder"
message: "You don't have permission to modify this deck"
};
}
@@ -59,19 +110,38 @@ export async function serviceProcessOCR(
const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown";
const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown";
const noteTypeId = await getOrCreateVocabularyNoteType(session.user.id);
let pairsCreated = 0;
for (const pair of ocrResult.pairs) {
try {
await repoCreatePair({
folderId: input.folderId,
language1: sourceLanguage,
language2: targetLanguage,
text1: pair.word,
text2: pair.definition,
const now = Date.now();
const noteId = await repoCreateNote({
noteTypeId,
userId: session.user.id,
fields: [pair.word, pair.definition, sourceLanguage, targetLanguage],
tags: ["ocr"],
});
await repoCreateCard({
id: BigInt(now + pairsCreated),
noteId,
deckId: input.deckId,
ord: 0,
due: pairsCreated + 1,
});
await repoCreateCard({
id: BigInt(now + pairsCreated + 10000),
noteId,
deckId: input.deckId,
ord: 1,
due: pairsCreated + 1,
});
pairsCreated++;
} catch (error) {
log.error("Failed to create pair", {
log.error("Failed to create note/card", {
word: pair.word,
error
});