refactor: 프로젝트에서 공개 범위(visibility) 제거

불필요한 프로젝트 공개 범위 데이터를 백엔드·프론트에서 일괄 제거(저장소 Repo 의 visibility 는 Gitea 연동용이라 유지).

백엔드:
- Project 엔티티 visibility 컬럼·ProjectVisibility 타입 제거(dev synchronize 가 컬럼 DROP)
- create/update DTO, ProjectResponse, create/update/ensureUnassigned/toResponse 에서 제거
- ai.service 프로젝트 컨텍스트 문자열에서 공개범위 표기 제거
- activity 타입 project.visibility_changed 제거

프론트:
- types/project·project.store·mock Project 에서 visibility 제거
- ProjectCreatePage·ProjectSettingsPage 공개 범위 입력 폼+CSS 제거
- ProjectListPage 공개여부 세그먼트 필터+배지 제거, ProjectHeader 배지 제거
- activity 타입/스토어 project.visibility_changed 케이스 제거

런타임 검증: DB projects.visibility 컬럼 DROP, 생성/목록 응답에 visibility 없음, CRUD 정상.
백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 21:39:48 +09:00
parent 9fdcecace8
commit 58c0b9510d
15 changed files with 10 additions and 408 deletions
@@ -16,7 +16,6 @@ export type ActivityType =
// 프로젝트(설정)
| 'project.created'
| 'project.renamed'
| 'project.visibility_changed'
// 저장소 연동
| 'repo.linked'
| 'repo.unlinked'
+1 -4
View File
@@ -395,10 +395,7 @@ export class AiService {
lines.push('- (없음)');
} else {
for (const p of projects.items) {
const vis = p.visibility === 'private' ? '비공개' : '공개';
lines.push(
`- ${p.name}${vis}, 업무 ${p.doneCount}/${p.totalCount}`,
);
lines.push(`- ${p.name} — 업무 ${p.doneCount}/${p.totalCount}`);
}
if (projects.total > projects.items.length) {
lines.push(`- … 외 ${projects.total - projects.items.length}`);
@@ -1,12 +1,5 @@
import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { ProjectVisibility } from '../entities/project.entity';
// 프로젝트 생성 DTO
export class CreateProjectDto {
@@ -24,13 +17,4 @@ export class CreateProjectDto {
@IsString()
@MaxLength(300)
description?: string;
@ApiProperty({
description: '공개 범위',
enum: ['private', 'public'],
required: false,
})
@IsOptional()
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
visibility?: ProjectVisibility;
}
@@ -1,6 +1,5 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { ProjectVisibility } from '../entities/project.entity';
// 프로젝트 수정 DTO — 제공된 필드만 갱신
export class UpdateProjectDto {
@@ -15,13 +14,4 @@ export class UpdateProjectDto {
@IsString()
@MaxLength(300)
description?: string;
@ApiProperty({
description: '공개 범위',
enum: ['private', 'public'],
required: false,
})
@IsOptional()
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
visibility?: ProjectVisibility;
}
@@ -9,9 +9,6 @@ import {
} from 'typeorm';
import { ProjectMember } from './project-member.entity';
// 프로젝트 공개 범위
export type ProjectVisibility = 'private' | 'public';
// 프로젝트 엔티티 — 작업의 기본 단위(우선). 저장소(Repo)는 프로젝트에 0~N개 연동된다.
@Entity('projects')
export class Project {
@@ -27,10 +24,6 @@ export class Project {
@Column({ type: 'varchar', nullable: true })
description!: string | null;
// 공개 범위
@Column({ type: 'varchar', default: 'private' })
visibility!: ProjectVisibility;
// 시스템 예약 프로젝트 여부 — "미분류"(Gitea import 기본 버킷)는 true. 삭제/이름변경 보호.
@Column({ name: 'is_system', default: false })
isSystem!: boolean;
@@ -13,7 +13,7 @@ import {
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { Project, type ProjectVisibility } from './entities/project.entity';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { Repo } from '../repo/entities/repo.entity';
import { Task } from '../task/entities/task.entity';
@@ -28,7 +28,6 @@ export interface ProjectResponse {
id: string;
name: string;
description: string | null;
visibility: ProjectVisibility;
isSystem: boolean;
members: PublicUser[];
memberCount: number;
@@ -120,7 +119,6 @@ export class ProjectService implements OnApplicationBootstrap {
this.projectRepo.create({
name: dto.name,
description: dto.description ?? null,
visibility: dto.visibility ?? 'private',
isSystem: false,
}),
);
@@ -155,7 +153,6 @@ export class ProjectService implements OnApplicationBootstrap {
}
if (dto.name !== undefined) project.name = dto.name;
if (dto.description !== undefined) project.description = dto.description;
if (dto.visibility !== undefined) project.visibility = dto.visibility;
await this.projectRepo.save(project);
return this.findOne(project.id, userId);
}
@@ -187,7 +184,6 @@ export class ProjectService implements OnApplicationBootstrap {
this.projectRepo.create({
name: UNASSIGNED_PROJECT_NAME,
description: 'Gitea 에서 가져온, 아직 프로젝트에 배정되지 않은 저장소',
visibility: 'private',
isSystem: true,
}),
);
@@ -275,7 +271,6 @@ export class ProjectService implements OnApplicationBootstrap {
id: project.id,
name: project.name,
description: project.description,
visibility: project.visibility,
isSystem: project.isSystem,
members: members
.filter((m) => m.user)
-34
View File
@@ -37,40 +37,6 @@ const extra = computed(() => props.moreCount ?? props.repo.moreCount)
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">{{ repo.name }}</span>
<span
class="vis"
:class="repo.visibility"
>
<svg
v-if="repo.visibility === 'private'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
>
<circle
cx="12"
cy="12"
r="10"
/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" />
</svg>
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
</span>
</div>
<div class="rh-desc">
{{ repo.desc }}
-1
View File
@@ -77,7 +77,6 @@ export interface Project {
id: string
name: string
desc: string
visibility: Visibility
/** "미분류" 시스템 프로젝트 */
isSystem: boolean
members: User[]
+1 -99
View File
@@ -1,17 +1,15 @@
<script setup lang="ts">
// 새 프로젝트 만들기 — 표시 제목/설명/공개 범위
// 새 프로젝트 만들기 — 표시 제목/설명
import { ref } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { useProjectStore } from '@/stores/project.store'
import type { Visibility } from '@/mock/relay.mock'
const router = useRouter()
const projectStore = useProjectStore()
const name = ref('')
const description = ref('')
const visibility = ref<Visibility>('private')
const submitting = ref(false)
// 생성 — 성공 시 새 프로젝트 상세로 이동
@@ -26,7 +24,6 @@ async function createProject() {
const project = await projectStore.create({
name: name.value.trim(),
description: description.value.trim() || undefined,
visibility: visibility.value,
})
await router.push(`/projects/${project.id}`)
} catch {
@@ -79,39 +76,6 @@ async function createProject() {
>
</div>
<!-- 공개 범위 -->
<div class="fblock">
<div class="flabel">
<span class="req">*</span> 공개 범위
</div>
<div class="radio-group">
<button
class="radio-card"
:class="{ sel: visibility === 'private' }"
type="button"
@click="visibility = 'private'"
>
<span class="rc-body">
<span class="rc-title">비공개</span>
<span class="rc-desc">초대된 멤버만 접근하고 업무를 확인할 있습니다.</span>
</span>
<span class="rc-radio" />
</button>
<button
class="radio-card"
:class="{ sel: visibility === 'public' }"
type="button"
@click="visibility = 'public'"
>
<span class="rc-body">
<span class="rc-title">공개</span>
<span class="rc-desc">조직 모든 구성원이 프로젝트를 보고 업무 현황을 열람할 있습니다.</span>
</span>
<span class="rc-radio" />
</button>
</div>
</div>
<!-- 설명 -->
<div class="fblock">
<div class="flabel">
@@ -234,68 +198,6 @@ textarea.tarea {
resize: vertical;
height: auto;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.radio-card {
display: flex;
align-items: flex-start;
gap: 0.8125rem;
padding: 0.8125rem 0.875rem;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
cursor: pointer;
background: #fff;
font-family: inherit;
text-align: left;
width: 100%;
}
.radio-card:hover {
border-color: var(--accent-border);
}
.radio-card.sel {
border-color: var(--accent);
background: var(--accent-weak);
}
.rc-body {
flex: 1;
display: flex;
flex-direction: column;
}
.rc-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
}
.rc-desc {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.125rem;
line-height: 1.45;
}
.rc-radio {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
flex-shrink: 0;
margin-top: 0.125rem;
display: grid;
place-items: center;
background: #fff;
}
.radio-card.sel .rc-radio {
border-color: var(--accent);
}
.radio-card.sel .rc-radio::after {
content: '';
width: 0.5625rem;
height: 0.5625rem;
border-radius: 50%;
background: var(--accent);
}
.form-footer {
display: flex;
align-items: center;
+4 -68
View File
@@ -6,12 +6,8 @@ import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useProjectStore } from '@/stores/project.store'
import type { Visibility } from '@/mock/relay.mock'
type Filter = 'all' | Visibility
const keyword = ref('')
const filter = ref<Filter>('all')
const projectStore = useProjectStore()
const { projects, loading, total, hasMore, loadingMore } = storeToRefs(projectStore)
@@ -20,15 +16,11 @@ onMounted(() => {
void projectStore.fetchList()
})
// 검색어 + 공개여부 필터 적용
// 검색어 필터 적용
const filteredRepos = computed(() =>
projects.value.filter((project) => {
const matchKeyword =
!keyword.value || project.name.includes(keyword.value)
const matchFilter =
filter.value === 'all' || project.visibility === filter.value
return matchKeyword && matchFilter
}),
projects.value.filter(
(project) => !keyword.value || project.name.includes(keyword.value),
),
)
</script>
@@ -69,26 +61,6 @@ const filteredRepos = computed(() =>
placeholder="프로젝트 검색…"
>
</div>
<div class="segmented">
<button
:class="{ active: filter === 'all' }"
@click="filter = 'all'"
>
전체
</button>
<button
:class="{ active: filter === 'private' }"
@click="filter = 'private'"
>
비공개
</button>
<button
:class="{ active: filter === 'public' }"
@click="filter = 'public'"
>
공개
</button>
</div>
<div class="sort">
<svg
viewBox="0 0 24 24"
@@ -151,42 +123,6 @@ const filteredRepos = computed(() =>
v-if="repo.isSystem"
class="repo-slug"
>시스템</span>
<span
class="vis"
:class="repo.visibility"
>
<svg
v-if="repo.visibility === 'private'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
>
<circle
cx="12"
cy="12"
r="10"
/>
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" />
</svg>
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
</span>
</div>
<div class="repo-desc">
{{ repo.desc }}
@@ -6,7 +6,7 @@ import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import { useProjectStore } from '@/stores/project.store'
import { type Project, type Visibility } from '@/mock/relay.mock'
import { type Project } from '@/mock/relay.mock'
const route = useRoute()
const router = useRouter()
@@ -26,7 +26,6 @@ const memberCount = computed(() =>
// 폼 상태(초기값은 프로젝트 데이터)
const displayTitle = ref('')
const description = ref('')
const visibility = ref<Visibility>('private')
const saving = ref(false)
// 프로젝트 로드 + 폼 초기화
@@ -45,7 +44,6 @@ async function loadRepo() {
}
displayTitle.value = repo.value.name
description.value = repo.value.desc
visibility.value = repo.value.visibility
}
watch(projectId, loadRepo, { immediate: true })
@@ -57,7 +55,6 @@ async function save() {
repo.value = await projectStore.update(projectId.value, {
name: displayTitle.value.trim(),
description: description.value.trim(),
visibility: visibility.value,
})
window.alert('변경 사항을 저장했습니다.')
} catch {
@@ -170,67 +167,6 @@ async function removeRepo() {
/>
</div>
<div class="fblock">
<div class="flabel">
공개 범위
</div>
<div class="fhint">
프로젝트와 안의 업무를 누가 있는지 결정합니다.
</div>
<div class="radio-group">
<button
class="radio-card"
:class="{ sel: visibility === 'private' }"
type="button"
@click="visibility = 'private'"
>
<span class="rc-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
</span>
<span class="rc-body">
<span class="rc-title">비공개</span>
<span class="rc-desc">프로젝트에 초대된 멤버만 접근하고 업무를 확인할 있습니다.</span>
</span>
<span class="rc-radio" />
</button>
<button
class="radio-card"
:class="{ sel: visibility === 'public' }"
type="button"
@click="visibility = 'public'"
>
<span class="rc-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle
cx="12"
cy="12"
r="10"
/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" /></svg>
</span>
<span class="rc-body">
<span class="rc-title">공개</span>
<span class="rc-desc">조직 모든 구성원이 프로젝트를 보고 업무 현황을 열람할 있습니다.</span>
</span>
<span class="rc-radio" />
</button>
</div>
</div>
<div class="card-foot">
<button
class="btn primary"
@@ -421,88 +357,6 @@ textarea.tarea {
height: 0.875rem;
}
/* 공개 범위 라디오 카드 */
.radio-group {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.radio-card {
display: flex;
align-items: flex-start;
gap: 0.8125rem;
padding: 0.8125rem 0.875rem;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
cursor: pointer;
background: #fff;
font-family: inherit;
text-align: left;
width: 100%;
}
.radio-card:hover {
border-color: var(--accent-border);
}
.radio-card.sel {
border-color: var(--accent);
background: var(--accent-weak);
}
.rc-ico {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: #f1f2f4;
color: var(--text-2);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rc-ico svg {
width: 1.0625rem;
height: 1.0625rem;
}
.radio-card.sel .rc-ico {
background: #fff;
color: var(--accent);
}
.rc-body {
flex: 1;
display: flex;
flex-direction: column;
}
.rc-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
}
.rc-desc {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.125rem;
line-height: 1.45;
}
.rc-radio {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
flex-shrink: 0;
margin-top: 0.4375rem;
display: grid;
place-items: center;
background: #fff;
}
.radio-card.sel .rc-radio {
border-color: var(--accent);
}
.radio-card.sel .rc-radio::after {
content: '';
width: 0.5625rem;
height: 0.5625rem;
border-radius: 50%;
background: var(--accent);
}
.input-ico {
display: flex;
align-items: center;
-7
View File
@@ -45,13 +45,6 @@ function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
icon: 'pencil',
html: `<b>${actor}</b>님이 표시 제목을 ${escapeHtml(pstr(p, 'newName'))}(으)로 변경했습니다`,
}
case 'project.visibility_changed':
return {
...base,
tone: 'setting',
icon: 'lock',
html: `<b>${actor}</b>님이 프로젝트 공개 범위를 <b>${pstr(p, 'visibility') === 'private' ? '비공개' : '공개'}</b>(으)로 변경했습니다`,
}
case 'repo.linked':
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소 <b>${escapeHtml(pstr(p, 'repoName'))}</b>을(를) 연동했습니다` }
case 'repo.unlinked':
-1
View File
@@ -31,7 +31,6 @@ function toProjectView(api: ApiProject): ProjectView {
id: api.id,
name: api.name,
desc: api.description ?? '',
visibility: api.visibility,
isSystem: api.isSystem,
members: shown,
moreCount: Math.max(0, api.memberCount - shown.length),
-1
View File
@@ -6,7 +6,6 @@ import type { ApiMember } from '@/types/repo'
export type ApiActivityType =
| 'project.created'
| 'project.renamed'
| 'project.visibility_changed'
| 'repo.linked'
| 'repo.unlinked'
| 'member.invited'
-4
View File
@@ -1,6 +1,5 @@
// 프로젝트 도메인 타입 — 백엔드 ProjectService 응답 / DTO 와 1:1
import type { Visibility } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
// 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1
@@ -8,7 +7,6 @@ export interface ApiProject {
id: string // UUID (라우팅 식별자)
name: string
description: string | null
visibility: Visibility
isSystem: boolean // "미분류" 시스템 프로젝트
members: ApiMember[]
memberCount: number
@@ -25,12 +23,10 @@ export interface ApiProject {
export interface CreateProjectPayload {
name: string
description?: string
visibility?: Visibility
}
// 프로젝트 수정 요청
export interface UpdateProjectPayload {
name?: string
description?: string
visibility?: Visibility
}