Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0c7608c
chore: amplitude 패키지 설치
liveforpresent Oct 12, 2025
5f9c10e
chore: amplitude api key 등록
liveforpresent Oct 12, 2025
8082791
feat: amplitude 모듈 생성 및 amplitude 초기화
liveforpresent Oct 12, 2025
60e3f99
feat: 로그인 성공 이벤트 구현
liveforpresent Oct 12, 2025
598bca6
feat: auth 객체 생성 시 로그인 성공 이벤트 생성
liveforpresent Oct 12, 2025
dad69f7
feat: amplitude 로직 구현
liveforpresent Oct 12, 2025
f8fe71d
feat: auth 이벤트 amplitude와 연동
liveforpresent Oct 12, 2025
b4f60c8
feat: 로그인/토큰 재발행 시 이벤트 발행
liveforpresent Oct 12, 2025
0ae12ff
fix: auth 앰플리튜드 이벤트 핸들러 클래스별로 분리 및 이벤트명 수정
liveforpresent Oct 15, 2025
c92f473
fix: 계정 생성 이벤트에 provider 필드 추가
liveforpresent Oct 15, 2025
fa230a2
fix: 로그인 성공 이벤트에 provider 필드 추가
liveforpresent Oct 15, 2025
4a5f2b3
fix: 스크랩 실행 이벤트에 tags 추가
liveforpresent Oct 15, 2025
afea34b
fix: 스크랩 삭제 이벤트에 tags 필드 추가
liveforpresent Oct 15, 2025
6057ed1
fix: 스크랩 객체 생성 파라미터 수정
liveforpresent Oct 15, 2025
6e0c701
feat: scrap 앰플리튜드 이벤트 핸들러 구현
liveforpresent Oct 15, 2025
6a66d1e
chore: analytics 모듈 이벤트 핸들러 import 변경
liveforpresent Oct 15, 2025
2d350d6
chore: 게시글 정보 조회 query 모듈 참조
liveforpresent Oct 15, 2025
3ae5e5b
chore: 패키지 업데이트
liveforpresent Oct 15, 2025
bbea95a
fix: 앰플리튜드 오류 수정
liveforpresent Oct 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@amplitude/analytics-node": "^1.5.14",
"@aws-sdk/client-s3": "^3.820.0",
"@aws-sdk/s3-request-presigner": "^3.820.0",
"@eslint/plugin-kit": "^0.3.5",
Expand All @@ -40,7 +41,7 @@
"@types/passport-jwt": "^4.0.1",
"axios": "^1.11.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
"mysql2": "^3.14.0",
Expand Down
35 changes: 35 additions & 0 deletions src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { init } from '@amplitude/analytics-node';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AnalyticsService } from './infrastructure/analytics.service';
import { CqrsModule } from '@nestjs/cqrs';
import { AuthCreatedEventHandler, LoginSucceededEventHandler } from './application/auth-event.handler';
import { ScrapAddedEventHandler, ScrapDeletedEventHandler } from './application/scrap-event.handler';

export const AMPLITUDE_CLIENT = Symbol('AMPLITUDE_CLIENT');

const eventHandlers = [
AuthCreatedEventHandler,
LoginSucceededEventHandler,
ScrapAddedEventHandler,
ScrapDeletedEventHandler,
];

@Module({
imports: [ConfigModule, CqrsModule],
providers: [
{
provide: 'AMPLITUDE_CLIENT',
useFactory: (configService: ConfigService) => {
return init(configService.getOrThrow<string>('amplitude.apiKey'), {
flushIntervalMillis: 1000,
});
},
inject: [ConfigService],
},
AnalyticsService,
...eventHandlers,
],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
25 changes: 25 additions & 0 deletions src/analytics/application/auth-event.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { AuthCreatedEvent } from 'src/auth/domain/event/auth-created.event';
import { AnalyticsService } from '../infrastructure/analytics.service';
import { LoginSucceededEvent } from 'src/auth/domain/event/login-succeeded.event';

@EventsHandler(AuthCreatedEvent)
export class AuthCreatedEventHandler implements IEventHandler<AuthCreatedEvent> {
constructor(private readonly analyticsService: AnalyticsService) {}

handle(event: AuthCreatedEvent) {
this.analyticsService.trackEvent(event.userId.value, 'Signed Up', {
email: event.email,
role: event.role,
});
}
}

@EventsHandler(LoginSucceededEvent)
export class LoginSucceededEventHandler implements IEventHandler<LoginSucceededEvent> {
constructor(private readonly analyticsService: AnalyticsService) {}

handle(event: LoginSucceededEvent) {
this.analyticsService.trackEvent(event.userId.value, 'Logged In', {});
}
}
26 changes: 26 additions & 0 deletions src/analytics/application/scrap-event.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ScrapAddedEvent } from 'src/scrap/command/domain/event/scrap-added.event';
import { ScrapDeletedEvent } from 'src/scrap/command/domain/event/scrap-deleted.event';
import { AnalyticsService } from '../infrastructure/analytics.service';

@EventsHandler(ScrapAddedEvent)
export class ScrapAddedEventHandler implements IEventHandler<ScrapAddedEvent> {
constructor(private readonly analyticsService: AnalyticsService) {}

handle(event: ScrapAddedEvent) {
this.analyticsService.trackEvent(event.userId, 'Article Scrapped', {
articleId: event.articleId,
});
}
}

@EventsHandler(ScrapDeletedEvent)
export class ScrapDeletedEventHandler implements IEventHandler<ScrapDeletedEvent> {
constructor(private readonly analyticsService: AnalyticsService) {}

handle(event: ScrapDeletedEvent) {
this.analyticsService.trackEvent(event.userId, 'Article Unscrapped', {
articleId: event.articleId,
});
}
}
21 changes: 21 additions & 0 deletions src/analytics/infrastructure/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { track } from '@amplitude/analytics-node';
import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);

trackEvent(userId: string, eventType: string, eventProperties?: Record<string, any>) {
try {
track({
user_id: userId,
event_type: eventType,
event_properties: eventProperties,
});
} catch (e: unknown) {
if (e instanceof Error) {
this.logger.error(`Amplitude tracking failed ${e.message}`);
}
}
}
}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MediaModule } from './media/media.module';
import { ScrapModule } from './scrap/scrap.module';
import mikroOrmConfig from './shared/config/mikro-orm.config';
import config from 'src/shared/config/configuration';
import { AnalyticsModule } from './analytics/analytics.module';

@Module({
imports: [
Expand All @@ -20,6 +21,7 @@ import config from 'src/shared/config/configuration';
isGlobal: true,
load: [config],
}),
AnalyticsModule,
AuthModule,
ArticleModule,
UserModule,
Expand Down
9 changes: 7 additions & 2 deletions src/auth/application/oauth-login/oauth-login.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,14 @@ export class OAuthLoginUseCase {
// 유저 생성 및 정보 가져오기
private async findOrCreateAuth(oauthId: string, provider: OAuthProviderType, email: string): Promise<Auth> {
const existingAuth = await this.authRepository.findByOAuthIdandProvider(oauthId, provider);
if (existingAuth) return existingAuth;
if (existingAuth) {
this.eventBus.publish(existingAuth);
return existingAuth;
}

const userId = Identifier.create();

await this.eventBus.publish(new AuthCreatedEvent(userId, email, Role.GENERAL));
await this.eventBus.publish(new AuthCreatedEvent(userId, email, Role.GENERAL, provider));

const auth = Auth.create({
id: Identifier.create(),
Expand All @@ -67,6 +70,8 @@ export class OAuthLoginUseCase {

await this.authRepository.save(auth);

await this.eventBus.publishAll(auth.pullDomainEvents());

return auth;
}

Expand Down
4 changes: 4 additions & 0 deletions src/auth/application/renew-token/renew-token.use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { RenewTokenRequestDto } from './dto/renew-token.request.dto';
import { RenewTokenResponseDto } from './dto/renew-token.response.dto';
import { CustomException } from 'src/shared/exception/custom-exception';
import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code';
import { EventBus } from '@nestjs/cqrs';

@Injectable()
export class RenewTokenUseCase {
constructor(
private readonly jwtProvider: JwtProvider,
@Inject(AUTH_REPOSITORY)
private readonly authRepository: AuthRepository,
private readonly eventBus: EventBus,
) {}

async execute(reqeustDto: RenewTokenRequestDto): Promise<RenewTokenResponseDto> {
Expand All @@ -26,6 +28,8 @@ export class RenewTokenUseCase {
auth.updateRefreshToken(newJti, new Date());
await this.authRepository.update(auth);

await this.eventBus.publishAll(auth.pullDomainEvents());

return { accessToken, refreshToken };
}
}
2 changes: 2 additions & 0 deletions src/auth/domain/entity/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { OAuthProviderType } from '../value-object/oauth-provider.enum';
import { AggregateRoot } from 'src/shared/core/domain/base.aggregate';
import { CustomException } from 'src/shared/exception/custom-exception';
import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code';
import { LoginSucceededEvent } from '../event/login-succeeded.event';

export interface AuthProps extends BaseEntityProps {
oauthId: string;
Expand All @@ -21,6 +22,7 @@ export class Auth extends AggregateRoot<AuthProps> {
public static create(props: AuthProps): Auth {
const auth = new Auth(props);
auth.validate();
auth.addDomainEvent(new LoginSucceededEvent(auth.userId, auth.provider));

return auth;
}
Expand Down
2 changes: 2 additions & 0 deletions src/auth/domain/event/auth-created.event.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event';
import { Identifier } from 'src/shared/core/domain/identifier';
import { Role } from 'src/user/command/domain/value-object/role.enum';
import { OAuthProviderType } from '../value-object/oauth-provider.enum';

export class AuthCreatedEvent implements BaseDomainEvent {
readonly timesstamp: Date;
Expand All @@ -9,6 +10,7 @@ export class AuthCreatedEvent implements BaseDomainEvent {
public readonly userId: Identifier,
public readonly email: string,
public readonly role: Role,
public readonly provider: OAuthProviderType,
) {
this.timesstamp = new Date();
}
Expand Down
14 changes: 14 additions & 0 deletions src/auth/domain/event/login-succeeded.event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event';
import { Identifier } from 'src/shared/core/domain/identifier';
import { OAuthProviderType } from '../value-object/oauth-provider.enum';

export class LoginSucceededEvent implements BaseDomainEvent {
readonly timesstamp: Date;

constructor(
public readonly userId: Identifier,
public readonly provider: OAuthProviderType,
) {
this.timesstamp = new Date();
}
}
21 changes: 9 additions & 12 deletions src/scrap/command/application/add-scrap/add-scrap.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { Inject, Injectable } from '@nestjs/common';
import { SCRAP_COMMAND_REPOSITORY, ScrapCommandRepository } from '../../domain/scrap.command.repository';
import { Scrap } from '../../domain/scrap';
import { Identifier } from 'src/shared/core/domain/identifier';
import {
ARTICLE_COMMAND_REPOSITORY,
ArticleCommandRepository,
} from 'src/article/command/domain/article.command.repository';
import { CommandHandler, EventBus } from '@nestjs/cqrs';
import { AddScrapCommand } from './add-scrap.command';
import { CustomException } from 'src/shared/exception/custom-exception';
import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code';
import {
ARTICLE_QUERY_REPOSITORY,
ArticleQueryRepository,
} from 'src/article/query/domain/repository/article.query.repository';
import { ScrapAddedEvent } from '../../domain/event/scrap-added.event';

@Injectable()
@CommandHandler(AddScrapCommand)
Expand All @@ -18,8 +19,8 @@ export class AddScrapHandler {
@Inject(SCRAP_COMMAND_REPOSITORY)
private readonly scrapCommandRepository: ScrapCommandRepository,
private readonly eventBus: EventBus,
@Inject(ARTICLE_COMMAND_REPOSITORY)
private readonly articleCommandRepository: ArticleCommandRepository,
@Inject(ARTICLE_QUERY_REPOSITORY)
private readonly articleQueryRepository: ArticleQueryRepository,
) {}

async execute(command: AddScrapCommand): Promise<void> {
Expand All @@ -28,7 +29,7 @@ export class AddScrapHandler {

const existingScrap = await this.scrapCommandRepository.findByArticleIdAndUserId(articleId, userId);
if (existingScrap) throw new CustomException(CustomExceptionCode.SCRAP_ALREADY_EXISTS);
const article = await this.articleCommandRepository.findById(articleId);
const article = await this.articleQueryRepository.findById(articleId);
if (!article) throw new CustomException(CustomExceptionCode.ARTICLE_NOT_FOUND);

const scrap = Scrap.create({
Expand All @@ -41,10 +42,6 @@ export class AddScrapHandler {

await this.scrapCommandRepository.save(scrap);

const events = scrap.pullDomainEvents();

for (const event of events) {
await this.eventBus.publish(event);
}
this.eventBus.publish(new ScrapAddedEvent(scrap.userId.value, scrap.articleId.value, article.tags));
}
}
10 changes: 10 additions & 0 deletions src/scrap/command/application/delete-scrap/delete-scrap.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { CommandHandler, EventBus } from '@nestjs/cqrs';
import { DeleteScrapCommand } from './delete-scrap.command';
import { CustomException } from 'src/shared/exception/custom-exception';
import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code';
import {
ARTICLE_QUERY_REPOSITORY,
ArticleQueryRepository,
} from 'src/article/query/domain/repository/article.query.repository';

@Injectable()
@CommandHandler(DeleteScrapCommand)
Expand All @@ -12,16 +16,22 @@ export class DeleteScrapHandler {
private readonly eventBus: EventBus,
@Inject(SCRAP_COMMAND_REPOSITORY)
private readonly scrapCommandRepository: ScrapCommandRepository,
@Inject(ARTICLE_QUERY_REPOSITORY)
private readonly articleQueryRepository: ArticleQueryRepository,
) {}

async execute(command: DeleteScrapCommand) {
const { articleId, userId } = command;

const scrap = await this.scrapCommandRepository.findByArticleIdAndUserId(articleId, userId);
if (!scrap) throw new CustomException(CustomExceptionCode.SCRAP_NOT_FOUND);
const article = await this.articleQueryRepository.findById(articleId);
if (!article) throw new CustomException(CustomExceptionCode.ARTICLE_NOT_FOUND);

await this.scrapCommandRepository.deleteByArticleIdAndUserId(articleId, userId);

scrap.delete(article.tags);

const events = scrap.pullDomainEvents();
await this.eventBus.publishAll(events);
}
Expand Down
6 changes: 5 additions & 1 deletion src/scrap/command/domain/event/scrap-added.event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event';
export class ScrapAddedEvent implements BaseDomainEvent {
readonly timesstamp: Date;

constructor(public readonly articleId: string) {
constructor(
public readonly userId: string,
public readonly articleId: string,
public readonly tags: string[],
) {
this.timesstamp = new Date();
}
}
6 changes: 5 additions & 1 deletion src/scrap/command/domain/event/scrap-deleted.event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event';
export class ScrapDeletedEvent implements BaseDomainEvent {
readonly timesstamp: Date;

constructor(public readonly articleId: string) {
constructor(
public readonly userId: string,
public readonly articleId: string,
public readonly tags: string[],
) {
this.timesstamp = new Date();
}
}
7 changes: 2 additions & 5 deletions src/scrap/command/domain/scrap.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { AggregateRoot } from 'src/shared/core/domain/base.aggregate';
import { BaseEntityProps } from 'src/shared/core/domain/base.entity';
import { Identifier } from 'src/shared/core/domain/identifier';
import { ScrapAddedEvent } from './event/scrap-added.event';
import { ScrapDeletedEvent } from './event/scrap-deleted.event';
import { CustomException } from 'src/shared/exception/custom-exception';
import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code';
Expand All @@ -20,8 +19,6 @@ export class Scrap extends AggregateRoot<ScrapProps> {
const scrap = new Scrap(props);
scrap.validate();

scrap.addDomainEvent(new ScrapAddedEvent(props.articleId.value));

return scrap;
}

Expand All @@ -31,8 +28,8 @@ export class Scrap extends AggregateRoot<ScrapProps> {
}
}

public delete(): void {
this.addDomainEvent(new ScrapDeletedEvent(this.articleId.value));
public delete(tags: string[]): void {
this.addDomainEvent(new ScrapDeletedEvent(this.userId.value, this.articleId.value, tags));
}

get articleId(): Identifier {
Expand Down
Loading