diff --git a/apps/backend/src/handlers/catalog/engine/create.ts b/apps/backend/src/handlers/catalog/engine/create.ts new file mode 100644 index 0000000..95243e6 --- /dev/null +++ b/apps/backend/src/handlers/catalog/engine/create.ts @@ -0,0 +1,33 @@ +import { Elysia } from "elysia"; +import { + CreateEngineRequestSchema, + ErrorResponseSchema, + EngineResponseSchema, + engineService, +} from "@cvsa/core"; +import { authMiddleware } from "@/middlewares"; +import { traceTask } from "@/common/trace"; + +export const engineCreateHandler = new Elysia({ name: "engineCreateHandler" }) + .use(authMiddleware) + .post( + "/engine", + async ({ body, status }) => { + const engine = await traceTask("engineService.create", async () => { + return await engineService.create(body); + }); + return status(201, engine); + }, + { + body: CreateEngineRequestSchema, + detail: { + summary: "Create Engine", + description: + "Create a new SVS engine entry in the catalog. Requires authentication.", + }, + response: { + 201: EngineResponseSchema, + 401: ErrorResponseSchema, + }, + } + ); diff --git a/apps/backend/src/handlers/catalog/engine/delete.ts b/apps/backend/src/handlers/catalog/engine/delete.ts new file mode 100644 index 0000000..b483c5a --- /dev/null +++ b/apps/backend/src/handlers/catalog/engine/delete.ts @@ -0,0 +1,34 @@ +import { Elysia } from "elysia"; +import { z } from "zod"; +import { ErrorResponseSchema, engineService } from "@cvsa/core"; +import { authMiddleware } from "@/middlewares"; +import { traceTask } from "@/common/trace"; + +export const engineDeleteHandler = new Elysia({ name: "engineDeleteHandler" }) + .use(authMiddleware) + .delete( + "/engine/:id", + async ({ params, set }) => { + await traceTask("engineService.delete", async () => { + return await engineService.delete(params.id); + }); + set.status = 204; + return null; + }, + { + params: z.object({ + id: z.coerce.number().int().positive(), + }), + detail: { + summary: "Delete Engine", + description: + "Soft delete an SVS engine from the catalog. Requires authentication. The engine record is marked as deleted but retained in the database for data integrity.", + }, + response: { + 204: z.null(), + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + 404: ErrorResponseSchema, + }, + } + ); diff --git a/apps/backend/src/handlers/catalog/engine/details.ts b/apps/backend/src/handlers/catalog/engine/details.ts new file mode 100644 index 0000000..3233e5c --- /dev/null +++ b/apps/backend/src/handlers/catalog/engine/details.ts @@ -0,0 +1,29 @@ +import { Elysia } from "elysia"; +import { ErrorResponseSchema, EngineDetailsResponseSchema, engineService } from "@cvsa/core"; +import z from "zod"; +import { traceTask } from "@/common/trace"; + +export const engineDetailsHandler = new Elysia().get( + "/engine/:id/details", + async ({ params, status }) => { + const engine = await traceTask("engineService.getDetails", async () => { + return await engineService.getDetails(params.id); + }); + return status(200, engine); + }, + { + detail: { + summary: "Engine Details", + description: + "Retrieve detailed information about a specific SVS engine by its ID. Includes name, description, and metadata.", + }, + response: { + 200: EngineDetailsResponseSchema, + 400: ErrorResponseSchema, + 404: ErrorResponseSchema, + }, + params: z.object({ + id: z.coerce.number().int().positive(), + }), + } +); diff --git a/apps/backend/src/handlers/catalog/engine/index.ts b/apps/backend/src/handlers/catalog/engine/index.ts new file mode 100644 index 0000000..fee3441 --- /dev/null +++ b/apps/backend/src/handlers/catalog/engine/index.ts @@ -0,0 +1,11 @@ +import { Elysia } from "elysia"; +import { engineCreateHandler } from "./create"; +import { engineUpdateHandler } from "./update"; +import { engineDeleteHandler } from "./delete"; +import { engineDetailsHandler } from "./details"; + +export const engineHandler = new Elysia({ name: "engineHandler" }) + .use(engineDetailsHandler) + .use(engineCreateHandler) + .use(engineUpdateHandler) + .use(engineDeleteHandler); diff --git a/apps/backend/src/handlers/catalog/engine/update.ts b/apps/backend/src/handlers/catalog/engine/update.ts new file mode 100644 index 0000000..90c1193 --- /dev/null +++ b/apps/backend/src/handlers/catalog/engine/update.ts @@ -0,0 +1,39 @@ +import { Elysia } from "elysia"; +import { z } from "zod"; +import { + UpdateEngineRequestSchema, + ErrorResponseSchema, + engineService, + EngineResponseSchema, +} from "@cvsa/core"; +import { authMiddleware } from "@/middlewares"; +import { traceTask } from "@/common/trace"; + +export const engineUpdateHandler = new Elysia({ name: "engineUpdateHandler" }) + .use(authMiddleware) + .patch( + "/engine/:id", + async ({ params, body, status }) => { + const engine = await traceTask("engineService.update", async () => { + return await engineService.update(params.id, body); + }); + return status(200, engine); + }, + { + body: UpdateEngineRequestSchema, + params: z.object({ + id: z.coerce.number().int().positive(), + }), + detail: { + summary: "Update Engine", + description: + "Update metadata of an existing SVS engine identified by its ID. Requires authentication.", + }, + response: { + 200: EngineResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + 404: ErrorResponseSchema, + }, + } + ); diff --git a/apps/backend/src/handlers/index.ts b/apps/backend/src/handlers/index.ts index 190d46f..0b8a509 100644 --- a/apps/backend/src/handlers/index.ts +++ b/apps/backend/src/handlers/index.ts @@ -1,3 +1,4 @@ export * from "./auth"; export * from "./catalog/song"; +export * from "./catalog/engine"; export * from "./dev"; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index ecdb0e6..c4acb23 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,7 +2,7 @@ import { Elysia } from "elysia"; import { onAfterHandler } from "./onAfterHandle"; import { getBindingInfo, logStartup } from "./startMessage"; import pkg from "../package.json"; -import { authHandler, songHandler } from "@handlers/index"; +import { authHandler, songHandler, engineHandler } from "@handlers/index"; import { errorHandler } from "./errorHandler"; import { openapi } from "@elysiajs/openapi"; import { requestLoggerMiddleware } from "@/middlewares"; @@ -32,6 +32,7 @@ export const app = new Elysia({ .use(openapi()) .use(authHandler) .use(songHandler) + .use(engineHandler) .use(devHandler) .listen(16412); diff --git a/apps/backend/tests/engine.test.ts b/apps/backend/tests/engine.test.ts new file mode 100644 index 0000000..4c42cc5 --- /dev/null +++ b/apps/backend/tests/engine.test.ts @@ -0,0 +1,237 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { treaty } from "@elysiajs/eden"; +import { app } from "@/index"; +import { prisma } from "@cvsa/db"; + +const api = treaty(app); + +describe("Engine E2E Tests", () => { + beforeAll(async () => { + await prisma.$connect(); + await prisma.session.deleteMany(); + await prisma.user.deleteMany(); + await prisma.svsEngine.deleteMany(); + }); + + afterAll(async () => { + await prisma.session.deleteMany(); + await prisma.user.deleteMany(); + await prisma.$disconnect(); + }); + + async function getAuthToken() { + const signup = await api.v2.user.post({ + username: `${Math.random()}`, + password: "password123", + }); + return signup.data?.data.token; + } + + describe("GET /v2/engine/:id/details - Get Engine Details", () => { + test("should retrieve an engine", async () => { + const engine = await prisma.svsEngine.create({ + data: { + name: "Test Engine", + description: "Test Description", + }, + }); + + const { data, status } = await api.v2.engine({ id: engine.id }).details.get(); + + expect(status).toBe(200); + expect(data).toMatchObject({ + id: expect.any(Number), + name: "Test Engine", + description: "Test Description", + }); + }); + + test("should return 404 for nonexistent engine", async () => { + const { error, status } = await api.v2.engine({ id: 99999 }).details.get(); + + expect(status).toBe(404); + expect(error?.value).toMatchObject({ + code: "NOT_FOUND", + }); + }); + }); + + describe("POST /v2/engine - Create Engine", () => { + test("should create an engine with authentication", async () => { + const token = await getAuthToken(); + + const payload = { + name: "VOCALOID", + description: "A singing voice synthesis software", + }; + + const { data, status } = await api.v2.engine.post(payload, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(status).toBe(201); + expect(data).toMatchObject({ + id: expect.any(Number), + name: "VOCALOID", + description: "A singing voice synthesis software", + }); + }); + + test("should return 401 without authentication", async () => { + const payload = { + name: "Test Engine", + }; + + const { error, status } = await api.v2.engine.post(payload); + + expect(status).toBe(401); + expect(error?.value).toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("should create an engine with localized descriptions", async () => { + const token = await getAuthToken(); + + const payload = { + name: "Synthesizer V", + description: "A singing voice synthesis software", + localizedDescriptions: { + zh: "歌声合成软件", + ja: "歌声合成ソフトウェア", + }, + }; + + const { data, status } = await api.v2.engine.post(payload, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(status).toBe(201); + expect(data).toMatchObject({ + id: expect.any(Number), + name: "Synthesizer V", + localizedDescriptions: { + zh: "歌声合成软件", + ja: "歌声合成ソフトウェア", + }, + }); + }); + }); + + describe("PATCH /v2/engine/:id - Update Engine", () => { + test("should update an engine with authentication", async () => { + const token = await getAuthToken(); + + const engine = await prisma.svsEngine.create({ + data: { + name: "Original Name", + description: "Original Description", + }, + }); + + const { data, status } = await api.v2.engine({ id: engine.id }).patch( + { name: "Updated Name", description: "Updated Description" }, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(200); + expect(data?.name).toBe("Updated Name"); + expect(data?.description).toBe("Updated Description"); + }); + + test("should return 401 without authentication", async () => { + const engine = await prisma.svsEngine.create({ + data: { + name: "Test Engine", + }, + }); + + const { status } = await api.v2.engine({ id: engine.id }).patch({ + name: "Updated Name", + }); + + expect(status).toBe(401); + }); + + test("should return 404 for non-existent engine", async () => { + const token = await getAuthToken(); + + const { error, status } = await api.v2.engine({ id: 999999 }).patch( + { name: "Updated Name" }, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(404); + expect(error?.value).toMatchObject({ + code: "NOT_FOUND", + }); + }); + }); + + describe("DELETE /v2/engine/:id - Delete Engine", () => { + test("should soft delete an engine with authentication", async () => { + const token = await getAuthToken(); + + const engine = await prisma.svsEngine.create({ + data: { + name: "Engine to Delete", + }, + }); + + const { status } = await api.v2.engine({ id: engine.id }).delete( + {}, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(204); + + const deletedEngine = await prisma.svsEngine.findUnique({ + where: { id: engine.id }, + }); + expect(deletedEngine?.deletedAt).not.toBeNull(); + }); + + test("should return 401 without authentication", async () => { + const engine = await prisma.svsEngine.create({ + data: { + name: "Test Engine", + }, + }); + + const { status } = await api.v2.engine({ id: engine.id }).delete({}, {}); + + expect(status).toBe(401); + }); + + test("should return 404 for non-existent engine", async () => { + const token = await getAuthToken(); + + const { status } = await api.v2.engine({ id: 999999 }).delete( + {}, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(404); + }); + }); +}); diff --git a/packages/core/src/modules/catalog/engine/container.ts b/packages/core/src/modules/catalog/engine/container.ts new file mode 100644 index 0000000..d66d88d --- /dev/null +++ b/packages/core/src/modules/catalog/engine/container.ts @@ -0,0 +1,6 @@ +import { prisma } from "@cvsa/db"; +import { EngineRepository } from "./repository"; +import { EngineService } from "./service"; + +export const engineRepository = new EngineRepository(prisma); +export const engineService = new EngineService(engineRepository); diff --git a/packages/core/src/modules/catalog/engine/dto.ts b/packages/core/src/modules/catalog/engine/dto.ts new file mode 100644 index 0000000..e0e789e --- /dev/null +++ b/packages/core/src/modules/catalog/engine/dto.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { SvsEngineSchema, type Serialized } from "@cvsa/db"; + +export type EngineId = number; + +export const CreateEngineRequestSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + localizedDescriptions: z.record(z.string(), z.string()).optional(), +}); + +export const UpdateEngineRequestSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().optional(), + localizedDescriptions: z.record(z.string(), z.string()).optional(), +}); + +export const EngineResponseSchema = SvsEngineSchema.omit({ deletedAt: true }); + +export const EngineDetailsResponseSchema = EngineResponseSchema; + +export type EngineResponseDto = Serialized>; +export type EngineDetailsResponseDto = Serialized>; +export type CreateEngineRequestDto = Serialized>; +export type UpdateEngineRequestDto = Serialized>; diff --git a/packages/core/src/modules/catalog/engine/index.ts b/packages/core/src/modules/catalog/engine/index.ts new file mode 100644 index 0000000..132cf0c --- /dev/null +++ b/packages/core/src/modules/catalog/engine/index.ts @@ -0,0 +1,5 @@ +export * from "./dto"; +export * from "./repository"; +export * from "./service"; +export * from "./repository.interface"; +export * from "./container"; diff --git a/packages/core/src/modules/catalog/engine/repository.interface.ts b/packages/core/src/modules/catalog/engine/repository.interface.ts new file mode 100644 index 0000000..a106f1c --- /dev/null +++ b/packages/core/src/modules/catalog/engine/repository.interface.ts @@ -0,0 +1,23 @@ +import type { TxClient } from "@cvsa/db"; +import type { IRepositoryWithGetDetails } from "@cvsa/core/internal"; +import type { + CreateEngineRequestDto, + EngineId, + UpdateEngineRequestDto, + EngineResponseDto, + EngineDetailsResponseDto, +} from "./dto"; + +export abstract class IEngineRepository + implements IRepositoryWithGetDetails +{ + abstract getById(id: EngineId, tx?: TxClient): Promise; + abstract getDetailsById(id: EngineId, tx?: TxClient): Promise; + abstract create(input: CreateEngineRequestDto, tx?: TxClient): Promise; + abstract update( + id: EngineId, + input: UpdateEngineRequestDto, + tx?: TxClient + ): Promise; + abstract softDelete(id: EngineId, tx?: TxClient): Promise; +} diff --git a/packages/core/src/modules/catalog/engine/repository.ts b/packages/core/src/modules/catalog/engine/repository.ts new file mode 100644 index 0000000..23670e2 --- /dev/null +++ b/packages/core/src/modules/catalog/engine/repository.ts @@ -0,0 +1,64 @@ +import type { PrismaClient } from "@cvsa/db"; +import type { + CreateEngineRequestDto, + EngineId, + UpdateEngineRequestDto, + EngineDetailsResponseDto, +} from "./dto"; +import type { IEngineRepository } from "./repository.interface"; +import { transformPrismaResult, type TxClient } from "@cvsa/db"; + +export class EngineRepository implements IEngineRepository { + constructor(private readonly prisma: PrismaClient) {} + + async getById(id: EngineId, tx?: TxClient) { + const client = tx ?? this.prisma; + return transformPrismaResult( + await client.svsEngine.findFirst({ + where: { id, deletedAt: null }, + omit: { deletedAt: true }, + }) + ); + } + + async getDetailsById(id: EngineId, tx?: TxClient): Promise { + const client = tx ?? this.prisma; + return transformPrismaResult( + await client.svsEngine.findFirst({ + where: { id, deletedAt: null }, + omit: { deletedAt: true }, + }) + ); + } + + async create(input: CreateEngineRequestDto, tx?: TxClient) { + const client = tx ?? this.prisma; + + return transformPrismaResult( + await client.svsEngine.create({ + data: input, + omit: { deletedAt: true }, + }) + ); + } + + async update(id: EngineId, input: UpdateEngineRequestDto, tx?: TxClient) { + const client = tx ?? this.prisma; + + return transformPrismaResult( + await client.svsEngine.update({ + where: { id }, + data: input, + omit: { deletedAt: true }, + }) + ); + } + + async softDelete(id: EngineId, tx?: TxClient) { + const client = tx ?? this.prisma; + await client.svsEngine.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + } +} diff --git a/packages/core/src/modules/catalog/engine/service.ts b/packages/core/src/modules/catalog/engine/service.ts new file mode 100644 index 0000000..6f46ea5 --- /dev/null +++ b/packages/core/src/modules/catalog/engine/service.ts @@ -0,0 +1,50 @@ +import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal"; +import type { + EngineDetailsResponseDto, + EngineId, + CreateEngineRequestDto, + UpdateEngineRequestDto, + EngineResponseDto, +} from "./dto"; +import type { IEngineRepository } from "./repository.interface"; +import { traceTask } from "@cvsa/observability"; + +export class EngineService implements IServiceWithGetDetails { + constructor(private readonly repository: IEngineRepository) {} + + async getDetails(id: EngineId) { + return traceTask("db findOne engine", async () => { + const result = await this.repository.getDetailsById(id); + if (result === null) { + throw new AppError("error.engine.notfound", "NOT_FOUND", 404); + } + return result; + }); + } + + async create(input: CreateEngineRequestDto): Promise { + return traceTask("db create engine", async () => { + return await this.repository.create(input); + }); + } + + async update(id: EngineId, input: UpdateEngineRequestDto): Promise { + const existing = await this.repository.getById(id); + if (existing === null) { + throw new AppError("error.engine.notfound", "NOT_FOUND", 404); + } + return traceTask("db update engine", async () => { + return await this.repository.update(id, input); + }); + } + + async delete(id: EngineId): Promise { + const existing = await this.repository.getById(id); + if (existing === null) { + throw new AppError("error.engine.notfound", "NOT_FOUND", 404); + } + await traceTask("db delete engine", async () => { + return await this.repository.softDelete(id); + }); + } +} diff --git a/packages/core/src/modules/catalog/index.ts b/packages/core/src/modules/catalog/index.ts index 153b6ce..69b91cc 100644 --- a/packages/core/src/modules/catalog/index.ts +++ b/packages/core/src/modules/catalog/index.ts @@ -1 +1,2 @@ export * from "./song"; +export * from "./engine"; diff --git a/packages/core/src/modules/catalog/song/service.ts b/packages/core/src/modules/catalog/song/service.ts index ec58fa1..8e9a6ba 100644 --- a/packages/core/src/modules/catalog/song/service.ts +++ b/packages/core/src/modules/catalog/song/service.ts @@ -107,9 +107,15 @@ export class SongService implements IServiceWithGetDetails { + const result = await traceTask("db create lyric", async () => { return await this.repository.createLyrics(id, input); }); + await traceTask("sync search index", async () => { + this.search.sync(id).catch((e) => { + appLogger.warn(Bun.inspect(e)); + }); + }); + return result; } async updateLyric( @@ -125,9 +131,15 @@ export class SongService implements IServiceWithGetDetails { + const result = await traceTask("db update lyric", async () => { return await this.repository.updateLyric(lyricId, input); }); + await traceTask("sync search index", async () => { + this.search.sync(id).catch((e) => { + appLogger.warn(Bun.inspect(e)); + }); + }); + return result; } async deleteLyric(id: SongId, lyricId: number): Promise { @@ -142,5 +154,10 @@ export class SongService implements IServiceWithGetDetails { return await this.repository.softDeleteLyric(lyricId); }); + await traceTask("sync search index", async () => { + this.search.sync(id).catch((e) => { + appLogger.warn(Bun.inspect(e)); + }); + }); } } diff --git a/packages/core/tests/integration/SongSearchService.test.ts b/packages/core/tests/integration/SongSearchService.test.ts index 3bfbd96..1d1e721 100644 --- a/packages/core/tests/integration/SongSearchService.test.ts +++ b/packages/core/tests/integration/SongSearchService.test.ts @@ -6,12 +6,6 @@ import type { EmbeddingAppApi } from "@cvsa/embedding"; import { SearchManager } from "../../src/search/manager"; import { env } from "@cvsa/env"; import { MeiliSearch } from "meilisearch"; -// import { OriginalMeiliSearch } from "../utils"; -// import type { MeiliSearch as MeiliType } from "meilisearch"; - -// const MeiliSearch = OriginalMeiliSearch.MeiliSearch; -// console.debug("CCCCCCC", MeiliSearch); -// type MeiliSearch = MeiliType; const MEILI_HOST = env.MEILI_API_URL ?? "http://127.0.0.1:7700"; const MEILI_MASTER_KEY = env.MEILI_MASTER_KEY ?? "";