refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移
This commit is contained in:
106
src/modules/note-type/note-type-action-dto.ts
Normal file
106
src/modules/note-type/note-type-action-dto.ts
Normal 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;
|
||||
};
|
||||
255
src/modules/note-type/note-type-action.ts
Normal file
255
src/modules/note-type/note-type-action.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
181
src/modules/note-type/note-type-repository-dto.ts
Normal file
181
src/modules/note-type/note-type-repository-dto.ts
Normal 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;
|
||||
}`;
|
||||
151
src/modules/note-type/note-type-repository.ts
Normal file
151
src/modules/note-type/note-type-repository.ts
Normal 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;
|
||||
}
|
||||
60
src/modules/note-type/note-type-service-dto.ts
Normal file
60
src/modules/note-type/note-type-service-dto.ts
Normal 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[];
|
||||
};
|
||||
272
src/modules/note-type/note-type-service.ts
Normal file
272
src/modules/note-type/note-type-service.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user