diff --git a/prisma/launcher.schema.prisma b/prisma/launcher.schema.prisma index 8181a08..f0523f4 100644 --- a/prisma/launcher.schema.prisma +++ b/prisma/launcher.schema.prisma @@ -45,6 +45,20 @@ model LauncherTerm { @@map("launcher_terms") } +model LauncherManualDocument { + id String @id @default(uuid()) @db.Uuid + appKey String @map("app_key") + title String + body String + isPublished Boolean @default(true) @map("is_published") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([appKey, isPublished, sortOrder]) + @@map("launcher_manual_documents") +} + model ProvisionedDevice { id String @id @default(uuid()) @db.Uuid accountEmail String @map("account_email") diff --git a/prisma/migrations/20260714090000_add_launcher_manual_documents/migration.sql b/prisma/migrations/20260714090000_add_launcher_manual_documents/migration.sql new file mode 100644 index 0000000..da786a0 --- /dev/null +++ b/prisma/migrations/20260714090000_add_launcher_manual_documents/migration.sql @@ -0,0 +1,15 @@ +CREATE TABLE "launcher_manual_documents" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "is_published" BOOLEAN NOT NULL DEFAULT true, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "launcher_manual_documents_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "launcher_manual_documents_key_key" ON "launcher_manual_documents"("key"); +CREATE INDEX "launcher_manual_documents_is_published_sort_order_idx" ON "launcher_manual_documents"("is_published", "sort_order"); diff --git a/prisma/migrations/20260714093000_add_launcher_manual_document_app_key/migration.sql b/prisma/migrations/20260714093000_add_launcher_manual_document_app_key/migration.sql new file mode 100644 index 0000000..98c6ab1 --- /dev/null +++ b/prisma/migrations/20260714093000_add_launcher_manual_document_app_key/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "launcher_manual_documents" +ADD COLUMN "app_key" TEXT NOT NULL DEFAULT 'Launcher'; + +DROP INDEX IF EXISTS "launcher_manual_documents_is_published_sort_order_idx"; +CREATE INDEX "launcher_manual_documents_app_key_is_published_sort_order_idx" +ON "launcher_manual_documents"("app_key", "is_published", "sort_order"); diff --git a/prisma/migrations/20260714100000_drop_launcher_manual_document_key/migration.sql b/prisma/migrations/20260714100000_drop_launcher_manual_document_key/migration.sql new file mode 100644 index 0000000..06bf806 --- /dev/null +++ b/prisma/migrations/20260714100000_drop_launcher_manual_document_key/migration.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS "launcher_manual_documents_key_key"; +ALTER TABLE "launcher_manual_documents" DROP COLUMN IF EXISTS "key"; diff --git a/src/app.module.ts b/src/app.module.ts index 96cc653..8e7417e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { GiteaModule } from './gitea/gitea.module'; import { HealthModule } from './health/health.module'; import { LauncherModule } from './launcher/launcher.module'; import { LicensesModule } from './licenses/licenses.module'; +import { ManualsModule } from './manuals/manuals.module'; import { NoticesModule } from './notices/notices.module'; import { OrganizationsModule } from './organizations/organizations.module'; import { PageAgentModule } from './page-agent/page-agent.module'; @@ -40,6 +41,7 @@ import { WoodpeckerModule } from './woodpecker/woodpecker.module'; HealthModule, LauncherModule, LicensesModule, + ManualsModule, NoticesModule, OrganizationsModule, PageAgentModule, diff --git a/src/manuals/dto.ts b/src/manuals/dto.ts new file mode 100644 index 0000000..6130832 --- /dev/null +++ b/src/manuals/dto.ts @@ -0,0 +1,66 @@ +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class ListManualDocumentsQuery { + @IsOptional() + @IsString() + appKey?: string; + + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + + @IsOptional() + @IsString() + keyword?: string; +} + +export class CreateManualDocumentDto { + @IsString() + @MaxLength(120) + appKey!: string; + + @IsString() + @MaxLength(160) + title!: string; + + @IsString() + body!: string; + + @IsOptional() + @IsBoolean() + isPublished?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} + +export class UpdateManualDocumentDto { + @IsOptional() + @IsString() + @MaxLength(120) + appKey?: string; + + @IsOptional() + @IsString() + @MaxLength(160) + title?: string; + + @IsOptional() + @IsString() + body?: string; + + @IsOptional() + @IsBoolean() + isPublished?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/src/manuals/manuals.controller.ts b/src/manuals/manuals.controller.ts new file mode 100644 index 0000000..86b52cf --- /dev/null +++ b/src/manuals/manuals.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; + +import { Public } from '../admin-auth/decorators/admin-auth.decorators'; +import { CreateManualDocumentDto, ListManualDocumentsQuery, UpdateManualDocumentDto } from './dto'; +import { ManualsService } from './manuals.service'; + +@Controller('manuals') +export class ManualsController { + constructor(private readonly manualsService: ManualsService) {} + + @Public() + @Get('public') + publicList(@Query('appKey') appKey?: string) { + return this.manualsService.publicList(appKey); + } + + @Get() + list(@Query() query: ListManualDocumentsQuery) { + return this.manualsService.list(query); + } + + @Post() + create(@Body() body: CreateManualDocumentDto) { + return this.manualsService.create(body); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() body: UpdateManualDocumentDto) { + return this.manualsService.update(id, body); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.manualsService.remove(id); + } +} diff --git a/src/manuals/manuals.module.ts b/src/manuals/manuals.module.ts new file mode 100644 index 0000000..d45c259 --- /dev/null +++ b/src/manuals/manuals.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; +import { ManualsController } from './manuals.controller'; +import { ManualsService } from './manuals.service'; + +@Module({ + imports: [LauncherPrismaModule], + controllers: [ManualsController], + providers: [ManualsService], +}) +export class ManualsModule {} diff --git a/src/manuals/manuals.service.ts b/src/manuals/manuals.service.ts new file mode 100644 index 0000000..9250ada --- /dev/null +++ b/src/manuals/manuals.service.ts @@ -0,0 +1,111 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/launcher-client'; + +import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; +import { CreateManualDocumentDto, ListManualDocumentsQuery, UpdateManualDocumentDto } from './dto'; + +@Injectable() +export class ManualsService { + constructor(private readonly launcherPrisma: LauncherPrismaService) {} + + async list(query: ListManualDocumentsQuery) { + const page = toPositiveInt(query.page, 1); + const limit = Math.min(toPositiveInt(query.limit, 20), 100); + const keyword = query.keyword?.trim(); + const where = { + ...(query.appKey?.trim() ? { appKey: query.appKey.trim() } : {}), + ...(keyword + ? { + OR: [ + { title: { contains: keyword, mode: 'insensitive' as const } }, + { body: { contains: keyword, mode: 'insensitive' as const } }, + ], + } + : {}), + }; + + const [documents, total] = await this.launcherPrisma.$transaction([ + this.launcherPrisma.launcherManualDocument.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + this.launcherPrisma.launcherManualDocument.count({ where }), + ]); + + return { + documents: documents.map((document, index) => ({ + ...document, + no: total - ((page - 1) * limit + index), + })), + total, + page, + limit, + }; + } + + async publicList(appKey?: string) { + const documents = await this.launcherPrisma.launcherManualDocument.findMany({ + where: { isPublished: true, ...(appKey?.trim() ? { appKey: appKey.trim() } : {}) }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + select: { + id: true, + appKey: true, + title: true, + body: true, + updatedAt: true, + }, + }); + + return { documents }; + } + + async create(body: CreateManualDocumentDto) { + const document = await this.launcherPrisma.launcherManualDocument.create({ + data: { + appKey: body.appKey.trim(), + title: body.title.trim(), + body: body.body.trim(), + isPublished: body.isPublished ?? true, + sortOrder: body.sortOrder ?? 0, + }, + }); + + return { document }; + } + + async update(id: string, body: UpdateManualDocumentDto) { + await this.ensureExists(id); + + const document = await this.launcherPrisma.launcherManualDocument.update({ + where: { id }, + data: { + ...(body.appKey === undefined ? {} : { appKey: body.appKey.trim() }), + ...(body.title === undefined ? {} : { title: body.title.trim() }), + ...(body.body === undefined ? {} : { body: body.body.trim() }), + ...(body.isPublished === undefined ? {} : { isPublished: body.isPublished }), + ...(body.sortOrder === undefined ? {} : { sortOrder: body.sortOrder }), + } as Prisma.LauncherManualDocumentUpdateInput, + }); + + return { document }; + } + + async remove(id: string) { + await this.ensureExists(id); + await this.launcherPrisma.launcherManualDocument.delete({ where: { id } }); + return { ok: true }; + } + + private async ensureExists(id: string) { + const document = await this.launcherPrisma.launcherManualDocument.findUnique({ where: { id }, select: { id: true } }); + if (!document) throw new NotFoundException('매뉴얼 문서를 찾을 수 없습니다.'); + } + +} + +function toPositiveInt(value: string | undefined, fallback: number) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}