This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user