add terms management for signup agreements
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 14:21:45 +09:00
parent e8ea575182
commit aac1d01526
7 changed files with 345 additions and 0 deletions
+15
View File
@@ -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")
@@ -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 $$;
+2
View File
@@ -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,
],
+49
View File
@@ -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;
}
+47
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+191
View File
@@ -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<T extends { createdByEmail?: string | null }>(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<string>('AUTH_PUBLIC_URL')
?? this.configService.get<string>('ATNMY_AUTH_URL')
?? this.configService.get<string>('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);
}