diff --git a/src/analytics/application/auth-event.handler.ts b/src/analytics/application/auth-event.handler.ts index acd57f6..916b6a9 100644 --- a/src/analytics/application/auth-event.handler.ts +++ b/src/analytics/application/auth-event.handler.ts @@ -10,7 +10,6 @@ export class AuthCreatedEventHandler implements IEventHandler handle(event: AuthCreatedEvent) { this.analyticsService.trackEvent(event.userId.value, 'Signed Up', { email: event.email, - role: event.role, }); } } diff --git a/src/article/command/application/create-article/create-article.command.ts b/src/article/command/application/create-article/create-article.command.ts new file mode 100644 index 0000000..91b34ae --- /dev/null +++ b/src/article/command/application/create-article/create-article.command.ts @@ -0,0 +1,17 @@ +import { ICommand } from '@nestjs/cqrs'; + +export class CreateArticleCommand implements ICommand { + constructor( + public readonly organizationId: string, + public readonly title: string, + public readonly organization: string, + public readonly description: string, + public readonly location: string, + public readonly startAt: string, + public readonly endAt: string, + public readonly tags: string[], + public readonly registrationUrl?: string, + public readonly registrationStartAt?: string, + public readonly registrationEndAt?: string, + ) {} +} diff --git a/src/article/command/application/create-article/create-article.use-case.ts b/src/article/command/application/create-article/create-article.use-case.ts index bb80fd9..b2fd90c 100644 --- a/src/article/command/application/create-article/create-article.use-case.ts +++ b/src/article/command/application/create-article/create-article.use-case.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { Identifier } from 'src/shared/core/domain/identifier'; import { Article } from '../../domain/article'; -import { CreateArticleRequestDto } from './dto/create-article.request.dto'; import { ARTICLE_COMMAND_REPOSITORY, ArticleCommandRepository } from '../../domain/article.command.repository'; import { CreateArticleResponseDto } from './dto/create-article.response.dto'; import { Tag } from 'src/tag/domain/entity/tag'; import { TAG_REPOSITORY, TagRepository } from 'src/tag/domain/repository/tag.repository'; +import { CreateArticleCommand } from './create-article.command'; @Injectable() export class CreateArticleUseCase { @@ -16,11 +16,11 @@ export class CreateArticleUseCase { private readonly tagRepo: TagRepository, ) {} - async execute(reqDto: CreateArticleRequestDto): Promise { + async execute(command: CreateArticleCommand): Promise { const tags: Tag[] = []; const articleId = Identifier.create(); - for (const tag of reqDto.tags) { + for (const tag of command.tags) { const existingTag = await this.tagRepo.findByName(tag); if (!existingTag) { @@ -41,15 +41,16 @@ export class CreateArticleUseCase { // Article 도메인 엔티티 생성 const article = Article.create({ id: articleId, - title: reqDto.title, - organization: reqDto.organization, - description: reqDto.description, - location: reqDto.location, - startAt: new Date(reqDto.startAt), - endAt: new Date(reqDto.endAt), - registrationUrl: reqDto.registrationUrl, - registrationStartAt: reqDto.registrationStartAt ? new Date(reqDto.registrationStartAt) : undefined, - registrationEndAt: reqDto.registrationEndAt ? new Date(reqDto.registrationEndAt) : undefined, + title: command.title, + organizationId: Identifier.from(command.organizationId), + organization: command.organization, + description: command.description, + location: command.location, + startAt: new Date(command.startAt), + endAt: new Date(command.endAt), + registrationUrl: command.registrationUrl, + registrationStartAt: command.registrationStartAt ? new Date(command.registrationStartAt) : undefined, + registrationEndAt: command.registrationEndAt ? new Date(command.registrationEndAt) : undefined, scrapCount: 0, viewCount: 0, mediaIds: [], diff --git a/src/article/command/application/delete-article/delete-article.comand.ts b/src/article/command/application/delete-article/delete-article.comand.ts new file mode 100644 index 0000000..6b974b1 --- /dev/null +++ b/src/article/command/application/delete-article/delete-article.comand.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; + +export class DeleteArticleCommand implements ICommand { + constructor( + public readonly id: string, + public readonly organizationId: string, + ) {} +} diff --git a/src/article/command/application/delete-article/delete-article.use-case.ts b/src/article/command/application/delete-article/delete-article.use-case.ts index 6e355f1..6878750 100644 --- a/src/article/command/application/delete-article/delete-article.use-case.ts +++ b/src/article/command/application/delete-article/delete-article.use-case.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { ARTICLE_COMMAND_REPOSITORY, ArticleCommandRepository } from '../../domain/article.command.repository'; +import { DeleteArticleCommand } from './delete-article.comand'; @Injectable() export class DeleteArticleUseCase { @@ -8,7 +9,7 @@ export class DeleteArticleUseCase { private readonly articleRepo: ArticleCommandRepository, ) {} - async execute(id: string): Promise { - await this.articleRepo.deleteById(id); + async execute(command: DeleteArticleCommand): Promise { + await this.articleRepo.deleteById(command.id); } } diff --git a/src/article/command/application/update-article/update-article.command.ts b/src/article/command/application/update-article/update-article.command.ts new file mode 100644 index 0000000..e6c78b3 --- /dev/null +++ b/src/article/command/application/update-article/update-article.command.ts @@ -0,0 +1,18 @@ +import { ICommand } from '@nestjs/cqrs'; + +export class UpdateArticleCommand implements ICommand { + constructor( + public readonly id: string, + public readonly organizationId: string, + public readonly title?: string, + public readonly organization?: string, + public readonly description?: string, + public readonly location?: string, + public readonly startAt?: string, + public readonly endAt?: string, + public readonly tags?: string[], + public readonly registrationUrl?: string, + public readonly registrationStartAt?: string, + public readonly registrationEndAt?: string, + ) {} +} diff --git a/src/article/command/application/update-article/update-article.use-case.ts b/src/article/command/application/update-article/update-article.use-case.ts index 478af02..421d30a 100644 --- a/src/article/command/application/update-article/update-article.use-case.ts +++ b/src/article/command/application/update-article/update-article.use-case.ts @@ -1,10 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Identifier } from 'src/shared/core/domain/identifier'; import { ARTICLE_COMMAND_REPOSITORY, ArticleCommandRepository } from '../../domain/article.command.repository'; -import { UpdateArticleRequestDto } from './dto/update-article.request.dto'; import { Tag } from 'src/tag/domain/entity/tag'; import { TAG_REPOSITORY, TagRepository } from 'src/tag/domain/repository/tag.repository'; import { MEDIA_COMMAND_REPOSITORY, MediaCommandRepository } from 'src/media/command/domain/media.command.repository'; +import { UpdateArticleCommand } from './update-article.command'; @Injectable() export class UpdateArticleUseCase { @@ -17,15 +17,15 @@ export class UpdateArticleUseCase { private readonly mediaCommandRepo: MediaCommandRepository, ) {} - async execute(articleId: string, reqDto: UpdateArticleRequestDto): Promise { + async execute(command: UpdateArticleCommand): Promise { // 기존 Article 조회 - const article = await this.articleCommandRepo.findById(articleId); + const article = await this.articleCommandRepo.findById(command.id); // 태그 처리 - if (reqDto.tags !== undefined) { + if (command.tags !== undefined) { const tags: Tag[] = []; - for (const tagName of reqDto.tags) { + for (const tagName of command.tags) { const existingTag = await this.tagRepo.findByName(tagName); if (!existingTag) { @@ -49,15 +49,15 @@ export class UpdateArticleUseCase { // Article 업데이트 article.update({ - title: reqDto.title, - organization: reqDto.organization, - description: reqDto.description, - location: reqDto.location, - startAt: reqDto.startAt ? new Date(reqDto.startAt) : undefined, - endAt: reqDto.endAt ? new Date(reqDto.endAt) : undefined, - registrationUrl: reqDto.registrationUrl, - registrationStartAt: reqDto.registrationStartAt ? new Date(reqDto.registrationStartAt) : undefined, - registrationEndAt: reqDto.registrationEndAt ? new Date(reqDto.registrationEndAt) : undefined, + title: command.title, + organization: command.organization, + description: command.description, + location: command.location, + startAt: command.startAt ? new Date(command.startAt) : undefined, + endAt: command.endAt ? new Date(command.endAt) : undefined, + registrationUrl: command.registrationUrl, + registrationStartAt: command.registrationStartAt ? new Date(command.registrationStartAt) : undefined, + registrationEndAt: command.registrationEndAt ? new Date(command.registrationEndAt) : undefined, }); await this.articleCommandRepo.update(article); diff --git a/src/article/command/article.command.module.ts b/src/article/command/article.command.module.ts index 352bf3d..c2b1433 100644 --- a/src/article/command/article.command.module.ts +++ b/src/article/command/article.command.module.ts @@ -1,7 +1,10 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; import { ArticleEntity } from './infrastructure/article.entity'; -import { ArticleCommandController } from './presentation/article.command.controller'; +import { + ArticleCommandController, + OrganizationArticleCommandController, +} from './presentation/article.command.controller'; import { CreateArticleUseCase } from './application/create-article/create-article.use-case'; import { UpdateArticleUseCase } from './application/update-article/update-article.use-case'; import { DeleteArticleUseCase } from './application/delete-article/delete-article.use-case'; @@ -45,6 +48,6 @@ const listeners = [IncreaseScrapCountListener, DecreaseScrapCountListener]; useClass: MediaCommandRepositoryImpl, }, ], - controllers: [ArticleCommandController], + controllers: [ArticleCommandController, OrganizationArticleCommandController], }) export class ArticleCommandModule {} diff --git a/src/article/command/domain/article.ts b/src/article/command/domain/article.ts index ef900a2..65cb7e9 100644 --- a/src/article/command/domain/article.ts +++ b/src/article/command/domain/article.ts @@ -9,15 +9,16 @@ export interface ArticleProps extends BaseEntityProps { organization: string; location: string; description: string; - registrationUrl: string; startAt: Date; endAt: Date; + registrationUrl?: string; registrationStartAt?: Date; registrationEndAt?: Date; scrapCount: number; viewCount: number; mediaIds: Identifier[]; tags: Tag[]; + organizationId: Identifier; } export class Article extends BaseDomainEntity { @@ -101,7 +102,7 @@ export class Article extends BaseDomainEntity { return this.props.description; } - get registrationUrl(): string { + get registrationUrl(): string | undefined { return this.props.registrationUrl; } @@ -137,6 +138,10 @@ export class Article extends BaseDomainEntity { return this.props.tags; } + get organizationId(): Identifier { + return this.props.organizationId; + } + public update(props: { title?: string; organization?: string; diff --git a/src/article/command/infrastructure/article.entity.ts b/src/article/command/infrastructure/article.entity.ts index 6326241..46f0946 100644 --- a/src/article/command/infrastructure/article.entity.ts +++ b/src/article/command/infrastructure/article.entity.ts @@ -8,6 +8,9 @@ export class ArticleEntity extends BaseEntity { @Property({ type: 'varchar' }) title: string; + @Property({ type: 'varchar' }) + organizationId: string; + @Property({ type: 'varchar' }) organization: string; @@ -17,8 +20,8 @@ export class ArticleEntity extends BaseEntity { @Property({ type: 'varchar', length: 2047 }) description: string; - @Property({ type: 'varchar' }) - registrationUrl: string; + @Property({ type: 'varchar', nullable: true }) + registrationUrl?: string; @Property({ type: 'datetime' }) startAt: Date; diff --git a/src/article/command/infrastructure/article.mapper.ts b/src/article/command/infrastructure/article.mapper.ts index 8108310..66be808 100644 --- a/src/article/command/infrastructure/article.mapper.ts +++ b/src/article/command/infrastructure/article.mapper.ts @@ -12,6 +12,7 @@ export class ArticleMapper { createdAt: entity.createdAt, updatedAt: entity.updatedAt, title: entity.title, + organizationId: Identifier.from(entity.organizationId), organization: entity.organization, location: entity.location, description: entity.description, @@ -33,6 +34,7 @@ export class ArticleMapper { entity.createdAt = domain.createdAt; entity.updatedAt = domain.updatedAt; entity.title = domain.title; + entity.organizationId = domain.organizationId.value; entity.organization = domain.organization; entity.location = domain.location; entity.description = domain.description; diff --git a/src/article/command/presentation/article.command.controller.ts b/src/article/command/presentation/article.command.controller.ts index 73ce4e9..c1ccd2e 100644 --- a/src/article/command/presentation/article.command.controller.ts +++ b/src/article/command/presentation/article.command.controller.ts @@ -1,12 +1,17 @@ -import { Body, Controller, Delete, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { CreateArticleUseCase } from '../application/create-article/create-article.use-case'; -import { CreateArticleRequestDto } from '../application/create-article/dto/create-article.request.dto'; +import { CreateArticleRequestDto } from './dto/create-article.request.dto'; import { CreateArticleResponseDto } from '../application/create-article/dto/create-article.response.dto'; import { DeleteArticleUseCase } from '../application/delete-article/delete-article.use-case'; import { UpdateArticleUseCase } from '../application/update-article/update-article.use-case'; -import { UpdateArticleRequestDto } from '../application/update-article/dto/update-article.request.dto'; +import { UpdateArticleRequestDto } from './dto/update-article.request.dto'; import { ArticleCommandDocs } from './article.command.docs'; +import { AuthGuard } from '@nestjs/passport'; +import { Organization, OrganizationPayload } from 'src/shared/core/presentation/organization.decorator'; +import { RolesGuard } from 'src/auth/core/infrastructure/guard/role.guard'; +import { Roles } from 'src/shared/core/presentation/role.decorator'; +import { Role } from 'src/auth/core/domain/value-object/role'; @ApiTags('article') @Controller('article') @@ -20,18 +25,59 @@ export class ArticleCommandController { @Post() @ArticleCommandDocs('create') async createArticle(@Body() reqDto: CreateArticleRequestDto): Promise { - return await this.createArticleUseCase.execute(reqDto); + return await this.createArticleUseCase.execute({ ...reqDto, organizationId: '1' }); } @Patch(':id') @ArticleCommandDocs('update') async updateArticle(@Param('id') id: string, @Body() reqDto: UpdateArticleRequestDto): Promise { - return await this.updateArticleUseCase.execute(id, reqDto); + return await this.updateArticleUseCase.execute({ ...reqDto, id, organizationId: '1' }); } @Delete(':id') @ArticleCommandDocs('delete') async delete(@Param('id') id: string): Promise { - return await this.deleteArticleUseCase.execute(id); + return await this.deleteArticleUseCase.execute({ id, organizationId: '1' }); + } +} + +@ApiTags('organization-article') +@Controller('organization/article') +export class OrganizationArticleCommandController { + constructor( + private readonly createArticleUseCase: CreateArticleUseCase, + private readonly updateArticleUseCase: UpdateArticleUseCase, + private readonly deleteArticleUseCase: DeleteArticleUseCase, + ) {} + + @Post() + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) + @ArticleCommandDocs('create') + async createArticle( + @Organization() organization: OrganizationPayload, + @Body() reqDto: CreateArticleRequestDto, + ): Promise { + return await this.createArticleUseCase.execute({ ...reqDto, organizationId: organization.organizationId }); + } + + @Patch(':id') + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) + @ArticleCommandDocs('update') + async updateArticle( + @Organization() organization: OrganizationPayload, + @Param('id') id: string, + @Body() reqDto: UpdateArticleRequestDto, + ): Promise { + return await this.updateArticleUseCase.execute({ ...reqDto, id, organizationId: organization.organizationId }); + } + + @Delete(':id') + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) + @ArticleCommandDocs('delete') + async delete(@Organization() organization: OrganizationPayload, @Param('id') id: string): Promise { + return await this.deleteArticleUseCase.execute({ id, organizationId: organization.organizationId }); } } diff --git a/src/article/command/presentation/article.command.docs.ts b/src/article/command/presentation/article.command.docs.ts index d82c771..b84319e 100644 --- a/src/article/command/presentation/article.command.docs.ts +++ b/src/article/command/presentation/article.command.docs.ts @@ -9,7 +9,7 @@ import { ApiOperation, } from '@nestjs/swagger'; import { createDocs } from 'src/shared/core/presentation/base.docs'; -import { UpdateArticleRequestDto } from '../application/update-article/dto/update-article.request.dto'; +import { UpdateArticleRequestDto } from './dto/update-article.request.dto'; export type ArticleCommandEndpoint = 'create' | 'update' | 'delete'; diff --git a/src/article/command/application/create-article/dto/create-article.request.dto.ts b/src/article/command/presentation/dto/create-article.request.dto.ts similarity index 95% rename from src/article/command/application/create-article/dto/create-article.request.dto.ts rename to src/article/command/presentation/dto/create-article.request.dto.ts index b842f40..59f4331 100644 --- a/src/article/command/application/create-article/dto/create-article.request.dto.ts +++ b/src/article/command/presentation/dto/create-article.request.dto.ts @@ -27,7 +27,7 @@ export class CreateArticleRequestDto { @IsString() @IsOptional() - registrationUrl: string; + registrationUrl?: string; @IsString() @IsOptional() diff --git a/src/article/command/application/update-article/dto/update-article.request.dto.ts b/src/article/command/presentation/dto/update-article.request.dto.ts similarity index 100% rename from src/article/command/application/update-article/dto/update-article.request.dto.ts rename to src/article/command/presentation/dto/update-article.request.dto.ts diff --git a/src/article/query/article.query.module.ts b/src/article/query/article.query.module.ts index 62f25b4..1027392 100644 --- a/src/article/query/article.query.module.ts +++ b/src/article/query/article.query.module.ts @@ -8,18 +8,32 @@ import { ArticleQueryController } from './presentation/article.query.controller' import { ARTICLE_QUERY_REPOSITORY } from './domain/repository/article.query.repository'; import { ArticleQueryRepositoryImpl } from './infrastructure/article.query.repository.impl'; import { MediaEntity } from 'src/media/command/infrastructure/media.entity'; +import { ARTICLE_READER } from './organization/domain/article.reader'; +import { ArticleReaderImpl } from './organization/infrastructure/article.reader.impl'; +import { ArticleOrganizationViewController } from './organization/presentation/article.view.controller'; +import { GetOrganizationArticleListUseCase } from './organization/application/article-list/get-article-list.use-case'; +import { ArticleViewEntity } from './organization/infrastructure/article.view.entity'; -const usecases = [GetArticleDetailUseCase, GetArticleListUseCase, GetArticleSearchUseCase]; +const usecases = [ + GetArticleDetailUseCase, + GetArticleListUseCase, + GetArticleSearchUseCase, + GetOrganizationArticleListUseCase, +]; @Module({ - imports: [MikroOrmModule.forFeature([ArticleEntity, MediaEntity])], + imports: [MikroOrmModule.forFeature([ArticleEntity, ArticleViewEntity, MediaEntity])], providers: [ ...usecases, { provide: ARTICLE_QUERY_REPOSITORY, useClass: ArticleQueryRepositoryImpl, }, + { + provide: ARTICLE_READER, + useClass: ArticleReaderImpl, + }, ], - controllers: [ArticleQueryController], + controllers: [ArticleQueryController, ArticleOrganizationViewController], }) export class ArticleQueryModule {} diff --git a/src/article/query/organization/application/article-list/get-article-list.query.ts b/src/article/query/organization/application/article-list/get-article-list.query.ts new file mode 100644 index 0000000..989f7d3 --- /dev/null +++ b/src/article/query/organization/application/article-list/get-article-list.query.ts @@ -0,0 +1,3 @@ +export class GetArticleListQuery { + constructor(public readonly organizationId: string) {} +} diff --git a/src/article/query/organization/application/article-list/get-article-list.use-case.ts b/src/article/query/organization/application/article-list/get-article-list.use-case.ts new file mode 100644 index 0000000..b408a81 --- /dev/null +++ b/src/article/query/organization/application/article-list/get-article-list.use-case.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ARTICLE_READER, ArticleReader } from '../../domain/article.reader'; +import { GetArticleListQuery } from './get-article-list.query'; + +@Injectable() +export class GetOrganizationArticleListUseCase { + constructor( + @Inject(ARTICLE_READER) + private readonly articleReader: ArticleReader, + ) {} + + async execute(query: GetArticleListQuery) { + return this.articleReader.findAllByOrganizationId(query.organizationId); + } +} diff --git a/src/article/query/organization/domain/article.model.ts b/src/article/query/organization/domain/article.model.ts new file mode 100644 index 0000000..6564b7c --- /dev/null +++ b/src/article/query/organization/domain/article.model.ts @@ -0,0 +1,83 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ArticleModel { + @ApiProperty({ + example: '21231423423', + description: '게시글 ID', + }) + id: string; + + @ApiProperty({ + example: '23131312312', + description: '기관 ID', + }) + organizationId: string; + + @ApiProperty({ + example: '제목', + description: '게시글 제목', + }) + title: string; + + @ApiProperty({ + example: '고려대학교', + description: '게시글 작성자', + }) + organization: string; + + @ApiProperty({ + example: 'https://example.com/thumbnail.jpg', + description: '썸네일 이미지 경로', + }) + thumbnailPath: string; + + @ApiProperty({ + example: 10, + description: '스크랩 수', + }) + scrapCount: number; + + @ApiProperty({ + example: 100, + description: '조회 수', + }) + viewCount: number; + + @ApiProperty({ + example: '태그1, 태그2', + description: '게시글 태그 목록', + }) + tags: string[]; + + @ApiProperty({ + example: '2023-10-01T00:00:00Z', + description: '행사 시작 시간', + }) + startAt: string; + + @ApiProperty({ + example: '2023-10-01T00:00:00Z', + description: '행사 종료 시간', + }) + endAt: string; + + @ApiProperty({ + example: '2023-09-01T00:00:00Z', + description: '등록 시작 시간', + required: false, + }) + registrationStartAt?: string; + + @ApiProperty({ + example: '2023-09-30T23:59:59Z', + description: '등록 종료 시간', + required: false, + }) + registrationEndAt?: string; + + @ApiProperty({ + example: '2023-08-01T12:00:00Z', + description: '게시글 생성 시간', + }) + createdAt: string; +} diff --git a/src/article/query/organization/domain/article.reader.ts b/src/article/query/organization/domain/article.reader.ts new file mode 100644 index 0000000..49eb6e9 --- /dev/null +++ b/src/article/query/organization/domain/article.reader.ts @@ -0,0 +1,7 @@ +import { ArticleModel } from './article.model'; + +export interface ArticleReader { + findAllByOrganizationId(organizationId: string): Promise; +} + +export const ARTICLE_READER = 'ARTICLE_READER'; diff --git a/src/article/query/organization/infrastructure/article.reader.impl.ts b/src/article/query/organization/infrastructure/article.reader.impl.ts new file mode 100644 index 0000000..f9fd33a --- /dev/null +++ b/src/article/query/organization/infrastructure/article.reader.impl.ts @@ -0,0 +1,19 @@ +import { InjectRepository } from '@mikro-orm/nestjs'; +import { ArticleViewEntity } from './article.view.entity'; +import { EntityRepository } from '@mikro-orm/mysql'; +import { ArticleReader } from '../domain/article.reader'; +import { ArticleModel } from '../domain/article.model'; +import { ArticleViewMapper } from './article.view.mapper'; + +export class ArticleReaderImpl implements ArticleReader { + constructor( + @InjectRepository(ArticleViewEntity) + private readonly ormRepository: EntityRepository, + ) {} + + async findAllByOrganizationId(organizationId: string): Promise { + const articles = await this.ormRepository.find({ organizationId }, { orderBy: { createdAt: 'DESC' } }); + + return articles.map((article) => ArticleViewMapper.toModel(article)); + } +} diff --git a/src/article/query/organization/infrastructure/article.view.entity.ts b/src/article/query/organization/infrastructure/article.view.entity.ts new file mode 100644 index 0000000..7f949d0 --- /dev/null +++ b/src/article/query/organization/infrastructure/article.view.entity.ts @@ -0,0 +1,70 @@ +import { Entity, Property } from '@mikro-orm/core'; + +@Entity({ + expression: ` + SELECT + a.id, + a.organization_id, + a.title, + a.organization, + m.media_path AS thumbnail_path, + a.scrap_count, + a.view_count, + a.start_at, + a.end_at, + tags.tags, + a.registration_start_at, + a.registration_end_at, + a.created_at + FROM article a + LEFT JOIN media m ON m.article_id = a.id AND m.order = 0 + LEFT JOIN ( + SELECT + at.article_entity_id, + GROUP_CONCAT(DISTINCT t.name ORDER BY t.name ASC SEPARATOR ',') AS tags + FROM article_tags at + JOIN tag t ON t.id = at.tag_entity_id + GROUP BY at.article_entity_id + ) tags ON tags.article_entity_id = a.id + `, +}) +export class ArticleViewEntity { + @Property() + id: string; + + @Property() + organizationId: string; + + @Property() + title: string; + + @Property() + organization: string; + + @Property() + scrapCount: number; + + @Property() + viewCount: number; + + @Property() + startAt: Date; + + @Property() + endAt: Date; + + @Property() + createdAt: Date; + + @Property() + thumbnailPath: string; + + @Property() + tags: string; + + @Property() + registrationStartAt?: Date; + + @Property() + registrationEndAt?: Date; +} diff --git a/src/article/query/organization/infrastructure/article.view.mapper.ts b/src/article/query/organization/infrastructure/article.view.mapper.ts new file mode 100644 index 0000000..50481fc --- /dev/null +++ b/src/article/query/organization/infrastructure/article.view.mapper.ts @@ -0,0 +1,23 @@ +import { ArticleModel } from '../domain/article.model'; +import { ArticleViewEntity } from './article.view.entity'; + +export class ArticleViewMapper { + static toModel(entity: ArticleViewEntity): ArticleModel { + const model = new ArticleModel(); + model.id = entity.id; + model.organizationId = entity.organizationId; + model.title = entity.title; + model.organization = entity.organization; + model.thumbnailPath = entity.thumbnailPath; + model.scrapCount = entity.scrapCount; + model.viewCount = entity.viewCount; + model.tags = entity.tags ? entity.tags.split(',').map((tag) => tag.trim()) : []; + model.startAt = entity.startAt.toISOString(); + model.endAt = entity.endAt.toISOString(); + model.registrationStartAt = entity.registrationStartAt ? entity.registrationStartAt.toISOString() : undefined; + model.registrationEndAt = entity.registrationEndAt ? entity.registrationEndAt.toISOString() : undefined; + model.createdAt = entity.createdAt.toISOString(); + + return model; + } +} diff --git a/src/article/query/organization/presentation/article.view.controller.ts b/src/article/query/organization/presentation/article.view.controller.ts new file mode 100644 index 0000000..c71a605 --- /dev/null +++ b/src/article/query/organization/presentation/article.view.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Organization, OrganizationPayload } from 'src/shared/core/presentation/organization.decorator'; +import { ArticleModel } from '../domain/article.model'; +import { GetOrganizationArticleListUseCase } from '../application/article-list/get-article-list.use-case'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from 'src/auth/core/infrastructure/guard/role.guard'; +import { Roles } from 'src/shared/core/presentation/role.decorator'; +import { Role } from 'src/auth/core/domain/value-object/role'; +import { OrganizationArticleViewDocs } from './article.view.docs'; + +@ApiTags('organization-article') +@Controller('organization/article') +export class ArticleOrganizationViewController { + constructor(private readonly getOrganizationArticleListUseCase: GetOrganizationArticleListUseCase) {} + + @Get() + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) + @OrganizationArticleViewDocs('list') + async getArticleList(@Organization() organization: OrganizationPayload): Promise { + return await this.getOrganizationArticleListUseCase.execute({ organizationId: organization.organizationId }); + } +} diff --git a/src/article/query/organization/presentation/article.view.docs.ts b/src/article/query/organization/presentation/article.view.docs.ts new file mode 100644 index 0000000..36a0607 --- /dev/null +++ b/src/article/query/organization/presentation/article.view.docs.ts @@ -0,0 +1,23 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiUnauthorizedResponse } from '@nestjs/swagger'; +import { createDocs } from 'src/shared/core/presentation/base.docs'; +import { ArticleModel } from '../domain/article.model'; + +export type OrganizationArticleViewEndpoint = 'list'; + +export const OrganizationArticleViewDocs = createDocs({ + list: () => + applyDecorators( + ApiOperation({ + summary: '조직 게시글 목록 조회', + description: '조직에 속한 게시글 목록을 조회합니다.', + }), + ApiOkResponse({ + description: '조직 게시글 목록 조회 성공', + type: [ArticleModel], + }), + ApiUnauthorizedResponse({ + description: '유효하지 않은 access token', + }), + ), +}); diff --git a/src/auth/auth-organization/application/login/login.use-case.ts b/src/auth/auth-organization/application/login/login.use-case.ts index 77e9c02..c0c1197 100644 --- a/src/auth/auth-organization/application/login/login.use-case.ts +++ b/src/auth/auth-organization/application/login/login.use-case.ts @@ -9,6 +9,7 @@ import { JwtProvider } from 'src/auth/core/infrastructure/jwt/jwt.provider'; import { TokenType } from 'src/auth/core/infrastructure/jwt/jwt.factory'; import { LoginResponseDto } from './dto/login.response.dto'; import { AuthOrganization } from '../../domain/auth-organization'; +import { Role } from 'src/auth/core/domain/value-object/role'; @Injectable() @CommandHandler(LoginCommand) @@ -44,8 +45,12 @@ export class LoginUseCase { private async generateTokens( organizationId: string, ): Promise<{ accessToken: string; refreshToken: string; jti: string }> { - const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, organizationId); - const { token: refreshToken, jti } = await this.jwtProvider.generateToken(TokenType.REFRESH, organizationId); + const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, organizationId, [ + Role.ORGANIZATION, + ]); + const { token: refreshToken, jti } = await this.jwtProvider.generateToken(TokenType.REFRESH, organizationId, [ + Role.ORGANIZATION, + ]); return { accessToken, refreshToken, jti }; } diff --git a/src/auth/auth-organization/application/renew-token/renew-token.use-case.ts b/src/auth/auth-organization/application/renew-token/renew-token.use-case.ts index 545aa6e..1f4a2d5 100644 --- a/src/auth/auth-organization/application/renew-token/renew-token.use-case.ts +++ b/src/auth/auth-organization/application/renew-token/renew-token.use-case.ts @@ -8,6 +8,7 @@ import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; import { RenewTokenResponseDto } from './dto/renew-token.response.dto'; import { AuthOrganization } from '../../domain/auth-organization'; +import { Role } from 'src/auth/core/domain/value-object/role'; @Injectable() @CommandHandler(RenewTokenCommand) @@ -40,8 +41,12 @@ export class RenewTokenUseCase implements ICommandHandler { private async generateTokens( organizationId: string, ): Promise<{ accessToken: string; refreshToken: string; jti: string }> { - const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, organizationId); - const { token: refreshToken, jti } = await this.jwtProvider.generateToken(TokenType.REFRESH, organizationId); + const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, organizationId, [ + Role.ORGANIZATION, + ]); + const { token: refreshToken, jti } = await this.jwtProvider.generateToken(TokenType.REFRESH, organizationId, [ + Role.ORGANIZATION, + ]); return { accessToken, refreshToken, jti }; } diff --git a/src/auth/auth-organization/presentation/auth-organization.controller.ts b/src/auth/auth-organization/presentation/auth-organization.controller.ts index d394aff..43e688f 100644 --- a/src/auth/auth-organization/presentation/auth-organization.controller.ts +++ b/src/auth/auth-organization/presentation/auth-organization.controller.ts @@ -2,7 +2,6 @@ import { Body, Controller, HttpStatus, Post, Res, UseGuards } from '@nestjs/comm import { ApiTags } from '@nestjs/swagger'; import { CreateAuthOrganizationUseCase } from '../application/create/create.use-case'; import { RegisterOrganizationRequestDto } from './dto/request/register-organization.request.dto'; -import { User, UserPayload } from 'src/shared/core/presentation/user.decorator'; import { Response } from 'express'; import { RenewTokenUseCase } from '../application/renew-token/renew-token.use-case'; import { accessTokenCookieOptions, refreshTokenCookieOptions } from 'src/shared/config/cookie.config'; @@ -14,6 +13,10 @@ import { AuthOrganizationDocs } from './auth-organization.docs'; import { CheckAccountIdRequestDto } from './dto/request/check-account-id.request.dto'; import { CheckAccountIdUseCase } from '../application/check-account-id/check-account-id.use-case'; import { CheckAccountIdResponseDto } from './dto/response/check-account-id.response.dto'; +import { Organization, OrganizationPayload } from 'src/shared/core/presentation/organization.decorator'; +import { Roles } from 'src/shared/core/presentation/role.decorator'; +import { RolesGuard } from 'src/auth/core/infrastructure/guard/role.guard'; +import { Role } from 'src/auth/core/domain/value-object/role'; @ApiTags('auth-organization') @Controller('auth/organization') @@ -53,36 +56,38 @@ export class AuthOrganizationController { password: dto.password, }); - res.cookie('orgAccessToken', accessToken, accessTokenCookieOptions); - res.cookie('orgRefreshToken', refreshToken, refreshTokenCookieOptions); + res.cookie('accessToken', accessToken, accessTokenCookieOptions); + res.cookie('refreshToken', refreshToken, refreshTokenCookieOptions); res.status(HttpStatus.OK).send(); } @Post('refresh') - @UseGuards(AuthGuard('jwt-refresh')) + @UseGuards(AuthGuard('jwt-refresh'), RolesGuard) + @Roles(Role.ORGANIZATION) @AuthOrganizationDocs('refresh') - async renewToken(@User() user: UserPayload, @Res() res: Response) { - const { userId, jti } = user; + async renewToken(@Organization() organization: OrganizationPayload, @Res() res: Response) { + const { organizationId, jti } = organization; const { accessToken, refreshToken } = await this.renewTokenUseCase.execute({ - organizationId: userId, + organizationId: organizationId, jti: jti, }); - res.cookie('orgAccessToken', accessToken, accessTokenCookieOptions); - res.cookie('orgRefreshToken', refreshToken, refreshTokenCookieOptions); + res.cookie('accessToken', accessToken, accessTokenCookieOptions); + res.cookie('refreshToken', refreshToken, refreshTokenCookieOptions); res.status(HttpStatus.OK).send(); } @Post('logout') - @UseGuards(AuthGuard('jwt-access')) + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) @AuthOrganizationDocs('logout') - async logout(@User() user: UserPayload, @Res() res: Response) { - await this.logoutUseCase.execute({ organizationId: user.userId }); + async logout(@Organization() organization: OrganizationPayload, @Res() res: Response) { + await this.logoutUseCase.execute({ organizationId: organization.organizationId }); - res.clearCookie('orgAccessToken', accessTokenCookieOptions); - res.clearCookie('orgRefreshToken', refreshTokenCookieOptions); + res.clearCookie('accessToken', accessTokenCookieOptions); + res.clearCookie('refreshToken', refreshTokenCookieOptions); res.status(HttpStatus.OK).send(); } diff --git a/src/auth/auth-user/application/oauth-login/oauth-login.handler.ts b/src/auth/auth-user/application/oauth-login/oauth-login.handler.ts index e457a94..f46ce36 100644 --- a/src/auth/auth-user/application/oauth-login/oauth-login.handler.ts +++ b/src/auth/auth-user/application/oauth-login/oauth-login.handler.ts @@ -7,11 +7,11 @@ import { JwtProvider } from 'src/auth/core/infrastructure/jwt/jwt.provider'; import { Identifier } from 'src/shared/core/domain/identifier'; import { OAuthLoginResponseDto } from './dto/oauth-login.response.dto'; import { Transactional } from '@mikro-orm/core'; -import { Role } from 'src/user/command/domain/value-object/role.enum'; import { CommandHandler, EventBus } from '@nestjs/cqrs'; import { AuthCreatedEvent } from 'src/auth/auth-user/domain/event/auth-created.event'; import { OAuthLoginCommand } from './oauth-login.command'; import { AUTH_USER_REPOSITORY, AuthUserRepository } from '../../domain/auth-user.repository'; +import { Role } from 'src/auth/core/domain/value-object/role'; @Injectable() @CommandHandler(OAuthLoginCommand) @@ -56,7 +56,7 @@ export class OAuthLoginUseCase { const userId = Identifier.create(); - await this.eventBus.publish(new AuthCreatedEvent(userId, email, Role.GENERAL, provider)); + await this.eventBus.publish(new AuthCreatedEvent(userId, email, provider)); const authUser = AuthUser.create({ id: Identifier.create(), @@ -78,8 +78,14 @@ export class OAuthLoginUseCase { // 토큰 생성 및 저장 private async generateAndSaveTokens(authUser: AuthUser) { - const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, authUser.userId.value); - const { token: refreshToken, jti } = await this.jwtProvider.generateToken(TokenType.REFRESH, authUser.userId.value); + const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, authUser.userId.value, [ + Role.USER, + ]); + const { token: refreshToken, jti } = await this.jwtProvider.generateToken( + TokenType.REFRESH, + authUser.userId.value, + [Role.USER], + ); authUser.updateRefreshToken(jti, this.now); await this.authUserRepository.update(authUser); diff --git a/src/auth/auth-user/application/renew-token/renew-token.use-case.ts b/src/auth/auth-user/application/renew-token/renew-token.use-case.ts index 62d0b4c..6d65ce5 100644 --- a/src/auth/auth-user/application/renew-token/renew-token.use-case.ts +++ b/src/auth/auth-user/application/renew-token/renew-token.use-case.ts @@ -7,6 +7,7 @@ import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; import { EventBus } from '@nestjs/cqrs'; import { AUTH_USER_REPOSITORY, AuthUserRepository } from '../../domain/auth-user.repository'; +import { Role } from 'src/auth/core/domain/value-object/role'; @Injectable() export class RenewTokenUseCase { @@ -23,8 +24,10 @@ export class RenewTokenUseCase { if (!authUser || userId != authUser.userId.value) throw new CustomException(CustomExceptionCode.AUTH_INVALID_REFRESH_TOKEN); - const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, userId); - const { token: refreshToken, jti: newJti } = await this.jwtProvider.generateToken(TokenType.REFRESH, userId); + const { token: accessToken } = await this.jwtProvider.generateToken(TokenType.ACCESS, userId, [Role.USER]); + const { token: refreshToken, jti: newJti } = await this.jwtProvider.generateToken(TokenType.REFRESH, userId, [ + Role.USER, + ]); authUser.updateRefreshToken(newJti, new Date()); await this.authUserRepository.update(authUser); diff --git a/src/auth/auth-user/domain/event/auth-created.event.ts b/src/auth/auth-user/domain/event/auth-created.event.ts index f891d5b..dfb32e2 100644 --- a/src/auth/auth-user/domain/event/auth-created.event.ts +++ b/src/auth/auth-user/domain/event/auth-created.event.ts @@ -1,6 +1,5 @@ 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 { @@ -9,7 +8,6 @@ export class AuthCreatedEvent implements BaseDomainEvent { constructor( public readonly userId: Identifier, public readonly email: string, - public readonly role: Role, public readonly provider: OAuthProviderType, ) { this.timesstamp = new Date(); diff --git a/src/auth/auth-user/presentation/auth-user.controller.ts b/src/auth/auth-user/presentation/auth-user.controller.ts index fa12ade..fc88850 100644 --- a/src/auth/auth-user/presentation/auth-user.controller.ts +++ b/src/auth/auth-user/presentation/auth-user.controller.ts @@ -11,6 +11,9 @@ import { LogoutUseCase } from '../application/logout/logout.use-case'; import { OAuthProviderType } from '../domain/value-object/oauth-provider.enum'; import { AuthUserDocs } from './auth-user.docs'; import { UnlinkOAuthUseCase } from '../application/unlink-oauth/unlink-oauth.use-case'; +import { RolesGuard } from 'src/auth/core/infrastructure/guard/role.guard'; +import { Role } from 'src/auth/core/domain/value-object/role'; +import { Roles } from 'src/shared/core/presentation/role.decorator'; @ApiTags('auth-user') @Controller('auth') @@ -59,7 +62,8 @@ export class AuthUserController { } @Get('refresh') - @UseGuards(AuthGuard('jwt-refresh')) + @UseGuards(AuthGuard('jwt-refresh'), RolesGuard) + @Roles(Role.USER) @AuthUserDocs('renewToken') async renewToken(@User() user: UserPayload, @Res() res: Response) { const { accessToken, refreshToken } = await this.renewTokenUseCase.execute({ userId: user.userId, jti: user.jti }); @@ -71,7 +75,8 @@ export class AuthUserController { } @Post('logout') - @UseGuards(AuthGuard('jwt-access')) + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.USER) @AuthUserDocs('logout') async logout(@User() user: UserPayload, @Res() res: Response) { await this.logoutUseCase.execute({ userId: user.userId }); @@ -83,7 +88,8 @@ export class AuthUserController { } @Post('withdraw') - @UseGuards(AuthGuard('jwt-access')) + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.USER) @AuthUserDocs('withdraw') async withdraw(@User() user: UserPayload, @Res() res: Response) { await this.unlinkOAuthUseCase.execute({ userId: user.userId, oAuthProviderType: OAuthProviderType.KAKAO }); diff --git a/src/auth/core/domain/value-object/role.ts b/src/auth/core/domain/value-object/role.ts new file mode 100644 index 0000000..44e08d7 --- /dev/null +++ b/src/auth/core/domain/value-object/role.ts @@ -0,0 +1,5 @@ +export enum Role { + USER = 'USER', + ADMIN = 'ADMIN', + ORGANIZATION = 'ORGANIZATION', +} diff --git a/src/auth/core/infrastructure/guard/role.guard.ts b/src/auth/core/infrastructure/guard/role.guard.ts new file mode 100644 index 0000000..019ec59 --- /dev/null +++ b/src/auth/core/infrastructure/guard/role.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from 'src/shared/core/presentation/role.decorator'; +import { JwtPayload } from '../jwt/jwt-payload'; +import { Request } from 'express'; +import { Role } from '../../domain/value-object/role'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(ctx: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ctx.getHandler(), ctx.getClass()]); + + if (!requiredRoles) return true; + + const request = ctx.switchToHttp().getRequest(); + const { roles } = request.user as JwtPayload; + + return requiredRoles.some((role: Role) => roles.includes(role)); + } +} diff --git a/src/auth/core/infrastructure/jwt/jwt-access.strategy.ts b/src/auth/core/infrastructure/jwt/jwt-access.strategy.ts index a8bb972..a1291d3 100644 --- a/src/auth/core/infrastructure/jwt/jwt-access.strategy.ts +++ b/src/auth/core/infrastructure/jwt/jwt-access.strategy.ts @@ -5,10 +5,7 @@ import { Request } from 'express'; import { ExtractJwt, JwtFromRequestFunction, Strategy } from 'passport-jwt'; import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; - -interface JwtPayload { - userId: number; -} +import { JwtPayload } from './jwt-payload'; @Injectable() export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') { @@ -29,7 +26,7 @@ export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') }); } - validate(payload: JwtPayload) { + validate(payload: JwtPayload): JwtPayload { return payload; } } diff --git a/src/auth/core/infrastructure/jwt/jwt-payload.ts b/src/auth/core/infrastructure/jwt/jwt-payload.ts new file mode 100644 index 0000000..4df518f --- /dev/null +++ b/src/auth/core/infrastructure/jwt/jwt-payload.ts @@ -0,0 +1,7 @@ +import { Role } from '../../domain/value-object/role'; + +export interface JwtPayload { + sub: string; + jti: string; + roles: Role[]; +} diff --git a/src/auth/core/infrastructure/jwt/jwt-refresh.strategy.ts b/src/auth/core/infrastructure/jwt/jwt-refresh.strategy.ts index f2144d9..0660967 100644 --- a/src/auth/core/infrastructure/jwt/jwt-refresh.strategy.ts +++ b/src/auth/core/infrastructure/jwt/jwt-refresh.strategy.ts @@ -5,11 +5,7 @@ import { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; - -interface RefreshTokenPayload { - userId: string; - jti: string; -} +import { JwtPayload } from './jwt-payload'; @Injectable() export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { @@ -30,7 +26,7 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh' }); } - validate(payload: RefreshTokenPayload): RefreshTokenPayload { + validate(payload: JwtPayload): JwtPayload { return payload; } } diff --git a/src/auth/core/infrastructure/jwt/jwt.provider.ts b/src/auth/core/infrastructure/jwt/jwt.provider.ts index 004ef12..7e64374 100644 --- a/src/auth/core/infrastructure/jwt/jwt.provider.ts +++ b/src/auth/core/infrastructure/jwt/jwt.provider.ts @@ -3,6 +3,8 @@ import { JwtService } from '@nestjs/jwt'; import { JwtSignOptionsMapper, JwtVerifyOptionsMapper, TokenType } from './jwt.factory'; import { Injectable } from '@nestjs/common'; import { v4 as uuidV4 } from 'uuid'; +import { JwtPayload } from './jwt-payload'; +import { Role } from '../../domain/value-object/role'; @Injectable() export class JwtProvider { @@ -11,10 +13,10 @@ export class JwtProvider { private readonly configService: ConfigService, ) {} - async generateToken(tokenType: TokenType, userId: string): Promise<{ token: string; jti: string }> { + async generateToken(tokenType: TokenType, sub: string, roles: Role[]): Promise<{ token: string; jti: string }> { const jwtSignOptions = JwtSignOptionsMapper(this.configService)[tokenType]; const jti = uuidV4(); - const payload = { userId, jti }; + const payload = { sub, jti, roles } as JwtPayload; const token = await this.jwtService.signAsync(payload, jwtSignOptions); return { token, jti }; diff --git a/src/organization/command/presentation/organization.command.controller.ts b/src/organization/command/presentation/organization.command.controller.ts index 419dd59..102d143 100644 --- a/src/organization/command/presentation/organization.command.controller.ts +++ b/src/organization/command/presentation/organization.command.controller.ts @@ -3,8 +3,8 @@ import { ApiTags } from '@nestjs/swagger'; import { UpdateOrganizationUseCase } from '../application/update-organization/update-organization.use-case'; import { UpdateOrganizationDto } from './dto/request/update-organization.request.dto'; import { AuthGuard } from '@nestjs/passport'; -import { User, UserPayload } from 'src/shared/core/presentation/user.decorator'; import { OrganizationCommandDocs } from './organization.command.docs'; +import { Organization, OrganizationPayload } from 'src/shared/core/presentation/organization.decorator'; @ApiTags('organization') @Controller('organization') @@ -14,9 +14,9 @@ export class OrganizationCommandController { @Patch() @UseGuards(AuthGuard('jwt-access')) @OrganizationCommandDocs('update') - async update(@User() user: UserPayload, @Body() dto: UpdateOrganizationDto): Promise { + async update(@Organization() organization: OrganizationPayload, @Body() dto: UpdateOrganizationDto): Promise { await this.updateOrganizationUseCase.execute({ - organizationId: user.userId, + organizationId: organization.organizationId, name: dto.name, contact: dto.contact, }); diff --git a/src/shared/core/presentation/organization.decorator.ts b/src/shared/core/presentation/organization.decorator.ts new file mode 100644 index 0000000..acbe87b --- /dev/null +++ b/src/shared/core/presentation/organization.decorator.ts @@ -0,0 +1,15 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Request } from 'express'; +import { JwtPayload } from 'src/auth/core/infrastructure/jwt/jwt-payload'; + +export interface OrganizationPayload { + organizationId: string; + jti: string; +} + +export const Organization = createParamDecorator((data: unknown, ctx: ExecutionContext): OrganizationPayload => { + const request = ctx.switchToHttp().getRequest(); + const { sub, jti } = request.user as JwtPayload; + + return { organizationId: sub, jti }; +}); diff --git a/src/shared/core/presentation/role.decorator.ts b/src/shared/core/presentation/role.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/src/shared/core/presentation/role.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/shared/core/presentation/user.decorator.ts b/src/shared/core/presentation/user.decorator.ts index b87c759..6b23fae 100644 --- a/src/shared/core/presentation/user.decorator.ts +++ b/src/shared/core/presentation/user.decorator.ts @@ -1,14 +1,15 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { Request } from 'express'; +import { JwtPayload } from 'src/auth/core/infrastructure/jwt/jwt-payload'; export interface UserPayload { userId: string; jti: string; } -export const User = createParamDecorator((data: unknown, ctx: ExecutionContext) => { +export const User = createParamDecorator((data: unknown, ctx: ExecutionContext): UserPayload => { const request = ctx.switchToHttp().getRequest(); - const user = request.user as UserPayload; + const { sub, jti } = request.user as JwtPayload; - return user; + return { userId: sub, jti }; }); diff --git a/src/user/command/application/create/create-user.handler.ts b/src/user/command/application/create/create-user.handler.ts index 2c45adc..eeeba85 100644 --- a/src/user/command/application/create/create-user.handler.ts +++ b/src/user/command/application/create/create-user.handler.ts @@ -3,6 +3,7 @@ import { USER_COMMAND_REPOSITORY, UserCommandRepository } from '../../domain/use import { User } from '../../domain/user'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CreateUserCommand } from './create.command'; +import { Role } from '../../domain/value-object/role.enum'; @Injectable() @CommandHandler(CreateUserCommand) @@ -13,14 +14,14 @@ export class CreateUserHandler implements ICommandHandler { ) {} async execute(command: CreateUserCommand): Promise { - const { userId, email, role } = command; + const { userId, email } = command; const now = new Date(); const user = User.create({ id: userId, createdAt: now, updatedAt: now, email: email, - role: role, + role: Role.GENERAL, }); await this.userCommandRepository.save(user); diff --git a/src/user/command/application/create/create-user.listener.ts b/src/user/command/application/create/create-user.listener.ts index b03b4eb..46f5e94 100644 --- a/src/user/command/application/create/create-user.listener.ts +++ b/src/user/command/application/create/create-user.listener.ts @@ -8,9 +8,9 @@ export class CreateUserListener implements IEventHandler { constructor(private readonly createUserHandler: CreateUserHandler) {} async handle(event: AuthCreatedEvent): Promise { - const { userId, email, role } = event; + const { userId, email } = event; - const createUserCommand = new CreateUserCommand(userId, email, role); + const createUserCommand = new CreateUserCommand(userId, email); await this.createUserHandler.execute(createUserCommand); } diff --git a/src/user/command/application/create/create.command.ts b/src/user/command/application/create/create.command.ts index 531f540..dfe8da5 100644 --- a/src/user/command/application/create/create.command.ts +++ b/src/user/command/application/create/create.command.ts @@ -1,11 +1,9 @@ import { ICommand } from '@nestjs/cqrs'; -import { Role } from '../../domain/value-object/role.enum'; import { Identifier } from 'src/shared/core/domain/identifier'; export class CreateUserCommand implements ICommand { constructor( public readonly userId: Identifier, public readonly email: string, - public readonly role: Role, ) {} } diff --git a/src/user/command/presentation/user.command.controller.ts b/src/user/command/presentation/user.command.controller.ts index 5991997..00a1b0b 100644 --- a/src/user/command/presentation/user.command.controller.ts +++ b/src/user/command/presentation/user.command.controller.ts @@ -7,13 +7,17 @@ import { CommandBus } from '@nestjs/cqrs'; import { DeleteMyInfoCommand } from '../application/delete/delete.command'; import { Response } from 'express'; import { accessTokenCookieOptions, refreshTokenCookieOptions } from 'src/shared/config/cookie.config'; +import { RolesGuard } from 'src/auth/core/infrastructure/guard/role.guard'; +import { Roles } from 'src/shared/core/presentation/role.decorator'; +import { Role } from 'src/auth/core/domain/value-object/role'; @ApiTags('user') @Controller('user') export class UserCommandController { constructor(private readonly commandBus: CommandBus) {} @Delete('me') - @UseGuards(AuthGuard('jwt-access')) + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.USER) @UserCommandDocs('deleteMyInfo') async deleteMyInfo(@User() user: UserPayload, @Res() res: Response) { const command = new DeleteMyInfoCommand(user.userId); diff --git a/src/user/query/presentation/user.query.controller.ts b/src/user/query/presentation/user.query.controller.ts index 2dce43c..5a0833a 100644 --- a/src/user/query/presentation/user.query.controller.ts +++ b/src/user/query/presentation/user.query.controller.ts @@ -5,6 +5,9 @@ import { User, UserPayload } from 'src/shared/core/presentation/user.decorator'; import { UserModel } from '../domain/user.model'; import { GetMyInfoUseCase } from '../application/get-my-info/get-my-info.use-case'; import { UserQueryDocs } from './user.query.docs'; +import { RolesGuard } from 'src/auth/core/infrastructure/guard/role.guard'; +import { Roles } from 'src/shared/core/presentation/role.decorator'; +import { Role } from 'src/auth/core/domain/value-object/role'; @ApiTags('user') @Controller('user') @@ -12,7 +15,8 @@ export class UserQueryController { constructor(private readonly getMyInfoUseCase: GetMyInfoUseCase) {} @Get('me') - @UseGuards(AuthGuard('jwt-access')) + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.USER) @UserQueryDocs('getMyInfo') async getMyInfo(@User() user: UserPayload): Promise { return await this.getMyInfoUseCase.execute({ userId: user.userId });