feat: 프로젝트 드라이브 폴더 연동 + AI 검토 문서 전환
프로젝트 직접 업로드(project_attachments) 폐기, AI 검토 문서를 드라이브 폴더 연동으로 전환. 폴더 연동 CRUD·연동 폴더 문서에서 텍스트 지연 추출(NCP 다운로드, xlsx 포함)·AI 추천 주입. 폴더 선택 피커 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,25 @@ export class NcpObjectStorageService extends StorageService {
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async getObjectBuffer(key: string): Promise<Buffer | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
const body = res.Body as
|
||||
| { transformToByteArray?: () => Promise<Uint8Array> }
|
||||
| undefined;
|
||||
// AWS SDK v3 의 스트림 → 바이트 배열(transformToByteArray)로 수집
|
||||
if (!body?.transformToByteArray) return null;
|
||||
const bytes = await body.transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch (err: unknown) {
|
||||
if (this.isNotFound(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async headObject(key: string): Promise<ObjectHead | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,9 @@ export abstract class StorageService {
|
||||
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
|
||||
abstract headObject(key: string): Promise<ObjectHead | null>;
|
||||
|
||||
// 객체 바이트 다운로드 — 서버 측 텍스트 추출(AI 검토) 등에 사용. 미존재 시 null.
|
||||
abstract getObjectBuffer(key: string): Promise<Buffer | null>;
|
||||
|
||||
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
|
||||
abstract deleteObject(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import type { AttachmentKind } from '../../task/entities/attachment.entity';
|
||||
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
|
||||
export type DriveFileStatus = 'pending' | 'active';
|
||||
|
||||
// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시)
|
||||
// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패
|
||||
export type DriveExtractStatus = 'pending' | 'done' | 'unsupported' | 'failed';
|
||||
|
||||
// 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관.
|
||||
// 업로드는 presigned URL 직접 전송 후 complete 로 확정한다.
|
||||
@Entity('drive_files')
|
||||
@@ -67,6 +71,19 @@ export class DriveFile {
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status!: DriveFileStatus;
|
||||
|
||||
// AI 검토용 추출 본문(프로젝트 연동 시 1회 추출·캐싱) — 본문은 응답에 노출하지 않음
|
||||
@Column({
|
||||
name: 'extracted_text',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
extractedText!: string | null;
|
||||
|
||||
// AI 추출 상태 — 연동 문서의 AI 반영 배지 산출 + 재추출 방지에 사용
|
||||
@Column({ name: 'extract_status', type: 'varchar', default: 'pending' })
|
||||
extractStatus!: DriveExtractStatus;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import type { DriveLinkPermission } from '../../drive/entities/project-folder-link.entity';
|
||||
|
||||
// 프로젝트에 드라이브 폴더를 연동하는 요청
|
||||
export class LinkDriveFolderDto {
|
||||
@ApiProperty({ description: '연동할 드라이브 폴더 ID', format: 'uuid' })
|
||||
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
|
||||
folderId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "연동 권한 ('read' 보기/다운로드 | 'read-write' 멤버 업로드 허용)",
|
||||
default: 'read',
|
||||
enum: ['read', 'read-write'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['read', 'read-write'], { message: '올바른 권한 값이 아닙니다.' })
|
||||
permission?: DriveLinkPermission;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Project } from './project.entity';
|
||||
import type { AttachmentKind } from '../../task/entities/attachment.entity';
|
||||
|
||||
// 프로젝트 문서 첨부 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관.
|
||||
// (업무 첨부 Attachment 와 동일 패턴 / 동일 업로드 설정 재사용)
|
||||
@Entity('project_attachments')
|
||||
export class ProjectAttachment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
|
||||
@Index()
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'uploader_id' })
|
||||
uploader!: User | null;
|
||||
|
||||
// 원본 파일명(다운로드 시 표시)
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 디스크 저장명(업로드 폴더 내 고유 파일명)
|
||||
@Column({ name: 'stored_name' })
|
||||
storedName!: string;
|
||||
|
||||
// 파일 크기(바이트)
|
||||
@Column({ type: 'int' })
|
||||
size!: number;
|
||||
|
||||
// MIME 타입
|
||||
@Column()
|
||||
mime!: string;
|
||||
|
||||
// 아이콘 종류(MIME/확장자에서 파생)
|
||||
@Column({ type: 'varchar' })
|
||||
kind!: AttachmentKind;
|
||||
|
||||
// AI 추천용 추출 본문 — 업로드 시 1회 추출·캐싱(매 호출 재파싱 방지).
|
||||
// 추출 불가 형식/실패 시 null. 용량이 크므로 기본 조회에서는 제외(select:false).
|
||||
@Column({
|
||||
name: 'extracted_text',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
extractedText!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import { extractRawText } from 'mammoth';
|
||||
import { Workbook } from 'exceljs';
|
||||
|
||||
// 문서 1건당 추출 텍스트 상한(자) — DB 용량과 AI 토큰 사용량을 함께 보호한다.
|
||||
export const PER_DOC_TEXT_LIMIT = 8000;
|
||||
@@ -9,7 +10,7 @@ export const PER_DOC_TEXT_LIMIT = 8000;
|
||||
// ready: 추출 성공(반영 가능) / failed: 지원 형식이나 추출 실패 / unsupported: 미지원 형식
|
||||
export type AttachmentAiStatus = 'ready' | 'failed' | 'unsupported';
|
||||
|
||||
// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx). 미지원/추출실패 배지 구분에 사용한다.
|
||||
// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx·xlsx). 미지원/추출실패 배지 구분에 사용한다.
|
||||
export function isExtractableType(mime: string, name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return (
|
||||
@@ -21,34 +22,49 @@ export function isExtractableType(mime: string, name: string): boolean {
|
||||
lower.endsWith('.pdf') ||
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
lower.endsWith('.docx')
|
||||
lower.endsWith('.docx') ||
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
lower.endsWith('.xlsx')
|
||||
);
|
||||
}
|
||||
|
||||
// 첨부 문서에서 평문 텍스트를 추출한다(업로드 시 1회). 추출 불가 형식/실패/빈 결과는 null.
|
||||
// 지원 형식: txt·csv(직접 읽기), pdf(pdf-parse), docx(mammoth).
|
||||
// 그 외(이미지·xlsx·ppt·압축 등)는 추출하지 않고 null 을 반환한다.
|
||||
// 디스크 경로 버전 — 버퍼를 읽어 공용 추출기에 위임한다.
|
||||
export async function extractAttachmentText(
|
||||
absPath: string,
|
||||
mime: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const buf = await readFile(absPath);
|
||||
return extractTextFromBuffer(buf, mime, name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 버퍼 버전 — 오브젝트 스토리지에서 받은 바이트에서 텍스트를 추출한다(드라이브 연동 문서용).
|
||||
// 지원 형식: txt·csv(직접 디코드), pdf(pdf-parse), docx(mammoth), xlsx(exceljs). 그 외는 null.
|
||||
export async function extractTextFromBuffer(
|
||||
buf: Buffer,
|
||||
mime: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const lower = name.toLowerCase();
|
||||
try {
|
||||
// 1) 평문 텍스트 — 라이브러리 없이 그대로 읽는다
|
||||
// 1) 평문 텍스트
|
||||
if (
|
||||
mime === 'text/plain' ||
|
||||
mime === 'text/csv' ||
|
||||
lower.endsWith('.txt') ||
|
||||
lower.endsWith('.csv')
|
||||
) {
|
||||
const buf = await readFile(absPath);
|
||||
return finalize(buf.toString('utf8'));
|
||||
}
|
||||
|
||||
// 2) PDF — pdf-parse v2(PDFParse 클래스)로 페이지 텍스트 추출
|
||||
// 2) PDF — pdf-parse v2(PDFParse 클래스)
|
||||
if (mime === 'application/pdf' || lower.endsWith('.pdf')) {
|
||||
const buf = await readFile(absPath);
|
||||
const parser = new PDFParse({ data: buf });
|
||||
try {
|
||||
const result = await parser.getText();
|
||||
@@ -58,19 +74,46 @@ export async function extractAttachmentText(
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Word(.docx) — mammoth 로 서식 제거한 본문만 추출
|
||||
// 3) Word(.docx) — mammoth (버퍼 입력)
|
||||
if (
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
lower.endsWith('.docx')
|
||||
) {
|
||||
const result = await extractRawText({ path: absPath });
|
||||
const result = await extractRawText({ buffer: buf });
|
||||
return finalize(result.value);
|
||||
}
|
||||
|
||||
// 4) Excel(.xlsx) — exceljs 로 시트별 셀 텍스트 추출(행=탭 구분)
|
||||
if (
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
lower.endsWith('.xlsx')
|
||||
) {
|
||||
const wb = new Workbook();
|
||||
// exceljs 타입(Buffer)과 Node Buffer 제네릭(ArrayBufferLike) 마찰 회피 —
|
||||
// load 의 실제 파라미터 타입으로 캐스팅한다.
|
||||
await wb.xlsx.load(buf as unknown as Parameters<typeof wb.xlsx.load>[0]);
|
||||
const lines: string[] = [];
|
||||
wb.eachSheet((sheet) => {
|
||||
const sheetLines: string[] = [];
|
||||
sheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
const cells: string[] = [];
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
const t = (cell.text ?? '').toString().trim();
|
||||
if (t) cells.push(t);
|
||||
});
|
||||
if (cells.length) sheetLines.push(cells.join('\t'));
|
||||
});
|
||||
// 내용 있는 시트만 제목과 함께 포함
|
||||
if (sheetLines.length) lines.push(`# ${sheet.name}`, ...sheetLines);
|
||||
});
|
||||
return finalize(lines.join('\n'));
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
// 추출 실패는 무시한다 — 첨부 자체는 정상 저장되어야 한다.
|
||||
// 추출 실패는 무시 — 호출부가 상태만 'failed' 로 기록한다.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,37 +12,22 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import {
|
||||
ProjectService,
|
||||
type ProjectResponse,
|
||||
type ProjectAttachmentResponse,
|
||||
type ProjectDriveLinkResponse,
|
||||
type ProjectDocumentResponse,
|
||||
} from './project.service';
|
||||
import {
|
||||
UPLOAD_DIR,
|
||||
MAX_FILE_SIZE,
|
||||
fileMimeFilter,
|
||||
type UploadedDiskFile,
|
||||
} from '../task/upload.config';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { LinkDriveFolderDto } from './dto/link-drive-folder.dto';
|
||||
|
||||
// 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요
|
||||
@ApiTags('Project')
|
||||
@@ -102,7 +87,7 @@ export class ProjectController {
|
||||
|
||||
@Delete(':projectId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '프로젝트 삭제 (소유자, 시스템 프로젝트 제외)' })
|
||||
@ApiOperation({ summary: '프로젝트 삭제 (소유자)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
async remove(
|
||||
@@ -113,65 +98,50 @@ export class ProjectController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================== 문서 첨부 ===================== */
|
||||
/* ===================== 드라이브 연동 폴더 / AI 검토 문서 ===================== */
|
||||
|
||||
@Post(':projectId/files')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: fileMimeFilter,
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: '프로젝트 문서 업로드 (admin)' })
|
||||
@ApiResponse({ status: 201, description: '업로드 성공' })
|
||||
addFile(
|
||||
@Get(':projectId/drive-links')
|
||||
@ApiOperation({ summary: '연동된 드라이브 폴더 목록 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listDriveLinks(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@UploadedFile() file: UploadedDiskFile,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectAttachmentResponse> {
|
||||
if (!file) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return this.projectService.addFile(projectId, user.id, file);
|
||||
): Promise<ProjectDriveLinkResponse[]> {
|
||||
return this.projectService.listDriveLinks(projectId);
|
||||
}
|
||||
|
||||
@Delete(':projectId/files/:fileId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '프로젝트 문서 삭제 (admin)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async removeFile(
|
||||
@Post(':projectId/drive-links')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '드라이브 폴더 연동 (admin, 본인 소유 폴더만)' })
|
||||
@ApiResponse({ status: 201, description: '연동 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
@ApiResponse({ status: 409, description: '이미 연동된 폴더 (BIZ_001)' })
|
||||
linkDriveFolder(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@Body() dto: LinkDriveFolderDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectDriveLinkResponse> {
|
||||
return this.projectService.linkDriveFolder(projectId, user.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':projectId/drive-links/:linkId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '드라이브 폴더 연동 해제 (admin)' })
|
||||
@ApiResponse({ status: 200, description: '해제 성공' })
|
||||
async unlinkDriveFolder(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('linkId', ParseUUIDPipe) linkId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.projectService.removeFile(projectId, user.id, fileId);
|
||||
await this.projectService.unlinkDriveFolder(projectId, user.id, linkId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Get(':projectId/files/:fileId/download')
|
||||
@ApiOperation({ summary: '프로젝트 문서 다운로드 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '파일 스트림' })
|
||||
async downloadFile(
|
||||
@Get(':projectId/documents')
|
||||
@ApiOperation({ summary: 'AI 검토 대상 문서 목록 (연동 폴더 내 파일)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listDocuments(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { attachment, absolutePath } =
|
||||
await this.projectService.getFileForDownload(projectId, user.id, fileId);
|
||||
res.setHeader('Content-Type', attachment.mime);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
|
||||
);
|
||||
res.sendFile(absolutePath);
|
||||
): Promise<ProjectDocumentResponse[]> {
|
||||
return this.projectService.listDocuments(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { ProjectAttachment } from './entities/project-attachment.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import { DriveFolder } from '../drive/entities/drive-folder.entity';
|
||||
import { DriveFile } from '../drive/entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from '../drive/entities/project-folder-link.entity';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { ProjectService } from './project.service';
|
||||
@@ -16,7 +18,14 @@ import { ProjectMemberController } from './project-member.controller';
|
||||
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Project, ProjectMember, ProjectAttachment, Task]),
|
||||
TypeOrmModule.forFeature([
|
||||
Project,
|
||||
ProjectMember,
|
||||
Task,
|
||||
DriveFolder,
|
||||
DriveFile,
|
||||
ProjectFolderLink,
|
||||
]),
|
||||
UserModule,
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
|
||||
@@ -1,48 +1,58 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ILike, Repository, type FindOptionsOrder } from 'typeorm';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { ILike, In, Like, Repository, type FindOptionsOrder } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { ProjectAttachment } from './entities/project-attachment.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
import { DriveFolder } from '../drive/entities/drive-folder.entity';
|
||||
import { DriveFile } from '../drive/entities/drive-file.entity';
|
||||
import {
|
||||
UPLOAD_DIR,
|
||||
deriveKind,
|
||||
decodeOriginalName,
|
||||
type UploadedDiskFile,
|
||||
} from '../task/upload.config';
|
||||
ProjectFolderLink,
|
||||
type DriveLinkPermission,
|
||||
} from '../drive/entities/project-folder-link.entity';
|
||||
import {
|
||||
extractAttachmentText,
|
||||
extractTextFromBuffer,
|
||||
isExtractableType,
|
||||
type AttachmentAiStatus,
|
||||
} from './extract-text';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { LinkDriveFolderDto } from './dto/link-drive-folder.dto';
|
||||
|
||||
// 프로젝트 첨부 응답
|
||||
export interface ProjectAttachmentResponse {
|
||||
id: string;
|
||||
// 문서의 AI 반영 상태 — 'pending'(추출 대기) 추가
|
||||
export type DocumentAiStatus = AttachmentAiStatus | 'pending';
|
||||
|
||||
// 프로젝트에 연동된 드라이브 폴더 응답
|
||||
export interface ProjectDriveLinkResponse {
|
||||
id: string; // 연동(link) ID
|
||||
folder: { id: string; name: string; path: string };
|
||||
permission: DriveLinkPermission;
|
||||
linkedBy: PublicUser | null;
|
||||
documentCount: number; // 연동 폴더(하위 포함) 내 활성 파일 수
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서(연동 폴더 내 드라이브 파일) 응답 — 본문은 노출하지 않음
|
||||
export interface ProjectDocumentResponse {
|
||||
id: string; // 드라이브 파일 ID(다운로드는 드라이브 API 사용)
|
||||
name: string;
|
||||
size: number;
|
||||
kind: AttachmentKind;
|
||||
url: string;
|
||||
uploader: PublicUser | null;
|
||||
createdAt: string;
|
||||
// AI 추천 반영 상태 — 추출 본문은 노출하지 않고 상태 플래그만 제공
|
||||
aiStatus: AttachmentAiStatus;
|
||||
aiStatus: DocumentAiStatus;
|
||||
}
|
||||
|
||||
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
|
||||
@@ -55,10 +65,11 @@ export interface ProjectResponse {
|
||||
doneCount: number; // 완료 업무 수
|
||||
totalCount: number; // 전체 업무 수
|
||||
updatedAt: string;
|
||||
// 단건(findOne)에서만 — 현재 사용자 권한 + 문서 첨부
|
||||
// 단건(findOne)에서만 — 현재 사용자 권한 + 연동 폴더/문서
|
||||
canManage?: boolean; // admin
|
||||
isOwner?: boolean; // 소유자
|
||||
attachments?: ProjectAttachmentResponse[];
|
||||
driveLinks?: ProjectDriveLinkResponse[];
|
||||
documents?: ProjectDocumentResponse[];
|
||||
}
|
||||
|
||||
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
|
||||
@@ -71,8 +82,13 @@ export class ProjectService {
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
@InjectRepository(ProjectAttachment)
|
||||
private readonly attachmentRepo: Repository<ProjectAttachment>,
|
||||
@InjectRepository(ProjectFolderLink)
|
||||
private readonly linkRepo: Repository<ProjectFolderLink>,
|
||||
@InjectRepository(DriveFolder)
|
||||
private readonly folderRepo: Repository<DriveFolder>,
|
||||
@InjectRepository(DriveFile)
|
||||
private readonly fileRepo: Repository<DriveFile>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@@ -125,7 +141,8 @@ export class ProjectService {
|
||||
...base,
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
attachments: await this.listFiles(project.id),
|
||||
driveLinks: await this.listDriveLinks(project.id),
|
||||
documents: await this.listDocuments(project.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,153 +230,226 @@ export class ProjectService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 문서 첨부 ---
|
||||
// --- 드라이브 연동 폴더 / AI 검토 문서 ---
|
||||
|
||||
// 첨부 목록(최신순) — 프로젝트 상세에 포함
|
||||
async listFiles(projectId: string): Promise<ProjectAttachmentResponse[]> {
|
||||
// extractedText 는 select:false 이지만, AI 반영 상태 배지 산출을 위해 명시적으로 선택한다.
|
||||
// (본문은 응답에 담지 않고 상태 판별에만 사용)
|
||||
const rows = await this.attachmentRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.uploader', 'uploader')
|
||||
.addSelect('a.extractedText')
|
||||
.where('a.project_id = :projectId', { projectId })
|
||||
.orderBy('a.created_at', 'DESC')
|
||||
.getMany();
|
||||
return rows.map((a) => this.toAttachmentResponse(a, projectId));
|
||||
// 프로젝트에 연동된 드라이브 폴더 목록(최신순) — 프로젝트 상세에 포함
|
||||
async listDriveLinks(projectId: string): Promise<ProjectDriveLinkResponse[]> {
|
||||
const links = await this.linkRepo.find({
|
||||
where: { project: { id: projectId } },
|
||||
relations: { folder: true, linkedBy: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
const out: ProjectDriveLinkResponse[] = [];
|
||||
for (const l of links) {
|
||||
out.push(
|
||||
this.toDriveLinkResponse(l, await this.countFilesInFolderTree(l.folder)),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// AI 업무 추천용 — 프로젝트 문서들의 캐싱된 추출 본문을 문서명과 함께 합쳐 반환한다.
|
||||
// 총량 상한으로 잘라 토큰 사용을 보호하며, 추출 텍스트가 없으면 빈 문자열.
|
||||
// (extractedText 는 select:false 이므로 명시적으로 선택해 조회한다.)
|
||||
// 드라이브 폴더를 프로젝트에 연동(admin, 본인 소유 폴더만)
|
||||
async linkDriveFolder(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
dto: LinkDriveFolderDto,
|
||||
): Promise<ProjectDriveLinkResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const folder = await this.folderRepo.findOne({
|
||||
where: { id: dto.folderId },
|
||||
relations: { owner: true },
|
||||
});
|
||||
if (!folder) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
// 타인 폴더 공유 방지 — 본인이 소유한 폴더만 연동 가능
|
||||
if (folder.owner?.id !== userId) {
|
||||
throw new BusinessException(
|
||||
'AUTH_003',
|
||||
'본인이 소유한 폴더만 연동할 수 있습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
const existing = await this.linkRepo.findOne({
|
||||
where: { project: { id: project.id }, folder: { id: folder.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 연동된 폴더입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
const user = await this.userService.findById(userId);
|
||||
const saved = await this.linkRepo.save(
|
||||
this.linkRepo.create({
|
||||
project: { id: project.id } as Project,
|
||||
folder: { id: folder.id } as DriveFolder,
|
||||
linkedBy: user ?? null,
|
||||
permission: dto.permission ?? 'read',
|
||||
}),
|
||||
);
|
||||
return this.toDriveLinkResponse(
|
||||
{ ...saved, folder, linkedBy: user ?? null },
|
||||
await this.countFilesInFolderTree(folder),
|
||||
);
|
||||
}
|
||||
|
||||
// 연동 해제(admin)
|
||||
async unlinkDriveFolder(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
linkId: string,
|
||||
): Promise<void> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const link = await this.linkRepo.findOne({
|
||||
where: { id: linkId, project: { id: project.id } },
|
||||
});
|
||||
if (!link) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await this.linkRepo.remove(link);
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서 목록 — 연동 폴더(하위 포함) 내 활성 파일 + 추출 상태
|
||||
async listDocuments(projectId: string): Promise<ProjectDocumentResponse[]> {
|
||||
const files = await this.collectLinkedFiles(projectId, false);
|
||||
return files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
kind: f.kind,
|
||||
aiStatus: this.driveAiStatus(f),
|
||||
}));
|
||||
}
|
||||
|
||||
// AI 업무 추천용 — 연동 폴더 내 문서들의 추출 본문을 문서명과 함께 합쳐 반환한다.
|
||||
// 미추출(pending) 문서는 이 시점에 NCP 에서 받아 추출·캐싱한다(지연 추출). 총량 상한 적용.
|
||||
async getAttachmentsTextForAi(projectId: string): Promise<string> {
|
||||
const TOTAL_LIMIT = 16000;
|
||||
const rows = await this.attachmentRepo
|
||||
.createQueryBuilder('a')
|
||||
.select(['a.name', 'a.extractedText'])
|
||||
.where('a.project_id = :projectId', { projectId })
|
||||
.orderBy('a.created_at', 'ASC')
|
||||
.getMany();
|
||||
|
||||
const files = await this.collectLinkedFiles(projectId, true);
|
||||
const parts: string[] = [];
|
||||
let used = 0;
|
||||
for (const r of rows) {
|
||||
const text = r.extractedText?.trim();
|
||||
for (const f of files) {
|
||||
let text = f.extractedText?.trim() ?? null;
|
||||
if (!text && f.extractStatus === 'pending') {
|
||||
text = await this.extractDriveFile(f);
|
||||
}
|
||||
if (!text) continue;
|
||||
const remaining = TOTAL_LIMIT - used;
|
||||
if (remaining <= 0) break;
|
||||
const slice = text.length > remaining ? text.slice(0, remaining) : text;
|
||||
parts.push(`### ${r.name}\n${slice}`);
|
||||
parts.push(`### ${f.name}\n${slice}`);
|
||||
used += slice.length;
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
// 첨부 업로드 — 관리자만. 디스크 저장은 multer 가 처리, 메타데이터만 영속
|
||||
async addFile(
|
||||
// 연동 폴더(자기+하위 트리) 내 활성 파일 수집. withText=true 면 추출 본문도 선택.
|
||||
private async collectLinkedFiles(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
file: UploadedDiskFile,
|
||||
): Promise<ProjectAttachmentResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const uploader = await this.userService.findById(userId);
|
||||
|
||||
const name = decodeOriginalName(file.originalname);
|
||||
// 업로드 시점에 텍스트를 1회 추출해 캐싱 — AI 추천에서 재파싱 없이 꺼내 쓴다.
|
||||
const extractedText = await extractAttachmentText(
|
||||
file.path,
|
||||
file.mimetype,
|
||||
name,
|
||||
);
|
||||
const saved = await this.attachmentRepo.save(
|
||||
this.attachmentRepo.create({
|
||||
project: { id: project.id } as Project,
|
||||
uploader: uploader ?? null,
|
||||
name,
|
||||
storedName: file.filename,
|
||||
size: file.size,
|
||||
mime: file.mimetype,
|
||||
kind: deriveKind(file.mimetype, name),
|
||||
extractedText,
|
||||
}),
|
||||
);
|
||||
return this.toAttachmentResponse(
|
||||
{ ...saved, uploader: uploader ?? null },
|
||||
project.id,
|
||||
);
|
||||
withText: boolean,
|
||||
): Promise<DriveFile[]> {
|
||||
const folderIds = await this.collectLinkedFolderIds(projectId);
|
||||
if (folderIds.length === 0) return [];
|
||||
const qb = this.fileRepo
|
||||
.createQueryBuilder('f')
|
||||
.leftJoin('f.folder', 'fd')
|
||||
.where('fd.id IN (:...folderIds)', { folderIds })
|
||||
.andWhere("f.status = 'active'")
|
||||
.orderBy('f.name', 'ASC');
|
||||
if (withText) qb.addSelect('f.extractedText');
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
// 첨부 삭제 — 관리자만. 디스크 파일도 정리
|
||||
async removeFile(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { id: fileId, project: { id: project.id } },
|
||||
// 프로젝트에 연동된 모든 폴더 + 그 하위 폴더 ID 집합(materialized path 기반)
|
||||
private async collectLinkedFolderIds(projectId: string): Promise<string[]> {
|
||||
const links = await this.linkRepo.find({
|
||||
where: { project: { id: projectId } },
|
||||
relations: { folder: true },
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new NotFoundException();
|
||||
if (links.length === 0) return [];
|
||||
const ids = new Set<string>();
|
||||
for (const l of links) {
|
||||
const tree = await this.folderRepo.find({
|
||||
where: { path: Like(`${l.folder.path}%`) },
|
||||
select: { id: true },
|
||||
});
|
||||
tree.forEach((f) => ids.add(f.id));
|
||||
ids.add(l.folder.id);
|
||||
}
|
||||
await this.attachmentRepo.remove(attachment);
|
||||
await this.deleteFileFromDisk(attachment.storedName);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
// 다운로드용 첨부 메타 + 디스크 절대경로 — 멤버면 조회 가능. 컨트롤러가 스트리밍
|
||||
async getFileForDownload(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<{ attachment: ProjectAttachment; absolutePath: string }> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectMember(project.id, userId);
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { id: fileId, project: { id: project.id } },
|
||||
// 폴더(자기+하위) 내 활성 파일 수
|
||||
private async countFilesInFolderTree(folder: DriveFolder): Promise<number> {
|
||||
const tree = await this.folderRepo.find({
|
||||
where: { path: Like(`${folder.path}%`) },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = tree.map((f) => f.id);
|
||||
if (folder.id && !ids.includes(folder.id)) ids.push(folder.id);
|
||||
if (ids.length === 0) return 0;
|
||||
return this.fileRepo.count({
|
||||
where: { folder: { id: In(ids) }, status: 'active' },
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return {
|
||||
attachment,
|
||||
absolutePath: join(UPLOAD_DIR, attachment.storedName),
|
||||
};
|
||||
}
|
||||
|
||||
// 디스크 파일 삭제(베스트 에포트 — 없어도 무시)
|
||||
private async deleteFileFromDisk(storedName: string): Promise<void> {
|
||||
// 드라이브 파일 1건 텍스트 추출(NCP 다운로드 → 추출 → 캐싱). 결과 상태를 함께 기록.
|
||||
private async extractDriveFile(f: DriveFile): Promise<string | null> {
|
||||
if (!isExtractableType(f.mime, f.name)) {
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'unsupported' });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await unlink(join(UPLOAD_DIR, storedName));
|
||||
const buf = await this.storage.getObjectBuffer(f.objectKey);
|
||||
if (!buf) {
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
|
||||
return null;
|
||||
}
|
||||
const text = await extractTextFromBuffer(buf, f.mime, f.name);
|
||||
if (text) {
|
||||
await this.fileRepo.update(f.id, {
|
||||
extractedText: text,
|
||||
extractStatus: 'done',
|
||||
});
|
||||
return text;
|
||||
}
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
|
||||
return null;
|
||||
} catch {
|
||||
// 이미 없거나 권한 문제 — 본 흐름을 막지 않는다
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toAttachmentResponse(
|
||||
att: ProjectAttachment,
|
||||
projectId: string,
|
||||
): ProjectAttachmentResponse {
|
||||
private toDriveLinkResponse(
|
||||
link: ProjectFolderLink,
|
||||
documentCount: number,
|
||||
): ProjectDriveLinkResponse {
|
||||
return {
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
size: att.size,
|
||||
kind: att.kind,
|
||||
url: `/api/projects/${projectId}/files/${att.id}/download`,
|
||||
uploader: att.uploader ? UserService.toPublic(att.uploader) : null,
|
||||
createdAt: att.createdAt.toISOString(),
|
||||
aiStatus: this.deriveAiStatus(att),
|
||||
id: link.id,
|
||||
folder: {
|
||||
id: link.folder.id,
|
||||
name: link.folder.name,
|
||||
path: link.folder.path,
|
||||
},
|
||||
permission: link.permission,
|
||||
linkedBy: link.linkedBy ? UserService.toPublic(link.linkedBy) : null,
|
||||
documentCount,
|
||||
createdAt: link.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// 첨부의 AI 반영 상태 산출 — 추출 본문 유무 + 형식으로 판별
|
||||
private deriveAiStatus(att: ProjectAttachment): AttachmentAiStatus {
|
||||
if (att.extractedText && att.extractedText.trim().length > 0) {
|
||||
return 'ready';
|
||||
}
|
||||
// 본문이 비어 있음 — 지원 형식이면 추출 실패, 아니면 미지원 형식
|
||||
return isExtractableType(att.mime, att.name) ? 'failed' : 'unsupported';
|
||||
// 드라이브 문서의 AI 반영 상태 — 추출 상태/형식으로 판별
|
||||
private driveAiStatus(f: DriveFile): DocumentAiStatus {
|
||||
if (f.extractStatus === 'done') return 'ready';
|
||||
if (f.extractStatus === 'unsupported') return 'unsupported';
|
||||
if (f.extractStatus === 'failed') return 'failed';
|
||||
// pending — 추출 가능 형식이면 '대기', 아니면 미지원
|
||||
return isExtractableType(f.mime, f.name) ? 'pending' : 'unsupported';
|
||||
}
|
||||
|
||||
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
|
||||
|
||||
Reference in New Issue
Block a user