Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
72730ab
feat: 마크다운 초안
davidpro08 Jan 6, 2026
50aa246
feat: 위젯 타입으로 위젯을 찾도록 메서드 추가
davidpro08 Jan 6, 2026
1572e9d
feat: 마크다운 제공하는 함수 만들어놓기
davidpro08 Jan 6, 2026
4a61bd2
test: 테스트 코드 추가
davidpro08 Jan 6, 2026
8f0d774
Merge branch 'main' into feat/62-export-markdown
davidpro08 Jan 7, 2026
110f09c
Merge branch 'main' into feat/62-export-markdown
davidpro08 Jan 8, 2026
cbd57d6
chore: axios 사용
davidpro08 Jan 8, 2026
ed6b588
feat: axios 사용 시 기본적인 에러 처리 하기
davidpro08 Jan 8, 2026
8469645
feat: 기존 마크다운 관련 isLoading 분리 + axios로 데이터 가져오는 훅 만들기
davidpro08 Jan 8, 2026
8a7b1bd
fix: 'api' 넣어서 백엔드 요청 명시
davidpro08 Jan 8, 2026
38d188a
feat: API 요청 보내고 받은 값 사용하기
davidpro08 Jan 8, 2026
aa5c123
feat: 적은 내용 없으면 적은 내용이 없다고 알려주기
davidpro08 Jan 8, 2026
33f7771
test: 테스트 수정
davidpro08 Jan 8, 2026
05c0642
Merge branch 'main' into feat/62-export-markdown
davidpro08 Jan 8, 2026
3bd62a9
chore: 병합 오류 해결
davidpro08 Jan 8, 2026
d0fad81
feat: async api 어색하거나 틀린 설명 고치기
davidpro08 Jan 8, 2026
6c8510c
refactor: api 요청 보낼 때 오류 문구 지우기
davidpro08 Jan 8, 2026
bcdec34
feat: 안 쓰는 상태 수정
davidpro08 Jan 8, 2026
7c7f37f
Merge branch 'main' into feat/62-export-markdown
davidpro08 Jan 8, 2026
3829b14
Merge branch 'main' into feat/62-export-markdown
davidpro08 Jan 8, 2026
5fb2d54
Revert "feat: 위젯 동시성 제어(비관적 락) 기능 구현 (#73)"
davidpro08 Jan 8, 2026
48ffb28
Revert "Merge branch 'main' into feat/62-export-markdown"
davidpro08 Jan 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { AppService } from './app.service';
import { WorkspaceModule } from './workspace/workspace.module';
import { WidgetModule } from './widget/widget.module';
import { CursorModule } from './cursor/cursor.module';
import { MarkdownModule } from './markdown/markdown.module';

@Module({
imports: [WorkspaceModule, WidgetModule, CursorModule],
imports: [WorkspaceModule, WidgetModule, CursorModule, MarkdownModule],
controllers: [AppController],
providers: [AppService],
})
Expand Down
37 changes: 37 additions & 0 deletions backend/src/markdown/__test__/markdown.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MarkdownController } from '../markdown.controller';
import { MarkdownService } from '../markdown.service';

describe('MarkdownController', () => {
let controller: MarkdownController;
const markdownServiceMock = {
generateMarkdown: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MarkdownController],
providers: [
{
provide: MarkdownService,
useValue: markdownServiceMock,
},
],
}).compile();

controller = module.get<MarkdownController>(MarkdownController);
});

it('컨트롤러가 정의되어 있어야 한다', () => {
expect(controller).toBeDefined();
});

it('generateMarkdown 결과를 반환한다', async () => {
markdownServiceMock.generateMarkdown.mockResolvedValue('# md');

const result = await controller.find('w1');

expect(markdownServiceMock.generateMarkdown).toHaveBeenCalledWith('w1');
expect(result).toEqual({ markdown: '# md' });
});
});
101 changes: 101 additions & 0 deletions backend/src/markdown/__test__/markdown.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Test, TestingModule } from '@nestjs/testing';

import { MarkdownService } from '../markdown.service';
import { IWidgetService, WIDGET_SERVICE } from '../../widget/widget.interface';
import { WidgetType } from '../../widget/dto/widget-content.dto';

type MockWidgetService = {
[P in keyof IWidgetService]: jest.Mock;
};

describe('MarkdownService', () => {
let service: MarkdownService;
let widgetServiceMock: MockWidgetService;
const workspaceId = 'w1';

beforeEach(async () => {
widgetServiceMock = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
findOneByWidgetType: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
updateLayout: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
MarkdownService,
{
provide: WIDGET_SERVICE,
useValue: widgetServiceMock,
},
],
}).compile();

service = module.get<MarkdownService>(MarkdownService);
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T12:00:00Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('모든 위젯이 없으면 헤더, 푸터, 안내 문구를 반환한다.', async () => {
widgetServiceMock.findOneByWidgetType.mockResolvedValue(null);

const markdown = await service.generateMarkdown(workspaceId);

expect(markdown).toContain('# 🚀 Project Team Align Report');
expect(markdown).toContain(
'아직 적은 내용이 없는 것 같습니다! 위젯에 내용을 추가해보세요! 🚀',
);
expect(markdown).toContain('*Generated by TeamConfig*');
});

it('각 위젯을 마크다운 섹션으로 변환한다', async () => {
widgetServiceMock.findOneByWidgetType
.mockImplementationOnce(() => ({
data: {
content: {
widgetType: WidgetType.GROUND_RULE,
rules: ['Folder', 'Commit'],
},
},
}))
.mockImplementationOnce(() => ({
data: {
content: {
widgetType: WidgetType.TECH_STACK,
selectedItems: ['React', 'NestJS'],
},
},
}))
.mockImplementationOnce(() => ({
data: {
content: {
widgetType: WidgetType.POST_IT,
text: '기타 메모',
},
},
}));

const markdown = await service.generateMarkdown(workspaceId);
const lines = markdown.split('\n');

// Ground Rule 섹션
expect(lines).toContain('## 1. 📋 Ground Rule');
expect(lines).toContain('| Folder | - |');
expect(lines).toContain('| Commit | - |');

// Tech Stack 섹션
expect(lines).toContain('## 2. 🛠 Tech Stack Selection');
expect(lines).toContain('| React | vLatest |');
expect(lines).toContain('| NestJS | vLatest |');

// Else 섹션 (Post-it)
expect(lines).toContain('## 3. Else');
expect(lines).toContain('기타 메모');
});
});
8 changes: 8 additions & 0 deletions backend/src/markdown/dto/get-markdown.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';

export class GetMarkdownDto {
@ApiProperty({ description: 'Markdown 내용', example: '# Hello, World!' })
@IsString()
readonly markdown: string;
}
28 changes: 28 additions & 0 deletions backend/src/markdown/markdown.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiQuery, ApiResponse } from '@nestjs/swagger';
import { MarkdownService } from './markdown.service';
import { GetMarkdownDto } from './dto/get-markdown.dto';

@Controller('markdown')
export class MarkdownController {
constructor(private readonly markdownService: MarkdownService) {}

@Get()
@ApiQuery({
name: 'workspaceId',
required: true,
description: '워크스페이스 ID',
example: 'w1',
})
@ApiResponse({
status: 200,
description: '생성된 마크다운 문서',
type: GetMarkdownDto,
})
async find(
@Query('workspaceId') workspaceId: string,
): Promise<GetMarkdownDto> {
const markdown = await this.markdownService.generateMarkdown(workspaceId);
return { markdown };
}
}
11 changes: 11 additions & 0 deletions backend/src/markdown/markdown.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MarkdownController } from './markdown.controller';
import { MarkdownService } from './markdown.service';
import { WidgetModule } from '../widget/widget.module';

@Module({
imports: [WidgetModule],
controllers: [MarkdownController],
providers: [MarkdownService],
})
export class MarkdownModule {}
124 changes: 124 additions & 0 deletions backend/src/markdown/markdown.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Injectable, Inject } from '@nestjs/common';
import type { IWidgetService } from '../widget/widget.interface';
import { WIDGET_SERVICE } from '../widget/widget.interface';
import {
WidgetType,
GroundRuleContentDto,
TechStackContentDto,
} from '../widget/dto/widget-content.dto';
import { CreateWidgetDto } from '../widget/dto/create-widget.dto';

@Injectable()
export class MarkdownService {
constructor(
@Inject(WIDGET_SERVICE) private readonly widgetService: IWidgetService,
) {}

private buildGroundRuleSection(widget: CreateWidgetDto | null): string[] {
if (!widget) return [];

const lines: string[] = [];
const content = widget.data.content as GroundRuleContentDto;

lines.push('## 1. 📋 Ground Rule');
lines.push('| Ground Rule| Value| ');
lines.push('| :--- | :--- | ');

if (content.rules && content.rules.length > 0) {
content.rules.forEach((rule) => {
lines.push(`| ${rule} | - |`);
});
}

lines.push('');
return lines;
}

private buildTechStackSection(widget: CreateWidgetDto | null): string[] {
if (!widget) return [];

const lines: string[] = [];
const content = widget.data.content as TechStackContentDto;

lines.push('## 2. 🛠 Tech Stack Selection');
lines.push('| Tech Name | Version |');
lines.push('| :--- | :--- |');

if (content.selectedItems && content.selectedItems.length > 0) {
content.selectedItems.forEach((item) => {
lines.push(`| ${item} | vLatest |`);
});
}

lines.push('');
return lines;
}

private buildElseSection(widget: CreateWidgetDto | null): string[] {
if (!widget) return [];

const lines: string[] = [];

lines.push('## 3. Else');
lines.push('---');

if (widget) {
const content = widget.data.content as { text?: string };
if (content.text) {
lines.push(content.text);
}
}

lines.push('');
return lines;
}

async generateMarkdown(workspaceId: string): Promise<string> {
const now = new Date();
const formattedDate = now.toLocaleString('ko-KR', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true,
});

const markdownParts: string[] = [];

markdownParts.push('# 🚀 Project Team Align Report');
markdownParts.push(`> Created at: ${formattedDate}`);
markdownParts.push('');

const groundRuleWidget = await this.widgetService.findOneByWidgetType(
workspaceId,
WidgetType.GROUND_RULE,
);
markdownParts.push(...this.buildGroundRuleSection(groundRuleWidget));

const techStackWidget = await this.widgetService.findOneByWidgetType(
workspaceId,
WidgetType.TECH_STACK,
);
markdownParts.push(...this.buildTechStackSection(techStackWidget));

const postItWidget = await this.widgetService.findOneByWidgetType(
workspaceId,
WidgetType.POST_IT,
);

markdownParts.push(...this.buildElseSection(postItWidget));

if (!groundRuleWidget && !techStackWidget && !postItWidget) {
markdownParts.push(
'아직 적은 내용이 없는 것 같습니다! 위젯에 내용을 추가해보세요! 🚀',
);
markdownParts.push('');
}

markdownParts.push('*Generated by TeamConfig*');

return markdownParts.join('\n');
}
}
Loading
Loading