약관 관리 구현 가이드
개요
이 문서는, 서비스 이용을 위한 약관의 버전 관리와 활성화 상태 관리, 다국어 지원 및 사용자 동의 이력 관리 기능을 구현하기 위한 가이드라인을 제공합니다.
참고: 이 문서는 약관 자체의 관리 및 동의 이력 기록 구현을 다룹니다. 특정 시점(예: 회원가입)에 사용자에게 표시해야 할 활성 약관과 선택적 동의 항목을 한 번에 조회하는 통합 API(GET /auth/terms-and-consents)에 대한 구현 가이드는 약관 및 동의 항목 통합 조회 구현 가이드를 참조하세요.
아키텍처
약관 관리 서비스는 다음과 같은 구성 요소로 이루어져 있습니다:
TermsController: 약관 관련 API 요청을 처리하는 컨트롤러TermsService: 약관 관리 비즈니스 로직을 구현한 서비스TermsRepository: 약관 데이터 저장소 인터페이스TermsVersionRepository: 약관 버전 데이터 저장소 인터페이스TermsTranslationRepository: 약관 번역 데이터 저장소 인터페이스UserAgreementRepository: 사용자 약관 동의 이력 저장소 인터페이스
도메인 모델
Terms Entity
// @file: libs/feature/auth/src/lib/domain/entities/terms.entity.ts
export class Terms {
id: string; // UUID
name: string; // UNIQUE
status: TermsStatus; // ACTIVE, INACTIVE 등 (약관 자체의 상태)
termsType: TermsType;
defaultLanguage: string; // 기본 언어 (e.g., 'de', 'en')
required: boolean; // 필수 동의 여부
createdBy: string;
createdAt: Date;
updatedAt: Date;
termsVersions?: TermsVersion[]; // 관계
userAgreements?: UserAgreement[]; // 관계
}
export enum TermsStatus {
DRAFT = 'DRAFT',
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
EXPIRED = 'EXPIRED'
}
export enum TermsType {
SERVICE = 'SERVICE',
PRIVACY = 'PRIVACY',
OPTIONAL = 'OPTIONAL',
MANDATORY = 'MANDATORY'
}
Terms Version Entity
// @file: libs/feature/auth/src/lib/domain/entities/terms-version.entity.ts
export class TermsVersion {
id: string; // UUID
termsId: string;
versionNumber: string; // e.g., "1.0", "1.1.0"
changelog?: string;
status: TermsStatus; // DRAFT, ACTIVE, INACTIVE, EXPIRED (버전의 상태)
validFrom?: Date;
validUntil?: Date;
createdAt: Date;
activatedAt?: Date;
createdBy?: string;
terms?: Terms; // 관계
translations?: TermsTranslation[]; // 관계
userAgreements?: UserAgreement[]; // 관계
}
Terms Translation Entity
// @file: libs/feature/auth/src/lib/domain/entities/terms-translation.entity.ts
export class TermsTranslation {
id: string; // UUID
termsVersionId: string;
language: string;
title: string;
contentUrl: string;
createdAt: Date;
updatedAt: Date;
createdBy?: string;
termsVersion?: TermsVersion; // 관계
}
User Agreement Entity
// @file: libs/feature/auth/src/lib/domain/entities/user-agreement.entity.ts
export class UserAgreement {
id: string; // UUID
userId: string;
termsId: string; // 상위 Terms 개념 ID
termsVersionId: string; // 동의한 특정 버전 ID
agreedAt: Date;
ipAddress: string;
userAgent?: string; // 사용자 브라우저/앱 정보
terms?: Terms; // 관계 (termsId 통해)
termsVersion?: TermsVersion; // 관계 (termsVersionId 통해)
}
구현 가이드
Mapper 구현 (TermsMapper)
컨트롤러의 책임을 줄이고 데이터 변환 로직을 재사용하기 위해 Injectable 매퍼 서비스를 사용합니다. TermsVersion을 기준으로 DTO를 생성합니다.
// @file: libs/feature/auth/src/lib/infrastructure/mappers/terms.mapper.ts
import { Injectable } from '@nestjs/common';
import { Terms, TermsVersion, TermsTranslation, UserAgreement, TermsStatus, TermsType } from '../../domain/entities';
import { TermsVersionDto, TermsTranslationDto } from '@shared-contracts/auth/dto';
@Injectable()
export class TermsMapper {
/**
* TermsVersion 엔티티를 TermsVersionDto로 변환합니다.
* 특정 언어의 번역 정보와 상위 Terms 정보를 포함합니다.
*/
toTermsVersionDto(termsVersion: TermsVersion, language: string): TermsVersionDto {
const translation = termsVersion.translations?.find(t => t.language === language)
?? termsVersion.translations?.find(t => t.language === termsVersion.terms?.defaultLanguage);
return {
termsId: termsVersion.termsId,
termsVersionId: termsVersion.id,
name: termsVersion.terms?.name,
version: termsVersion.versionNumber,
title: translation?.title ?? 'Title not available',
contentUrl: translation?.contentUrl,
type: termsVersion.terms?.termsType,
required: termsVersion.terms?.required ?? false,
status: termsVersion.status,
language: translation?.language ?? termsVersion.terms?.defaultLanguage,
availableLanguages: termsVersion.translations?.map(t => t.language) ?? [],
validFrom: termsVersion.validFrom?.getTime(),
validUntil: termsVersion.validUntil?.getTime(),
createdAt: termsVersion.createdAt.getTime(),
updatedAt: termsVersion.terms?.updatedAt.getTime(),
activatedAt: termsVersion.activatedAt?.getTime(),
changelog: termsVersion.changelog,
};
}
/**
* TermsTranslation 엔티티를 TermsTranslationDto로 변환합니다.
*/
toTermsTranslationDto(translation: TermsTranslation): TermsTranslationDto {
return {
termsVersionId: translation.termsVersionId,
language: translation.language,
title: translation.title,
contentUrl: translation.contentUrl,
createdAt: translation.createdAt.getTime(),
updatedAt: translation.updatedAt.getTime(),
};
}
/**
* TermsVersion 엔티티 배열을 DTO 배열로 변환합니다.
*/
toTermsVersionDtoList(termsVersions: TermsVersion[], language: string): TermsVersionDto[] {
return termsVersions.map(version => this.toTermsVersionDto(version, language));
}
}
Controller 구현
// @file: apps/dta-wide-api/src/app/auth/controllers/terms.controller.ts
import { Controller, Post, Body, UseGuards, Param, Patch, Get, Query, Headers, Put } from '@nestjs/common';
import { TermsService } from '@feature/auth-core';
import { AdminGuard } from '@shared/guards';
import { CreateTermsDto, AddTranslationDto, UpdateTermsVersionStatusDto, GetActiveTermsQueryDto, TermsVersionDto, TermsTranslationDto, CreateNewVersionDto, UpdateTranslationDto, TermsVersionStatusUpdateResultDto } from '@shared-contracts/auth/dto';
import { ApiResponse } from '@shared-contracts/auth/dto/api-response.dto';
import { extractLanguage } from '@shared/utils';
@Controller('auth/terms')
export class TermsController {
constructor(private readonly termsService: TermsService) {}
@Post()
@UseGuards(AdminGuard)
async createTerms(@Body() dto: CreateTermsDto): Promise<ApiResponse<TermsVersionDto>> {
const data = await this.termsService.createTermsAndInitialVersionDto(dto);
return { data };
}
@Post(':termsId/versions')
@UseGuards(AdminGuard)
async createNewVersion(
@Param('termsId') termsId: string,
@Body() dto: CreateNewVersionDto
): Promise<ApiResponse<TermsVersionDto>> {
const data = await this.termsService.createNewVersionDto(termsId, dto);
return { data };
}
@Post('versions/:versionId/translations')
@UseGuards(AdminGuard)
async addTranslation(
@Param('versionId') versionId: string,
@Body() dto: AddTranslationDto
): Promise<ApiResponse<TermsTranslationDto>> {
const data = await this.termsService.addTranslationDto(versionId, dto);
return { data };
}
@Put('versions/:versionId/translations/:language')
@UseGuards(AdminGuard)
async updateTranslation(
@Param('versionId') versionId: string,
@Param('language') language: string,
@Body() dto: UpdateTranslationDto
): Promise<ApiResponse<TermsTranslationDto>> {
const data = await this.termsService.updateTranslationDto(versionId, language, dto);
return { data };
}
@Patch('versions/:versionId/status')
@UseGuards(AdminGuard)
async updateVersionStatus(
@Param('versionId') versionId: string,
@Body() dto: UpdateTermsVersionStatusDto
): Promise<ApiResponse<TermsVersionStatusUpdateResultDto>> {
const result = await this.termsService.updateVersionStatus(versionId, dto.status);
return { data: result };
}
@Get('active')
async getActiveTerms(
@Query() query: GetActiveTermsQueryDto,
@Headers('Accept-Language') acceptLanguage: string = 'de-DE'
): Promise<ApiResponse<TermsVersionDto[]>> {
const language = extractLanguage(acceptLanguage);
const data = await this.termsService.getActiveTermsDto(language, query.type);
return { data };
}
@Get('versions/:versionId')
async getTermsVersion(
@Param('versionId') versionId: string,
@Headers('Accept-Language') acceptLanguage: string = 'de-DE'
): Promise<ApiResponse<TermsVersionDto>> {
const language = extractLanguage(acceptLanguage);
const data = await this.termsService.getTermsVersionByIdDto(versionId, language);
return { data };
}
}
Service 구현
// @file: libs/feature/auth/src/lib/domain/services/terms.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TermsRepository } from '../repositories/terms.repository';
import { TermsVersionRepository } from '../repositories/terms-version.repository';
import { TermsTranslationRepository } from '../repositories/terms-translation.repository';
import { UserAgreementRepository } from '../repositories/user-agreement.repository';
import { Terms, TermsVersion, TermsTranslation, UserAgreement, TermsStatus, TermsType } from '../entities';
import { CreateTermsDto, AddTranslationDto, UpdateTermsVersionStatusDto, CreateNewVersionDto, UpdateTranslationDto, TermsVersionDto, TermsTranslationDto, TermsVersionStatusUpdateResultDto } from '@shared-contracts/auth/dto';
import { TermsMapper } from '../../infrastructure/mappers/terms.mapper';
@Injectable()
export class TermsService {
constructor(
private readonly termsRepository: TermsRepository,
private readonly termsVersionRepository: TermsVersionRepository,
private readonly translationRepository: TermsTranslationRepository,
private readonly userAgreementRepository: UserAgreementRepository,
private readonly termsMapper: TermsMapper, // 매퍼 주입
) {}
// 약관 개념과 첫 버전을 함께 생성
async createTermsAndInitialVersion(dto: CreateTermsDto): Promise<TermsVersion> {
// 1. Terms 생성
const newTerms = await this.termsRepository.create({
name: dto.name,
termsType: dto.type,
status: TermsStatus.ACTIVE,
required: dto.required,
defaultLanguage: dto.defaultLanguage,
createdBy: dto.createdBy || 'admin',
});
// 2. 첫 TermsVersion 생성
const newVersion = await this.termsVersionRepository.create({
termsId: newTerms.id,
versionNumber: dto.initialVersion ?? '1.0',
status: TermsStatus.DRAFT,
validFrom: dto.validFrom ? new Date(dto.validFrom) : null,
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
createdBy: dto.createdBy || 'admin',
});
// 3. 첫 버전의 기본 언어 번역 생성
await this.translationRepository.create({
termsVersionId: newVersion.id,
language: dto.defaultLanguage,
title: dto.title,
contentUrl: dto.contentUrl,
createdBy: dto.createdBy || 'admin',
});
// 생성된 버전 정보 반환 (번역 포함)
return this.termsVersionRepository.findById(newVersion.id, dto.defaultLanguage, true);
}
/**
* 약관 생성 후 결과를 DTO로 반환합니다.
*/
async createTermsAndInitialVersionDto(dto: CreateTermsDto): Promise<TermsVersionDto> {
const termsVersion = await this.createTermsAndInitialVersion(dto);
const language = dto.defaultLanguage ?? 'de';
return this.termsMapper.toTermsVersionDto(termsVersion, language);
}
// 특정 약관의 새 버전 생성
async createNewVersion(termsId: string, dto: CreateNewVersionDto): Promise<TermsVersion> {
const terms = await this.termsRepository.findById(termsId);
if (!terms) throw new NotFoundException('약관을 찾을 수 없습니다.');
const newVersion = await this.termsVersionRepository.create({
termsId: termsId,
versionNumber: dto.versionNumber,
changelog: dto.changelog,
status: TermsStatus.DRAFT,
validFrom: dto.validFrom ? new Date(dto.validFrom) : null,
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
createdBy: dto.createdBy || 'admin',
});
// 새 버전의 기본 언어 번역 생성
await this.translationRepository.create({
termsVersionId: newVersion.id,
language: dto.language,
title: dto.title,
contentUrl: dto.contentUrl,
createdBy: dto.createdBy || 'admin',
});
return this.termsVersionRepository.findById(newVersion.id, dto.language, true);
}
/**
* 새 버전 생성 후 결과를 DTO로 반환합니다.
*/
async createNewVersionDto(termsId: string, dto: CreateNewVersionDto): Promise<TermsVersionDto> {
const termsVersion = await this.createNewVersion(termsId, dto);
const language = dto.language ?? 'de';
return this.termsMapper.toTermsVersionDto(termsVersion, language);
}
async addTranslation(versionId: string, dto: AddTranslationDto): Promise<TermsTranslation> {
const termsVersion = await this.termsVersionRepository.findById(versionId);
if (!termsVersion) {
throw new NotFoundException('약관 버전을 찾을 수 없습니다.');
}
const existing = await this.translationRepository.findByVersionIdAndLanguage(versionId, dto.language);
if (existing) {
throw new BadRequestException('해당 언어의 번역이 이미 존재합니다.');
}
return this.translationRepository.create({
termsVersionId: versionId,
language: dto.language,
title: dto.title,
contentUrl: dto.contentUrl,
createdBy: dto.createdBy || 'admin',
});
}
/**
* 번역 추가 후 결과를 DTO로 반환합니다.
*/
async addTranslationDto(versionId: string, dto: AddTranslationDto): Promise<TermsTranslationDto> {
const translation = await this.addTranslation(versionId, dto);
return this.termsMapper.toTermsTranslationDto(translation);
}
async updateTranslation(versionId: string, language: string, dto: UpdateTranslationDto): Promise<TermsTranslation> {
const translation = await this.translationRepository.findByVersionIdAndLanguage(versionId, language);
if (!translation) {
throw new NotFoundException('번역을 찾을 수 없습니다.');
}
return this.translationRepository.update(translation.id, {
title: dto.title,
contentUrl: dto.contentUrl,
});
}
/**
* 번역 업데이트 후 결과를 DTO로 반환합니다.
*/
async updateTranslationDto(versionId: string, language: string, dto: UpdateTranslationDto): Promise<TermsTranslationDto> {
const translation = await this.updateTranslation(versionId, language, dto);
return this.termsMapper.toTermsTranslationDto(translation);
}
async updateVersionStatus(versionId: string, status: TermsStatus): Promise<TermsVersionStatusUpdateResultDto> {
const version = await this.termsVersionRepository.findById(versionId, undefined, true);
if (!version) {
throw new NotFoundException('약관 버전을 찾을 수 없습니다.');
}
const termsId = version.termsId;
const previousStatus = version.status;
let activatedAt: Date | null = version.activatedAt;
// 활성화 시 처리
if (status === TermsStatus.ACTIVE) {
// 1. 동일 Terms의 다른 ACTIVE 버전 비활성화 (INACTIVE)
await this.termsVersionRepository.deactivateOtherActiveVersions(termsId, versionId);
// 2. 현재 버전 활성화
activatedAt = new Date();
} else {
activatedAt = null; // 비활성화 시 activatedAt 초기화
}
// 상태 업데이트
await this.termsVersionRepository.updateStatus(versionId, status, activatedAt);
// 결과 반환
return {
versionId: versionId,
status: status,
previousStatus: previousStatus,
activatedAt: activatedAt,
updatedAt: new Date(),
};
}
/**
* 활성화된 약관 버전 목록을 조회합니다.
* @param language 조회할 언어 코드 (예: 'ko', 'en', 'de')
* @param type 필터링할 약관 타입 (선택 사항)
* @returns 활성화된 약관 버전 목록 (TermsVersion 배열, 번역 및 상위 Terms 포함)
*/
async getActiveTerms(language: string = 'de', type?: TermsType): Promise<TermsVersion[]> {
return this.termsVersionRepository.findActive(language, type);
}
/**
* 활성화된 약관 버전 목록을 DTO로 조회합니다.
*/
async getActiveTermsDto(language: string = 'de', type?: TermsType): Promise<TermsVersionDto[]> {
const activeVersions = await this.getActiveTerms(language, type);
return this.termsMapper.toTermsVersionDtoList(activeVersions, language);
}
/**
* 특정 사용자가 특정 약관 버전에 동의했는지 확인합니다.
* @param userId 사용자 ID
* @param termsVersionId 약관 버전 ID
* @returns 동의 여부 (boolean)
*/
async hasUserAgreedToVersion(userId: string, termsVersionId: string): Promise<boolean> {
const agreement = await this.userAgreementRepository.findByUserIdAndVersionId(userId, termsVersionId);
return !!agreement;
}
/**
* 특정 ID의 약관 버전을 조회합니다.
* @param versionId 약관 버전 ID
* @param language 조회할 언어 코드
* @param includeTerms 상위 Terms 정보 포함 여부
* @returns 약관 버전 정보 (요청 언어 또는 기본 언어 번역 및 상위 Terms 포함)
*/
async getTermsVersionById(versionId: string, language: string = 'de', includeTerms: boolean = true): Promise<TermsVersion> {
const termsVersion = await this.termsVersionRepository.findById(versionId, language, includeTerms);
if (!termsVersion) {
throw new NotFoundException('약관 버전을 찾을 수 없습니다.');
}
return termsVersion;
}
/**
* 특정 ID의 약관 버전을 DTO로 조회합니다.
*/
async getTermsVersionByIdDto(versionId: string, language: string = 'de'): Promise<TermsVersionDto> {
const termsVersion = await this.getTermsVersionById(versionId, language);
return this.termsMapper.toTermsVersionDto(termsVersion, language);
}
// 사용자 동의 기록
async recordAgreement(userId: string, termsVersionId: string, ipAddress: string, userAgent?: string): Promise<UserAgreement> {
const version = await this.termsVersionRepository.findById(termsVersionId);
if (!version) throw new NotFoundException('약관 버전을 찾을 수 없습니다.');
// 이미 동의했는지 확인 (선택적)
const existing = await this.userAgreementRepository.findByUserIdAndVersionId(userId, termsVersionId);
if (existing) return existing; // 이미 동의함
return this.userAgreementRepository.create({
userId: userId,
termsId: version.termsId,
termsVersionId: termsVersionId,
ipAddress: ipAddress,
userAgent: userAgent,
});
}
}
Repository 인터페이스 정의
약관 데이터를 조회하고 관리하기 위한 Repository 인터페이스입니다.
// @file: libs/feature/auth/src/lib/domain/repositories/terms.repository.ts
import { Terms, TermsType } from '../entities';
export interface TermsRepository {
create(data: Partial<Terms>): Promise<Terms>;
findById(id: string): Promise<Terms | null>;
findByName(name: string): Promise<Terms | null>;
update(id: string, data: Partial<Terms>): Promise<Terms>;
}
// @file: libs/feature/auth/src/lib/domain/repositories/terms-version.repository.ts
import { TermsVersion, TermsStatus, TermsType } from '../entities';
export interface TermsVersionRepository {
create(data: Partial<TermsVersion>): Promise<TermsVersion>;
findById(id: string, language?: string, includeTerms?: boolean, includeTranslations?: boolean): Promise<TermsVersion | null>;
findActive(language: string, type?: TermsType): Promise<TermsVersion[]>;
findLatestActiveByTermsId(termsId: string, language: string): Promise<TermsVersion | null>;
updateStatus(id: string, status: TermsStatus, activatedAt?: Date): Promise<void>;
deactivateOtherActiveVersions(termsId: string, currentVersionId: string): Promise<void>;
}
// @file: libs/feature/auth/src/lib/domain/repositories/terms-translation.repository.ts
import { TermsTranslation } from '../entities';
export interface TermsTranslationRepository {
create(data: Partial<TermsTranslation>): Promise<TermsTranslation>;
findByVersionIdAndLanguage(versionId: string, language: string): Promise<TermsTranslation | null>;
update(id: string, data: Partial<TermsTranslation>): Promise<TermsTranslation>;
}
// @file: libs/feature/auth/src/lib/domain/repositories/user-agreement.repository.ts
import { UserAgreement, TermsType } from '../entities';
export interface UserAgreementRepository {
create(data: Partial<UserAgreement>): Promise<UserAgreement>;
findByUserIdAndVersionId(userId: string, versionId: string): Promise<UserAgreement | null>;
findUserAgreements(userId: string, type?: TermsType): Promise<UserAgreement[]>;
}
Repository 구현 예시
// @file: libs/feature/auth/src/lib/infrastructure/repositories/prisma-terms.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@core/database';
import { Terms } from '../../domain/entities';
import { TermsRepository } from '../../domain/repositories/terms.repository';
@Injectable()
export class PrismaTermsRepository implements TermsRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data: Partial<Terms>): Promise<Terms> {
return this.prisma.terms.create({ data });
}
async findById(id: string): Promise<Terms | null> {
return this.prisma.terms.findUnique({ where: { id } });
}
async findByName(name: string): Promise<Terms | null> {
return this.prisma.terms.findUnique({ where: { name } });
}
async update(id: string, data: Partial<Terms>): Promise<Terms> {
return this.prisma.terms.update({
where: { id },
data
});
}
}
// @file: libs/feature/auth/src/lib/infrastructure/repositories/prisma-terms-version.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@core/database';
import { TermsVersion, TermsStatus, TermsType } from '../../domain/entities';
import { TermsVersionRepository } from '../../domain/repositories/terms-version.repository';
@Injectable()
export class PrismaAgreementsVersionRepository implements AgreementsVersionRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data: Partial<TermsVersion>): Promise<TermsVersion> {
return this.prisma.termsVersion.create({ data });
}
async findById(id: string, language?: string, includeTerms: boolean = false, includeTranslations: boolean = true): Promise<TermsVersion | null> {
return this.prisma.termsVersion.findUnique({
where: { id },
include: {
terms: includeTerms,
translations: includeTranslations
? (language ? { where: { language } } : true)
: false,
},
});
}
async findActive(language: string, type?: TermsType): Promise<TermsVersion[]> {
const now = new Date();
return this.prisma.termsVersion.findMany({
where: {
status: TermsStatus.ACTIVE,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
...(type && { terms: { termsType: type } }),
},
include: {
terms: true,
translations: {
where: {
OR: [
{ language: language },
{ language: { equals: { _ref: 'terms.defaultLanguage' } } }
]
},
take: 1
},
},
orderBy: {
createdAt: 'desc',
},
});
}
async findLatestActiveByTermsId(termsId: string, language: string): Promise<TermsVersion | null> {
const now = new Date();
const versions = await this.prisma.termsVersion.findMany({
where: {
termsId: termsId,
status: TermsStatus.ACTIVE,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: {
terms: true,
translations: { where: { language } }
},
orderBy: { versionNumber: 'desc' },
take: 1
});
return versions.length > 0 ? versions[0] : null;
}
async updateStatus(id: string, status: TermsStatus, activatedAt?: Date | null): Promise<void> {
await this.prisma.termsVersion.update({
where: { id },
data: { status, activatedAt },
});
}
async deactivateOtherActiveVersions(termsId: string, currentVersionId: string): Promise<void> {
await this.prisma.termsVersion.updateMany({
where: {
termsId: termsId,
id: { not: currentVersionId },
status: TermsStatus.ACTIVE,
},
data: {
status: TermsStatus.INACTIVE,
activatedAt: null,
},
});
}
}
// @file: libs/feature/auth/src/lib/infrastructure/repositories/prisma-terms-translation.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@core/database';
import { TermsTranslation } from '../../domain/entities';
import { TermsTranslationRepository } from '../../domain/repositories/terms-translation.repository';
@Injectable()
export class PrismaTermsTranslationRepository implements TermsTranslationRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data: Partial<TermsTranslation>): Promise<TermsTranslation> {
return this.prisma.termsTranslation.create({ data });
}
async findByVersionIdAndLanguage(versionId: string, language: string): Promise<TermsTranslation | null> {
return this.prisma.termsTranslation.findUnique({
where: { termsVersionId_language: { termsVersionId: versionId, language: language } },
});
}
async update(id: string, data: Partial<TermsTranslation>): Promise<TermsTranslation> {
return this.prisma.termsTranslation.update({
where: { id },
data
});
}
}
// @file: libs/feature/auth/src/lib/infrastructure/repositories/prisma-user-agreement.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@core/database';
import { UserAgreement, TermsType } from '../../domain/entities';
import { UserAgreementRepository } from '../../domain/repositories/user-agreement.repository';
@Injectable()
export class PrismaUserAgreementRepository implements UserAgreementRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data: Partial<UserAgreement>): Promise<UserAgreement> {
return this.prisma.userAgreement.create({ data });
}
async findByUserIdAndVersionId(userId: string, versionId: string): Promise<UserAgreement | null> {
return this.prisma.userAgreement.findUnique({
where: { userId_termsVersionId: { userId: userId, termsVersionId: versionId } }
});
}
async findUserAgreements(userId: string, type?: TermsType): Promise<UserAgreement[]> {
return this.prisma.userAgreement.findMany({
where: {
userId: userId,
...(type && { terms: { termsType: type } })
},
include: {
terms: true,
termsVersion: true,
},
orderBy: { agreedAt: 'desc' }
});
}
}
보안 고려사항
- 약관 관리 API는 관리자 권한을 가진 사용자만 접근할 수 있어야 합니다.
- 사용자 동의 이력은 감사 목적으로 변경되지 않고 추가만 되어야 합니다.
- IP 주소와 사용자 에이전트 정보를 동의 이력에 기록하여 감사 추적이 가능하도록 합니다.
- 약관 번역 콘텐츠는 안전한 출처(예: CDN)에서 제공되어야 하며, 무단 변경을 방지해야 합니다.
테스트 전략
- 약관 생성, 버전 관리, 번역 관리, 활성화 등 기본 기능에 대한 단위 테스트
- 사용자 동의 기록 및 조회 기능에 대한 통합 테스트
- API 엔드포인트에 대한 엔드 투 엔드 테스트
- 권한 관리에 대한 보안 테스트
오류 처리
약관 관리 관련 비즈니스 로직에서 발생할 수 있는 주요 예외 상황과 오류 처리 방식은 다음과 같습니다:
// 약관을 찾을 수 없는 경우
throw new NotFoundException({
code: 2001,
message: 'TERMS_NOT_FOUND',
detail: '요청한 약관을 찾을 수 없습니다.'
});
// 약관 버전을 찾을 수 없는 경우
throw new NotFoundException({
code: 2002,
message: 'TERMS_VERSION_NOT_FOUND',
detail: '요청한 약관 버전을 찾을 수 없습니다.'
});
// 이미 존재하는 번역이 있는 경우
throw new BadRequestException({
code: 2003,
message: 'TRANSLATION_ALREADY_EXISTS',
detail: '해당 언어의 번역이 이미 존재합니다.'
});
// 번역을 찾을 수 없는 경우
throw new NotFoundException({
code: 2004,
message: 'TRANSLATION_NOT_FOUND',
detail: '요청한 번역을 찾을 수 없습니다.'
});
// 잘못된 상태 변경 요청
throw new BadRequestException({
code: 2005,
message: 'INVALID_STATUS_TRANSITION',
detail: '현재 상태에서 요청한 상태로 변경할 수 없습니다.'
});
오류 코드 관리 방식과 구현에 대한 자세한 내용은 오류 관리 가이드를 참조하세요.