This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { EmailService } from './email.service';
/**
* EmailModule
*
* @description
* 이메일 발송 기능을 제공하는 모듈입니다.
* nodemailer를 사용하여 SMTP 서버를 통해 이메일을 전송합니다.
*
* 사용 예:
* - 인증번호 발송
* - 비밀번호 재설정 안내
* - 시스템 알림
*
* @export
* @class EmailModule
*/
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
/**
* 이메일 발송 서비스
*
* @export
* @class EmailService
*/
@Injectable()
export class EmailService {
private transporter; // nodemailer 전송 객체
constructor(private configService: ConfigService) {
// SMTP 서버 설정 (AWS SES, Gmail 등)
this.transporter = nodemailer.createTransport({ // .env 파일 EMAIL CONFIGURATION
host: this.configService.get('SMTP_HOST'),
port: parseInt(this.configService.get('SMTP_PORT')),
secure: this.configService.get('SMTP_PORT') === '465',
auth: {
user: this.configService.get('SMTP_USER'),
pass: this.configService.get('SMTP_PASS'),
},
});
}
/**
* 인증번호 이메일 발송
*
* @async
* @param {string} email - 수신자 이메일
* @param {string} code - 6자리 인증번호
* @returns {Promise<void>}
*/
async sendVerificationCode(email: string, code: string): Promise<void> {
await this.transporter.sendMail({
from: this.configService.get('FROM_EMAIL'),
to: email,
subject: '[한우 유전능력 시스템] 인증번호 안내',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">인증번호 안내</h2>
<p>아래 인증번호를 입력해주세요.</p>
<div style="background-color: #f5f5f5; padding: 20px; text-align: center; margin: 20px 0;">
<h1 style="color: #4CAF50; font-size: 32px; margin: 0;">${code}</h1>
</div>
<p style="color: #666;">인증번호는 3분간 유효합니다.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="color: #999; font-size: 12px;">본 메일은 발신 전용입니다.</p>
</div>
`,
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { FilterEngineService } from './filter-engine.service';
@Module({
providers: [FilterEngineService],
exports: [FilterEngineService],
})
export class FilterEngineModule {}

View File

@@ -0,0 +1,319 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FilterEngineService } from './filter-engine.service';
import { SelectQueryBuilder } from 'typeorm';
describe('FilterEngineService', () => { // describe: 테스트 그룹 (테스트할 클래스/모듈)
let service: FilterEngineService;
let mockQueryBuilder: any;
beforeEach(async () => { // beforeEach: 각 테스트 실행 전에 실행
const module: TestingModule = await Test.createTestingModule({
providers: [FilterEngineService], // 테스트할 서비스 주입
}).compile();
service = module.get<FilterEngineService>(FilterEngineService);
// Mock QueryBuilder
mockQueryBuilder = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(100),
getMany: jest.fn().mockResolvedValue([
{ id: 1, name: 'Test1' },
{ id: 2, name: 'Test2' },
]),
};
});
it('should be defined', () => { // it: 개별 테스트 케이스
expect(service).toBeDefined(); // expect: 검증
});
describe('applyFilters', () => { // 특정 메서드에 대한 하위 그룹
it('필터가 없으면 queryBuilder를 그대로 반환해야 함', () => {
const result = service.applyFilters(mockQueryBuilder, []);
expect(result).toBe(mockQueryBuilder);
expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled();
});
it('eq 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'status', operator: 'eq', value: 'active' },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'status = :param_0',
{ param_0: 'active' },
);
});
it('ne 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'status', operator: 'ne', value: 'deleted' },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'status != :param_0',
{ param_0: 'deleted' },
);
});
it('gt 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'age', operator: 'gt', value: 18 },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'age > :param_0',
{ param_0: 18 },
);
});
it('gte 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'age', operator: 'gte', value: 18 },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'age >= :param_0',
{ param_0: 18 },
);
});
it('lt 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'age', operator: 'lt', value: 65 },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'age < :param_0',
{ param_0: 65 },
);
});
it('lte 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'age', operator: 'lte', value: 65 },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'age <= :param_0',
{ param_0: 65 },
);
});
it('like 연산자를 올바르게 적용해야 함 (와일드카드 자동 추가)', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'name', operator: 'like', value: 'John' },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'name LIKE :param_0',
{ param_0: '%John%' },
);
});
it('in 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'status', operator: 'in', value: ['active', 'pending'] },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'status IN (:...param_0)',
{ param_0: ['active', 'pending'] },
);
});
it('between 연산자를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'age', operator: 'between', value: [18, 65] },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'age BETWEEN :param_0_min AND :param_0_max',
{ param_0_min: 18, param_0_max: 65 },
);
});
it('다중 필터를 올바르게 적용해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'status', operator: 'eq', value: 'active' },
{ field: 'age', operator: 'gte', value: 18 },
{ field: 'name', operator: 'like', value: 'John' },
]);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(3);
});
it('between 연산자에서 배열 길이가 2가 아니면 무시해야 함', () => {
service.applyFilters(mockQueryBuilder, [
{ field: 'age', operator: 'between', value: [18] }, // 길이 1
]);
// between은 호출되지 않아야 함
expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled();
});
});
describe('applySortOptions', () => {
it('정렬 옵션이 없으면 queryBuilder를 그대로 반환해야 함', () => {
const result = service.applySortOptions(mockQueryBuilder, []);
expect(result).toBe(mockQueryBuilder);
expect(mockQueryBuilder.orderBy).not.toHaveBeenCalled();
});
it('단일 정렬 옵션을 올바르게 적용해야 함', () => {
service.applySortOptions(mockQueryBuilder, [
{ field: 'createdAt', order: 'DESC' },
]);
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('createdAt', 'DESC');
});
it('다중 정렬 옵션을 올바르게 적용해야 함 (첫 번째는 orderBy, 나머지는 addOrderBy)', () => {
service.applySortOptions(mockQueryBuilder, [
{ field: 'status', order: 'ASC' },
{ field: 'createdAt', order: 'DESC' },
{ field: 'name', order: 'ASC' },
]);
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('status', 'ASC');
expect(mockQueryBuilder.addOrderBy).toHaveBeenCalledWith('createdAt', 'DESC');
expect(mockQueryBuilder.addOrderBy).toHaveBeenCalledWith('name', 'ASC');
expect(mockQueryBuilder.addOrderBy).toHaveBeenCalledTimes(2);
});
});
describe('applyPagination', () => {
it('페이지네이션을 올바르게 적용해야 함 (1페이지)', () => {
service.applyPagination(mockQueryBuilder, 1, 10);
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
});
it('페이지네이션을 올바르게 적용해야 함 (2페이지)', () => {
service.applyPagination(mockQueryBuilder, 2, 10);
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(10);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
});
it('페이지네이션을 올바르게 적용해야 함 (3페이지, limit 20)', () => {
service.applyPagination(mockQueryBuilder, 3, 20);
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(40);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
});
});
describe('executeFilteredQuery', () => {
it('필터, 정렬, 페이지네이션을 모두 적용하고 결과를 반환해야 함', async () => {
const options = {
filters: [
{ field: 'status', operator: 'eq' as const, value: 'active' },
],
sorts: [
{ field: 'createdAt', order: 'DESC' as const },
],
pagination: {
page: 1,
limit: 10,
},
};
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
// 필터 적용 확인
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
// 정렬 적용 확인
expect(mockQueryBuilder.orderBy).toHaveBeenCalled();
// 개수 조회 확인
expect(mockQueryBuilder.getCount).toHaveBeenCalled();
// 페이지네이션 적용 확인
expect(mockQueryBuilder.skip).toHaveBeenCalled();
expect(mockQueryBuilder.take).toHaveBeenCalled();
// 데이터 조회 확인
expect(mockQueryBuilder.getMany).toHaveBeenCalled();
// 결과 구조 확인
expect(result).toEqual({
data: [
{ id: 1, name: 'Test1' },
{ id: 2, name: 'Test2' },
],
total: 100,
page: 1,
limit: 10,
totalPages: 10,
});
});
it('페이지네이션 없이 실행할 수 있어야 함', async () => {
const options = {
filters: [
{ field: 'status', operator: 'eq' as const, value: 'active' },
],
};
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
// 페이지네이션은 적용되지 않아야 함
expect(mockQueryBuilder.skip).not.toHaveBeenCalled();
expect(mockQueryBuilder.take).not.toHaveBeenCalled();
// 결과에 페이지 정보가 없어야 함
expect(result).toEqual({
data: [
{ id: 1, name: 'Test1' },
{ id: 2, name: 'Test2' },
],
total: 100,
});
});
it('필터와 정렬 없이 페이지네이션만 적용할 수 있어야 함', async () => {
const options = {
pagination: {
page: 2,
limit: 20,
},
};
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
// 필터와 정렬은 적용되지 않아야 함
expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled();
expect(mockQueryBuilder.orderBy).not.toHaveBeenCalled();
// 페이지네이션은 적용되어야 함
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
// totalPages 계산 확인
expect(result.totalPages).toBe(5); // 100 / 20 = 5
});
it('totalPages를 올바르게 계산해야 함 (나누어떨어지지 않는 경우)', async () => {
mockQueryBuilder.getCount.mockResolvedValue(105);
const options = {
pagination: {
page: 1,
limit: 10,
},
};
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
expect(result.totalPages).toBe(11); // Math.ceil(105 / 10) = 11
});
});
});

View File

@@ -0,0 +1,187 @@
import { Injectable } from '@nestjs/common';
import { SelectQueryBuilder } from 'typeorm';
import {
FilterCondition,
FilterEngineOptions,
FilterEngineResult,
SortOption,
} from './interfaces/filter.interface';
/**
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
*
* @export
* @class FilterEngineService
*/
@Injectable()
export class FilterEngineService {
/**
* QueryBuilder에 필터 조건 적용
*
* @template T
* @param {SelectQueryBuilder<T>} queryBuilder
* @param {FilterCondition[]} filters
* @returns {SelectQueryBuilder<T>}
*/
applyFilters<T>(
queryBuilder: SelectQueryBuilder<T>,
filters: FilterCondition[],
): SelectQueryBuilder<T> {
if (!filters || filters.length === 0) {
return queryBuilder; // 필터 없으면 그대로 반환
}
filters.forEach((filter, index) => {
const { field, operator, value } = filter;
const paramName = `param_${index}`;
switch (operator) { // 파라미터 바인딩
case 'eq':
queryBuilder.andWhere(`${field} = :${paramName}`, { [paramName]: value });
break;
case 'ne':
queryBuilder.andWhere(`${field} != :${paramName}`, { [paramName]: value });
break;
case 'gt':
queryBuilder.andWhere(`${field} > :${paramName}`, { [paramName]: value });
break;
case 'gte':
queryBuilder.andWhere(`${field} >= :${paramName}`, { [paramName]: value });
break;
case 'lt':
queryBuilder.andWhere(`${field} < :${paramName}`, { [paramName]: value });
break;
case 'lte':
queryBuilder.andWhere(`${field} <= :${paramName}`, { [paramName]: value });
break;
case 'like':
queryBuilder.andWhere(`${field} LIKE :${paramName}`, {
[paramName]: `%${value}%`,
});
break;
case 'in':
queryBuilder.andWhere(`${field} IN (:...${paramName})`, {
[paramName]: value,
});
break;
case 'between':
if (Array.isArray(value) && value.length === 2) {
queryBuilder.andWhere(`${field} BETWEEN :${paramName}_min AND :${paramName}_max`, {
[`${paramName}_min`]: value[0],
[`${paramName}_max`]: value[1],
});
}
break;
}
});
return queryBuilder;
}
/**
* QueryBuilder에 정렬 옵션 적용
*
* @template T
* @param {SelectQueryBuilder<T>} queryBuilder
* @param {SortOption[]} sorts
* @returns {SelectQueryBuilder<T>}
*/
applySortOptions<T>(
queryBuilder: SelectQueryBuilder<T>,
sorts: SortOption[],
): SelectQueryBuilder<T> {
if (!sorts || sorts.length === 0) {
return queryBuilder;
}
sorts.forEach((sort, index) => {
if (index === 0) {
queryBuilder.orderBy(sort.field, sort.order);
} else {
queryBuilder.addOrderBy(sort.field, sort.order);
}
});
return queryBuilder;
}
/**
* QueryBuilder에 페이지네이션 적용
*
* @template T
* @param {SelectQueryBuilder<T>} queryBuilder
* @param {number} page
* @param {number} limit
* @returns {SelectQueryBuilder<T>}
*/
applyPagination<T>(
queryBuilder: SelectQueryBuilder<T>,
page: number,
limit: number,
): SelectQueryBuilder<T> {
const skip = (page - 1) * limit;
return queryBuilder.skip(skip).take(limit);
// SQL: LIMIT 5 OFFSET 10
// 10개 건너뛰고, 5개 가져오기 (11~15번째)
}
/**
* 필터링된 쿼리 실행 및 결과 반환 (전체실행 메인 함수)
*
* @async
* @template T
* @param {SelectQueryBuilder<T>} queryBuilder
* @param {FilterEngineOptions} options
* @returns {Promise<FilterEngineResult<T>>}
*/
async executeFilteredQuery<T>(
queryBuilder: SelectQueryBuilder<T>,
options: FilterEngineOptions,
): Promise<FilterEngineResult<T>> {
// 1. 필터 적용
if (options.filters) {
this.applyFilters(queryBuilder, options.filters);
}
// 2. 정렬 적용
if (options.sorts) {
this.applySortOptions(queryBuilder, options.sorts);
}
// 3. 전체 개수 조회 (페이지네이션 전)
const total = await queryBuilder.getCount();
// 4. 페이지네이션 적용
if (options.pagination) {
const { page, limit } = options.pagination;
this.applyPagination(queryBuilder, page, limit);
}
// 5. 데이터 조회
const data = await queryBuilder.getMany();
// 6. 결과 구성
const result: FilterEngineResult<T> = {
data,
total,
};
if (options.pagination) {
const { page, limit } = options.pagination;
result.page = page;
result.limit = limit;
result.totalPages = Math.ceil(total / limit);
}
return result;
}
}

View File

@@ -0,0 +1,91 @@
/**코드 작성 전 TypeScript의 타입 정의 */
/**
* 필터 연산자 타입
*/
export type FilterOperator =
| 'eq' // 같음
| 'ne' // 같지 않음
| 'gt' // 초과
| 'gte' // 이상
| 'lt' // 미만
| 'lte' // 이하
| 'like' // 포함 (문자열)
| 'in' // 배열 내 포함
| 'between'; // 범위
/**
* 필터 조건
*/
export interface FilterCondition {
/** 필터링할 컬럼명 */
field: string;
/** 연산자 (eq,like)*/
operator: FilterOperator;
/** 비교 값 (between인 경우 [min, max] 배열) */
value: any;
}
/**
* 정렬 방향
*/
export type SortOrder = 'ASC' | 'DESC';
/**
* 정렬 옵션
*/
export interface SortOption {
/** 정렬할 컬럼명 */
field: string;
/** 정렬 방향 */
order: SortOrder;
}
/**
* 페이지네이션 옵션
*/
export interface PaginationOption {
/** 페이지 번호 (1부터 시작) */
page: number;
/** 페이지당 아이템 수 */
limit: number;
}
/**
* FilterEngine 옵션
* FilterEngine에 전달할 모든 옵션을 하나로 묶음
*/
export interface FilterEngineOptions {
/** 필터 조건 배열 */
filters?: FilterCondition[];
/** 정렬 옵션 배열 */
sorts?: SortOption[];
/** 페이지네이션 옵션 */
pagination?: PaginationOption;
}
/**
* FilterEngine 실행 결과
*/
export interface FilterEngineResult<T> {
/** 조회된 데이터 */
data: T[];
/** 전체 데이터 개수 (페이지네이션 전) */
total: number;
/** 현재 페이지 */
page?: number;
/** 페이지당 아이템 수 */
limit?: number;
/** 전체 페이지 수 */
totalPages?: number;
}

View File

@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { EmailModule } from './email/email.module';
import { VerificationModule } from './verification/verification.module';
import { FilterEngineModule } from './filter/filter-engine.module';
/**
* SharedModule
*
* @description
* 범용적이고 재사용 가능한 기능들을 담는 통합 모듈입니다.
* 특정 도메인에 종속되지 않으며, 여러 피처 모듈에서 광범위하게 사용됩니다.
*
* 포함된 모듈:
* - EmailModule: 이메일 발송 서비스
* - VerificationModule: 인증번호 생성 및 검증 서비스
* - FilterEngineModule: 동적 필터링, 정렬, 페이지네이션 엔진
*
* @export
* @class SharedModule
*/
@Module({
imports: [
EmailModule,
VerificationModule,
FilterEngineModule,
],
exports: [
EmailModule,
VerificationModule,
FilterEngineModule,
],
})
export class SharedModule {}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { VerificationService } from './verification.service';
/**
* VerificationModule
*
* @description
* 인증번호 생성 및 검증 기능을 제공하는 모듈입니다.
* Redis를 사용하여 인증번호를 임시 저장하고 검증합니다.
*
* 사용 예:
* - 아이디 찾기 인증번호 발송
* - 비밀번호 재설정 인증번호 발송
* - 회원가입 이메일 인증
*
* RedisModule이 @Global로 설정되어 있어 자동으로 주입됩니다.
*
* @export
* @class VerificationModule
*/
@Module({
providers: [VerificationService],
exports: [VerificationService],
})
export class VerificationModule {}

View File

@@ -0,0 +1,97 @@
import { Injectable } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import * as crypto from 'crypto';
import { VERIFICATION_CONFIG } from 'src/common/config/VerificationConfig';
/**
* 인증번호 생성 및 검증 서비스 (Redis 기반)
*
* @export
* @class VerificationService
*/
@Injectable()
export class VerificationService {
constructor(@InjectRedis() private readonly redis: Redis) {}
/**
* 6자리 인증번호 생성
*
* @returns {string} 6자리 숫자
*/
generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
/**
* 인증번호 저장 (Redis 3분 후 자동 삭제)
*
* @async
* @param {string} key - Redis 키 (예: find-id:test@example.com)
* @param {string} code - 인증번호
* @returns {Promise<void>}
*/
async saveCode(key: string, code: string): Promise<void> {
await this.redis.set(key, code, 'EX', VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS);
}
/**
* 인증번호 검증
*
* @async
* @param {string} key - Redis 키
* @param {string} code - 사용자가 입력한 인증번호
* @returns {Promise<boolean>} 검증 성공 여부
*/
async verifyCode(key: string, code: string): Promise<boolean> {
const savedCode = await this.redis.get(key);
console.log(`[DEBUG VerificationService] Key: ${key}, Input code: ${code}, Saved code: ${savedCode}`);
if (!savedCode) {
console.log(`[DEBUG VerificationService] No saved code found for key: ${key}`);
return false; // 인증번호 없음 (만료 또는 미발급)
}
if (savedCode !== code) {
console.log(`[DEBUG VerificationService] Code mismatch - Saved: ${savedCode}, Input: ${code}`);
return false; // 인증번호 불일치
}
// 검증 성공 시 Redis에서 삭제 (1회용)
await this.redis.del(key);
console.log(`[DEBUG VerificationService] Code verified successfully!`);
return true;
}
/**
* 비밀번호 재설정 토큰 생성 및 저장
*
* @async
* @param {string} userId - 사용자 ID
* @returns {Promise<string>} 재설정 토큰
*/
async generateResetToken(userId: string): Promise<string> {
const token = crypto.randomBytes(VERIFICATION_CONFIG.TOKEN_BYTES_LENGTH).toString('hex');
await this.redis.set(`reset:${token}`, userId, 'EX', VERIFICATION_CONFIG.RESET_TOKEN_EXPIRY_SECONDS);
return token;
}
/**
* 비밀번호 재설정 토큰 검증
*
* @async
* @param {string} token - 재설정 토큰
* @returns {Promise<string | null>} 사용자 ID 또는 null
*/
async verifyResetToken(token: string): Promise<string | null> {
const userId = await this.redis.get(`reset:${token}`);
if (!userId) {
return null; // 토큰 없음 (만료 또는 미발급)
}
// 검증 성공 시 토큰 삭제 (1회용)
await this.redis.del(`reset:${token}`);
return userId;
}
}