diff --git a/backend/package.json b/backend/package.json index 42e1c56..0ba549d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts new file mode 100644 index 0000000..5a1d850 --- /dev/null +++ b/backend/src/data-source.ts @@ -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}'], +}); diff --git a/backend/src/migrations/1782060613058-CreateInitialSchema.ts b/backend/src/migrations/1782060613058-CreateInitialSchema.ts new file mode 100644 index 0000000..179f891 --- /dev/null +++ b/backend/src/migrations/1782060613058-CreateInitialSchema.ts @@ -0,0 +1,221 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateInitialSchema1782060613058 implements MigrationInterface { + name = 'CreateInitialSchema1782060613058'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + 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"`); + } +}