From aac1d0152673d5f6295853350406e0aa8903533d Mon Sep 17 00:00:00 2001 From: chyoo Date: Fri, 10 Jul 2026 14:21:45 +0900 Subject: [PATCH] add terms management for signup agreements --- prisma/launcher.schema.prisma | 15 ++ .../migration.sql | 28 +++ src/app.module.ts | 2 + src/terms/dto.ts | 49 +++++ src/terms/terms.controller.ts | 47 +++++ src/terms/terms.module.ts | 13 ++ src/terms/terms.service.ts | 191 ++++++++++++++++++ 7 files changed, 345 insertions(+) create mode 100644 prisma/migrations/20260710112000_add_launcher_terms/migration.sql create mode 100644 src/terms/dto.ts create mode 100644 src/terms/terms.controller.ts create mode 100644 src/terms/terms.module.ts create mode 100644 src/terms/terms.service.ts diff --git a/prisma/launcher.schema.prisma b/prisma/launcher.schema.prisma index d7d04c0..8181a08 100644 --- a/prisma/launcher.schema.prisma +++ b/prisma/launcher.schema.prisma @@ -30,6 +30,21 @@ model LauncherNotice { @@map("launcher_notices") } +model LauncherTerm { + id String @id @default(uuid()) @db.Uuid + key String @unique + body String + isRequired Boolean @default(true) @map("is_required") + createdById String? @map("created_by_id") @db.Uuid + createdByName String? @map("created_by_name") + createdByEmail String? @map("created_by_email") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([createdByEmail]) + @@map("launcher_terms") +} + model ProvisionedDevice { id String @id @default(uuid()) @db.Uuid accountEmail String @map("account_email") diff --git a/prisma/migrations/20260710112000_add_launcher_terms/migration.sql b/prisma/migrations/20260710112000_add_launcher_terms/migration.sql new file mode 100644 index 0000000..f280d6a --- /dev/null +++ b/prisma/migrations/20260710112000_add_launcher_terms/migration.sql @@ -0,0 +1,28 @@ +DO $$ +BEGIN + IF to_regclass('public.launcher_notices') IS NOT NULL THEN + CREATE TABLE IF NOT EXISTS "launcher_terms" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "body" TEXT NOT NULL, + "is_required" BOOLEAN NOT NULL DEFAULT true, + "created_by_id" UUID, + "created_by_name" TEXT, + "created_by_email" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "launcher_terms_pkey" PRIMARY KEY ("id") + ); + + CREATE UNIQUE INDEX IF NOT EXISTS "launcher_terms_key_key" ON "launcher_terms"("key"); + CREATE INDEX IF NOT EXISTS "launcher_terms_created_by_email_idx" ON "launcher_terms"("created_by_email"); + + ALTER TABLE "launcher_terms" DROP COLUMN IF EXISTS "title"; + ALTER TABLE "launcher_terms" DROP COLUMN IF EXISTS "priority"; + ALTER TABLE "launcher_terms" DROP COLUMN IF EXISTS "version"; + ALTER TABLE "launcher_terms" DROP COLUMN IF EXISTS "is_active"; + ALTER TABLE "launcher_terms" DROP COLUMN IF EXISTS "starts_at"; + ALTER TABLE "launcher_terms" DROP COLUMN IF EXISTS "ends_at"; + END IF; +END $$; diff --git a/src/app.module.ts b/src/app.module.ts index 6899a9d..96cc653 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { PageAgentModule } from './page-agent/page-agent.module'; import { PrismaModule } from './prisma/prisma.module'; import { RuntimeModule } from './runtime/runtime.module'; import { TailscaleModule } from './tailscale/tailscale.module'; +import { TermsModule } from './terms/terms.module'; import { UsersModule } from './users/users.module'; import { WoodpeckerModule } from './woodpecker/woodpecker.module'; @@ -45,6 +46,7 @@ import { WoodpeckerModule } from './woodpecker/woodpecker.module'; PrismaModule, RuntimeModule, TailscaleModule, + TermsModule, UsersModule, WoodpeckerModule, ], diff --git a/src/terms/dto.ts b/src/terms/dto.ts new file mode 100644 index 0000000..ed18fc5 --- /dev/null +++ b/src/terms/dto.ts @@ -0,0 +1,49 @@ +import { + IsBoolean, + IsOptional, + IsString, +} from 'class-validator'; + +export class ListTermsQuery { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + + @IsOptional() + @IsString() + key?: string; + + @IsOptional() + @IsString() + keyword?: string; +} + +export class CreateTermDto { + @IsString() + key!: string; + + @IsString() + body!: string; + + @IsOptional() + @IsBoolean() + isRequired?: boolean; +} + +export class UpdateTermDto { + @IsOptional() + @IsString() + key?: string; + + @IsOptional() + @IsString() + body?: string; + + @IsOptional() + @IsBoolean() + isRequired?: boolean; +} diff --git a/src/terms/terms.controller.ts b/src/terms/terms.controller.ts new file mode 100644 index 0000000..ac58c93 --- /dev/null +++ b/src/terms/terms.controller.ts @@ -0,0 +1,47 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; + +import { CreateTermDto, ListTermsQuery, UpdateTermDto } from './dto'; +import { TermsService } from './terms.service'; + +@Controller('terms') +export class TermsController { + constructor(private readonly termsService: TermsService) {} + + @Get() + list(@Query() query: ListTermsQuery) { + return this.termsService.list(query); + } + + @Get(':id') + get(@Param('id') id: string) { + return this.termsService.get(id); + } + + @Post() + create( + @Body() body: CreateTermDto, + @Req() request: { admin?: { id: string; name: string; email: string } }, + ) { + return this.termsService.create(body, request.admin); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() body: UpdateTermDto) { + return this.termsService.update(id, body); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.termsService.remove(id); + } +} diff --git a/src/terms/terms.module.ts b/src/terms/terms.module.ts new file mode 100644 index 0000000..f51b2b5 --- /dev/null +++ b/src/terms/terms.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; +import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; +import { TermsController } from './terms.controller'; +import { TermsService } from './terms.service'; + +@Module({ + imports: [AuthPrismaModule, LauncherPrismaModule], + controllers: [TermsController], + providers: [TermsService], +}) +export class TermsModule {} diff --git a/src/terms/terms.service.ts b/src/terms/terms.service.ts new file mode 100644 index 0000000..f678ec3 --- /dev/null +++ b/src/terms/terms.service.ts @@ -0,0 +1,191 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { Prisma } from '@prisma/launcher-client'; +import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; +import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; +import { getUserAvatarUrl } from '../users/users.service'; +import { CreateTermDto, ListTermsQuery, UpdateTermDto } from './dto'; + +type AdminRequester = { + id: string; + name: string; + email: string; +}; + +@Injectable() +export class TermsService { + constructor( + private readonly authPrisma: AuthPrismaService, + private readonly configService: ConfigService, + private readonly launcherPrisma: LauncherPrismaService, + ) {} + + async list(query: ListTermsQuery) { + const keyword = query.keyword?.trim(); + const page = toPositiveInt(query.page, 1); + const limit = Math.min(toPositiveInt(query.limit, 20), 100); + const where = { + key: query.key || undefined, + OR: keyword + ? [ + { key: { contains: keyword, mode: 'insensitive' as const } }, + { body: { contains: keyword, mode: 'insensitive' as const } }, + ] + : undefined, + }; + + const [terms, total] = await this.launcherPrisma.$transaction([ + this.launcherPrisma.launcherTerm.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.launcherPrisma.launcherTerm.count({ where }), + ]); + + return { + terms: await this.withCreatorAvatars(terms.map((term, index) => ({ + ...term, + no: total - ((page - 1) * limit + index), + }))), + total, + page, + limit, + }; + } + + async get(id: string) { + const term = await this.launcherPrisma.launcherTerm.findUnique({ + where: { id }, + }); + + if (!term) { + throw new NotFoundException('약관을 찾을 수 없습니다.'); + } + + return { term }; + } + + async create(body: CreateTermDto, requester?: AdminRequester) { + await this.ensureUniqueKey(body.key); + + const term = await this.launcherPrisma.launcherTerm.create({ + data: { + ...toCreateTermData(body), + ...this.creatorData(requester), + }, + }); + + return { term }; + } + + async update(id: string, body: UpdateTermDto) { + await this.ensureExists(id); + if (body.key) { + await this.ensureUniqueKey(body.key, id); + } + + const term = await this.launcherPrisma.launcherTerm.update({ + where: { id }, + data: toUpdateTermData(body), + }); + + return { term }; + } + + async remove(id: string) { + await this.ensureExists(id); + + await this.launcherPrisma.launcherTerm.delete({ + where: { id }, + }); + + return { ok: true }; + } + + private creatorData(requester?: AdminRequester) { + return requester + ? { + createdById: requester.id, + createdByName: requester.name, + createdByEmail: requester.email, + } + : {}; + } + + private async withCreatorAvatars(items: T[]) { + const emails = [...new Set(items.map((item) => item.createdByEmail).filter((email): email is string => Boolean(email)))]; + if (emails.length === 0) return items.map((item) => ({ ...item, createdByAvatarUrl: null })); + + const users = await this.authPrisma.user.findMany({ + where: { email: { in: emails } }, + select: { + id: true, + email: true, + profileImageMime: true, + }, + }); + const userByEmail = new Map(users.map((user) => [user.email, user])); + const authBaseUrl = this.configService.get('AUTH_PUBLIC_URL') + ?? this.configService.get('ATNMY_AUTH_URL') + ?? this.configService.get('AUTH_EXTERNAL_URL') + ?? 'https://auth.autonomystudio.net'; + + return items.map((item) => { + const user = item.createdByEmail ? userByEmail.get(item.createdByEmail) : null; + return { + ...item, + createdByAvatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl), + }; + }); + } + + private async ensureExists(id: string) { + const term = await this.launcherPrisma.launcherTerm.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!term) { + throw new NotFoundException('약관을 찾을 수 없습니다.'); + } + } + + private async ensureUniqueKey(key: string, excludeId?: string) { + const term = await this.launcherPrisma.launcherTerm.findFirst({ + where: { + key, + id: excludeId ? { not: excludeId } : undefined, + }, + select: { id: true }, + }); + + if (term) { + throw new ConflictException('이미 등록된 약관입니다.'); + } + } +} + +function toCreateTermData(body: CreateTermDto): Prisma.LauncherTermCreateInput { + return { + key: body.key, + body: body.body, + isRequired: body.isRequired, + }; +} + +function toUpdateTermData(body: UpdateTermDto): Prisma.LauncherTermUpdateInput { + return { + key: body.key, + body: body.body, + isRequired: body.isRequired, + }; +} + +function toPositiveInt(value: string | number | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +}