From d801d2a34d12352e2ca17d9946be78a0eded7526 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 13:55:14 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20article=EC=97=90=20organizationId?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/article/command/domain/article.ts | 9 +++++++-- src/article/command/infrastructure/article.entity.ts | 7 +++++-- src/article/command/infrastructure/article.mapper.ts | 2 ++ 3 files changed, 14 insertions(+), 4 deletions(-) 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; From 8352e264b7d2c156c24a9efe1a2e26f3b478a8d3 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 13:55:45 +0900 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20organizationId=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-article/create-article.command.ts | 17 +++++++++++++ .../create-article/create-article.use-case.ts | 25 ++++++++++--------- .../dto/create-article.request.dto.ts | 2 +- 3 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 src/article/command/application/create-article/create-article.command.ts rename src/article/command/{application/create-article => presentation}/dto/create-article.request.dto.ts (95%) 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/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() From abd70e2f24ffd81df6e549a690e31e89063d5119 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 13:56:03 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20organizationId=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/update-article.request.dto.ts | 101 ------------------ .../update-article/update-article.command.ts | 18 ++++ .../update-article/update-article.use-case.ts | 28 ++--- 3 files changed, 32 insertions(+), 115 deletions(-) delete mode 100644 src/article/command/application/update-article/dto/update-article.request.dto.ts create mode 100644 src/article/command/application/update-article/update-article.command.ts diff --git a/src/article/command/application/update-article/dto/update-article.request.dto.ts b/src/article/command/application/update-article/dto/update-article.request.dto.ts deleted file mode 100644 index fa6415d..0000000 --- a/src/article/command/application/update-article/dto/update-article.request.dto.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsArray, IsOptional, IsString, Length } from 'class-validator'; - -export class UpdateArticleRequestDto { - @ApiProperty({ - description: '게시글 제목', - example: '2024 스타트업 컨퍼런스', - required: false, - }) - @IsString() - @IsOptional() - title?: string; - - @ApiProperty({ - description: '주최 기관/단체', - example: '한국스타트업협회', - required: false, - }) - @IsString() - @IsOptional() - organization?: string; - - @ApiProperty({ - description: '게시글 내용 설명', - example: '최신 기술 트렌드와 창업 노하우를 공유하는 컨퍼런스입니다.', - required: false, - }) - @IsString() - @Length(0) - @IsOptional() - description?: string; - - @ApiProperty({ - description: '행사 장소', - example: '서울 강남구 테헤란로 123', - required: false, - }) - @IsString() - @IsOptional() - location?: string; - - @ApiProperty({ - description: '행사 시작 일시 (ISO 8601 형식)', - example: '2024-03-15T10:00:00Z', - required: false, - }) - @IsString() - @IsOptional() - startAt?: string; - - @ApiProperty({ - description: '행사 종료 일시 (ISO 8601 형식)', - example: '2024-03-15T18:00:00Z', - required: false, - }) - @IsString() - @IsOptional() - endAt?: string; - - @ApiProperty({ - description: '등록/신청 URL', - example: 'https://example.com/register', - required: false, - }) - @IsString() - @Length(0) - @IsOptional() - registrationUrl?: string; - - @ApiProperty({ - description: '신청 시작 일시 (ISO 8601 형식)', - example: '2024-03-01T00:00:00Z', - required: false, - }) - @IsString() - @Length(0) - @IsOptional() - registrationStartAt?: string; - - @ApiProperty({ - description: '신청 종료 일시 (ISO 8601 형식)', - example: '2024-03-10T23:59:59Z', - required: false, - }) - @IsString() - @Length(0) - @IsOptional() - registrationEndAt?: string; - - @ApiProperty({ - description: '관련 태그 목록', - example: ['스타트업', '기술', '컨퍼런스'], - type: [String], - required: false, - }) - @IsArray() - @IsOptional() - @Type(() => String) - tags?: string[]; -} 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); From c5fccaff83af03f37808fd765af4399cbe6e9d67 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 13:56:24 +0900 Subject: [PATCH 04/21] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20roganizationId=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/delete-article/delete-article.comand.ts | 8 ++++++++ .../application/delete-article/delete-article.use-case.ts | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 src/article/command/application/delete-article/delete-article.comand.ts 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); } } From 28fcbe3993193373472c7378ac8015f35a891381 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 13:57:06 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20organization=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20article=20controller=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20guar?= =?UTF-8?q?d=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article.command.controller.ts | 52 +++++++-- .../presentation/article.command.docs.ts | 2 +- .../dto/update-article.request.dto.ts | 101 ++++++++++++++++++ 3 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/article/command/presentation/dto/update-article.request.dto.ts diff --git a/src/article/command/presentation/article.command.controller.ts b/src/article/command/presentation/article.command.controller.ts index 73ce4e9..e118094 100644 --- a/src/article/command/presentation/article.command.controller.ts +++ b/src/article/command/presentation/article.command.controller.ts @@ -1,12 +1,14 @@ -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 { User, UserPayload } from 'src/shared/core/presentation/user.decorator'; @ApiTags('article') @Controller('article') @@ -20,18 +22,56 @@ 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() + @ArticleCommandDocs('create') + @UseGuards(AuthGuard('jwt-access')) + async createArticle( + @User() user: UserPayload, + @Body() reqDto: CreateArticleRequestDto, + ): Promise { + return await this.createArticleUseCase.execute({ ...reqDto, organizationId: user.userId }); + } + + @Patch(':id') + @ArticleCommandDocs('update') + @UseGuards(AuthGuard('jwt-access')) + async updateArticle( + @User() user: UserPayload, + @Param('id') id: string, + @Body() reqDto: UpdateArticleRequestDto, + ): Promise { + return await this.updateArticleUseCase.execute({ ...reqDto, id, organizationId: user.userId }); + } + + @Delete(':id') + @ArticleCommandDocs('delete') + @UseGuards(AuthGuard('jwt-access')) + async delete(@User() user: UserPayload, @Param('id') id: string): Promise { + return await this.deleteArticleUseCase.execute({ id, organizationId: user.userId }); } } 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/presentation/dto/update-article.request.dto.ts b/src/article/command/presentation/dto/update-article.request.dto.ts new file mode 100644 index 0000000..fa6415d --- /dev/null +++ b/src/article/command/presentation/dto/update-article.request.dto.ts @@ -0,0 +1,101 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsOptional, IsString, Length } from 'class-validator'; + +export class UpdateArticleRequestDto { + @ApiProperty({ + description: '게시글 제목', + example: '2024 스타트업 컨퍼런스', + required: false, + }) + @IsString() + @IsOptional() + title?: string; + + @ApiProperty({ + description: '주최 기관/단체', + example: '한국스타트업협회', + required: false, + }) + @IsString() + @IsOptional() + organization?: string; + + @ApiProperty({ + description: '게시글 내용 설명', + example: '최신 기술 트렌드와 창업 노하우를 공유하는 컨퍼런스입니다.', + required: false, + }) + @IsString() + @Length(0) + @IsOptional() + description?: string; + + @ApiProperty({ + description: '행사 장소', + example: '서울 강남구 테헤란로 123', + required: false, + }) + @IsString() + @IsOptional() + location?: string; + + @ApiProperty({ + description: '행사 시작 일시 (ISO 8601 형식)', + example: '2024-03-15T10:00:00Z', + required: false, + }) + @IsString() + @IsOptional() + startAt?: string; + + @ApiProperty({ + description: '행사 종료 일시 (ISO 8601 형식)', + example: '2024-03-15T18:00:00Z', + required: false, + }) + @IsString() + @IsOptional() + endAt?: string; + + @ApiProperty({ + description: '등록/신청 URL', + example: 'https://example.com/register', + required: false, + }) + @IsString() + @Length(0) + @IsOptional() + registrationUrl?: string; + + @ApiProperty({ + description: '신청 시작 일시 (ISO 8601 형식)', + example: '2024-03-01T00:00:00Z', + required: false, + }) + @IsString() + @Length(0) + @IsOptional() + registrationStartAt?: string; + + @ApiProperty({ + description: '신청 종료 일시 (ISO 8601 형식)', + example: '2024-03-10T23:59:59Z', + required: false, + }) + @IsString() + @Length(0) + @IsOptional() + registrationEndAt?: string; + + @ApiProperty({ + description: '관련 태그 목록', + example: ['스타트업', '기술', '컨퍼런스'], + type: [String], + required: false, + }) + @IsArray() + @IsOptional() + @Type(() => String) + tags?: string[]; +} From 74dc9b11e43d544bef7788c40eb1da8b316d5bec Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 14:55:29 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20Role=20type=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/core/domain/value-object/role.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/auth/core/domain/value-object/role.ts 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', +} From f1c14cd3cb4c35bb17319604c20c805a57e0abf0 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 14:56:30 +0900 Subject: [PATCH 07/21] =?UTF-8?q?fix:=20JwtPayload=20=EB=B2=94=EC=9A=A9?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=B4=20userId=20->=20sub?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/core/infrastructure/jwt/jwt-access.strategy.ts | 7 ++----- src/auth/core/infrastructure/jwt/jwt-payload.ts | 7 +++++++ src/auth/core/infrastructure/jwt/jwt-refresh.strategy.ts | 8 ++------ src/shared/core/presentation/user.decorator.ts | 7 ++++--- 4 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 src/auth/core/infrastructure/jwt/jwt-payload.ts 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/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 }; }); From 4a878091b08cdef4d1eb6f16f533cee655e83b8c Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 14:58:02 +0900 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=8B=9C=20Role=EC=9D=84=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/login/login.use-case.ts | 9 +++++++-- .../renew-token/renew-token.use-case.ts | 9 +++++++-- .../application/oauth-login/oauth-login.handler.ts | 14 ++++++++++---- .../renew-token/renew-token.use-case.ts | 7 +++++-- src/auth/core/infrastructure/jwt/jwt.provider.ts | 6 ++++-- 5 files changed, 33 insertions(+), 12 deletions(-) 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-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/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 }; From c689d8d2acebfdbdb820cba4bec3f77493458efb Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 14:58:21 +0900 Subject: [PATCH 09/21] =?UTF-8?q?fix:=20=EC=9C=A0=EC=A0=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/analytics/application/auth-event.handler.ts | 1 - src/auth/auth-user/domain/event/auth-created.event.ts | 2 -- src/user/command/application/create/create-user.handler.ts | 5 +++-- src/user/command/application/create/create-user.listener.ts | 4 ++-- src/user/command/application/create/create.command.ts | 2 -- 5 files changed, 5 insertions(+), 9 deletions(-) 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/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/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, ) {} } From aecd570665f333ac6eaf5cf8313350023978681d Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 15:06:42 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20organization=20decorator=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article.command.controller.ts | 20 +++++++++---------- .../auth-organization.controller.ts | 12 +++++------ .../organization.command.controller.ts | 6 +++--- .../presentation/organization.decorator.ts | 15 ++++++++++++++ 4 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 src/shared/core/presentation/organization.decorator.ts diff --git a/src/article/command/presentation/article.command.controller.ts b/src/article/command/presentation/article.command.controller.ts index e118094..567ac18 100644 --- a/src/article/command/presentation/article.command.controller.ts +++ b/src/article/command/presentation/article.command.controller.ts @@ -8,7 +8,7 @@ import { UpdateArticleUseCase } from '../application/update-article/update-artic import { UpdateArticleRequestDto } from './dto/update-article.request.dto'; import { ArticleCommandDocs } from './article.command.docs'; import { AuthGuard } from '@nestjs/passport'; -import { User, UserPayload } from 'src/shared/core/presentation/user.decorator'; +import { Organization, OrganizationPayload } from 'src/shared/core/presentation/organization.decorator'; @ApiTags('article') @Controller('article') @@ -48,30 +48,30 @@ export class OrganizationArticleCommandController { ) {} @Post() - @ArticleCommandDocs('create') @UseGuards(AuthGuard('jwt-access')) + @ArticleCommandDocs('create') async createArticle( - @User() user: UserPayload, + @Organization() organization: OrganizationPayload, @Body() reqDto: CreateArticleRequestDto, ): Promise { - return await this.createArticleUseCase.execute({ ...reqDto, organizationId: user.userId }); + return await this.createArticleUseCase.execute({ ...reqDto, organizationId: organization.organizationId }); } @Patch(':id') - @ArticleCommandDocs('update') @UseGuards(AuthGuard('jwt-access')) + @ArticleCommandDocs('update') async updateArticle( - @User() user: UserPayload, + @Organization() organization: OrganizationPayload, @Param('id') id: string, @Body() reqDto: UpdateArticleRequestDto, ): Promise { - return await this.updateArticleUseCase.execute({ ...reqDto, id, organizationId: user.userId }); + return await this.updateArticleUseCase.execute({ ...reqDto, id, organizationId: organization.organizationId }); } @Delete(':id') - @ArticleCommandDocs('delete') @UseGuards(AuthGuard('jwt-access')) - async delete(@User() user: UserPayload, @Param('id') id: string): Promise { - return await this.deleteArticleUseCase.execute({ id, organizationId: user.userId }); + @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/auth/auth-organization/presentation/auth-organization.controller.ts b/src/auth/auth-organization/presentation/auth-organization.controller.ts index d394aff..20625a2 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,7 @@ 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'; @ApiTags('auth-organization') @Controller('auth/organization') @@ -62,10 +62,10 @@ export class AuthOrganizationController { @Post('refresh') @UseGuards(AuthGuard('jwt-refresh')) @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, }); @@ -78,8 +78,8 @@ export class AuthOrganizationController { @Post('logout') @UseGuards(AuthGuard('jwt-access')) @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); 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 }; +}); From 3b4388dc33995aeed3386ab8ebf48cfcde9df195 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 20:14:12 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20role=20decorator=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/core/presentation/role.decorator.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/shared/core/presentation/role.decorator.ts 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); From 577a3f5944585bda486bb12dece4dcea7cded912 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 20:15:08 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20role=20guard=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/infrastructure/guard/role.guard.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/auth/core/infrastructure/guard/role.guard.ts 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..71f81c1 --- /dev/null +++ b/src/auth/core/infrastructure/guard/role.guard.ts @@ -0,0 +1,24 @@ +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; + + console.log('Required Roles:', roles); + + return requiredRoles.some((role: Role) => roles.includes(role)); + } +} From 85110ef32ac71c8aa2452a62af54223bdf5a15ae Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 20:16:09 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20organization=20role=20guard=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article.command.controller.ts | 12 ++++++++--- .../auth-organization.controller.ts | 21 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/article/command/presentation/article.command.controller.ts b/src/article/command/presentation/article.command.controller.ts index 567ac18..c1ccd2e 100644 --- a/src/article/command/presentation/article.command.controller.ts +++ b/src/article/command/presentation/article.command.controller.ts @@ -9,6 +9,9 @@ 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') @@ -48,7 +51,8 @@ export class OrganizationArticleCommandController { ) {} @Post() - @UseGuards(AuthGuard('jwt-access')) + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) @ArticleCommandDocs('create') async createArticle( @Organization() organization: OrganizationPayload, @@ -58,7 +62,8 @@ export class OrganizationArticleCommandController { } @Patch(':id') - @UseGuards(AuthGuard('jwt-access')) + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) @ArticleCommandDocs('update') async updateArticle( @Organization() organization: OrganizationPayload, @@ -69,7 +74,8 @@ export class OrganizationArticleCommandController { } @Delete(':id') - @UseGuards(AuthGuard('jwt-access')) + @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/auth/auth-organization/presentation/auth-organization.controller.ts b/src/auth/auth-organization/presentation/auth-organization.controller.ts index 20625a2..43e688f 100644 --- a/src/auth/auth-organization/presentation/auth-organization.controller.ts +++ b/src/auth/auth-organization/presentation/auth-organization.controller.ts @@ -14,6 +14,9 @@ import { CheckAccountIdRequestDto } from './dto/request/check-account-id.request 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,14 +56,15 @@ 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(@Organization() organization: OrganizationPayload, @Res() res: Response) { const { organizationId, jti } = organization; @@ -69,20 +73,21 @@ export class AuthOrganizationController { 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(@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(); } From ef09a4cef5308985d441da1c01d02d82df974515 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sat, 15 Nov 2025 20:39:44 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20user=20role=20guard=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth-user/presentation/auth-user.controller.ts | 12 +++++++++--- .../command/presentation/user.command.controller.ts | 6 +++++- src/user/query/presentation/user.query.controller.ts | 6 +++++- 3 files changed, 19 insertions(+), 5 deletions(-) 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/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 }); From 6f247accae69d369661a7260e2285a9dcc9f3512 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sun, 16 Nov 2025 00:07:33 +0900 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20article=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organization/domain/article.model.ts | 77 +++++++++++++++++++ .../infrastructure/article.view.entity.ts | 72 +++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/article/query/organization/domain/article.model.ts create mode 100644 src/article/query/organization/infrastructure/article.view.entity.ts 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..9a4caf2 --- /dev/null +++ b/src/article/query/organization/domain/article.model.ts @@ -0,0 +1,77 @@ +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; +} 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..6141b29 --- /dev/null +++ b/src/article/query/organization/infrastructure/article.view.entity.ts @@ -0,0 +1,72 @@ +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 + 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() + userId: 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; +} From 80e86ac6eb8096a89e677d2569810f871701277d Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sun, 16 Nov 2025 00:08:13 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20organization=20article=20reposito?= =?UTF-8?q?ry=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organization/domain/article.reader.ts | 7 +++++++ .../infrastructure/article.reader.impl.ts | 19 ++++++++++++++++++ .../infrastructure/article.view.mapper.ts | 20 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 src/article/query/organization/domain/article.reader.ts create mode 100644 src/article/query/organization/infrastructure/article.reader.impl.ts create mode 100644 src/article/query/organization/infrastructure/article.view.mapper.ts 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..3adef6b --- /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 }); + + return articles.map((article) => ArticleViewMapper.toModel(article)); + } +} 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..3335c65 --- /dev/null +++ b/src/article/query/organization/infrastructure/article.view.mapper.ts @@ -0,0 +1,20 @@ +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(); + + return model; + } +} From c4e656007c386bbbabe7854f4f902df94698f15d Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sun, 16 Nov 2025 00:08:41 +0900 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20=EA=B8=B0=EA=B4=80=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article-list/get-article-list.query.ts | 3 +++ .../article-list/get-article-list.use-case.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/article/query/organization/application/article-list/get-article-list.query.ts create mode 100644 src/article/query/organization/application/article-list/get-article-list.use-case.ts 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); + } +} From 2fece12984c52381d78cddfc23e4b6f4f6dc4e8b Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sun, 16 Nov 2025 00:09:06 +0900 Subject: [PATCH 18/21] =?UTF-8?q?feat:=20=EA=B8=B0=EA=B4=80=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/article/command/article.command.module.ts | 7 ++++-- src/article/query/article.query.module.ts | 20 ++++++++++++++--- .../presentation/article.view.controller.ts | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 src/article/query/organization/presentation/article.view.controller.ts 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/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/presentation/article.view.controller.ts b/src/article/query/organization/presentation/article.view.controller.ts new file mode 100644 index 0000000..8daabec --- /dev/null +++ b/src/article/query/organization/presentation/article.view.controller.ts @@ -0,0 +1,22 @@ +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'; + +@ApiTags('organization-article') +@Controller('organization/article') +export class ArticleOrganizationViewController { + constructor(private readonly getOrganizationArticleListUseCase: GetOrganizationArticleListUseCase) {} + + @Get() + @UseGuards(AuthGuard('jwt-access'), RolesGuard) + @Roles(Role.ORGANIZATION) + async getArticleList(@Organization() organization: OrganizationPayload): Promise { + return await this.getOrganizationArticleListUseCase.execute({ organizationId: organization.organizationId }); + } +} From c40f47411ec03d2323f6fe079500facf4eaf04e7 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sun, 16 Nov 2025 00:23:11 +0900 Subject: [PATCH 19/21] =?UTF-8?q?docs:=20organization=20article=20swagger?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/article.view.controller.ts | 2 ++ .../presentation/article.view.docs.ts | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/article/query/organization/presentation/article.view.docs.ts diff --git a/src/article/query/organization/presentation/article.view.controller.ts b/src/article/query/organization/presentation/article.view.controller.ts index 8daabec..c71a605 100644 --- a/src/article/query/organization/presentation/article.view.controller.ts +++ b/src/article/query/organization/presentation/article.view.controller.ts @@ -7,6 +7,7 @@ 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') @@ -16,6 +17,7 @@ export class ArticleOrganizationViewController { @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', + }), + ), +}); From 50480625486d19fbbe1cf1c869f77db9c2e15578 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sun, 16 Nov 2025 14:13:02 +0900 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20=EA=B8=B0=EA=B4=80=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C,=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=EC=9E=91/=EB=A7=88?= =?UTF-8?q?=EA=B0=90=20=EC=9D=BC=EC=8B=9C=20=ED=91=9C=EC=8B=9C=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/organization/infrastructure/article.reader.impl.ts | 2 +- .../query/organization/infrastructure/article.view.entity.ts | 4 ++-- .../query/organization/infrastructure/article.view.mapper.ts | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/article/query/organization/infrastructure/article.reader.impl.ts b/src/article/query/organization/infrastructure/article.reader.impl.ts index 3adef6b..f9fd33a 100644 --- a/src/article/query/organization/infrastructure/article.reader.impl.ts +++ b/src/article/query/organization/infrastructure/article.reader.impl.ts @@ -12,7 +12,7 @@ export class ArticleReaderImpl implements ArticleReader { ) {} async findAllByOrganizationId(organizationId: string): Promise { - const articles = await this.ormRepository.find({ organizationId }); + 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 index 6141b29..b14a184 100644 --- a/src/article/query/organization/infrastructure/article.view.entity.ts +++ b/src/article/query/organization/infrastructure/article.view.entity.ts @@ -65,8 +65,8 @@ export class ArticleViewEntity { tags: string; @Property() - registrationStartAt: Date; + registrationStartAt?: Date; @Property() - registrationEndAt: Date; + registrationEndAt?: Date; } diff --git a/src/article/query/organization/infrastructure/article.view.mapper.ts b/src/article/query/organization/infrastructure/article.view.mapper.ts index 3335c65..401e10e 100644 --- a/src/article/query/organization/infrastructure/article.view.mapper.ts +++ b/src/article/query/organization/infrastructure/article.view.mapper.ts @@ -14,6 +14,8 @@ export class ArticleViewMapper { 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; return model; } From 551fcbc27b533a27e195955cbd35fa4ba0c4b293 Mon Sep 17 00:00:00 2001 From: liverforpresent Date: Sun, 16 Nov 2025 14:35:31 +0900 Subject: [PATCH 21/21] =?UTF-8?q?fix:=20copilot=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/article/query/organization/domain/article.model.ts | 6 ++++++ .../organization/infrastructure/article.view.entity.ts | 6 ++---- .../organization/infrastructure/article.view.mapper.ts | 1 + src/auth/core/infrastructure/guard/role.guard.ts | 2 -- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/article/query/organization/domain/article.model.ts b/src/article/query/organization/domain/article.model.ts index 9a4caf2..6564b7c 100644 --- a/src/article/query/organization/domain/article.model.ts +++ b/src/article/query/organization/domain/article.model.ts @@ -74,4 +74,10 @@ export class ArticleModel { required: false, }) registrationEndAt?: string; + + @ApiProperty({ + example: '2023-08-01T12:00:00Z', + description: '게시글 생성 시간', + }) + createdAt: string; } diff --git a/src/article/query/organization/infrastructure/article.view.entity.ts b/src/article/query/organization/infrastructure/article.view.entity.ts index b14a184..7f949d0 100644 --- a/src/article/query/organization/infrastructure/article.view.entity.ts +++ b/src/article/query/organization/infrastructure/article.view.entity.ts @@ -14,7 +14,8 @@ import { Entity, Property } from '@mikro-orm/core'; a.end_at, tags.tags, a.registration_start_at, - a.registration_end_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 ( @@ -34,9 +35,6 @@ export class ArticleViewEntity { @Property() organizationId: string; - @Property() - userId: string; - @Property() title: string; diff --git a/src/article/query/organization/infrastructure/article.view.mapper.ts b/src/article/query/organization/infrastructure/article.view.mapper.ts index 401e10e..50481fc 100644 --- a/src/article/query/organization/infrastructure/article.view.mapper.ts +++ b/src/article/query/organization/infrastructure/article.view.mapper.ts @@ -16,6 +16,7 @@ export class ArticleViewMapper { 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/auth/core/infrastructure/guard/role.guard.ts b/src/auth/core/infrastructure/guard/role.guard.ts index 71f81c1..019ec59 100644 --- a/src/auth/core/infrastructure/guard/role.guard.ts +++ b/src/auth/core/infrastructure/guard/role.guard.ts @@ -17,8 +17,6 @@ export class RolesGuard implements CanActivate { const request = ctx.switchToHttp().getRequest(); const { roles } = request.user as JwtPayload; - console.log('Required Roles:', roles); - return requiredRoles.some((role: Role) => roles.includes(role)); } }