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:
2026-06-30 18:21:23 +09:00
parent f023bd0ed7
commit cf1f4db6af
20 changed files with 1526 additions and 478 deletions
@@ -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;
}
+54 -11
View File
@@ -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);
}
}
+11 -2
View File
@@ -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,
+225 -135
View File
@@ -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';
}
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용