Compare commits

..

5 Commits

Author SHA1 Message Date
ttipo fd2e3245b4 docs: command.md 에 기존 내용 제거 2026-06-22 02:09:25 +09:00
ttipo 791c74f388 feat: 초기 마이그레이션에 베이스라인 가드 추가(기존 스키마 DB 자동 처리)
이미 스키마가 존재하는 DB(과거 synchronize 로 생성·데이터 보존)에 배포해도
부팅이 실패하지 않도록, up() 진입 시 핵심 테이블(users) 존재를 확인해 DDL 을
건너뛰고 마이그레이션을 '적용됨'으로만 기록한다(데이터/스키마 보존). 빈 DB 는
기존대로 전체 스키마를 생성한다.

검증: 빈 DB 전체 생성 / 스키마 존재 DB(이력 없음) 건너뛰고 기록·데이터 보존 /
재실행 멱등(No migrations pending) 모두 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:06:50 +09:00
ttipo 034bcf9bc7 docs: command.md 에 DB 마이그레이션 명령어 가이드 추가
동작 방식(dev synchronize / 운영 migrationsRun 자동), 스크립트(generate/create/
run/revert), 새 마이그레이션 생성 절차(임시 DB diff), 기존 운영 DB 베이스라인 SQL 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:59:31 +09:00
ttipo 43254fc388 style: 백엔드 lint 포맷 정리 (eslint --fix)
마이그레이션 셋업 중 백엔드 lint 실행으로 정리된 포맷(줄바꿈/따옴표) 반영. 동작 변경 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:54:30 +09:00
ttipo 0d453fc426 chore: TypeORM 마이그레이션 셋업 + 초기 스키마 마이그레이션
운영(synchronize 비활성, migrationsRun 활성)에서 스키마를 구성할 마이그레이션이
전무해 신규 운영 DB 배포 시 테이블이 생성되지 않던 문제를 해결한다.

- data-source.ts(CLI 전용 DataSource) + package.json 마이그레이션 스크립트
  (typeorm/migration:generate/create/run/revert) 추가
- 초기 마이그레이션(CreateInitialSchema) 생성 — 전체 스키마(users·projects·
  project_members·tasks·task_assignees·checklist_items·comments·attachments·
  activities·notifications·refresh_sessions·ai_conversations·ai_messages,
  인덱스·FK 포함), uuid-ossp 확장 생성 보장
- 임시 빈 DB에 up/down 적용 검증, 현재 dev 스키마와 드리프트 없음(No changes) 확인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:54:15 +09:00
7 changed files with 267 additions and 15 deletions
+6 -1
View File
@@ -17,7 +17,12 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs -d ./src/data-source.ts",
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d ./src/data-source.ts",
"migration:create": "typeorm-ts-node-commonjs migration:create",
"migration:run": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts"
},
"dependencies": {
"@keyv/redis": "^5.1.6",
+19
View File
@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
// TypeORM CLI 전용 DataSource — 마이그레이션 생성/실행/되돌리기(generate/run/revert)에 사용한다.
// 런타임(앱)은 app.module 의 TypeOrmModule 설정을 사용하며, 운영에서는 migrationsRun=true 로
// 부팅 시 이 폴더(dist/migrations)의 마이그레이션을 자동 실행한다.
// DB 접속값은 환경변수에서 읽는다(dev/CI/운영에서 주입). app.module 과 동일한 키를 사용.
export default new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USER || process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || process.env.DB_DATABASE || 'project_db',
// CLI 는 절대 스키마 자동 동기화를 하지 않는다(마이그레이션만 사용)
synchronize: false,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
});
@@ -0,0 +1,228 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateInitialSchema1782060613058 implements MigrationInterface {
name = 'CreateInitialSchema1782060613058';
public async up(queryRunner: QueryRunner): Promise<void> {
// 베이스라인 가드 — 이미 스키마가 존재하는 기존 DB(과거 synchronize 로 생성·데이터 보존)면
// 초기 마이그레이션을 '베이스라인'으로 간주하고 DDL 을 건너뛴다. 마이그레이션은 '적용됨'으로
// 기록되어 다음 변경 마이그레이션부터 정상 진행되며, 기존 데이터/스키마는 보존된다.
// (빈 DB 면 이 가드를 통과해 아래 DDL 로 전체 스키마를 생성한다.)
if (await queryRunner.hasTable('users')) {
return;
}
// uuid_generate_v4() 기본값에 필요한 확장 — 신규(빈) 운영 DB 에서도 보장
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await queryRunner.query(
`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password_hash" character varying, "provider" character varying NOT NULL DEFAULT 'local', "email_verified" boolean NOT NULL DEFAULT false, "email_verify_token_hash" character varying, "email_verify_expires_at" TIMESTAMP WITH TIME ZONE, "password_reset_token_hash" character varying, "password_reset_expires_at" TIMESTAMP WITH TIME ZONE, "name" character varying NOT NULL, "role" character varying, "avatar_url" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "project_members" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role_type" character varying NOT NULL DEFAULT 'member', "is_owner" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "projectId" uuid, "userId" uuid, CONSTRAINT "UQ_326b2a901eb18ac24eabc9b0581" UNIQUE ("projectId", "userId"), CONSTRAINT "PK_0b2f46f804be4aea9234c78bcc9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "projects" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "description" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "checklist_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" character varying NOT NULL, "category" character varying NOT NULL DEFAULT '필수', "done" boolean NOT NULL DEFAULT false, "review_note" text, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "taskId" uuid, CONSTRAINT "PK_bae00945a1d4789bd648e583e29" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "seq" integer NOT NULL, "title" character varying NOT NULL, "content" jsonb NOT NULL DEFAULT '[]', "status" character varying NOT NULL DEFAULT 'todo', "due_date" TIMESTAMP WITH TIME ZONE, "approval_note" text, "approval_requested_at" TIMESTAMP WITH TIME ZONE, "changes_note" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "issuerId" uuid, "approval_requested_by" uuid, CONSTRAINT "UQ_011566614ffa01ec7281ee29754" UNIQUE ("project_id", "seq"), CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_73f95adb41a77c1bfd7e19dca7" ON "tasks" ("seq") `,
);
await queryRunner.query(
`CREATE TABLE "attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_5e1f050bcff31e3084a1d662412" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "author_id" uuid, "parent_id" uuid, "attachment_id" uuid, CONSTRAINT "PK_8bf68bc960f2b69e818bdb90dcb" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "read" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "recipient_id" uuid, CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_92be92313f09987bc6ad70ab41" ON "notifications" ("recipient_id", "created_at") `,
);
await queryRunner.query(
`CREATE TABLE "refresh_sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "current_jti" uuid NOT NULL, "prev_jti" uuid, "prev_at" TIMESTAMP WITH TIME ZONE, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_9190032f6967b7971dca07d69f3" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "ai_conversations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(120) NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_60db12765b82858ba00c8aa4ae2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "activities" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "task_seq" integer, "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "actor_id" uuid, CONSTRAINT "PK_7f4004429f731ffb9c88eb486a8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7297db78eb572fa29db5aaf815" ON "activities" ("project_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_8291258c0c575bdab03443ef47" ON "activities" ("task_seq") `,
);
await queryRunner.query(
`CREATE TABLE "ai_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role" character varying NOT NULL, "content" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "conversation_id" uuid NOT NULL, CONSTRAINT "PK_a390434d4a515ba18a41bc996c2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "task_assignees" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_7ae8012667c1cc4ca8266002afc" PRIMARY KEY ("task_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0141288f2306f20da9a60ec8d6" ON "task_assignees" ("task_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_bb8051e376a2b083e074678cb6" ON "task_assignees" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_d19892d8f03928e5bfc7313780c" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checklist_items" ADD CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_950b234a262e85a895b9f9e634e" FOREIGN KEY ("issuerId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd" FOREIGN KEY ("approval_requested_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_e62fd181b97caa6b150b09220b1" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_18c2493067c11f44efb35ca0e03" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_e6d38899c31997c45d128a8973b" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_d6f93329801a93536da4241e386" FOREIGN KEY ("parent_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351" FOREIGN KEY ("attachment_id") REFERENCES "attachments"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "notifications" ADD CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d" FOREIGN KEY ("recipient_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "refresh_sessions" ADD CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" ADD CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "activities" ADD CONSTRAINT "FK_7297db78eb572fa29db5aaf8158" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "activities" ADD CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285" FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ai_messages" ADD CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d" FOREIGN KEY ("conversation_id") REFERENCES "ai_conversations"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_0141288f2306f20da9a60ec8d69" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_bb8051e376a2b083e074678cb60" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_bb8051e376a2b083e074678cb60"`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_0141288f2306f20da9a60ec8d69"`,
);
await queryRunner.query(
`ALTER TABLE "ai_messages" DROP CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d"`,
);
await queryRunner.query(
`ALTER TABLE "activities" DROP CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285"`,
);
await queryRunner.query(
`ALTER TABLE "activities" DROP CONSTRAINT "FK_7297db78eb572fa29db5aaf8158"`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" DROP CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb"`,
);
await queryRunner.query(
`ALTER TABLE "refresh_sessions" DROP CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a"`,
);
await queryRunner.query(
`ALTER TABLE "notifications" DROP CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_d6f93329801a93536da4241e386"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_e6d38899c31997c45d128a8973b"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_18c2493067c11f44efb35ca0e03"`,
);
await queryRunner.query(
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7"`,
);
await queryRunner.query(
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_e62fd181b97caa6b150b09220b1"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_950b234a262e85a895b9f9e634e"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4"`,
);
await queryRunner.query(
`ALTER TABLE "checklist_items" DROP CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578"`,
);
await queryRunner.query(
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb"`,
);
await queryRunner.query(
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_d19892d8f03928e5bfc7313780c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_bb8051e376a2b083e074678cb6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0141288f2306f20da9a60ec8d6"`,
);
await queryRunner.query(`DROP TABLE "task_assignees"`);
await queryRunner.query(`DROP TABLE "ai_messages"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_8291258c0c575bdab03443ef47"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7297db78eb572fa29db5aaf815"`,
);
await queryRunner.query(`DROP TABLE "activities"`);
await queryRunner.query(`DROP TABLE "ai_conversations"`);
await queryRunner.query(`DROP TABLE "refresh_sessions"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_92be92313f09987bc6ad70ab41"`,
);
await queryRunner.query(`DROP TABLE "notifications"`);
await queryRunner.query(`DROP TABLE "comments"`);
await queryRunner.query(`DROP TABLE "attachments"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_73f95adb41a77c1bfd7e19dca7"`,
);
await queryRunner.query(`DROP TABLE "tasks"`);
await queryRunner.query(`DROP TABLE "checklist_items"`);
await queryRunner.query(`DROP TABLE "projects"`);
await queryRunner.query(`DROP TABLE "project_members"`);
await queryRunner.query(`DROP TABLE "users"`);
}
}
+2 -1
View File
@@ -35,7 +35,8 @@ export class ChecklistReviewDto {
done!: boolean;
@ApiProperty({
description: '항목별 보완 내용(미완성 항목에만 적용). 생략 시 보완 내용 없음',
description:
'항목별 보완 내용(미완성 항목에만 적용). 생략 시 보완 내용 없음',
required: false,
})
@IsOptional()
+11 -5
View File
@@ -188,11 +188,17 @@ describe('TaskService', () => {
taskRepo.save.mockResolvedValue(task);
checklistRepo.save.mockResolvedValue(task.checklist);
const result = await svc.requestChanges('q3-launch', 'admin', 9, '보완 바랍니다', [
{ id: 'c1', done: false, note: '여백을 더 주세요' }, // 완성 → 미완성(X) + 보완 내용
{ id: 'c2', done: true, note: '무시됨' }, // 미완성 → 완성(체크): 보완 내용 비움
// c3 은 미전달 → 기존값(true) 유지
]);
const result = await svc.requestChanges(
'q3-launch',
'admin',
9,
'보완 바랍니다',
[
{ id: 'c1', done: false, note: '여백을 더 주세요' }, // 완성 → 미완성(X) + 보완 내용
{ id: 'c2', done: true, note: '무시됨' }, // 미완성 → 완성(체크): 보완 내용 비움
// c3 은 미전달 → 기존값(true) 유지
],
);
expect(checklistRepo.save).toHaveBeenCalledTimes(1);
const c1 = task.checklist.find((c) => c.id === 'c1');
+1 -1
View File
@@ -911,7 +911,7 @@ export class TaskService {
const reviewed = reviewMap.get(item.id);
if (!reviewed) continue;
// 완료 처리된 항목은 보완 내용 비움, 미완성 항목만 보완 내용 보관
const nextNote = reviewed.done ? null : (reviewed.note?.trim() || null);
const nextNote = reviewed.done ? null : reviewed.note?.trim() || null;
if (item.done !== reviewed.done || item.reviewNote !== nextNote) {
item.done = reviewed.done;
item.reviewNote = nextNote;
-7
View File
@@ -26,10 +26,3 @@ sudo docker compose --env-file .env.production up -d --build
# 로그 확인
sudo docker compose --env-file .env.production logs -f backend
```
## 외부 노출 (선택)
```bash
# ngrok 으로 백엔드 포트(3000) 임시 노출 — <YOUR_DOMAIN> 을 본인 도메인으로 교체
ngrok http --domain=<YOUR_DOMAIN>.ngrok.app 3000
```