Compare commits
16 Commits
65796a0893
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01132d2ac3 | |||
| 5ff9bf0df8 | |||
| fb0669990d | |||
| de92fc01b4 | |||
| a65b5aac05 | |||
| 9a79e169c8 | |||
| 2d4f2e2c19 | |||
| 3adb596511 | |||
| bd4f3e70bf | |||
| 8d00d71072 | |||
| 26cad39857 | |||
| 5ee5ad6973 | |||
| f7b8a4c42d | |||
| e42ccf7139 | |||
| ec54ba0e8d | |||
| 2cbd19e791 |
@@ -80,3 +80,12 @@ LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-dev
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID)
|
||||
# ==========================================
|
||||
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
|
||||
# node -e "console.log(require('web-push').generateVAPIDKeys())"
|
||||
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
@@ -83,3 +83,12 @@ LIVEKIT_URL=http://livekit:7880
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-prod
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID)
|
||||
# ==========================================
|
||||
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
|
||||
# node -e "console.log(require('web-push').generateVAPIDKeys())"
|
||||
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
@@ -51,3 +51,9 @@ CLAUDE_MODEL=claude-sonnet-4-6
|
||||
# 미설정 시 드라이브 기능만 비활성화되고 앱은 정상 부팅된다.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
|
||||
# ==========================================
|
||||
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
@@ -50,3 +50,9 @@ CLAUDE_MODEL=claude-sonnet-4-6
|
||||
# 운영은 권한 최소화를 위해 해당 버킷에만 접근 가능한 서브계정 키 사용 권장.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
|
||||
# ==========================================
|
||||
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
Generated
+85
@@ -25,6 +25,7 @@
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -46,6 +47,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4962,6 +4964,15 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -5939,6 +5950,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -6291,6 +6311,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -6710,6 +6742,12 @@
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.4",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz",
|
||||
"integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -9510,6 +9548,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -9551,6 +9598,19 @@
|
||||
"node": ">=10.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
|
||||
@@ -11686,6 +11746,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -15094,6 +15160,25 @@
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/web-worker": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -62,6 +63,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 드라이브 파일 삭제 감사(소프트 삭제) 컬럼 추가.
|
||||
// - deleted_at: 삭제 시각(null 이면 미삭제)
|
||||
// - deleted_by_id: 삭제한 사용자(FK users, 사용자 삭제 시 SET NULL)
|
||||
// 파일 삭제 시 실제 객체는 즉시 영구 삭제하되, 메타 행은 status='deleted' 묘비로 보존해
|
||||
// "누가·언제 지웠는지" 추적한다. status 는 기존 varchar 라 값만 추가(스키마 변경 없음).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
|
||||
export class AddDriveFileSoftDeleteAudit1783100000000 implements MigrationInterface {
|
||||
name = 'AddDriveFileSoftDeleteAudit1783100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_by_id" uuid`,
|
||||
);
|
||||
// FK 는 제약 이름 존재 검사로 중복 추가를 방지(IF NOT EXISTS 미지원 대비)
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_files_deleted_by'
|
||||
) THEN
|
||||
ALTER TABLE "drive_files"
|
||||
ADD CONSTRAINT "FK_drive_files_deleted_by"
|
||||
FOREIGN KEY ("deleted_by_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT IF EXISTS "FK_drive_files_deleted_by"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_by_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 전사 공유 드라이브 전환 + 전역 감사 로그.
|
||||
// - drive_activities: 누가 무엇을(추가/이름변경/삭제) 했는지 전역 감사 로그(프로젝트 무관).
|
||||
// - drive_files / drive_folders 의 owner_id FK: CASCADE → SET NULL 로 변경
|
||||
// (공유 드라이브에서 업로더/생성자가 탈퇴해도 공유 파일·폴더는 보존).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / owner_id 의 기존 FK 를 동적으로 모두 제거 후 재생성(제약 이름 상이 대비).
|
||||
export class AddDriveSharedAndAudit1783200000000 implements MigrationInterface {
|
||||
name = 'AddDriveSharedAndAudit1783200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ===== 감사 로그 테이블 =====
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "drive_activities" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"type" character varying NOT NULL,
|
||||
"payload" jsonb NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"actor_id" uuid,
|
||||
CONSTRAINT "PK_drive_activities" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_drive_activities_actor" ON "drive_activities" ("actor_id")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_activities_actor'
|
||||
) THEN
|
||||
ALTER TABLE "drive_activities"
|
||||
ADD CONSTRAINT "FK_drive_activities_actor"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ===== owner_id FK 를 SET NULL 로 교체(drive_files, drive_folders) =====
|
||||
for (const table of ['drive_files', 'drive_folders']) {
|
||||
// 혹시 NOT NULL 이면 해제(공유 파일은 업로더 없이도 존재 가능)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "${table}" ALTER COLUMN "owner_id" DROP NOT NULL`,
|
||||
);
|
||||
// owner_id 컬럼에 걸린 기존 외래키를 이름과 무관하게 모두 제거 → SET NULL 로 재생성
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_attribute att
|
||||
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
|
||||
WHERE rel.relname = '${table}'
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = 'owner_id'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
|
||||
END LOOP;
|
||||
ALTER TABLE "${table}"
|
||||
ADD CONSTRAINT "FK_${table}_owner"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// owner_id FK 를 CASCADE 로 되돌린다(이름 무관 제거 후 재생성)
|
||||
for (const table of ['drive_files', 'drive_folders']) {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_attribute att
|
||||
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
|
||||
WHERE rel.relname = '${table}'
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = 'owner_id'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
|
||||
END LOOP;
|
||||
ALTER TABLE "${table}"
|
||||
ADD CONSTRAINT "FK_${table}_owner"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_activities" DROP CONSTRAINT IF EXISTS "FK_drive_activities_actor"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "drive_activities"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 웹 푸시 구독 테이블(push_subscriptions) 추가 — 브라우저 PushSubscription 을 사용자별로 보관.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
|
||||
export class AddPushSubscriptions1783300000000 implements MigrationInterface {
|
||||
name = 'AddPushSubscriptions1783300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh" text NOT NULL,
|
||||
"auth" text NOT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"user_id" uuid,
|
||||
CONSTRAINT "PK_push_subscriptions" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_push_subscriptions_endpoint" ON "push_subscriptions" ("endpoint")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_push_subscriptions_user" ON "push_subscriptions" ("user_id")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_push_subscriptions_user'
|
||||
) THEN
|
||||
ALTER TABLE "push_subscriptions"
|
||||
ADD CONSTRAINT "FK_push_subscriptions_user"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "push_subscriptions" DROP CONSTRAINT IF EXISTS "FK_push_subscriptions_user"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "push_subscriptions"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 공동 지시자 기능 — 업무별 공동 지시자(task_co_issuers) 다대다 조인 테이블 추가.
|
||||
// 원 지시자(tasks.issuer_id)와 함께 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskCoIssuers1783400000000 implements MigrationInterface {
|
||||
name = 'AddTaskCoIssuers1783400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 공동 지시자 조인 테이블(task_assignees 와 동일 구조)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "task_co_issuers" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_task_co_issuers" PRIMARY KEY ("task_id", "user_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_task" ON "task_co_issuers" ("task_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_user" ON "task_co_issuers" ("user_id")`,
|
||||
);
|
||||
|
||||
// FK — 업무/사용자 삭제 시 조인 행도 함께 삭제
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_user" 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_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_user"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_task"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_user"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_task"`);
|
||||
await queryRunner.query(`DROP TABLE "task_co_issuers"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 검토자 기록 — 업무의 최근 검토(승인/수정요청) 수행자(tasks.reviewed_by) 컬럼 추가.
|
||||
// 원 지시자·공동 지시자 중 실제로 검토한 사람을 저장해, 완료/수정요청 카드에 정확한 검토자를 표기한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskReviewedBy1783500000000 implements MigrationInterface {
|
||||
name = 'AddTaskReviewedBy1783500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "tasks" ADD "reviewed_by" uuid`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_tasks_reviewed_by" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_tasks_reviewed_by"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "reviewed_by"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 마감 임박·초과 알림(cron) 중복 발송 방지 플래그 — tasks 에 발송 시각 컬럼 2개 추가.
|
||||
// 마감이 변경되면 서비스에서 다시 null 로 초기화해 새 마감 기준으로 재알림한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskDueNotifyFlags1783600000000 implements MigrationInterface {
|
||||
name = 'AddTaskDueNotifyFlags1783600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "due_soon_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "overdue_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "overdue_notified_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "due_soon_notified_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,11 @@ export type ActivityType =
|
||||
| 'task.edited'
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.file_removed'
|
||||
| 'task.removed';
|
||||
| 'task.removed'
|
||||
// 드라이브(프로젝트 연동 폴더 한정 — 누가 올렸/지웠는지 추적)
|
||||
| 'drive.file_uploaded'
|
||||
| 'drive.file_deleted'
|
||||
| 'drive.folder_deleted';
|
||||
|
||||
// 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고
|
||||
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import {
|
||||
DriveActivity,
|
||||
type DriveActivityType,
|
||||
} from './entities/drive-activity.entity';
|
||||
|
||||
// 감사 로그 응답(원시) — 표시 문구/아이콘은 프론트가 type+payload 로 조립
|
||||
export interface DriveActivityResponse {
|
||||
id: string;
|
||||
type: DriveActivityType;
|
||||
actor: PublicUser | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 감사 로그 적재 파라미터
|
||||
export interface RecordDriveActivityParams {
|
||||
actorId: string | null;
|
||||
type: DriveActivityType;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 드라이브 감사 로그 비즈니스 로직 — DriveService 가 record() 로 행위를 적재하고,
|
||||
// 드라이브 화면이 list() 로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
@Injectable()
|
||||
export class DriveAuditService {
|
||||
private readonly logger = new Logger(DriveAuditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriveActivity)
|
||||
private readonly activityRepo: Repository<DriveActivity>,
|
||||
) {}
|
||||
|
||||
// 감사 로그 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
|
||||
async record(params: RecordDriveActivityParams): Promise<void> {
|
||||
try {
|
||||
const activity = this.activityRepo.create({
|
||||
actor: params.actorId ? { id: params.actorId } : null,
|
||||
type: params.type,
|
||||
payload: params.payload ?? {},
|
||||
});
|
||||
await this.activityRepo.save(activity);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`드라이브 감사 로그 적재 실패: ${params.type}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 감사 로그 — 최신순, 오프셋 페이지네이션. 날짜 그룹핑은 프론트.
|
||||
async list(
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<DriveActivityResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
const [rows, total] = await this.activityRepo.findAndCount({
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
return paginated(
|
||||
rows.map((a) => this.toResponse(a)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑
|
||||
private toResponse(a: DriveActivity): DriveActivityResponse {
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
actor: a.actor ? UserService.toPublic(a.actor) : null,
|
||||
payload: a.payload ?? {},
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
@@ -23,21 +26,27 @@ import {
|
||||
type DriveListResponse,
|
||||
type PresignUploadResponse,
|
||||
} from './drive.service';
|
||||
import {
|
||||
DriveAuditService,
|
||||
type DriveActivityResponse,
|
||||
} from './drive-audit.service';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
import { RenameDto } from './dto/rename.dto';
|
||||
|
||||
// 드라이브 컨트롤러 — 프로젝트와 독립된 개인 드라이브. 모든 엔드포인트 인증 필요.
|
||||
// 드라이브 컨트롤러 — 전사 공유 드라이브(로그인 사용자 전원 공유). 모든 엔드포인트 인증 필요.
|
||||
@ApiTags('Drive')
|
||||
@Controller('drive')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DriveController {
|
||||
constructor(private readonly driveService: DriveService) {}
|
||||
constructor(
|
||||
private readonly driveService: DriveService,
|
||||
private readonly auditService: DriveAuditService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
|
||||
list(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('folderId') folderId?: string,
|
||||
@@ -45,6 +54,16 @@ export class DriveController {
|
||||
return this.driveService.list(user.id, folderId);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
@ApiOperation({ summary: '드라이브 감사 로그(누가 무엇을 했는지, 최신순)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
activity(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<DriveActivityResponse>> {
|
||||
return this.auditService.list(page, size);
|
||||
}
|
||||
|
||||
/* ===================== 폴더 ===================== */
|
||||
|
||||
@Post('folders')
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
import { ProjectMember } from '../project/entities/project-member.entity';
|
||||
import { DriveActivity } from './entities/drive-activity.entity';
|
||||
import { DriveService } from './drive.service';
|
||||
import { DriveAuditService } from './drive-audit.service';
|
||||
import { DriveController } from './drive.controller';
|
||||
|
||||
// 드라이브 모듈 — 프로젝트와 독립된 파일 저장소.
|
||||
// 드라이브 모듈 — 전사 공유 파일 저장소(로그인 사용자 전원 공유).
|
||||
// 저장소(StorageService)는 전역 StorageModule 에서 주입된다.
|
||||
// ProjectMember 는 폴더-프로젝트 연동 시 멤버십(권한) 판정을 위해 forFeature 로 등록한다.
|
||||
// ProjectFolderLink 는 프로젝트 연동(AI 검토 등) 부가 기능을 위해 유지한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DriveFolder,
|
||||
DriveFile,
|
||||
ProjectFolderLink,
|
||||
ProjectMember,
|
||||
DriveActivity,
|
||||
]),
|
||||
UserModule,
|
||||
ActivityModule,
|
||||
],
|
||||
controllers: [DriveController],
|
||||
providers: [DriveService],
|
||||
providers: [DriveService, DriveAuditService],
|
||||
exports: [DriveService],
|
||||
})
|
||||
export class DriveModule {}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { StorageService } from '../../common/storage/storage.service';
|
||||
import type { PresignedUpload } from '../../common/storage/storage.types';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { ProjectMember } from '../project/entities/project-member.entity';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import type { ActivityType } from '../activity/entities/activity.entity';
|
||||
import { DriveAuditService } from './drive-audit.service';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
@@ -28,14 +30,6 @@ import {
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
|
||||
// 폴더/파일에 대한 접근 수준 — 높을수록 권한이 강하다.
|
||||
export type DriveAccess = 'owner' | 'write' | 'read';
|
||||
const ACCESS_RANK: Record<DriveAccess, number> = {
|
||||
read: 1,
|
||||
write: 2,
|
||||
owner: 3,
|
||||
};
|
||||
|
||||
// --- 응답 형태 ---
|
||||
export interface DriveFolderResponse {
|
||||
id: string;
|
||||
@@ -84,26 +78,27 @@ export class DriveService {
|
||||
private readonly fileRepo: Repository<DriveFile>,
|
||||
@InjectRepository(ProjectFolderLink)
|
||||
private readonly linkRepo: Repository<ProjectFolderLink>,
|
||||
@InjectRepository(ProjectMember)
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly userService: UserService,
|
||||
private readonly activity: ActivityService,
|
||||
private readonly audit: DriveAuditService,
|
||||
) {}
|
||||
|
||||
/* ===================== 조회 ===================== */
|
||||
|
||||
// 드라이브 목록 — folderId 없으면 내 루트, 있으면 해당 폴더(읽기 권한 필요).
|
||||
async list(userId: string, folderId?: string): Promise<DriveListResponse> {
|
||||
// 드라이브 목록 — folderId 없으면 공유 루트, 있으면 해당 폴더.
|
||||
// 전사 공유 드라이브이므로 로그인 사용자 모두가 동일한 트리를 본다(소유자 필터 없음).
|
||||
async list(_userId: string, folderId?: string): Promise<DriveListResponse> {
|
||||
if (!folderId) {
|
||||
// 루트 — 내 소유의 최상위 폴더/파일만
|
||||
// 루트 — 공유 최상위 폴더/파일 전체
|
||||
const [folders, files] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { owner: { id: userId }, parent: IsNull() },
|
||||
where: { parent: IsNull() },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.fileRepo.find({
|
||||
where: { owner: { id: userId }, folder: IsNull(), status: 'active' },
|
||||
where: { folder: IsNull(), status: 'active' },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
@@ -116,7 +111,7 @@ export class DriveService {
|
||||
};
|
||||
}
|
||||
|
||||
const folder = await this.getFolderForAccess(userId, folderId, 'read');
|
||||
const folder = await this.loadFolder(folderId);
|
||||
const [folders, files, breadcrumb] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { parent: { id: folder.id } },
|
||||
@@ -140,14 +135,14 @@ export class DriveService {
|
||||
|
||||
/* ===================== 폴더 CRUD ===================== */
|
||||
|
||||
// 폴더 생성 — 루트면 내 드라이브, 하위면 상위 폴더에 쓰기 권한 필요.
|
||||
// 폴더 생성 — 루트 또는 상위 폴더 하위에 생성(로그인 사용자 전원 가능).
|
||||
async createFolder(
|
||||
userId: string,
|
||||
dto: CreateFolderDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
let parent: DriveFolder | null = null;
|
||||
if (dto.parentId) {
|
||||
parent = await this.getFolderForAccess(userId, dto.parentId, 'write');
|
||||
parent = await this.loadFolder(dto.parentId);
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
// 1차 저장으로 id 확보 → materialized path 구성 후 재저장
|
||||
@@ -161,32 +156,60 @@ export class DriveService {
|
||||
);
|
||||
created.path = `${parent ? parent.path : ''}${created.id}/`;
|
||||
await this.folderRepo.save(created);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_created',
|
||||
payload: { name: created.name },
|
||||
});
|
||||
return this.toFolderResponse({ ...created, owner }, parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 이름 변경 — 쓰기 권한 필요.
|
||||
// 폴더 이름 변경 — 로그인 사용자 전원 가능.
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): Promise<DriveFolderResponse> {
|
||||
const folder = await this.getFolderForAccess(userId, folderId, 'write');
|
||||
const folder = await this.loadFolder(folderId);
|
||||
const fromName = folder.name;
|
||||
folder.name = name;
|
||||
await this.folderRepo.save(folder);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_renamed',
|
||||
payload: { fromName, toName: name },
|
||||
});
|
||||
return this.toFolderResponse(folder, folder.parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 삭제 — 쓰기 권한 필요. 하위 트리의 객체 스토리지 파일까지 정리.
|
||||
// 폴더 삭제 — 로그인 사용자 전원 가능. 하위 트리의 객체 스토리지 파일까지 정리.
|
||||
async deleteFolder(userId: string, folderId: string): Promise<void> {
|
||||
const folder = await this.getFolderForAccess(userId, folderId, 'write');
|
||||
const folder = await this.loadFolder(folderId);
|
||||
// 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨)
|
||||
const descendantFiles = await this.fileRepo
|
||||
.createQueryBuilder('f')
|
||||
.innerJoin('f.folder', 'fd')
|
||||
.where('fd.path LIKE :prefix', { prefix: `${folder.path}%` })
|
||||
.getMany();
|
||||
// 활동 기록은 제거 전에 — 연동 프로젝트 해석에 폴더 경로(자기+조상)가 필요하다.
|
||||
const projectIds = await this.resolveLinkedProjectIds(
|
||||
this.parseFolderPath(folder.path),
|
||||
);
|
||||
await this.folderRepo.remove(folder);
|
||||
await this.deleteObjects(descendantFiles.map((f) => f.objectKey));
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_deleted',
|
||||
payload: { name: folder.name, fileCount: descendantFiles.length },
|
||||
});
|
||||
for (const projectId of projectIds) {
|
||||
await this.activity.record({
|
||||
projectId,
|
||||
actorId: userId,
|
||||
type: 'drive.folder_deleted',
|
||||
payload: { folderName: folder.name, fileCount: descendantFiles.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 파일 업로드(Presigned) ===================== */
|
||||
@@ -205,7 +228,7 @@ export class DriveService {
|
||||
}
|
||||
let folder: DriveFolder | null = null;
|
||||
if (dto.folderId) {
|
||||
folder = await this.getFolderForAccess(userId, dto.folderId, 'write');
|
||||
folder = await this.loadFolder(dto.folderId);
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
const objectKey = buildObjectKey(userId, dto.filename);
|
||||
@@ -245,9 +268,14 @@ export class DriveService {
|
||||
if (!file) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
// 발급자 본인만 확정 가능(업로드 2단계 핸드셰이크 무결성 — 접근 권한과 무관)
|
||||
if (file.owner?.id !== userId) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
if (file.status === 'deleted') {
|
||||
// 이미 삭제된 묘비 행 — 확정 불가
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (file.status === 'active') {
|
||||
// 멱등 — 이미 확정된 경우 현재 상태 반환
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
@@ -282,36 +310,60 @@ export class DriveService {
|
||||
file.size = head.size;
|
||||
file.status = 'active';
|
||||
await this.fileRepo.save(file);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_uploaded',
|
||||
payload: { name: file.name },
|
||||
});
|
||||
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 업로드 기록
|
||||
await this.recordFileActivity(file, userId, 'drive.file_uploaded');
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
/* ===================== 파일 CRUD ===================== */
|
||||
|
||||
// 파일 이름 변경 — 쓰기 권한 필요.
|
||||
// 파일 이름 변경 — 로그인 사용자 전원 가능.
|
||||
async renameFile(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
name: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
const file = await this.getFileForAccess(userId, fileId, 'write');
|
||||
const file = await this.loadFile(fileId);
|
||||
const fromName = file.name;
|
||||
file.name = name;
|
||||
await this.fileRepo.save(file);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_renamed',
|
||||
payload: { fromName, toName: name },
|
||||
});
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
// 파일 삭제 — 쓰기 권한 필요. 객체 스토리지 파일도 정리.
|
||||
// 파일 삭제 — 로그인 사용자 전원 가능. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고,
|
||||
// 메타 행은 누가·언제 지웠는지 추적할 수 있도록 묘비(tombstone)로 보존(소프트 삭제)한다.
|
||||
async deleteFile(userId: string, fileId: string): Promise<void> {
|
||||
const file = await this.getFileForAccess(userId, fileId, 'write');
|
||||
await this.fileRepo.remove(file);
|
||||
const file = await this.loadFile(fileId);
|
||||
file.status = 'deleted';
|
||||
file.deletedAt = new Date();
|
||||
file.deletedBy = { id: userId } as User;
|
||||
await this.fileRepo.save(file);
|
||||
await this.deleteObjects([file.objectKey]);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_deleted',
|
||||
payload: { name: file.name },
|
||||
});
|
||||
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 삭제 기록
|
||||
await this.recordFileActivity(file, userId, 'drive.file_deleted');
|
||||
}
|
||||
|
||||
// 다운로드용 단기 presigned GET URL 발급 — 읽기 권한 필요.
|
||||
// 다운로드용 단기 presigned GET URL 발급 — 로그인 사용자 전원 가능.
|
||||
async getDownloadUrl(
|
||||
userId: string,
|
||||
_userId: string,
|
||||
fileId: string,
|
||||
): Promise<{ url: string }> {
|
||||
const file = await this.getFileForAccess(userId, fileId, 'read');
|
||||
const file = await this.loadFile(fileId);
|
||||
if (file.status !== 'active') {
|
||||
// 아직 업로드 확정 전 — 다운로드 불가
|
||||
throw new NotFoundException();
|
||||
@@ -324,14 +376,10 @@ export class DriveService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
/* ===================== 권한 해석 ===================== */
|
||||
/* ===================== 로딩 ===================== */
|
||||
|
||||
// 폴더를 로딩하고 요구 접근 수준을 만족하는지 검사(불충분 시 403, 미존재 404).
|
||||
private async getFolderForAccess(
|
||||
userId: string,
|
||||
folderId: string,
|
||||
required: DriveAccess,
|
||||
): Promise<DriveFolder> {
|
||||
// 폴더 로딩(미존재 404). 전사 공유라 권한 검사는 없다.
|
||||
private async loadFolder(folderId: string): Promise<DriveFolder> {
|
||||
const folder = await this.folderRepo.findOne({
|
||||
where: { id: folderId },
|
||||
relations: { owner: true, parent: true },
|
||||
@@ -339,86 +387,21 @@ export class DriveService {
|
||||
if (!folder) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const access = await this.resolveFolderAccess(userId, folder);
|
||||
this.assertAccess(access, required);
|
||||
return folder;
|
||||
}
|
||||
|
||||
// 파일을 로딩하고 요구 접근 수준을 만족하는지 검사.
|
||||
private async getFileForAccess(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
required: DriveAccess,
|
||||
): Promise<DriveFile> {
|
||||
// 파일 로딩(미존재/소프트 삭제 묘비는 404 — 목록·다운로드·재삭제 차단).
|
||||
private async loadFile(fileId: string): Promise<DriveFile> {
|
||||
const file = await this.fileRepo.findOne({
|
||||
where: { id: fileId },
|
||||
relations: { owner: true, folder: true },
|
||||
});
|
||||
if (!file) {
|
||||
if (!file || file.status === 'deleted') {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const access = await this.resolveFileAccess(userId, file);
|
||||
this.assertAccess(access, required);
|
||||
return file;
|
||||
}
|
||||
|
||||
// 폴더 접근 수준 산출 — 소유자 / 연동(쓰기·읽기) / 없음.
|
||||
private async resolveFolderAccess(
|
||||
userId: string,
|
||||
folder: DriveFolder,
|
||||
): Promise<DriveAccess | null> {
|
||||
if (folder.owner?.id === userId) return 'owner';
|
||||
// 자기 + 조상 폴더 중 하나라도 내가 멤버인 프로젝트에 연동되어 있으면 그 권한을 적용
|
||||
return this.resolveLinkedAccess(this.parseFolderPath(folder.path), userId);
|
||||
}
|
||||
|
||||
// 파일 접근 수준 산출 — 소유자 / 소속 폴더의 연동 권한 / 없음(루트 파일은 소유자 전용).
|
||||
private async resolveFileAccess(
|
||||
userId: string,
|
||||
file: DriveFile,
|
||||
): Promise<DriveAccess | null> {
|
||||
if (file.owner?.id === userId) return 'owner';
|
||||
if (!file.folder) return null;
|
||||
return this.resolveLinkedAccess(
|
||||
this.parseFolderPath(file.folder.path),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// 폴더 id 집합(자기+조상)에 대해, 내가 멤버인 프로젝트로의 연동 권한 중 최상위를 반환.
|
||||
private async resolveLinkedAccess(
|
||||
folderIds: string[],
|
||||
userId: string,
|
||||
): Promise<DriveAccess | null> {
|
||||
if (folderIds.length === 0) return null;
|
||||
const links = await this.linkRepo.find({
|
||||
where: { folder: { id: In(folderIds) } },
|
||||
relations: { project: true },
|
||||
});
|
||||
if (links.length === 0) return null;
|
||||
const projectIds = [...new Set(links.map((l) => l.project.id))];
|
||||
const memberships = await this.memberRepo.find({
|
||||
where: { project: { id: In(projectIds) }, user: { id: userId } },
|
||||
relations: { project: true },
|
||||
});
|
||||
const memberProjectIds = new Set(memberships.map((m) => m.project.id));
|
||||
const accessible = links.filter((l) => memberProjectIds.has(l.project.id));
|
||||
if (accessible.length === 0) return null;
|
||||
return accessible.some((l) => l.permission === 'read-write')
|
||||
? 'write'
|
||||
: 'read';
|
||||
}
|
||||
|
||||
// 접근 수준이 요구치 미만이면 403
|
||||
private assertAccess(
|
||||
access: DriveAccess | null,
|
||||
required: DriveAccess,
|
||||
): void {
|
||||
if (!access || ACCESS_RANK[access] < ACCESS_RANK[required]) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
// "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
|
||||
private parseFolderPath(path: string): string[] {
|
||||
return path.split('/').filter(Boolean);
|
||||
@@ -442,6 +425,37 @@ export class DriveService {
|
||||
|
||||
/* ===================== 내부 유틸 ===================== */
|
||||
|
||||
// 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다.
|
||||
// (전역 감사 로그와는 별개로, 프로젝트 화면의 활동 피드 표시를 위한 부가 기록)
|
||||
private async recordFileActivity(
|
||||
file: DriveFile,
|
||||
actorId: string,
|
||||
type: ActivityType,
|
||||
): Promise<void> {
|
||||
const folderIds = file.folder ? this.parseFolderPath(file.folder.path) : [];
|
||||
const projectIds = await this.resolveLinkedProjectIds(folderIds);
|
||||
for (const projectId of projectIds) {
|
||||
await this.activity.record({
|
||||
projectId,
|
||||
actorId,
|
||||
type,
|
||||
payload: { fileName: file.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 폴더 id 집합(자기+조상)이 연동된 프로젝트 id 목록(중복 제거)을 반환.
|
||||
private async resolveLinkedProjectIds(
|
||||
folderIds: string[],
|
||||
): Promise<string[]> {
|
||||
if (folderIds.length === 0) return [];
|
||||
const links = await this.linkRepo.find({
|
||||
where: { folder: { id: In(folderIds) } },
|
||||
relations: { project: true },
|
||||
});
|
||||
return [...new Set(links.map((l) => l.project.id))];
|
||||
}
|
||||
|
||||
private async getUserOrThrow(userId: string): Promise<User> {
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 드라이브 감사 로그 이벤트 타입 — 추가/이름변경/삭제 행위에서 자동 적재(읽기 전용)
|
||||
export type DriveActivityType =
|
||||
| 'file_uploaded'
|
||||
| 'file_renamed'
|
||||
| 'file_deleted'
|
||||
| 'folder_created'
|
||||
| 'folder_renamed'
|
||||
| 'folder_deleted';
|
||||
|
||||
// 드라이브 감사 로그 — 전사 공유 드라이브에서 "누가 무엇을 했는지" 전역 추적.
|
||||
// 프로젝트 활동(activities)은 project NOT NULL 이라 전사 로그로 못 쓰므로 별도 테이블로 둔다.
|
||||
// 표시 문구는 프론트가 type+payload 로 조립한다(파생값 규칙).
|
||||
@Entity('drive_activities')
|
||||
export class DriveActivity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 행위자 (사용자 삭제 시에도 기록은 유지 — SET NULL)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'actor_id' })
|
||||
actor!: User | null;
|
||||
|
||||
// 이벤트 타입
|
||||
@Column({ type: 'varchar' })
|
||||
type!: DriveActivityType;
|
||||
|
||||
// 대상 정보(자유형) — 예: name, fromName/toName(이름변경), folderName(상위 폴더 컨텍스트), fileCount(폴더 삭제)
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import type { AttachmentKind } from '../../task/entities/attachment.entity';
|
||||
|
||||
// 드라이브 파일 업로드 상태
|
||||
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
|
||||
export type DriveFileStatus = 'pending' | 'active';
|
||||
// deleted: 소프트 삭제(객체는 영구 제거됨, 메타 행은 감사용 묘비로 보존 — 누가·언제 삭제했는지)
|
||||
export type DriveFileStatus = 'pending' | 'active' | 'deleted';
|
||||
|
||||
// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시)
|
||||
// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패
|
||||
@@ -29,11 +30,11 @@ export class DriveFile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소유자 (사용자 삭제 시 파일 메타도 함께 삭제 — 객체 정리는 서비스가 담당)
|
||||
// 업로더 (전사 공유 드라이브 — 업로더가 탈퇴해도 공유 파일은 보존하므로 SET NULL)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User;
|
||||
owner!: User | null;
|
||||
|
||||
// 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE).
|
||||
@Index()
|
||||
@@ -89,4 +90,13 @@ export class DriveFile {
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// 삭제 시각(소프트 삭제) — null 이면 미삭제. 객체는 즉시 영구 삭제되며 이 행만 묘비로 남는다.
|
||||
@Column({ name: 'deleted_at', type: 'timestamp', nullable: true })
|
||||
deletedAt!: Date | null;
|
||||
|
||||
// 삭제한 사용자 — 소유자가 아닌 연동 멤버가 지울 수 있으므로 별도 기록(사용자 삭제 시 SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'deleted_by_id' })
|
||||
deletedBy!: User | null;
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@ import {
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 드라이브 폴더 — 사용자 소유의 폴더 트리(adjacency list).
|
||||
// 프로젝트와 독립적이며, ProjectFolderLink 로 프로젝트에 연동(공유)할 수 있다.
|
||||
// 드라이브 폴더 — 전사 공유 폴더 트리(adjacency list). 로그인 사용자 모두가 공유한다.
|
||||
// ProjectFolderLink 로 프로젝트에 연동(AI 검토 등)할 수 있으나 접근은 전원 공유다.
|
||||
@Entity('drive_folders')
|
||||
export class DriveFolder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소유자 (사용자 삭제 시 폴더도 함께 삭제)
|
||||
// 생성자(업로더) — 전사 공유라 생성자가 탈퇴해도 폴더는 보존하므로 SET NULL
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User;
|
||||
owner!: User | null;
|
||||
|
||||
// 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE).
|
||||
// Relation<> 래퍼: 자기참조 순환 회피
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 브라우저 PushSubscription.keys
|
||||
class PushKeysDto {
|
||||
@ApiProperty({ description: '구독 공개키(p256dh)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
p256dh!: string;
|
||||
|
||||
@ApiProperty({ description: '인증 시크릿(auth)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
auth!: string;
|
||||
}
|
||||
|
||||
// 푸시 구독 등록 — 브라우저 PushSubscription.toJSON() 형태
|
||||
export class SubscribePushDto {
|
||||
@ApiProperty({ description: '푸시 서비스 엔드포인트 URL' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint!: string;
|
||||
|
||||
@ApiProperty({ description: '구독 키(p256dh/auth)' })
|
||||
@ValidateNested()
|
||||
@Type(() => PushKeysDto)
|
||||
keys!: PushKeysDto;
|
||||
}
|
||||
|
||||
// 푸시 구독 해제
|
||||
export class UnsubscribePushDto {
|
||||
@ApiProperty({ description: '해제할 구독 엔드포인트 URL' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint!: string;
|
||||
}
|
||||
@@ -18,9 +18,12 @@ export type NotificationType =
|
||||
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
|
||||
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
|
||||
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
|
||||
| 'task.due_soon' // 내가 담당한 업무의 마감이 하루 앞으로 다가옴
|
||||
| 'task.overdue' // 내가 담당한 업무의 마감이 지남(미완료)
|
||||
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
|
||||
| 'task.removed' // 내 관련 업무가 삭제됨
|
||||
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
|
||||
| 'task.file_attached' // 내 관련 업무에 파일이 첨부됨
|
||||
| 'member.invited' // 프로젝트에 멤버로 초대됨
|
||||
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
|
||||
| 'member.removed' // 프로젝트에서 제외됨
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 웹 푸시 구독 — 브라우저(PushManager)가 발급한 구독 정보를 사용자별로 보관한다.
|
||||
// 한 사용자가 여러 기기/브라우저를 가질 수 있으므로 userId당 N개. endpoint 가 고유 키.
|
||||
@Entity('push_subscriptions')
|
||||
export class PushSubscription {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 구독 소유자 (사용자 삭제 시 구독도 함께 삭제 — Notification.recipient 와 동일 패턴)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User;
|
||||
|
||||
// 브라우저 푸시 엔드포인트(푸시 서비스 URL) — 구독의 고유 식별자
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'text' })
|
||||
endpoint!: string;
|
||||
|
||||
// 암호화 키(브라우저 PushSubscription.keys)
|
||||
@Column({ type: 'text' })
|
||||
p256dh!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
auth!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
@@ -22,13 +23,18 @@ import {
|
||||
NotificationService,
|
||||
type NotificationListResult,
|
||||
} from './notification.service';
|
||||
import { WebPushService } from './web-push.service';
|
||||
import { SubscribePushDto, UnsubscribePushDto } from './dto/subscribe-push.dto';
|
||||
|
||||
// 알림 컨트롤러 — 모든 엔드포인트 인증 필요(본인 수신함 전용)
|
||||
@ApiTags('Notification')
|
||||
@Controller('notifications')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) {}
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly webPush: WebPushService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '내 알림 목록 (페이지네이션 + 미읽음 수)' })
|
||||
@@ -62,6 +68,35 @@ export class NotificationController {
|
||||
return this.notificationService.markAllRead(user.id);
|
||||
}
|
||||
|
||||
@Post('push/subscribe')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '웹 푸시 구독 등록(브라우저 PushSubscription)' })
|
||||
@ApiResponse({ status: 200, description: '등록 성공' })
|
||||
async subscribePush(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: SubscribePushDto,
|
||||
): Promise<null> {
|
||||
await this.webPush.subscribe(
|
||||
user.id,
|
||||
dto.endpoint,
|
||||
dto.keys.p256dh,
|
||||
dto.keys.auth,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('push/unsubscribe')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '웹 푸시 구독 해제' })
|
||||
@ApiResponse({ status: 200, description: '해제 성공' })
|
||||
async unsubscribePush(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: UnsubscribePushDto,
|
||||
): Promise<null> {
|
||||
await this.webPush.unsubscribe(user.id, dto.endpoint);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 실시간 스트림(SSE) — 표준 응답 래퍼를 건너뛰고 text/event-stream 으로 흘려보낸다.
|
||||
// 인증은 access_token 쿠키(EventSource withCredentials)로 연결 시점에 1회 검증된다.
|
||||
@Sse('stream')
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { PushSubscription } from './entities/push-subscription.entity';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { WebPushService } from './web-push.service';
|
||||
import { NotificationController } from './notification.controller';
|
||||
|
||||
// 알림 모듈 — 다른 도메인 모듈(task/repo)이 import 해 NotificationService 로 알림을 적재한다.
|
||||
// (Notification 은 User 만 참조하므로 순환 의존 없음)
|
||||
// 알림 모듈 — 다른 도메인 모듈(task/project/schedule)이 import 해 NotificationService 로 알림을 적재한다.
|
||||
// 인앱(DB) + 실시간(SSE) + 웹 푸시(WebPushService) 3경로로 전달한다.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Notification])],
|
||||
imports: [TypeOrmModule.forFeature([Notification, PushSubscription])],
|
||||
controllers: [NotificationController],
|
||||
providers: [NotificationService],
|
||||
providers: [NotificationService, WebPushService],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { Subject, merge, interval, type Observable } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import Redis from 'ioredis';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Notification,
|
||||
type NotificationType,
|
||||
} from './entities/notification.entity';
|
||||
import { WebPushService } from './web-push.service';
|
||||
|
||||
// 알림 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
|
||||
export interface NotificationResponse {
|
||||
@@ -64,10 +66,13 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
|
||||
private subscriber: Redis | null = null;
|
||||
// 실시간 알림 채널명
|
||||
private static readonly CHANNEL = 'relay:notifications';
|
||||
// 읽은 알림 보관 기간(일) — 이보다 오래된 '읽음' 알림은 주기적으로 정리해 무한 증가를 막는다.
|
||||
private static readonly READ_RETENTION_DAYS = 90;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Notification)
|
||||
private readonly notificationRepo: Repository<Notification>,
|
||||
private readonly webPush: WebPushService,
|
||||
) {}
|
||||
|
||||
// 기동 시 Redis pub/sub 연결 — REDIS_HOST 있을 때만. 실패해도 인메모리로 폴백.
|
||||
@@ -156,12 +161,19 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
const saved = await this.notificationRepo.save(entities);
|
||||
|
||||
// 저장 성공분을 각 수신자에게 실시간 전달(Redis 발행 또는 로컬)
|
||||
// 푸시 표시 내용은 type+payload 로 1회만 산출(수신자 공통)
|
||||
const pushContent = this.webPush.notificationContent(
|
||||
params.type,
|
||||
params.payload ?? {},
|
||||
);
|
||||
|
||||
// 저장 성공분을 각 수신자에게 실시간 전달(Redis/로컬 SSE) + 웹 푸시(사이트 닫힘 대비)
|
||||
for (let i = 0; i < saved.length; i++) {
|
||||
this.dispatch({
|
||||
userId: recipients[i],
|
||||
data: this.toResponse(saved[i]),
|
||||
});
|
||||
void this.webPush.sendToUser(recipients[i], pushContent);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -215,6 +227,29 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
|
||||
return { unreadCount: 0 };
|
||||
}
|
||||
|
||||
// 오래된 '읽음' 알림 정리 — 매일 새벽 4시. 무한 증가 방지(미읽음은 보존).
|
||||
// best-effort: 실패해도 서비스에 영향 없음(다음 주기에 재시도).
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async cleanupOldRead(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(
|
||||
Date.now() -
|
||||
NotificationService.READ_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const result = await this.notificationRepo.delete({
|
||||
read: true,
|
||||
createdAt: LessThan(cutoff),
|
||||
});
|
||||
if (result.affected) {
|
||||
this.logger.log(`오래된 읽음 알림 ${result.affected}건 정리`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`읽음 알림 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자별 실시간 스트림(SSE) — 본인 알림만 필터 + 주기적 하트비트(프록시 idle 차단 방지)
|
||||
streamFor(userId: string): Observable<MessageEvent> {
|
||||
const events = this.stream$.asObservable().pipe(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { WebPushService } from './web-push.service';
|
||||
import type { NotificationType } from './entities/notification.entity';
|
||||
|
||||
// 모든 NotificationType 에 대해 푸시 문구가 조립되는지 검증한다.
|
||||
// (NotificationType 을 추가하고 notificationContent 의 case 를 빠뜨리면 기본 문구가 남아 실패)
|
||||
//
|
||||
// SSOT 주의: 인앱 표시 문구는 프론트 notification.store.ts 의 toView() 가 별도로 담당한다.
|
||||
// 새 타입을 추가하면 이 목록과 양쪽(백엔드 notificationContent, 프론트 toView)을 함께 갱신할 것.
|
||||
const ALL_TYPES: NotificationType[] = [
|
||||
'task.assigned',
|
||||
'task.unassigned',
|
||||
'task.approval_requested',
|
||||
'task.approved',
|
||||
'task.changes_requested',
|
||||
'task.started',
|
||||
'task.due_changed',
|
||||
'task.due_soon',
|
||||
'task.overdue',
|
||||
'task.checkpoint_reported',
|
||||
'task.removed',
|
||||
'task.commented',
|
||||
'task.file_attached',
|
||||
'member.invited',
|
||||
'member.role_changed',
|
||||
'member.removed',
|
||||
'schedule.invited',
|
||||
'schedule.updated',
|
||||
'schedule.removed',
|
||||
];
|
||||
|
||||
// 서버 기본 문구(케이스 누락 시 남는 값) — 이 문구가 남으면 매핑이 빠진 것.
|
||||
const DEFAULT_BODY = '새 알림이 도착했습니다';
|
||||
|
||||
describe('WebPushService.notificationContent', () => {
|
||||
// notificationContent 는 repo/config 를 쓰지 않으므로 의존성 없이 인스턴스화한다.
|
||||
const service = new WebPushService(
|
||||
null as unknown as never,
|
||||
null as unknown as never,
|
||||
);
|
||||
|
||||
// 표시 문구 조립에 쓰이는 대표 payload(모든 필드 채워 어떤 case 든 자연스럽게)
|
||||
const payload = {
|
||||
actorName: '홍길동',
|
||||
projectName: '테스트 프로젝트',
|
||||
projectId: 'p-1',
|
||||
taskTitle: '샘플 업무',
|
||||
taskSeq: 12,
|
||||
eventTitle: '주간 회의',
|
||||
roleType: 'admin',
|
||||
isReply: true,
|
||||
fileName: '보고서.pdf',
|
||||
dueDate: '2026-07-10T00:00:00.000Z',
|
||||
resumed: true,
|
||||
};
|
||||
|
||||
it.each(ALL_TYPES)('%s 타입의 본문이 기본 문구가 아니어야 한다', (type) => {
|
||||
const content = service.notificationContent(type, payload);
|
||||
expect(content.title).toBeTruthy();
|
||||
expect(content.body).not.toBe(DEFAULT_BODY);
|
||||
expect(content.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('타입 목록이 19종(현재 정의)과 일치해야 한다', () => {
|
||||
// NotificationType 추가 시 이 테스트가 알려주도록 개수를 고정한다.
|
||||
expect(ALL_TYPES).toHaveLength(19);
|
||||
expect(new Set(ALL_TYPES).size).toBe(ALL_TYPES.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as webpush from 'web-push';
|
||||
import { PushSubscription } from './entities/push-subscription.entity';
|
||||
import type { NotificationType } from './entities/notification.entity';
|
||||
|
||||
// 푸시 표시 내용 — 서비스워커가 showNotification 에 그대로 사용한다.
|
||||
export interface PushContent {
|
||||
title: string;
|
||||
body: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
// payload(자유형) 안전 추출
|
||||
function pstr(p: Record<string, unknown>, k: string): string {
|
||||
const v = p[k];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
function pnum(p: Record<string, unknown>, k: string): number | null {
|
||||
const v = p[k];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
function pbool(p: Record<string, unknown>, k: string): boolean {
|
||||
return p[k] === true;
|
||||
}
|
||||
|
||||
// 마감일(ISO) → 한국식 날짜 라벨(한국 시간대 고정). 파싱 실패 시 빈 문자열.
|
||||
function fmtDueKo(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'long',
|
||||
timeZone: 'Asia/Seoul',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// 웹 푸시 발송 + 구독 관리.
|
||||
// VAPID 키 미설정 시 비활성(발송 skip) — storage 미설정 패턴과 동일한 graceful degrade.
|
||||
@Injectable()
|
||||
export class WebPushService implements OnModuleInit {
|
||||
private readonly logger = new Logger(WebPushService.name);
|
||||
private enabled = false;
|
||||
// 푸시 서비스가 오프라인 기기에 보관·재시도하는 최대 시간(초). 하루 지나면 폐기.
|
||||
private static readonly PUSH_TTL_SECONDS = 60 * 60 * 24;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PushSubscription)
|
||||
private readonly subRepo: Repository<PushSubscription>,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const publicKey = this.config.get<string>('VAPID_PUBLIC_KEY');
|
||||
const privateKey = this.config.get<string>('VAPID_PRIVATE_KEY');
|
||||
const subject =
|
||||
this.config.get<string>('VAPID_SUBJECT') || 'mailto:admin@example.com';
|
||||
if (!publicKey || !privateKey) {
|
||||
this.logger.log('웹 푸시: 비활성(VAPID 키 미설정) — 푸시 발송 건너뜀');
|
||||
return;
|
||||
}
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
this.enabled = true;
|
||||
this.logger.log('웹 푸시: 활성화됨(VAPID)');
|
||||
}
|
||||
|
||||
// 구독 등록(endpoint 기준 upsert — 같은 브라우저 재구독 시 키 갱신)
|
||||
async subscribe(
|
||||
userId: string,
|
||||
endpoint: string,
|
||||
p256dh: string,
|
||||
auth: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.subRepo.findOne({ where: { endpoint } });
|
||||
if (existing) {
|
||||
existing.user = { id: userId } as PushSubscription['user'];
|
||||
existing.p256dh = p256dh;
|
||||
existing.auth = auth;
|
||||
await this.subRepo.save(existing);
|
||||
return;
|
||||
}
|
||||
await this.subRepo.save(
|
||||
this.subRepo.create({
|
||||
user: { id: userId } as PushSubscription['user'],
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 구독 해제(본인 구독만)
|
||||
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||
await this.subRepo.delete({ endpoint, user: { id: userId } });
|
||||
}
|
||||
|
||||
// type+payload → 푸시 표시 내용(제목=행위자/맥락, 본문=행위, url=이동 경로).
|
||||
// 프론트 toView 의 푸시 전용 plain 버전(서비스워커가 번들 모듈을 import 못하므로 서버에서 완성).
|
||||
//
|
||||
// SSOT 주의: 인앱 표시 문구는 프론트 `notification.store.ts` 의 `toView()` 가 별도로 조립한다
|
||||
// (인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **두 곳 모두** case 를 넣어야 하며,
|
||||
// 여기 커버리지는 `web-push.service.spec.ts` 가 기본 문구 잔존 여부로 검증한다.
|
||||
notificationContent(
|
||||
type: NotificationType,
|
||||
payload: Record<string, unknown>,
|
||||
): PushContent {
|
||||
const actor = pstr(payload, 'actorName') || '누군가';
|
||||
const projectName = pstr(payload, 'projectName');
|
||||
const taskTitle = pstr(payload, 'taskTitle');
|
||||
const eventTitle = pstr(payload, 'eventTitle');
|
||||
const projectId = pstr(payload, 'projectId');
|
||||
const taskSeq = pnum(payload, 'taskSeq');
|
||||
const taskUrl =
|
||||
projectId && taskSeq !== null
|
||||
? `/projects/${projectId}/tasks/${taskSeq}`
|
||||
: null;
|
||||
const projectUrl = projectId ? `/projects/${projectId}` : null;
|
||||
|
||||
// 기본값 — 대부분 제목=행위자, 본문=행위 문구
|
||||
let title = actor;
|
||||
let body = '새 알림이 도착했습니다';
|
||||
let url: string | null = taskUrl;
|
||||
|
||||
switch (type) {
|
||||
case 'task.assigned':
|
||||
body = `'${taskTitle}' 업무를 지시했습니다`;
|
||||
break;
|
||||
case 'task.unassigned':
|
||||
body = `'${taskTitle}' 업무 담당에서 회원님을 제외했습니다`;
|
||||
break;
|
||||
case 'task.started':
|
||||
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
|
||||
body = `'${taskTitle}' 업무를 ${pbool(payload, 'resumed') ? '재개' : '시작'}했습니다`;
|
||||
break;
|
||||
case 'task.due_changed': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
body = label
|
||||
? `'${taskTitle}' 업무의 마감기한을 ${label}(으)로 변경했습니다`
|
||||
: `'${taskTitle}' 업무의 마감기한을 해제했습니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.due_soon': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
title = '마감 임박';
|
||||
body = label
|
||||
? `'${taskTitle}' 업무의 마감이 다가옵니다 (마감: ${label})`
|
||||
: `'${taskTitle}' 업무의 마감이 다가옵니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.overdue': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
title = '마감 초과';
|
||||
body = label
|
||||
? `'${taskTitle}' 업무가 마감기한을 넘겼습니다 (마감: ${label})`
|
||||
: `'${taskTitle}' 업무가 마감기한을 넘겼습니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.checkpoint_reported':
|
||||
body = `'${taskTitle}' 업무의 중간 점검을 보고했습니다`;
|
||||
break;
|
||||
case 'task.removed':
|
||||
body = `'${taskTitle}' 업무를 삭제했습니다`;
|
||||
url = projectUrl; // 업무가 삭제되므로 프로젝트로 이동
|
||||
break;
|
||||
case 'task.approval_requested':
|
||||
body = `'${taskTitle}' 승인을 요청했습니다`;
|
||||
break;
|
||||
case 'task.approved':
|
||||
title = '업무 승인';
|
||||
body = `'${taskTitle}' 업무가 승인되었습니다`;
|
||||
break;
|
||||
case 'task.changes_requested':
|
||||
body = `'${taskTitle}' 수정을 요청했습니다`;
|
||||
break;
|
||||
case 'task.commented':
|
||||
body = `'${taskTitle}'에 ${payload.isReply === true ? '답글' : '댓글'}을 남겼습니다`;
|
||||
break;
|
||||
case 'task.file_attached': {
|
||||
const fileName = pstr(payload, 'fileName');
|
||||
body = fileName
|
||||
? `'${taskTitle}'에 파일 '${fileName}'을(를) 첨부했습니다`
|
||||
: `'${taskTitle}'에 파일을 첨부했습니다`;
|
||||
break;
|
||||
}
|
||||
case 'member.invited':
|
||||
body = `'${projectName}' 프로젝트에 초대했습니다`;
|
||||
url = projectUrl;
|
||||
break;
|
||||
case 'member.role_changed':
|
||||
body = `'${projectName}'에서 회원님의 역할을 ${pstr(payload, 'roleType') === 'admin' ? '관리자' : '멤버'}(으)로 변경했습니다`;
|
||||
url = projectUrl;
|
||||
break;
|
||||
case 'member.removed':
|
||||
body = `'${projectName}' 프로젝트에서 회원님을 제외했습니다`;
|
||||
url = projectUrl;
|
||||
break;
|
||||
case 'schedule.invited':
|
||||
body = `'${eventTitle}' 일정에 회원님을 참석자로 지정했습니다`;
|
||||
url = '/schedule';
|
||||
break;
|
||||
case 'schedule.updated':
|
||||
body = `참석 일정 '${eventTitle}'을(를) 수정했습니다`;
|
||||
url = '/schedule';
|
||||
break;
|
||||
case 'schedule.removed':
|
||||
body = `참석 일정 '${eventTitle}'을(를) 삭제했습니다`;
|
||||
url = '/schedule';
|
||||
break;
|
||||
}
|
||||
return { title, body, url };
|
||||
}
|
||||
|
||||
// 한 사용자의 모든 구독(기기)에 발송 — 만료(404/410) 구독은 정리. best-effort.
|
||||
// 다기기 병렬 발송(allSettled) — 하나가 실패/지연돼도 다른 기기 발송을 막지 않는다.
|
||||
async sendToUser(userId: string, content: PushContent): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const subs = await this.subRepo.find({ where: { user: { id: userId } } });
|
||||
if (subs.length === 0) return;
|
||||
const json = JSON.stringify(content);
|
||||
await Promise.allSettled(subs.map((s) => this.sendOne(s, json)));
|
||||
}
|
||||
|
||||
// 단일 구독 발송 — 만료 구독 정리 포함. 예외는 흡수(호출부 흐름 보호).
|
||||
private async sendOne(s: PushSubscription, json: string): Promise<void> {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
|
||||
json,
|
||||
// 오프라인 기기용 보관 시간 + 일반 우선순위(배터리 절약 정책 존중)
|
||||
{ TTL: WebPushService.PUSH_TTL_SECONDS, urgency: 'normal' },
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as { statusCode?: number })?.statusCode;
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
// 만료/해지된 구독 — 정리
|
||||
await this.subRepo.delete({ id: s.id });
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,13 +242,16 @@ export class ProjectService {
|
||||
const out: ProjectDriveLinkResponse[] = [];
|
||||
for (const l of links) {
|
||||
out.push(
|
||||
this.toDriveLinkResponse(l, await this.countFilesInFolderTree(l.folder)),
|
||||
this.toDriveLinkResponse(
|
||||
l,
|
||||
await this.countFilesInFolderTree(l.folder),
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 드라이브 폴더를 프로젝트에 연동(admin, 본인 소유 폴더만)
|
||||
// 드라이브 폴더를 프로젝트에 연동(admin). 전사 공유 드라이브이므로 모든 폴더를 연동할 수 있다.
|
||||
async linkDriveFolder(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
@@ -263,14 +266,6 @@ export class ProjectService {
|
||||
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 } },
|
||||
});
|
||||
|
||||
@@ -145,16 +145,14 @@ export class ScheduleService {
|
||||
});
|
||||
const saved = await this.eventRepo.save(event);
|
||||
|
||||
// 참석자로 지정된 사용자에게 알림(전 직원 일정은 개별 알림 생략)
|
||||
await this.notifyEvent(
|
||||
'schedule.invited',
|
||||
this.idsOf(dto.attendeeIds),
|
||||
actor,
|
||||
{
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
},
|
||||
);
|
||||
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: this.idsOf(dto.attendeeIds);
|
||||
await this.notifyEvent('schedule.invited', recipients, actor, {
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
});
|
||||
return this.findEventOrThrow(saved.id);
|
||||
}
|
||||
|
||||
@@ -169,8 +167,9 @@ export class ScheduleService {
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
|
||||
// 수정 전 참석자(알림 대상 비교용)
|
||||
// 수정 전 참석자·전직원 여부(알림 대상 비교용) — 아래 필드 변경 전에 캡처
|
||||
const oldIds = new Set((event.attendees ?? []).map((a) => a.id));
|
||||
const wasAllHands = event.allHands;
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
@@ -195,10 +194,16 @@ export class ScheduleService {
|
||||
}
|
||||
await this.eventRepo.save(event);
|
||||
|
||||
// 알림: 새로 추가된 참석자 → 초대, 기존 참석자 → 수정됨
|
||||
const newIds = (event.attendees ?? []).map((a) => a.id);
|
||||
const invited = newIds.filter((i) => !oldIds.has(i));
|
||||
const stillThere = newIds.filter((i) => oldIds.has(i));
|
||||
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로 신규→초대, 기존→수정됨.
|
||||
// (전 직원 일정이면 전 사용자가 실효 참석자 — 수정 전/후 어느 쪽이든 반영)
|
||||
const allIds =
|
||||
wasAllHands || event.allHands ? await this.userService.findAllIds() : [];
|
||||
const oldEffective = new Set(wasAllHands ? allIds : [...oldIds]);
|
||||
const newEffective = event.allHands
|
||||
? allIds
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
const invited = newEffective.filter((i) => !oldEffective.has(i));
|
||||
const stillThere = newEffective.filter((i) => oldEffective.has(i));
|
||||
const payload = { eventTitle: event.title, start: event.startDate };
|
||||
await this.notifyEvent('schedule.invited', invited, actor, payload);
|
||||
await this.notifyEvent('schedule.updated', stillThere, actor, payload);
|
||||
@@ -213,7 +218,10 @@ export class ScheduleService {
|
||||
relations: ['attendees'],
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
const recipients = (event.attendees ?? []).map((a) => a.id);
|
||||
// 전 직원 일정이면 전 사용자에게 삭제 알림
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
|
||||
await this.eventRepo.delete({ id });
|
||||
|
||||
|
||||
@@ -73,6 +73,19 @@ export class CreateTaskDto {
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'공동 지시자 사용자 ID 배열(선택, 프로젝트 멤버만 가능) — 원 지시자와 함께 완료 검토 권한을 가진다',
|
||||
type: [String],
|
||||
required: false,
|
||||
example: ['b2c3d4e5-...'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
coIssuerIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '마감 기한(ISO8601, 선택)',
|
||||
required: false,
|
||||
|
||||
@@ -80,6 +80,18 @@ export class UpdateTaskDto {
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'공동 지시자 사용자 ID 배열(프로젝트 멤버만 가능). 제공 시 전체 교체, 빈 배열이면 모두 해제',
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
coIssuerIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '마감 기한(ISO8601). null 이면 마감 해제',
|
||||
required: false,
|
||||
|
||||
@@ -60,6 +60,16 @@ export class Task {
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
issuer!: User | null;
|
||||
|
||||
// 공동 지시자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증).
|
||||
// 원 지시자와 동일하게 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
name: 'task_co_issuers',
|
||||
joinColumn: { name: 'task_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||
})
|
||||
coIssuers!: User[];
|
||||
|
||||
// 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증)
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
@@ -96,6 +106,23 @@ export class Task {
|
||||
@Column({ name: 'changes_note', type: 'text', nullable: true })
|
||||
changesNote!: string | null;
|
||||
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 원 지시자 또는 공동 지시자.
|
||||
// 완료/수정요청 카드의 '검토자' 표기에 사용(사용자 삭제 시 SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'reviewed_by' })
|
||||
reviewedBy!: User | null;
|
||||
|
||||
// --- 마감 임박·초과 알림(cron) 발송 여부 플래그 ---
|
||||
// 중복 발송을 막기 위해 발송 시각을 기록하고, 마감일이 변경되면 다시 null 로 초기화한다.
|
||||
|
||||
// 마감 임박(하루 앞) 알림 발송 시각 — 미발송이면 null
|
||||
@Column({ name: 'due_soon_notified_at', type: 'timestamptz', nullable: true })
|
||||
dueSoonNotifiedAt!: Date | null;
|
||||
|
||||
// 마감 초과 알림 발송 시각 — 미발송이면 null
|
||||
@Column({ name: 'overdue_notified_at', type: 'timestamptz', nullable: true })
|
||||
overdueNotifiedAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -2,10 +2,18 @@ import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Repository, type SelectQueryBuilder } from 'typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
Brackets,
|
||||
In,
|
||||
IsNull,
|
||||
Repository,
|
||||
type SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
@@ -161,6 +169,8 @@ export interface TaskDetailResponse {
|
||||
checklist: ChecklistItemResponse[];
|
||||
assignees: PublicUser[];
|
||||
issuer: PublicUser | null;
|
||||
// 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다
|
||||
coIssuers: PublicUser[];
|
||||
dueDate: string | null;
|
||||
createdAt: string;
|
||||
comments: CommentResponse[];
|
||||
@@ -168,6 +178,8 @@ export interface TaskDetailResponse {
|
||||
files: AttachmentResponse[];
|
||||
approval: ApprovalInfo | null;
|
||||
changesNote: string | null;
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 완료/수정요청 카드의 검토자 표기용
|
||||
reviewer: PublicUser | null;
|
||||
// 중간 점검일 + 작업자 보고 이력
|
||||
checkpoints: CheckpointResponse[];
|
||||
checkpointReports: CheckpointReportResponse[];
|
||||
@@ -194,6 +206,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
// 업무 비즈니스 로직
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
private readonly logger = new Logger(TaskService.name);
|
||||
|
||||
// 마감 임박 판정 창(시간) — 미완료 업무의 마감이 이 시간 이내로 다가오면 임박 알림 1회 발송
|
||||
private static readonly DUE_SOON_WINDOW_HOURS = 24;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
@@ -230,6 +247,99 @@ export class TaskService {
|
||||
};
|
||||
}
|
||||
|
||||
// 마감 알림 payload — 행위자 없는 시스템 알림. 마감 라벨 표시를 위해 dueDate 동봉
|
||||
private dueNotifyPayload(task: Task): Record<string, unknown> {
|
||||
return {
|
||||
projectId: task.project.id,
|
||||
projectName: task.project.name,
|
||||
taskSeq: task.seq,
|
||||
taskTitle: task.title,
|
||||
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 마감 임박·초과 알림 — 매일 오전 9시. 미완료(done 아님) 업무 중
|
||||
// (1) 마감이 DUE_SOON_WINDOW_HOURS 이내로 다가온 건 → 담당자에게 '마감 임박' 1회
|
||||
// (2) 마감이 이미 지난 건 → 담당자에게 '마감 초과' 1회
|
||||
// 각각 발송 플래그(dueSoonNotifiedAt/overdueNotifiedAt)로 중복 발송을 막는다.
|
||||
// best-effort: 실패해도 서비스에 영향 없음(다음 주기 재시도).
|
||||
// 서버 타임존과 무관하게 한국 시간 오전 9시에 발송되도록 timeZone 고정.
|
||||
@Cron(CronExpression.EVERY_DAY_AT_9AM, { timeZone: 'Asia/Seoul' })
|
||||
async notifyDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await this.notifyDueSoon(now);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`마감 임박 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.notifyOverdue(now);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`마감 초과 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 마감 임박(하루 앞) 미발송 업무 → 담당자에게 알림. 다중 인스턴스 중복 발송을 막기 위해
|
||||
// 플래그를 원자적으로 선점(UPDATE ... WHERE ... IS NULL RETURNING)한 인스턴스만 발송한다.
|
||||
private async notifyDueSoon(now: Date): Promise<void> {
|
||||
const soonEnd = new Date(
|
||||
now.getTime() + TaskService.DUE_SOON_WINDOW_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
const claimed = await this.taskRepo
|
||||
.createQueryBuilder()
|
||||
.update(Task)
|
||||
.set({ dueSoonNotifiedAt: now })
|
||||
.where('status != :done', { done: 'done' })
|
||||
.andWhere('due_date BETWEEN :now AND :soonEnd', { now, soonEnd })
|
||||
.andWhere('due_soon_notified_at IS NULL')
|
||||
.returning('id')
|
||||
.execute();
|
||||
await this.notifyClaimedDue(claimed, 'task.due_soon', '마감 임박');
|
||||
}
|
||||
|
||||
// 마감 초과 미발송 업무 → 담당자에게 알림(위와 동일하게 선점 후 발송)
|
||||
private async notifyOverdue(now: Date): Promise<void> {
|
||||
const claimed = await this.taskRepo
|
||||
.createQueryBuilder()
|
||||
.update(Task)
|
||||
.set({ overdueNotifiedAt: now })
|
||||
.where('status != :done', { done: 'done' })
|
||||
.andWhere('due_date < :now', { now })
|
||||
.andWhere('overdue_notified_at IS NULL')
|
||||
.returning('id')
|
||||
.execute();
|
||||
await this.notifyClaimedDue(claimed, 'task.overdue', '마감 초과');
|
||||
}
|
||||
|
||||
// 선점(UPDATE RETURNING id)한 업무들을 로드해 담당자에게 마감 알림을 발송
|
||||
private async notifyClaimedDue(
|
||||
claimed: { raw: unknown },
|
||||
type: 'task.due_soon' | 'task.overdue',
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const ids = Array.isArray(claimed.raw)
|
||||
? (claimed.raw as { id: string }[]).map((r) => r.id)
|
||||
: [];
|
||||
if (ids.length === 0) return;
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { id: In(ids) },
|
||||
relations: ['assignees', 'project'],
|
||||
});
|
||||
for (const task of tasks) {
|
||||
await this.notification.notify({
|
||||
recipientIds: (task.assignees ?? []).map((a) => a.id),
|
||||
actorId: null,
|
||||
type,
|
||||
payload: this.dueNotifyPayload(task),
|
||||
});
|
||||
}
|
||||
this.logger.log(`${label} 알림 ${tasks.length}건 발송`);
|
||||
}
|
||||
|
||||
// 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트
|
||||
async list(
|
||||
projectId: string,
|
||||
@@ -369,6 +479,13 @@ export class TaskService {
|
||||
dto.assigneeIds,
|
||||
);
|
||||
|
||||
// 공동 지시자(선택) — 프로젝트 멤버만 가능, 원 지시자 본인은 제외
|
||||
const coIssuers = await this.resolveCoIssuers(
|
||||
project.id,
|
||||
dto.coIssuerIds,
|
||||
issuer.user.id,
|
||||
);
|
||||
|
||||
// 프로젝트 내 다음 순번 채번
|
||||
const last = await this.taskRepo.findOne({
|
||||
where: { project: { id: project.id } },
|
||||
@@ -384,6 +501,7 @@ export class TaskService {
|
||||
status: 'todo',
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
issuer: issuer.user,
|
||||
coIssuers,
|
||||
assignees,
|
||||
checklist: (dto.checklist ?? []).map((c, idx) =>
|
||||
this.checklistRepo.create({
|
||||
@@ -443,7 +561,15 @@ export class TaskService {
|
||||
if (dto.title !== undefined) task.title = dto.title;
|
||||
if (dto.content !== undefined) task.content = dto.content;
|
||||
if (dto.dueDate !== undefined) {
|
||||
task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
const newDue = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
const changed =
|
||||
(task.dueDate?.getTime() ?? null) !== (newDue?.getTime() ?? null);
|
||||
task.dueDate = newDue;
|
||||
// 마감일이 실제로 바뀌면 임박·초과 알림을 새 마감 기준으로 다시 보낼 수 있게 플래그 초기화
|
||||
if (changed) {
|
||||
task.dueSoonNotifiedAt = null;
|
||||
task.overdueNotifiedAt = null;
|
||||
}
|
||||
}
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
task.assignees = await this.resolveMemberAssignees(
|
||||
@@ -451,6 +577,13 @@ export class TaskService {
|
||||
dto.assigneeIds,
|
||||
);
|
||||
}
|
||||
if (dto.coIssuerIds !== undefined) {
|
||||
task.coIssuers = await this.resolveCoIssuers(
|
||||
project.id,
|
||||
dto.coIssuerIds,
|
||||
task.issuer?.id ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
await this.taskRepo.save(task);
|
||||
|
||||
@@ -851,6 +984,20 @@ export class TaskService {
|
||||
taskSeq: task.seq,
|
||||
});
|
||||
|
||||
// 알림 — 업무 관련자(지시자 + 담당자, 업로더 본인 제외)에게 '파일 첨부'
|
||||
await this.notification.notify({
|
||||
recipientIds: [
|
||||
...(task.issuer ? [task.issuer.id] : []),
|
||||
...task.assignees.map((a) => a.id),
|
||||
],
|
||||
actorId: actingUserId,
|
||||
type: 'task.file_attached',
|
||||
payload: {
|
||||
...this.taskNotifyPayload(project, task, membership.user.name),
|
||||
fileName: name,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toAttachmentResponse(
|
||||
{ ...saved, uploader: membership.user },
|
||||
project,
|
||||
@@ -1058,7 +1205,7 @@ export class TaskService {
|
||||
);
|
||||
}
|
||||
|
||||
// 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
|
||||
// 내가 지시(생성)한 업무 — 원 지시자 또는 공동 지시자. 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
|
||||
async listIssued(
|
||||
userId: string,
|
||||
page?: number,
|
||||
@@ -1072,7 +1219,16 @@ export class TaskService {
|
||||
.leftJoinAndSelect('task.assignees', 'assignee')
|
||||
.leftJoinAndSelect('task.issuer', 'issuer')
|
||||
.leftJoinAndSelect('task.checklist', 'checklist')
|
||||
.where('task.issuer = :userId', { userId });
|
||||
// 원 지시자이거나 공동 지시자(task_co_issuers)면 '내가 지시한 업무'에 포함.
|
||||
// 공동 지시자는 EXISTS 서브쿼리로 판정해 조인으로 인한 행 중복(페이지네이션 왜곡)을 피한다.
|
||||
.where(
|
||||
new Brackets((w) => {
|
||||
w.where('task.issuer = :userId').orWhere(
|
||||
'EXISTS (SELECT 1 FROM task_co_issuers tci WHERE tci.task_id = task.id AND tci.user_id = :userId)',
|
||||
);
|
||||
}),
|
||||
)
|
||||
.setParameter('userId', userId);
|
||||
this.applyMyTaskFilters(qb, filters);
|
||||
const [tasks, total] = await qb
|
||||
.skip(pg.skip)
|
||||
@@ -1127,17 +1283,26 @@ export class TaskService {
|
||||
|
||||
const isAdmin = membership.roleType === 'admin';
|
||||
const isIssuer = task.issuer?.id === actingUserId;
|
||||
// 공동 지시자도 원 지시자와 동일한 검토 권한을 가진다
|
||||
const isCoIssuer = (task.coIssuers ?? []).some(
|
||||
(u) => u.id === actingUserId,
|
||||
);
|
||||
const isAssignee = task.assignees.some((a) => a.id === actingUserId);
|
||||
// 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
|
||||
// 승인/수정요청(review 출발)은 지시자·공동 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
|
||||
const permitted =
|
||||
from === 'review'
|
||||
? isAdmin || isIssuer
|
||||
: isAdmin || isIssuer || isAssignee;
|
||||
? isAdmin || isIssuer || isCoIssuer
|
||||
: isAdmin || isIssuer || isCoIssuer || isAssignee;
|
||||
if (!permitted) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
task.status = next;
|
||||
// 검토 전이(승인→done / 수정요청→changes) 시 실제 검토자 기록 — 완료/수정요청 카드의 검토자 표기용.
|
||||
// 원 지시자·공동 지시자·admin 누구든 검토한 사람이 남는다.
|
||||
if (next === 'done' || next === 'changes') {
|
||||
task.reviewedBy = membership.user;
|
||||
}
|
||||
await this.taskRepo.save(task);
|
||||
|
||||
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
|
||||
@@ -1158,11 +1323,16 @@ export class TaskService {
|
||||
taskSeq: task.seq,
|
||||
});
|
||||
|
||||
// 알림 — 전이 종류별 수신자(승인요청→지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
|
||||
// 알림 — 전이 종류별 수신자(승인요청→지시자·공동지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
|
||||
const actorName = membership.user.name;
|
||||
// 검토자(원 지시자 + 공동 지시자) — 승인요청·시작 알림 수신 대상. 행위자는 notify 에서 자동 제외
|
||||
const reviewerIds = [
|
||||
...(task.issuer ? [task.issuer.id] : []),
|
||||
...(task.coIssuers ?? []).map((u) => u.id),
|
||||
];
|
||||
if (next === 'review') {
|
||||
await this.notification.notify({
|
||||
recipientIds: task.issuer ? [task.issuer.id] : [],
|
||||
recipientIds: reviewerIds,
|
||||
actorId: actingUserId,
|
||||
type: 'task.approval_requested',
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
@@ -1182,12 +1352,16 @@ export class TaskService {
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
});
|
||||
} else if (next === 'prog') {
|
||||
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게
|
||||
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자·공동 지시자에게.
|
||||
// resumed=true 면 '재개', 아니면 '시작'으로 문구 분기(수정 요청 후 재개 구분).
|
||||
await this.notification.notify({
|
||||
recipientIds: task.issuer ? [task.issuer.id] : [],
|
||||
recipientIds: reviewerIds,
|
||||
actorId: actingUserId,
|
||||
type: 'task.started',
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
payload: {
|
||||
...this.taskNotifyPayload(project, task, actorName),
|
||||
resumed: from === 'changes',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1207,7 +1381,14 @@ export class TaskService {
|
||||
private async getTaskOrThrow(projectId: string, seq: number): Promise<Task> {
|
||||
const task = await this.taskRepo.findOne({
|
||||
where: { project: { id: projectId }, seq },
|
||||
relations: ['assignees', 'issuer', 'checklist', 'approvalRequestedBy'],
|
||||
relations: [
|
||||
'assignees',
|
||||
'issuer',
|
||||
'coIssuers',
|
||||
'checklist',
|
||||
'approvalRequestedBy',
|
||||
'reviewedBy',
|
||||
],
|
||||
});
|
||||
if (!task) {
|
||||
throw new NotFoundException();
|
||||
@@ -1282,6 +1463,31 @@ export class TaskService {
|
||||
return memberships.map((m) => m.user);
|
||||
}
|
||||
|
||||
// 공동 지시자 ID 목록 → User[] 로 변환. 프로젝트 멤버만 허용하며 원 지시자(excludeUserId)는 제외.
|
||||
// 목록이 없거나 비면 빈 배열.
|
||||
private async resolveCoIssuers(
|
||||
projectId: string,
|
||||
coIssuerIds: string[] | undefined,
|
||||
excludeUserId: string | null,
|
||||
): Promise<ProjectMember['user'][]> {
|
||||
const uniqueIds = [...new Set(coIssuerIds ?? [])].filter(
|
||||
(id) => id !== excludeUserId,
|
||||
);
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const memberships = await this.memberRepo.find({
|
||||
where: { project: { id: projectId }, user: { id: In(uniqueIds) } },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (memberships.length !== uniqueIds.length) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'공동 지시자는 프로젝트 멤버만 지정할 수 있습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return memberships.map((m) => m.user);
|
||||
}
|
||||
|
||||
// 업무별 댓글 수(답글 포함) 집계 — 목록 commentCount 용
|
||||
private async commentCountsByTask(
|
||||
taskIds: string[],
|
||||
@@ -1540,9 +1746,10 @@ export class TaskService {
|
||||
reported: reportedCheckpointIds.has(c.id),
|
||||
}));
|
||||
|
||||
// 보고 '내용'은 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
|
||||
// 보고 '내용'은 지시자 + 공동 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
|
||||
const canViewReports =
|
||||
task.issuer?.id === viewerId ||
|
||||
(task.coIssuers ?? []).some((u) => u.id === viewerId) ||
|
||||
(task.assignees ?? []).some((a) => a.id === viewerId);
|
||||
const checkpointReports: CheckpointReportResponse[] = canViewReports
|
||||
? reportRows.map((r) => ({
|
||||
@@ -1580,6 +1787,7 @@ export class TaskService {
|
||||
checklist,
|
||||
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
|
||||
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
|
||||
coIssuers: (task.coIssuers ?? []).map((u) => UserService.toPublic(u)),
|
||||
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
comments,
|
||||
@@ -1587,6 +1795,7 @@ export class TaskService {
|
||||
files,
|
||||
approval,
|
||||
changesNote: task.status === 'changes' ? task.changesNote : null,
|
||||
reviewer: task.reviewedBy ? UserService.toPublic(task.reviewedBy) : null,
|
||||
checkpoints,
|
||||
checkpointReports,
|
||||
};
|
||||
|
||||
@@ -76,6 +76,12 @@ export class UserService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
// 전체 사용자 id 목록 — 전사 알림(예: 전 직원 일정)의 수신자 산출용
|
||||
async findAllIds(): Promise<string[]> {
|
||||
const rows = await this.userRepository.find({ select: { id: true } });
|
||||
return rows.map((u) => u.id);
|
||||
}
|
||||
|
||||
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
|
||||
findByEmailWithPassword(email: string): Promise<User | null> {
|
||||
return this.userRepository
|
||||
|
||||
@@ -13,6 +13,8 @@ services:
|
||||
# 화상회의 — 브라우저가 접속할 LiveKit URL(개발은 LiveKit Cloud 사용).
|
||||
# 런타임 env 로 주입해 이미지 재빌드 없이 .env 변경 + 컨테이너 재생성만으로 반영된다.
|
||||
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
|
||||
# 웹 푸시(VAPID) 공개키 — dev 는 Vite dev 서버 런타임 env 로 주입
|
||||
- VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
volumes:
|
||||
- ./${FRONTEND_DIR}:/app
|
||||
- /app/node_modules
|
||||
|
||||
@@ -13,6 +13,8 @@ services:
|
||||
- VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
|
||||
# 브라우저가 접속할 LiveKit WebSocket URL
|
||||
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
|
||||
# 웹 푸시(VAPID) 공개키 — 브라우저 푸시 구독에 사용
|
||||
- VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
@@ -72,6 +74,9 @@ services:
|
||||
- OBJECT_STORAGE_ENDPOINT=${OBJECT_STORAGE_ENDPOINT}
|
||||
- OBJECT_STORAGE_REGION=${OBJECT_STORAGE_REGION}
|
||||
- OBJECT_STORAGE_BUCKET=${OBJECT_STORAGE_BUCKET}
|
||||
# 웹 푸시(VAPID) — 비밀 아닌 값만 루트에서 주입(PRIVATE_KEY 는 backend env_file)
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -16,6 +16,8 @@ ARG VITE_AI_TIMEOUT_MS
|
||||
ENV VITE_AI_TIMEOUT_MS=$VITE_AI_TIMEOUT_MS
|
||||
ARG VITE_LIVEKIT_URL
|
||||
ENV VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL
|
||||
ARG VITE_VAPID_PUBLIC_KEY
|
||||
ENV VITE_VAPID_PUBLIC_KEY=$VITE_VAPID_PUBLIC_KEY
|
||||
|
||||
RUN npm run build-only -- --mode ${BUILD_MODE}
|
||||
|
||||
|
||||
Vendored
+2
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_AI_TIMEOUT_MS: string
|
||||
/** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */
|
||||
readonly VITE_LIVEKIT_URL: string
|
||||
/** 웹 푸시(VAPID) 공개키 — 푸시 구독에 사용. 미설정 시 푸시 비활성 */
|
||||
readonly VITE_VAPID_PUBLIC_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable */
|
||||
// Relay 서비스워커 — 웹 푸시(Level2): 사이트/탭을 닫아도 OS 토스트를 띄운다.
|
||||
// 푸시 페이로드는 백엔드 WebPushService 가 { title, body, url } 로 보낸다.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// 새 워커를 즉시 활성화
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return
|
||||
let payload = {}
|
||||
try {
|
||||
payload = event.data.json()
|
||||
} catch (e) {
|
||||
payload = { title: 'Relay', body: '새 알림이 도착했습니다' }
|
||||
}
|
||||
const title = payload.title || 'Relay'
|
||||
const body = payload.body || ''
|
||||
const url = payload.url || null
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 열린 클라이언트(탭)가 있으면 페이지의 SSE 가 이미 토스트를 처리 → 중복 표시 생략
|
||||
const clients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
if (clients.length > 0) return
|
||||
await self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/relay-icon-256.png',
|
||||
badge: '/relay-icon-256.png',
|
||||
data: { url },
|
||||
})
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해
|
||||
// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. best-effort(실패 시 앱 재진입의
|
||||
// enablePush(true) 가 최신화). 백엔드 경로는 기본 프록시('/api') 기준.
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
// 브라우저가 새 구독을 넘겨줬으면 그대로, 아니면 이전 구독의 VAPID 키로 재구독
|
||||
let sub = event.newSubscription || null
|
||||
if (!sub) {
|
||||
const opts =
|
||||
event.oldSubscription && event.oldSubscription.options
|
||||
? event.oldSubscription.options
|
||||
: null
|
||||
const appServerKey = opts && opts.applicationServerKey
|
||||
// 키가 없으면 재구독 불가 — 앱 재진입 때 복구되도록 조용히 종료
|
||||
if (!appServerKey) return
|
||||
sub = await self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: appServerKey,
|
||||
})
|
||||
}
|
||||
if (!sub) return
|
||||
const json = sub.toJSON()
|
||||
await fetch('/api/notifications/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: (json.keys && json.keys.p256dh) || '',
|
||||
auth: (json.keys && json.keys.auth) || '',
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
/* 재구독/재등록 실패 무시 — 앱 재진입 시 최신화 */
|
||||
}
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = (event.notification.data && event.notification.data.url) || '/'
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
// 이미 열린 창이 있으면 포커스 후 해당 경로로 이동
|
||||
for (const client of clients) {
|
||||
if ('focus' in client) {
|
||||
await client.focus()
|
||||
if ('navigate' in client && url) {
|
||||
try {
|
||||
await client.navigate(url)
|
||||
} catch (e) {
|
||||
/* 이동 실패 무시 */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// 열린 창이 없으면 새 창
|
||||
if (self.clients.openWindow) await self.clients.openWindow(url)
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
// 공용 활동 피드 — 날짜 그룹 단위로 "원형 아이콘 + 아바타 + 본문 + 시간" 행을 렌더한다.
|
||||
// 프로젝트 활동 페이지와 드라이브 기록 패널이 동일한 디자인을 공유하기 위한 컴포넌트.
|
||||
// 본문(item.html)은 스토어에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다.
|
||||
import { defineComponent, h } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import type { ActivityIcon, ProjectActivityDay } from '@/mock/relay.mock'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
days: ProjectActivityDay[]
|
||||
// 카드 테두리/배경 없이(모달 등 내부에 임베드할 때) 본문만 렌더
|
||||
bare?: boolean
|
||||
}>(),
|
||||
{ bare: false },
|
||||
)
|
||||
|
||||
// 활동 아이콘 — name 으로 SVG path 분기
|
||||
const ACT_PATHS: Record<ActivityIcon, string> = {
|
||||
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||
'member-add': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>',
|
||||
'member-check': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/>',
|
||||
'member-remove': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
repo: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
}
|
||||
const ActIcon = defineComponent({
|
||||
props: { name: { type: String, required: true } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- act-text 의 v-html 은 스토어에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="feed"
|
||||
:class="{ bare }"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<template
|
||||
v-for="group in days"
|
||||
:key="group.day"
|
||||
>
|
||||
<div class="day">
|
||||
{{ group.day }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, i) in group.items"
|
||||
:key="group.day + i"
|
||||
class="act"
|
||||
:class="{ 'last-in-group': i === group.items.length - 1 }"
|
||||
>
|
||||
<span
|
||||
class="act-ico"
|
||||
:class="item.tone"
|
||||
><ActIcon :name="item.icon" /></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av"><UserAvatar :url="item.avatarUrl" /></span>
|
||||
<span
|
||||
class="act-text"
|
||||
v-html="item.html"
|
||||
/>
|
||||
</div>
|
||||
<span class="act-time">{{ item.time }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 활동 피드 */
|
||||
.feed {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0.375rem 1.25rem 1.125rem;
|
||||
}
|
||||
/* 모달 등 내부 임베드 — 카드 외곽 제거 */
|
||||
.feed.bare {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
.feed-inner {
|
||||
position: relative;
|
||||
}
|
||||
.day {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.0125rem;
|
||||
padding: 1.125rem 0 0.375rem;
|
||||
}
|
||||
/* bare 모드의 첫 날짜 헤더는 위 여백 축소(모달 본문 padding 과 중복 방지) */
|
||||
.feed.bare .feed-inner > .day:first-child {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.5625rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.act::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.969rem;
|
||||
top: 50%;
|
||||
bottom: -0.5625rem;
|
||||
width: 1.5px;
|
||||
background: var(--border);
|
||||
z-index: 0;
|
||||
}
|
||||
.act.last-in-group::before {
|
||||
display: none;
|
||||
}
|
||||
.act-ico {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.act-ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.act-ico.task {
|
||||
background: var(--blue-weak);
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-ico.member {
|
||||
background: var(--green-weak);
|
||||
color: var(--green);
|
||||
}
|
||||
.act-ico.setting {
|
||||
background: var(--amber-weak);
|
||||
color: var(--amber);
|
||||
}
|
||||
.act-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.act-av {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.act-text {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.act-text :deep(b) {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.tgt) {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.act-text :deep(.tgt:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.act-text :deep(.st) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.st.prog) {
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-text :deep(.st.done) {
|
||||
color: var(--green);
|
||||
}
|
||||
.act-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 AI 검토 문서 모달 — 연동 드라이브 폴더 내 문서를 리스트로 표시(다운로드 전용)
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import { fileBadgeOf, formatBytes } from '@/shared/utils/format'
|
||||
import type { ApiProjectDocument, ApiProjectDriveLink } from '@/types/project'
|
||||
|
||||
defineProps<{
|
||||
documents: ApiProjectDocument[]
|
||||
driveLinks: ApiProjectDriveLink[]
|
||||
}>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const drive = useDrive()
|
||||
|
||||
// 드라이브 단기 presigned URL 발급 후 다운로드
|
||||
async function download(fileId: string): Promise<void> {
|
||||
const url = await drive.getDownloadUrl(fileId)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
title="AI 검토 문서"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #subtitle>
|
||||
연동된 드라이브 폴더 <b>{{ driveLinks.length }}</b>개의 문서를 AI 업무 추천에 활용합니다.
|
||||
</template>
|
||||
|
||||
<EmptyState v-if="documents.length === 0">
|
||||
연동 폴더에 문서가 없습니다. 설정에서 드라이브 폴더를 연동해 보세요.
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="pdm-list"
|
||||
>
|
||||
<button
|
||||
v-for="d in documents"
|
||||
:key="d.id"
|
||||
class="pdm-row"
|
||||
type="button"
|
||||
title="다운로드"
|
||||
@click="download(d.id)"
|
||||
>
|
||||
<span
|
||||
class="pdm-ico"
|
||||
:class="fileBadgeOf(d.name).cls"
|
||||
>{{ fileBadgeOf(d.name).label }}</span>
|
||||
<span class="pdm-main">
|
||||
<span class="pdm-name">{{ d.name }}</span>
|
||||
<span class="pdm-size">{{ formatBytes(d.size) }}</span>
|
||||
</span>
|
||||
<AttachmentAiBadge :status="d.aiStatus" />
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pdm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pdm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.pdm-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.pdm-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.pdm-ico {
|
||||
flex: 0 0 auto;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
.pdm-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.pdm-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pdm-size {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* 파일 종류 배지 색상(format.fileBadgeOf 와 동일) */
|
||||
.fi-img {
|
||||
background: #2aa775;
|
||||
}
|
||||
.fi-pdf {
|
||||
background: #e25950;
|
||||
}
|
||||
.fi-doc {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.fi-xls {
|
||||
background: #1f9d57;
|
||||
}
|
||||
.fi-ppt {
|
||||
background: #e0823d;
|
||||
}
|
||||
.fi-zip {
|
||||
background: #8a64d6;
|
||||
}
|
||||
.fi-txt {
|
||||
background: #5b6470;
|
||||
}
|
||||
.fi-media {
|
||||
background: #c0469d;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
DriveActivityPage,
|
||||
DriveFile,
|
||||
DriveFolder,
|
||||
DriveList,
|
||||
@@ -58,6 +59,16 @@ export function useDrive() {
|
||||
await api.delete(`/drive/files/${id}`)
|
||||
}
|
||||
|
||||
// 감사 로그(최신순, 페이지네이션)
|
||||
async function listActivity(
|
||||
page = 1,
|
||||
size = 20,
|
||||
): Promise<DriveActivityPage> {
|
||||
return (await api.get('/drive/activity', {
|
||||
params: { page, size },
|
||||
})) as unknown as DriveActivityPage
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
createFolder,
|
||||
@@ -68,5 +79,6 @@ export function useDrive() {
|
||||
getDownloadUrl,
|
||||
renameFile,
|
||||
deleteFile,
|
||||
listActivity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,5 +31,18 @@ export function useNotification() {
|
||||
)) as unknown as UnreadCountResult
|
||||
}
|
||||
|
||||
return { list, markRead, markAllRead }
|
||||
// 웹 푸시 구독 등록(브라우저 PushSubscription)
|
||||
async function subscribePush(body: {
|
||||
endpoint: string
|
||||
keys: { p256dh: string; auth: string }
|
||||
}): Promise<void> {
|
||||
await api.post('/notifications/push/subscribe', body)
|
||||
}
|
||||
|
||||
// 웹 푸시 구독 해제
|
||||
async function unsubscribePush(endpoint: string): Promise<void> {
|
||||
await api.post('/notifications/push/unsubscribe', { endpoint })
|
||||
}
|
||||
|
||||
return { list, markRead, markAllRead, subscribePush, unsubscribePush }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ref } from 'vue'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
// VAPID 공개키(base64url) → Uint8Array (applicationServerKey 형식)
|
||||
// ArrayBuffer 백킹으로 명시(BufferSource 타입 충족)
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(base64)
|
||||
const arr = new Uint8Array(new ArrayBuffer(raw.length))
|
||||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
|
||||
return arr
|
||||
}
|
||||
|
||||
// 푸시 켜기 결과 — UI 가 사유별 안내를 띄울 수 있도록 상태로 반환
|
||||
export type PushResult =
|
||||
| 'subscribed' // 구독 성공
|
||||
| 'denied' // 브라우저가 알림을 차단함
|
||||
| 'dismissed' // 사용자가 권한 팝업을 닫음(미결정 유지)
|
||||
| 'unsupported' // 미지원/비보안 컨텍스트
|
||||
| 'no-key' // 서버 VAPID 공개키 미설정
|
||||
| 'error' // 그 외 실패(SW/구독)
|
||||
|
||||
// 웹 푸시(Level2) 구독 관리 — 권한 요청 + PushManager 구독 + 백엔드 등록/해제.
|
||||
export function usePush() {
|
||||
const api = useNotification()
|
||||
const supported =
|
||||
typeof navigator !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
typeof window !== 'undefined' &&
|
||||
'PushManager' in window &&
|
||||
typeof Notification !== 'undefined'
|
||||
|
||||
// 현재 권한 상태(UI 토글용)
|
||||
const permission = ref<NotificationPermission>(
|
||||
supported ? Notification.permission : 'denied',
|
||||
)
|
||||
|
||||
// 서비스워커 등록 확보 — 명시적 register 로 .ready 무한대기를 피한다.
|
||||
// 활성화 전이면 최대 5초만 기다린다(그래도 없으면 그대로 진행 — subscribe 가 처리).
|
||||
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!supported) return null
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js')
|
||||
if (!reg.active) {
|
||||
await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||
])
|
||||
}
|
||||
return reg
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 권한 요청(필요 시) + 푸시 구독 + 백엔드 등록. 결과 상태를 반환(UI 안내용).
|
||||
// silent=true 면 이미 granted 인 경우에만 진행(권한 팝업 띄우지 않음 — 자동 재구독용).
|
||||
async function enablePush(silent = false): Promise<PushResult> {
|
||||
if (!supported) return 'unsupported'
|
||||
const key = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (!key) return 'no-key'
|
||||
|
||||
let perm = Notification.permission
|
||||
if (perm !== 'granted') {
|
||||
if (silent) return 'denied'
|
||||
perm = await Notification.requestPermission()
|
||||
}
|
||||
permission.value = perm
|
||||
if (perm === 'denied') return 'denied'
|
||||
if (perm !== 'granted') return 'dismissed'
|
||||
|
||||
const reg = await getRegistration()
|
||||
if (!reg) return 'error'
|
||||
try {
|
||||
let sub = await reg.pushManager.getSubscription()
|
||||
if (!sub) {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key),
|
||||
})
|
||||
}
|
||||
const json = sub.toJSON()
|
||||
await api.subscribePush({
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: json.keys?.p256dh ?? '',
|
||||
auth: json.keys?.auth ?? '',
|
||||
},
|
||||
})
|
||||
return 'subscribed'
|
||||
} catch {
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
// 구독 해제(로그아웃 등) — 백엔드 삭제 + 브라우저 구독 해지
|
||||
async function disablePush(): Promise<void> {
|
||||
if (!supported) return
|
||||
const reg = await getRegistration()
|
||||
if (!reg) return
|
||||
try {
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await api.unsubscribePush(sub.endpoint)
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
} catch {
|
||||
// 해제 실패 무시
|
||||
}
|
||||
}
|
||||
|
||||
return { supported, permission, enablePush, disablePush }
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useNotificationStore } from '@/stores/notification.store'
|
||||
import { usePush } from '@/composables/usePush'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import RelayMark from '@/components/RelayMark.vue'
|
||||
import AgentChatPanel from '@/components/AgentChatPanel.vue'
|
||||
@@ -52,6 +54,45 @@ const {
|
||||
|
||||
const notiOpen = ref(false)
|
||||
|
||||
// --- PC 데스크톱 알림(웹 푸시) ---
|
||||
const dialog = useDialog()
|
||||
const { supported: pushSupported, permission: pushPermission, enablePush, disablePush } =
|
||||
usePush()
|
||||
// 종 드롭다운에서 "PC 알림 켜기" 노출 조건(지원 + 아직 허용 전)
|
||||
const canEnablePush = computed(
|
||||
() => pushSupported && pushPermission.value !== 'granted',
|
||||
)
|
||||
async function onEnablePush() {
|
||||
const result = await enablePush()
|
||||
if (result === 'subscribed') {
|
||||
notiOpen.value = false
|
||||
void dialog.alert('이 브라우저에서 PC 알림을 받습니다.', {
|
||||
title: 'PC 알림 켜짐',
|
||||
})
|
||||
} else if (result === 'denied') {
|
||||
void dialog.alert(
|
||||
'브라우저에서 이 사이트의 알림이 차단되어 있습니다. 주소창 왼쪽 자물쇠(ⓘ) 아이콘 → 알림을 “허용”으로 바꾼 뒤 다시 시도해 주세요.',
|
||||
{ title: 'PC 알림 차단됨', variant: 'danger' },
|
||||
)
|
||||
} else if (result === 'unsupported') {
|
||||
void dialog.alert(
|
||||
'이 브라우저(또는 보안 컨텍스트)에서는 PC 알림을 지원하지 않습니다. localhost 또는 https 환경의 최신 Chrome/Edge 에서 이용해 주세요.',
|
||||
{ title: 'PC 알림 미지원', variant: 'danger' },
|
||||
)
|
||||
} else if (result === 'no-key') {
|
||||
void dialog.alert(
|
||||
'서버에 푸시 키가 설정되어 있지 않습니다. 관리자에게 문의해 주세요.',
|
||||
{ title: 'PC 알림', variant: 'danger' },
|
||||
)
|
||||
} else if (result === 'error') {
|
||||
void dialog.alert(
|
||||
'PC 알림 설정에 실패했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
{ title: 'PC 알림', variant: 'danger' },
|
||||
)
|
||||
}
|
||||
// 'dismissed'(권한 팝업 닫음)는 조용히 무시
|
||||
}
|
||||
|
||||
// 화면 전환 시 모바일 드로어 닫기
|
||||
watch(
|
||||
() => route.path,
|
||||
@@ -70,11 +111,16 @@ async function onNotiClick(v: NotificationView) {
|
||||
// AppShell 은 인증 이후 화면에서만 마운트되므로 여기서 스트림 연결(멱등)
|
||||
// (store 싱글톤이 연결을 보유 → 화면 전환에도 끊기지 않음. 해제는 로그아웃 시에만)
|
||||
onMounted(() => {
|
||||
if (authStore.isLoggedIn) void notiStore.connect()
|
||||
if (authStore.isLoggedIn) {
|
||||
void notiStore.connect()
|
||||
// 이미 권한을 허용한 사용자면 조용히 재구독(권한 팝업 없이) — 구독 최신화
|
||||
void enablePush(true)
|
||||
}
|
||||
})
|
||||
|
||||
// 로그아웃 → 알림 스트림 해제 후 로그인 화면으로
|
||||
// 로그아웃 → 푸시 구독·알림 스트림 해제 후 로그인 화면으로
|
||||
async function onLogout() {
|
||||
await disablePush()
|
||||
notiStore.disconnect()
|
||||
await authStore.logout()
|
||||
await router.push('/login')
|
||||
@@ -392,14 +438,36 @@ async function onLogout() {
|
||||
>
|
||||
<div class="noti-head">
|
||||
<span class="noti-title">알림</span>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="noti-readall"
|
||||
@click="notiStore.markAllRead()"
|
||||
>
|
||||
모두 읽음
|
||||
</button>
|
||||
<span class="noti-head-actions">
|
||||
<button
|
||||
v-if="canEnablePush"
|
||||
type="button"
|
||||
class="noti-pushbtn"
|
||||
title="이 브라우저에서 PC 알림 받기"
|
||||
@click="onEnablePush"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
PC 알림 켜기
|
||||
</button>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="noti-readall"
|
||||
@click="notiStore.markAllRead()"
|
||||
>
|
||||
모두 읽음
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="noti-list">
|
||||
<div
|
||||
@@ -795,6 +863,11 @@ async function onLogout() {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.noti-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.noti-readall {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -809,6 +882,29 @@ async function onLogout() {
|
||||
.noti-readall:hover {
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
/* PC 알림 켜기 — 권한 허용 전에만 노출 */
|
||||
.noti-pushbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem 0.3125rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.noti-pushbtn svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.noti-pushbtn:hover {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.noti-list {
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -13,3 +13,12 @@ app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 웹 푸시용 서비스워커 등록 — 푸시 수신/클릭 처리(Level2). 실패해도 앱에 영향 없음.
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
void navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// 등록 실패(미지원/비보안 컨텍스트) 무시 — 인앱/SSE 알림은 정상 동작
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@ export interface TaskDetail {
|
||||
activities: Activity[]
|
||||
assignees: User[]
|
||||
issuer: User
|
||||
/** 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다 */
|
||||
coIssuers: User[]
|
||||
dueLabel: string
|
||||
dday: string
|
||||
/** 원본 마감 ISO(중간 점검 보고 가능 구간 계산용) — 없으면 null */
|
||||
@@ -184,6 +186,8 @@ export interface TaskDetail {
|
||||
}
|
||||
/** 수정 요청 메시지(수정 요청 상태일 때) */
|
||||
changesNote?: string
|
||||
/** 최근 검토(승인/수정요청)를 수행한 사용자 — 없으면(레거시) 원 지시자로 대체 표기 */
|
||||
reviewer: User | null
|
||||
issuedLabel: string
|
||||
/** 중간 점검일 목록 */
|
||||
checkpoints: TaskCheckpointView[]
|
||||
|
||||
@@ -6,6 +6,9 @@ import { watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import ActivityFeed from '@/components/common/ActivityFeed.vue'
|
||||
import DriveNameModal from '@/components/drive/DriveNameModal.vue'
|
||||
import { useDriveStore } from '@/stores/drive.store'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
@@ -22,8 +25,19 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useDriveStore()
|
||||
const dialog = useDialog()
|
||||
const { current, breadcrumb, folders, files, loading, uploads, itemCount, isEmpty } =
|
||||
storeToRefs(store)
|
||||
const {
|
||||
current,
|
||||
breadcrumb,
|
||||
folders,
|
||||
files,
|
||||
loading,
|
||||
uploads,
|
||||
itemCount,
|
||||
isEmpty,
|
||||
activityDays,
|
||||
activityLoading,
|
||||
hasMoreActivity,
|
||||
} = storeToRefs(store)
|
||||
|
||||
// 로컬 상태 — 검색(현재 폴더 내), 모달, 드래그, 파일 입력
|
||||
const keyword = ref('')
|
||||
@@ -69,9 +83,6 @@ const modalProps = computed(() => {
|
||||
function openFolder(f: DriveFolder): void {
|
||||
router.push(`/drive/${f.id}`)
|
||||
}
|
||||
function goRoot(): void {
|
||||
router.push('/drive')
|
||||
}
|
||||
|
||||
// --- 모달 열기 ---
|
||||
function openCreateFolder(): void {
|
||||
@@ -168,6 +179,13 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// --- 기록(감사 로그) 패널 ---
|
||||
const showActivity = ref(false)
|
||||
function openActivity(): void {
|
||||
showActivity.value = true
|
||||
void store.fetchActivity() // 열 때마다 최신으로 갱신
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -179,24 +197,27 @@ watch(
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<!-- 브레드크럼 -->
|
||||
<nav class="crumb dr-crumb">
|
||||
<!-- 브레드크럼 — 전역 .crumb/.lnk/.sep 시스템(프로젝트 화면과 동일) -->
|
||||
<nav class="crumb">
|
||||
<template v-if="breadcrumb.length === 0">
|
||||
<b>드라이브</b>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="goRoot"
|
||||
>드라이브</a>
|
||||
<RouterLink
|
||||
to="/drive"
|
||||
class="lnk"
|
||||
>
|
||||
드라이브
|
||||
</RouterLink>
|
||||
<template
|
||||
v-for="(c, i) in breadcrumb"
|
||||
:key="c.id"
|
||||
>
|
||||
<span class="dr-sep">›</span>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
v-if="i < breadcrumb.length - 1"
|
||||
:to="`/drive/${c.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ c.name }}
|
||||
</RouterLink>
|
||||
@@ -210,7 +231,7 @@ watch(
|
||||
<span class="count-pill">{{ itemCount }}</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
파일과 폴더를 저장하고 관리하세요. 파일을 이 영역에 끌어다 놓아 업로드할 수 있습니다.
|
||||
파일을 올리고 폴더로 정리해 모든 구성원과 함께 관리하세요.
|
||||
</p>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -238,6 +259,28 @@ watch(
|
||||
aria-label="현재 폴더 내 검색"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
@click="openActivity"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
기록
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
@@ -358,7 +401,7 @@ watch(
|
||||
</span>
|
||||
<span class="dr-main">
|
||||
<span class="dr-name">{{ f.name }}</span>
|
||||
<span class="dr-meta">폴더 · {{ relativeTimeKo(f.updatedAt) }}</span>
|
||||
<span class="dr-meta">폴더<template v-if="f.owner"> · {{ f.owner.name }}</template> · {{ relativeTimeKo(f.updatedAt) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="dr-actions">
|
||||
@@ -413,7 +456,7 @@ watch(
|
||||
>{{ fileBadgeOf(f.name).label }}</span>
|
||||
<span class="dr-main">
|
||||
<span class="dr-name">{{ f.name }}</span>
|
||||
<span class="dr-meta">{{ formatBytes(f.size) }} · {{ relativeTimeKo(f.updatedAt) }}</span>
|
||||
<span class="dr-meta">{{ formatBytes(f.size) }}<template v-if="f.owner"> · {{ f.owner.name }}</template> · {{ relativeTimeKo(f.updatedAt) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="dr-actions">
|
||||
@@ -500,25 +543,38 @@ watch(
|
||||
@save="onModalSave"
|
||||
@close="modal = null"
|
||||
/>
|
||||
|
||||
<!-- 기록(감사 로그) 패널 -->
|
||||
<BaseModal
|
||||
v-if="showActivity"
|
||||
title="드라이브 기록"
|
||||
@close="showActivity = false"
|
||||
>
|
||||
<template #subtitle>
|
||||
누가 무엇을 추가·변경·삭제했는지 전체 기록입니다.
|
||||
</template>
|
||||
|
||||
<EmptyState v-if="activityLoading && activityDays.length === 0">
|
||||
불러오는 중…
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="activityDays.length === 0">
|
||||
아직 기록이 없습니다.
|
||||
</EmptyState>
|
||||
<ActivityFeed
|
||||
v-else
|
||||
:days="activityDays"
|
||||
bare
|
||||
/>
|
||||
<LoadMore
|
||||
v-if="hasMoreActivity"
|
||||
:loading="activityLoading"
|
||||
@load="store.loadMoreActivity()"
|
||||
/>
|
||||
</BaseModal>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 브레드크럼 링크 */
|
||||
.dr-crumb a,
|
||||
.dr-crumb :deep(a) {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dr-crumb a:hover,
|
||||
.dr-crumb :deep(a:hover) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dr-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 — 다른 목록 페이지(ProjectListPage)와 동일 스페이싱 */
|
||||
.pagehead {
|
||||
display: flex;
|
||||
@@ -538,9 +594,9 @@ watch(
|
||||
margin: 0.25rem 0 1.125rem;
|
||||
}
|
||||
|
||||
/* 툴바 — 검색은 늘리고 버튼은 우측 */
|
||||
.toolbar .search {
|
||||
flex: 1 1 auto;
|
||||
/* 툴바 — 검색은 전역 고정폭(17.5rem) 사용(프로젝트·회의 화면과 통일), 버튼 그룹은 우측 정렬 */
|
||||
.toolbar > .btn:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
.dr-file-input {
|
||||
display: none;
|
||||
@@ -616,9 +672,19 @@ watch(
|
||||
}
|
||||
|
||||
/* 목록 — 전역 .list 카드(radius 0.625rem + shadow-sm) 사용 */
|
||||
/* 페이지를 세로 플렉스로 만들어 목록 영역이 남는 높이를 모두 채우게 한다(드롭 영역 확장). */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 3.25rem); /* 100vh - .topbar 높이 */
|
||||
}
|
||||
.dr-listwrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto; /* 파일 영역이 페이지의 남는 공간 전체를 차지 → 드래그 드롭 영역도 그만큼 */
|
||||
min-height: 16rem; /* 짧은 뷰포트에서도 최소 드롭 영역 보장 */
|
||||
}
|
||||
/* 비어있음/검색없음일 때도 안내문 카드는 상단에 폭을 꽉 채워 배치(프로젝트·회의 화면과 통일).
|
||||
남는 아래 공간은 그대로 드래그 드롭 영역으로 유지된다. */
|
||||
.dr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 활동 — 날짜 그룹 단위 활동 피드 (API 연동)
|
||||
import { computed, defineComponent, h, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import ActivityFeed from '@/components/common/ActivityFeed.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useActivityStore } from '@/stores/activity.store'
|
||||
import type { Project, ActivityIcon } from '@/mock/relay.mock'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | 'task' | 'member' | 'setting'
|
||||
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
|
||||
@@ -82,32 +82,6 @@ const memberCount = computed(() =>
|
||||
)
|
||||
const loading = computed(() => activityStore.loading)
|
||||
const isEmpty = computed(() => !loading.value && days.value.length === 0)
|
||||
|
||||
// 활동 아이콘 — name 으로 SVG path 분기
|
||||
const ACT_PATHS: Record<ActivityIcon, string> = {
|
||||
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||
'member-add': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>',
|
||||
'member-check': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/>',
|
||||
'member-remove': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
repo: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
}
|
||||
const ActIcon = defineComponent({
|
||||
props: { name: { type: String, required: true } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -187,42 +161,10 @@ const ActIcon = defineComponent({
|
||||
</EmptyState>
|
||||
|
||||
<!-- 활동 피드 -->
|
||||
<!-- act-text 의 v-html 은 store 에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
<ActivityFeed
|
||||
v-else
|
||||
class="feed"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<template
|
||||
v-for="group in days"
|
||||
:key="group.day"
|
||||
>
|
||||
<div class="day">
|
||||
{{ group.day }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, i) in group.items"
|
||||
:key="group.day + i"
|
||||
class="act"
|
||||
:class="{ 'last-in-group': i === group.items.length - 1 }"
|
||||
>
|
||||
<span
|
||||
class="act-ico"
|
||||
:class="item.tone"
|
||||
><ActIcon :name="item.icon" /></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av"><UserAvatar :url="item.avatarUrl" /></span>
|
||||
<span
|
||||
class="act-text"
|
||||
v-html="item.html"
|
||||
/>
|
||||
</div>
|
||||
<span class="act-time">{{ item.time }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
:days="days"
|
||||
/>
|
||||
|
||||
<!-- 더보기 — 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
|
||||
<LoadMore
|
||||
@@ -245,120 +187,4 @@ const ActIcon = defineComponent({
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* 활동 피드 */
|
||||
.feed {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0.375rem 1.25rem 1.125rem;
|
||||
}
|
||||
.feed-inner {
|
||||
position: relative;
|
||||
}
|
||||
.day {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.0125rem;
|
||||
padding: 1.125rem 0 0.375rem;
|
||||
}
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.5625rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.act::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.969rem;
|
||||
top: 50%;
|
||||
bottom: -0.5625rem;
|
||||
width: 1.5px;
|
||||
background: var(--border);
|
||||
z-index: 0;
|
||||
}
|
||||
.act.last-in-group::before {
|
||||
display: none;
|
||||
}
|
||||
.act-ico {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.act-ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.act-ico.task {
|
||||
background: var(--blue-weak);
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-ico.member {
|
||||
background: var(--green-weak);
|
||||
color: var(--green);
|
||||
}
|
||||
.act-ico.setting {
|
||||
background: var(--amber-weak);
|
||||
color: var(--amber);
|
||||
}
|
||||
.act-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.act-av {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.act-text {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.act-text :deep(b) {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.tgt) {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.act-text :deep(.tgt:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.act-text :deep(.st) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.st.prog) {
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-text :deep(.st.done) {
|
||||
color: var(--green);
|
||||
}
|
||||
.act-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -12,12 +12,11 @@ import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue'
|
||||
import ProjectDocumentsModal from '@/components/project/ProjectDocumentsModal.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
|
||||
import { taskStatusLabel, formatBytes } from '@/shared/utils/format'
|
||||
import { taskStatusLabel } from '@/shared/utils/format'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -36,17 +35,8 @@ async function loadProject() {
|
||||
}
|
||||
}
|
||||
|
||||
// 연동 문서 다운로드 — 드라이브 단기 presigned URL 발급 후 다운로드
|
||||
const driveApi = useDrive()
|
||||
async function downloadDoc(fileId: string): Promise<void> {
|
||||
const url = await driveApi.getDownloadUrl(fileId)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
// AI 검토 문서 모달 표시 여부
|
||||
const docsModalOpen = ref(false)
|
||||
|
||||
const keyword = ref('')
|
||||
const segment = ref<TaskSegment>('all')
|
||||
@@ -187,12 +177,16 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
<!-- AI 검토 문서(연동 드라이브 폴더 내 파일, 다운로드 전용) -->
|
||||
<!-- AI 검토 문서 — 버튼으로 모달 열기(연동 폴더가 있을 때만 노출) -->
|
||||
<div
|
||||
v-if="(repo.documents && repo.documents.length) || (repo.driveLinks && repo.driveLinks.length)"
|
||||
class="proj-docs"
|
||||
class="docs-bar"
|
||||
>
|
||||
<div class="pd-head">
|
||||
<button
|
||||
type="button"
|
||||
class="btn docs-btn"
|
||||
@click="docsModalOpen = true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -201,39 +195,18 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
|
||||
AI 검토 문서 <span class="pd-ct">{{ repo.documents?.length ?? 0 }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="repo.driveLinks && repo.driveLinks.length"
|
||||
class="pd-sub"
|
||||
>
|
||||
연동된 드라이브 폴더 {{ repo.driveLinks.length }}개의 문서를 AI 업무 추천에 활용합니다.
|
||||
</p>
|
||||
<div
|
||||
v-if="repo.documents && repo.documents.length"
|
||||
class="pd-list"
|
||||
>
|
||||
<button
|
||||
v-for="d in repo.documents"
|
||||
:key="d.id"
|
||||
class="pd-item"
|
||||
type="button"
|
||||
title="다운로드"
|
||||
@click="downloadDoc(d.id)"
|
||||
>
|
||||
<span class="pd-name">{{ d.name }}</span>
|
||||
<AttachmentAiBadge :status="d.aiStatus" />
|
||||
<span class="pd-size">{{ formatBytes(d.size) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="pd-empty"
|
||||
>
|
||||
연동 폴더에 문서가 없습니다.
|
||||
</p>
|
||||
AI 검토 문서
|
||||
<span class="count-pill">{{ repo.documents?.length ?? 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ProjectDocumentsModal
|
||||
v-if="docsModalOpen"
|
||||
:documents="repo.documents ?? []"
|
||||
:drive-links="repo.driveLinks ?? []"
|
||||
@close="docsModalOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
@@ -518,76 +491,12 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
}
|
||||
|
||||
/* 프로젝트 문서 */
|
||||
.proj-docs {
|
||||
/* AI 검토 문서 — 모달 열기 버튼 */
|
||||
.docs-bar {
|
||||
margin: 0.25rem 0 1rem;
|
||||
padding: 0.875rem 1.125rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.pd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
.pd-head svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.pd-head .pd-ct {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.pd-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pd-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin: -0.25rem 0 0.625rem;
|
||||
}
|
||||
.pd-empty {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.pd-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 100%;
|
||||
padding: 0.375rem 0.6875rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.pd-item:hover {
|
||||
background: #fafbfc;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.pd-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pd-size {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
.docs-btn .count-pill {
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
.toolbar .search {
|
||||
width: 15rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달 (백엔드 연동)
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
@@ -140,6 +140,29 @@ const usersLoading = ref(false)
|
||||
const showUserDropdown = ref(false)
|
||||
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 자동완성 드롭다운은 body 로 Teleport + fixed 위치로 띄운다 — 모달의 overflow 에 잘리지 않도록.
|
||||
const emailInputRef = ref<HTMLInputElement | null>(null)
|
||||
const ddStyle = ref<Record<string, string>>({})
|
||||
// 입력창 기준으로 드롭다운 좌표/최대높이 계산(뷰포트 하단을 넘지 않게 제한)
|
||||
function positionDropdown() {
|
||||
const el = emailInputRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const gap = 6
|
||||
const margin = 12 // 뷰포트 하단 여백
|
||||
const below = window.innerHeight - r.bottom - gap - margin
|
||||
ddStyle.value = {
|
||||
top: `${r.bottom + gap}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
maxHeight: `${Math.min(256, Math.max(120, below))}px`,
|
||||
}
|
||||
}
|
||||
// 스크롤/리사이즈 시 열려 있으면 위치 재계산
|
||||
function onDropdownReposition() {
|
||||
if (showUserDropdown.value) positionDropdown()
|
||||
}
|
||||
|
||||
// 현재 프로젝트에 이미 속한 사용자 id (자동완성에서 제외)
|
||||
const memberIds = computed(() => new Set(members.value.map((m) => m.user.id)))
|
||||
const userOptions = computed(() =>
|
||||
@@ -163,10 +186,12 @@ function scheduleUserSearch() {
|
||||
}
|
||||
function onEmailFocus() {
|
||||
showUserDropdown.value = true
|
||||
void nextTick(positionDropdown)
|
||||
void runUserSearch() // 포커스 시 전체(또는 현재 입력 기준) 목록 표시
|
||||
}
|
||||
function onEmailInput() {
|
||||
showUserDropdown.value = true
|
||||
void nextTick(positionDropdown)
|
||||
scheduleUserSearch()
|
||||
}
|
||||
function onEmailBlur() {
|
||||
@@ -216,8 +241,17 @@ function onKeydown(e: KeyboardEvent) {
|
||||
openRoleFor.value = null
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('resize', onDropdownReposition)
|
||||
// capture=true — 모달 본문 등 내부 스크롤도 포착해 위치를 따라가게 한다
|
||||
window.addEventListener('scroll', onDropdownReposition, true)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('resize', onDropdownReposition)
|
||||
window.removeEventListener('scroll', onDropdownReposition, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -472,6 +506,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<div class="form-field uc-field">
|
||||
<label class="form-label">초대할 사람 (이메일)</label>
|
||||
<input
|
||||
ref="emailInputRef"
|
||||
v-model="inviteEmail"
|
||||
class="form-input"
|
||||
type="email"
|
||||
@@ -481,40 +516,43 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
@input="onEmailInput"
|
||||
@blur="onEmailBlur"
|
||||
>
|
||||
<!-- 사용자 자동완성 드롭다운 -->
|
||||
<div
|
||||
v-if="showUserDropdown"
|
||||
class="user-dd"
|
||||
>
|
||||
<!-- 사용자 자동완성 드롭다운 — body 로 Teleport(모달 overflow 밖에서 잘리지 않도록) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="usersLoading"
|
||||
class="user-dd-msg"
|
||||
v-if="showUserDropdown"
|
||||
class="user-dd"
|
||||
:style="ddStyle"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="user-dd-item"
|
||||
@mousedown.prevent="selectUser(u)"
|
||||
>
|
||||
<span class="avatar user-av"><UserAvatar
|
||||
:url="u.avatarUrl"
|
||||
:name="u.name"
|
||||
/></span>
|
||||
<span class="user-nm">{{ u.name }}</span>
|
||||
<span class="user-em">{{ u.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="userOptions.length === 0"
|
||||
v-if="usersLoading"
|
||||
class="user-dd-msg"
|
||||
>
|
||||
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
|
||||
불러오는 중…
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="user-dd-item"
|
||||
@mousedown.prevent="selectUser(u)"
|
||||
>
|
||||
<span class="avatar user-av"><UserAvatar
|
||||
:url="u.avatarUrl"
|
||||
:name="u.name"
|
||||
/></span>
|
||||
<span class="user-nm">{{ u.name }}</span>
|
||||
<span class="user-em">{{ u.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="userOptions.length === 0"
|
||||
class="user-dd-msg"
|
||||
>
|
||||
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
@@ -833,21 +871,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
.invite-body .uc-field {
|
||||
position: relative;
|
||||
}
|
||||
.invite-body .user-dd {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
/* 자동완성 드롭다운 — body 로 Teleport 되므로 .invite-body 후손 선택자를 쓰지 않는다.
|
||||
위치/최대높이(top/left/width/maxHeight)는 컴포넌트가 인라인 style 로 지정(fixed). */
|
||||
.user-dd {
|
||||
position: fixed;
|
||||
z-index: 1000; /* 모달 backdrop(100) 위 */
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5625rem;
|
||||
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
|
||||
padding: 0.375rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.invite-body .user-dd-item {
|
||||
.user-dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
@@ -860,22 +896,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.invite-body .user-dd-item:hover {
|
||||
.user-dd-item:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.invite-body .user-av {
|
||||
.user-dd .user-av {
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
font-size: 0.6875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.invite-body .user-nm {
|
||||
.user-nm {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.invite-body .user-em {
|
||||
.user-em {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
@@ -885,7 +921,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
max-width: 14rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.invite-body .user-dd-msg {
|
||||
.user-dd-msg {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
|
||||
@@ -96,6 +96,7 @@ async function loadTaskForEdit() {
|
||||
contentText.value = t.content.join('\n')
|
||||
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
|
||||
assignees.value = t.assignees.map(toUserView)
|
||||
coIssuers.value = (t.coIssuers ?? []).map(toUserView)
|
||||
// id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지)
|
||||
checklist.value = t.checklist.map((c) => ({
|
||||
id: c.id,
|
||||
@@ -169,6 +170,34 @@ function onAssigneeBlur() {
|
||||
setTimeout(() => (showAssigneeDropdown.value = false), 120)
|
||||
}
|
||||
|
||||
// --- 공동 지시자 선택 (프로젝트 멤버 중, 원 지시자 본인 제외) ---
|
||||
// 원 지시자와 함께 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
const coIssuers = ref<User[]>([])
|
||||
const coIssuerQuery = ref('')
|
||||
const showCoIssuerDropdown = ref(false)
|
||||
function removeCoIssuer(id: string) {
|
||||
coIssuers.value = coIssuers.value.filter((c) => c.id !== id)
|
||||
}
|
||||
// 아직 선택되지 않은 멤버 후보(이름 부분일치) — 원 지시자 본인은 제외
|
||||
const coIssuerCandidates = computed(() => {
|
||||
const selected = new Set(coIssuers.value.map((c) => c.id))
|
||||
const kw = coIssuerQuery.value.trim()
|
||||
return memberStore.members
|
||||
.filter((m) => m.user.id !== issuer.value.id && !selected.has(m.user.id))
|
||||
.filter((m) => !kw || m.user.name.includes(kw) || m.email.includes(kw))
|
||||
})
|
||||
function addCoIssuer(userId: string) {
|
||||
const m = memberStore.members.find((x) => x.user.id === userId)
|
||||
if (m && !coIssuers.value.some((c) => c.id === userId)) {
|
||||
coIssuers.value.push(m.user)
|
||||
}
|
||||
coIssuerQuery.value = ''
|
||||
showCoIssuerDropdown.value = false
|
||||
}
|
||||
function onCoIssuerBlur() {
|
||||
setTimeout(() => (showCoIssuerDropdown.value = false), 120)
|
||||
}
|
||||
|
||||
// --- 마감기한 ---
|
||||
const dueDate = ref('') // yyyy-MM-dd (input[type=date])
|
||||
|
||||
@@ -446,6 +475,7 @@ async function submitTask() {
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const assigneeIds = assignees.value.map((a) => a.id)
|
||||
const coIssuerIds = coIssuers.value.map((c) => c.id)
|
||||
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
|
||||
const checkpointIsos = checkpoints.value.map((d) => new Date(d).toISOString())
|
||||
|
||||
@@ -456,6 +486,7 @@ async function submitTask() {
|
||||
title: title.value.trim(),
|
||||
content,
|
||||
assigneeIds,
|
||||
coIssuerIds,
|
||||
dueDate: dueIso,
|
||||
checklist: checklist.value.map((c) =>
|
||||
c.id
|
||||
@@ -470,6 +501,7 @@ async function submitTask() {
|
||||
title: title.value.trim(),
|
||||
content: content.length ? content : undefined,
|
||||
assigneeIds,
|
||||
coIssuerIds: coIssuerIds.length ? coIssuerIds : undefined,
|
||||
dueDate: dueIso ?? undefined,
|
||||
checklist: checklist.value.length
|
||||
? checklist.value.map((c) => ({ text: c.text, category: c.category }))
|
||||
@@ -1232,6 +1264,86 @@ function goBack() {
|
||||
<div class="nm">{{ issuer.name }}</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 공동 지시자(선택) — 원 지시자와 함께 완료 검토 권한을 가진다 -->
|
||||
<div class="co-issuer-block">
|
||||
<div class="side-label co-label">
|
||||
공동 지시자 <span class="muted">· 선택</span>
|
||||
</div>
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
<input
|
||||
v-model="coIssuerQuery"
|
||||
class="combo-input"
|
||||
placeholder="멤버 검색…"
|
||||
autocomplete="off"
|
||||
@focus="showCoIssuerDropdown = true"
|
||||
@blur="onCoIssuerBlur"
|
||||
>
|
||||
<span class="combo-caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 멤버 후보 드롭다운 -->
|
||||
<div
|
||||
v-if="showCoIssuerDropdown"
|
||||
class="assignee-dd"
|
||||
>
|
||||
<button
|
||||
v-for="m in coIssuerCandidates"
|
||||
:key="m.user.id"
|
||||
type="button"
|
||||
class="assignee-dd-item"
|
||||
@mousedown.prevent="addCoIssuer(m.user.id)"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="m.user.avatarUrl"
|
||||
:name="m.user.name"
|
||||
/></span>
|
||||
<span class="ad-nm">{{ m.user.name }}</span>
|
||||
<span class="ad-em">{{ m.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="coIssuerCandidates.length === 0"
|
||||
class="assignee-dd-msg"
|
||||
>
|
||||
{{ memberStore.members.length === 0 ? '프로젝트 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 선택된 공동 지시자 칩 -->
|
||||
<div
|
||||
v-if="coIssuers.length"
|
||||
class="assignee-chips"
|
||||
>
|
||||
<span
|
||||
v-for="c in coIssuers"
|
||||
:key="c.id"
|
||||
class="chip"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="c.avatarUrl"
|
||||
:name="c.name"
|
||||
/></span>
|
||||
{{ c.name }}
|
||||
<span
|
||||
class="x"
|
||||
@click="removeCoIssuer(c.id)"
|
||||
>×</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="co-issuer-hint">
|
||||
지정하면 원 지시자와 함께 완료된 할 일을 검토(승인·수정요청)할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
{{ isEdit
|
||||
? '변경 사항을 저장하면 담당자에게 반영됩니다.'
|
||||
@@ -2444,4 +2556,20 @@ function goBack() {
|
||||
line-height: 1.6;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* 공동 지시자 블록 — 지시자 카드 안에서 상단 구분선으로 분리 */
|
||||
.co-issuer-block {
|
||||
margin-top: 0.875rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.co-label {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.co-issuer-hint {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -309,6 +309,20 @@ const me = computed<User>(() => {
|
||||
const isIssuer = computed(
|
||||
() => !!task.value && !!me.value.id && task.value.issuer.id === me.value.id,
|
||||
)
|
||||
// 공동 지시자 — 원 지시자와 함께 완료 검토(승인/수정요청) 권한을 가진다(편집 권한은 없음)
|
||||
const isCoIssuer = computed(
|
||||
() =>
|
||||
!!task.value &&
|
||||
!!me.value.id &&
|
||||
task.value.coIssuers.some((c) => c.id === me.value.id),
|
||||
)
|
||||
// 검토자 = 원 지시자 또는 공동 지시자. 검토 UI/보고 열람 권한은 이 값으로 판단
|
||||
const isReviewer = computed(() => isIssuer.value || isCoIssuer.value)
|
||||
// 실제 검토(승인/수정요청)를 수행한 사람 이름 — 완료/수정요청 카드 표기용.
|
||||
// 공동 지시자가 검토했으면 그 이름을, 없으면(레거시 데이터) 원 지시자로 대체.
|
||||
const reviewerName = computed(
|
||||
() => task.value?.reviewer?.name ?? task.value?.issuer.name ?? '',
|
||||
)
|
||||
const isAssignee = computed(
|
||||
() => !!task.value && task.value.assignees.some((a) => a.id === me.value.id),
|
||||
)
|
||||
@@ -346,8 +360,8 @@ const CP_STATE_LABEL: Record<CheckpointState, string> = {
|
||||
reported: '보고 완료',
|
||||
missed: '누락',
|
||||
}
|
||||
// 보고 '내용' 열람 권한 — 지시자 또는 해당 업무 담당자(백엔드도 동일하게 제한)
|
||||
const canViewReports = computed(() => isIssuer.value || isAssignee.value)
|
||||
// 보고 '내용' 열람 권한 — 지시자·공동 지시자 또는 해당 업무 담당자(백엔드도 동일하게 제한)
|
||||
const canViewReports = computed(() => isReviewer.value || isAssignee.value)
|
||||
|
||||
// 체크포인트 상태 — 보고됨 / 당일 / 지남(누락) / 예정. 보고됨은 reported 플래그(전원 공유) 기준
|
||||
function checkpointState(cp: TaskCheckpointView): CheckpointState {
|
||||
@@ -996,9 +1010,9 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
|
||||
<!-- 사이드바 -->
|
||||
<aside class="side">
|
||||
<!-- 지시자: 승인 대기(review) → 승인/수정요청 -->
|
||||
<!-- 지시자·공동 지시자: 승인 대기(review) → 승인/수정요청 -->
|
||||
<div
|
||||
v-if="task.status === 'review' && isIssuer"
|
||||
v-if="task.status === 'review' && isReviewer"
|
||||
class="card ap-card review"
|
||||
>
|
||||
<div class="ap-card-head">
|
||||
@@ -1139,7 +1153,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
<span class="ap-card-title">수정 요청됨</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
지시자 <b>{{ reviewerName }}</b>님이 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
</div>
|
||||
<div
|
||||
v-if="task.changesNote"
|
||||
@@ -1204,7 +1218,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
<span class="ap-card-title">승인 완료</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
|
||||
지시자 <b>{{ reviewerName }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1362,6 +1376,27 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
/></span>
|
||||
<span><div class="nm">{{ task.issuer.name }}</div><div class="rl">{{ task.issuer.role }}</div></span>
|
||||
</div>
|
||||
|
||||
<!-- 공동 지시자 — 있을 때만 노출. 원 지시자와 함께 검토 권한을 가진다 -->
|
||||
<div
|
||||
v-if="task.coIssuers.length"
|
||||
class="co-issuers"
|
||||
>
|
||||
<div class="co-issuers-label">
|
||||
공동 지시자
|
||||
</div>
|
||||
<div
|
||||
v-for="c in task.coIssuers"
|
||||
:key="c.id"
|
||||
class="issuer"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="c.avatarUrl"
|
||||
:name="c.name"
|
||||
/></span>
|
||||
<span><div class="nm">{{ c.name }}</div><div class="rl">{{ c.role }}</div></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -3265,6 +3300,21 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 공동 지시자 — 지시자 아래 구분선으로 분리, 여러 명 세로 나열 */
|
||||
.co-issuers {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.co-issuers-label {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 승인 요청 카드 */
|
||||
.ap-card {
|
||||
padding: 1rem 1.125rem 1.125rem;
|
||||
|
||||
@@ -112,6 +112,30 @@ function toProjectActivityItem(api: ApiActivity): ProjectActivityItem {
|
||||
}
|
||||
case 'task.removed':
|
||||
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 삭제했습니다` }
|
||||
case 'drive.file_uploaded':
|
||||
return {
|
||||
...base,
|
||||
tone: 'setting',
|
||||
icon: 'repo',
|
||||
html: `<b>${actor}</b>님이 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 업로드했습니다`,
|
||||
}
|
||||
case 'drive.file_deleted':
|
||||
return {
|
||||
...base,
|
||||
tone: 'setting',
|
||||
icon: 'pencil',
|
||||
html: `<b>${actor}</b>님이 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 삭제했습니다`,
|
||||
}
|
||||
case 'drive.folder_deleted': {
|
||||
const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0
|
||||
const suffix = cnt > 0 ? ` (파일 ${cnt}개 포함)` : ''
|
||||
return {
|
||||
...base,
|
||||
tone: 'setting',
|
||||
icon: 'pencil',
|
||||
html: `<b>${actor}</b>님이 폴더 <b>${escapeHtml(pstr(p, 'folderName'))}</b>을(를) 삭제했습니다${suffix}`,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님의 활동` }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import type { DriveCrumb, DriveFile, DriveFolder } from '@/types/drive'
|
||||
import {
|
||||
dayGroupKo,
|
||||
escapeHtml,
|
||||
relativeTimeKo,
|
||||
} from '@/shared/utils/format'
|
||||
import type {
|
||||
DriveActivity,
|
||||
DriveCrumb,
|
||||
DriveFile,
|
||||
DriveFolder,
|
||||
} from '@/types/drive'
|
||||
import type { PresignedUpload } from '@/types/drive'
|
||||
import type { ProjectActivityDay, ProjectActivityItem } from '@/mock/relay.mock'
|
||||
|
||||
// 감사 로그 페이지 크기
|
||||
const ACTIVITY_PAGE_SIZE = 20
|
||||
|
||||
// payload(자유형)에서 문자열 안전 추출
|
||||
function pstr(payload: Record<string, unknown>, key: string): string {
|
||||
const v = payload[key]
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
// 드라이브 감사 로그 1건 → 공용 활동 아이템(프로젝트 활동과 동일 디자인 토큰).
|
||||
// 드라이브 행위는 모두 tone 'setting'(앰버), 추가/생성=repo·변경/삭제=pencil 아이콘.
|
||||
// 사용자 입력(파일/폴더명)은 escapeHtml 로 감싸 XSS 를 방지한다.
|
||||
function toDriveActivityItem(a: DriveActivity): ProjectActivityItem {
|
||||
const base: Pick<
|
||||
ProjectActivityItem,
|
||||
'avatarUrl' | 'actorId' | 'actorName' | 'time'
|
||||
> = {
|
||||
avatarUrl: a.actor?.avatarUrl ?? null,
|
||||
actorId: a.actor?.id ?? null,
|
||||
actorName: a.actor?.name ?? null,
|
||||
time: relativeTimeKo(a.createdAt),
|
||||
}
|
||||
const actor = escapeHtml(a.actor?.name ?? '알 수 없음')
|
||||
const p = a.payload
|
||||
const name = `<b>${escapeHtml(pstr(p, 'name'))}</b>`
|
||||
const renamePair = `<b>${escapeHtml(pstr(p, 'fromName'))}</b> → <b>${escapeHtml(pstr(p, 'toName'))}</b>`
|
||||
|
||||
switch (a.type) {
|
||||
case 'file_uploaded':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 파일 ${name}을(를) 업로드했습니다` }
|
||||
case 'file_renamed':
|
||||
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 파일 이름을 ${renamePair}(으)로 변경했습니다` }
|
||||
case 'file_deleted':
|
||||
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 파일 ${name}을(를) 삭제했습니다` }
|
||||
case 'folder_created':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 폴더 ${name}을(를) 만들었습니다` }
|
||||
case 'folder_renamed':
|
||||
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 폴더 이름을 ${renamePair}(으)로 변경했습니다` }
|
||||
case 'folder_deleted': {
|
||||
const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0
|
||||
const suffix = cnt > 0 ? ` (파일 ${cnt}개 포함)` : ''
|
||||
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 폴더 ${name}을(를) 삭제했습니다${suffix}` }
|
||||
}
|
||||
default:
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님의 활동` }
|
||||
}
|
||||
}
|
||||
|
||||
// 진행 중 업로드 1건(UI 표시용)
|
||||
export interface UploadItem {
|
||||
@@ -58,6 +117,30 @@ export const useDriveStore = defineStore('drive', () => {
|
||||
const loading = ref(false)
|
||||
const uploads = ref<UploadItem[]>([])
|
||||
|
||||
// 감사 로그(기록 패널)
|
||||
const activities = ref<DriveActivity[]>([])
|
||||
const activityLoading = ref(false)
|
||||
const activityPage = ref(0) // 마지막으로 불러온 페이지(0=미로드)
|
||||
const activityTotal = ref(0)
|
||||
const hasMoreActivity = computed(
|
||||
() => activities.value.length < activityTotal.value,
|
||||
)
|
||||
|
||||
// 누적 감사 로그 → 날짜 그룹(최신순이라 같은 날은 연속) — 공용 ActivityFeed 입력
|
||||
const activityDays = computed<ProjectActivityDay[]>(() => {
|
||||
const groups: ProjectActivityDay[] = []
|
||||
let current: ProjectActivityDay | null = null
|
||||
for (const a of activities.value) {
|
||||
const day = dayGroupKo(a.createdAt)
|
||||
if (!current || current.day !== day) {
|
||||
current = { day, items: [] }
|
||||
groups.push(current)
|
||||
}
|
||||
current.items.push(toDriveActivityItem(a))
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const isEmpty = computed(
|
||||
() => folders.value.length === 0 && files.value.length === 0,
|
||||
)
|
||||
@@ -154,6 +237,36 @@ export const useDriveStore = defineStore('drive', () => {
|
||||
uploads.value = uploads.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
// --- 감사 로그(기록 패널) ---
|
||||
// 첫 페이지 로드(패널을 열 때마다 최신 상태로 갱신)
|
||||
async function fetchActivity(): Promise<void> {
|
||||
activityLoading.value = true
|
||||
try {
|
||||
const res = await drive.listActivity(1, ACTIVITY_PAGE_SIZE)
|
||||
activities.value = res.items
|
||||
activityTotal.value = res.total
|
||||
activityPage.value = res.page
|
||||
} finally {
|
||||
activityLoading.value = false
|
||||
}
|
||||
}
|
||||
// 다음 페이지 누적
|
||||
async function loadMoreActivity(): Promise<void> {
|
||||
if (activityLoading.value || !hasMoreActivity.value) return
|
||||
activityLoading.value = true
|
||||
try {
|
||||
const res = await drive.listActivity(
|
||||
activityPage.value + 1,
|
||||
ACTIVITY_PAGE_SIZE,
|
||||
)
|
||||
activities.value = [...activities.value, ...res.items]
|
||||
activityTotal.value = res.total
|
||||
activityPage.value = res.page
|
||||
} finally {
|
||||
activityLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
folderId,
|
||||
current,
|
||||
@@ -162,6 +275,10 @@ export const useDriveStore = defineStore('drive', () => {
|
||||
files,
|
||||
loading,
|
||||
uploads,
|
||||
activities,
|
||||
activityDays,
|
||||
activityLoading,
|
||||
hasMoreActivity,
|
||||
isEmpty,
|
||||
itemCount,
|
||||
fetchList,
|
||||
@@ -173,5 +290,7 @@ export const useDriveStore = defineStore('drive', () => {
|
||||
download,
|
||||
uploadOne,
|
||||
dismissUpload,
|
||||
fetchActivity,
|
||||
loadMoreActivity,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import router from '@/router'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import { escapeHtml, relativeTimeKo, taskDueInfo } from '@/shared/utils/format'
|
||||
import type { ApiNotification, NotificationView } from '@/types/notification'
|
||||
|
||||
// Level1 — 페이지가 열려 있으면(포커스 여부 무관) 항상 OS 토스트를 띄운다.
|
||||
// 페이지가 닫혀 있을 때는 서비스워커 푸시가 담당(SW 는 열린 탭이 있으면 표시 생략 → 중복 방지).
|
||||
function showLocalNotification(view: NotificationView): void {
|
||||
if (typeof Notification === 'undefined') return
|
||||
if (Notification.permission !== 'granted') return
|
||||
try {
|
||||
const notif = new Notification('Relay', {
|
||||
body: view.text,
|
||||
tag: view.id, // 동일 알림 중복 토스트 방지
|
||||
icon: '/relay-icon-256.png',
|
||||
})
|
||||
notif.onclick = () => {
|
||||
window.focus()
|
||||
if (view.to) void router.push(view.to)
|
||||
notif.close()
|
||||
}
|
||||
} catch {
|
||||
// 알림 생성 실패(권한/환경) 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 조립된 html → 태그/엔티티 제거 평문(OS 토스트 본문용)
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
// payload(자유형)에서 문자열/숫자 값 안전 추출
|
||||
function pstr(p: Record<string, unknown>, k: string): string {
|
||||
const v = p[k]
|
||||
@@ -16,6 +49,10 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
|
||||
}
|
||||
|
||||
// API 알림 → 화면 뷰(type+payload → 톤/문구/이동경로). 사용자 입력은 escapeHtml 후 신뢰 태그만 조립
|
||||
//
|
||||
// SSOT 주의: 푸시(사이트 닫힘) 표시 문구는 백엔드 web-push.service.ts 의 notificationContent() 가
|
||||
// 별도로 조립한다(인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **양쪽 모두** case 를 넣을 것.
|
||||
// (백엔드 커버리지는 web-push.service.spec.ts 가 검증)
|
||||
function toView(n: ApiNotification): NotificationView {
|
||||
const p = n.payload
|
||||
const actor = escapeHtml(pstr(p, 'actorName') || '누군가')
|
||||
@@ -44,7 +81,8 @@ function toView(n: ApiNotification): NotificationView {
|
||||
break
|
||||
case 'task.started':
|
||||
tone = 'blue'
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 시작했습니다`
|
||||
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 ${p.resumed === true ? '재개' : '시작'}했습니다`
|
||||
break
|
||||
case 'task.due_changed': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
@@ -55,6 +93,24 @@ function toView(n: ApiNotification): NotificationView {
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 ${body}`
|
||||
break
|
||||
}
|
||||
case 'task.due_soon': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : ''
|
||||
tone = 'amber'
|
||||
html = label
|
||||
? `<b>${taskTitle}</b> 업무의 마감이 다가옵니다 (마감 <b>${label}</b>)`
|
||||
: `<b>${taskTitle}</b> 업무의 마감이 다가옵니다`
|
||||
break
|
||||
}
|
||||
case 'task.overdue': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : ''
|
||||
tone = 'red'
|
||||
html = label
|
||||
? `<b>${taskTitle}</b> 업무가 마감기한을 넘겼습니다 (마감 <b>${label}</b>)`
|
||||
: `<b>${taskTitle}</b> 업무가 마감기한을 넘겼습니다`
|
||||
break
|
||||
}
|
||||
case 'task.checkpoint_reported':
|
||||
tone = 'blue'
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 중간 점검 상태를 보고했습니다`
|
||||
@@ -80,6 +136,14 @@ function toView(n: ApiNotification): NotificationView {
|
||||
tone = ''
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>에 ${p.isReply === true ? '답글' : '댓글'}을 남겼습니다`
|
||||
break
|
||||
case 'task.file_attached': {
|
||||
const fileName = escapeHtml(pstr(p, 'fileName'))
|
||||
tone = ''
|
||||
html = fileName
|
||||
? `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일 <b>${fileName}</b>을(를) 첨부했습니다`
|
||||
: `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일을 첨부했습니다`
|
||||
break
|
||||
}
|
||||
case 'member.invited':
|
||||
tone = 'accent'
|
||||
to = projectId ? `/projects/${projectId}` : null
|
||||
@@ -113,7 +177,15 @@ function toView(n: ApiNotification): NotificationView {
|
||||
html = `<b>${actor}</b>님이 참석 일정 <b>${eventTitle}</b>을(를) 삭제했습니다`
|
||||
break
|
||||
}
|
||||
return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to }
|
||||
return {
|
||||
id: n.id,
|
||||
read: n.read,
|
||||
tone,
|
||||
html,
|
||||
text: stripHtml(html),
|
||||
time: relativeTimeKo(n.createdAt),
|
||||
to,
|
||||
}
|
||||
}
|
||||
|
||||
// SSE 재연결 지연(ms) — 토큰 만료/네트워크 끊김 시 정리 후 재시도
|
||||
@@ -151,6 +223,8 @@ export const useNotificationStore = defineStore('notification', () => {
|
||||
items.value.unshift(n)
|
||||
total.value += 1
|
||||
if (!n.read) unreadCount.value += 1
|
||||
// OS 토스트(Level1) — 페이지가 열려 있으면 포커스 여부와 무관하게 표시
|
||||
showLocalNotification(toView(n))
|
||||
} catch {
|
||||
// 파싱 실패(하트비트 등 비정상 페이로드) 무시
|
||||
}
|
||||
@@ -161,20 +235,40 @@ export const useNotificationStore = defineStore('notification', () => {
|
||||
es.close()
|
||||
es = null
|
||||
}
|
||||
if (wantConnected && !reconnectTimer) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (wantConnected) openStream()
|
||||
}, RECONNECT_DELAY)
|
||||
}
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 재연결 예약(중복 방지) — 지연 후 '재동기화 → 스트림 오픈' 순으로 복구한다.
|
||||
// 재동기화(fetchInitial)는 axios 인터셉터를 타므로,
|
||||
// 1) 끊긴 사이 놓친 알림을 수신함에 복구하고(미읽음 수도 서버값으로 보정)
|
||||
// 2) access 토큰이 만료됐다면 자동 refresh 되어 쿠키가 갱신된다
|
||||
// → 만료된 토큰으로 SSE 를 여는 무한 실패 루프를 방지.
|
||||
function scheduleReconnect(): void {
|
||||
if (!wantConnected || reconnectTimer) return
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
reconnectTimer = null
|
||||
if (!wantConnected) return
|
||||
try {
|
||||
await fetchInitial()
|
||||
} catch {
|
||||
// 재동기화 실패(세션 만료/네트워크 지속) — 스트림은 열지 않고 다음 주기 재시도
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
if (wantConnected) openStream()
|
||||
}, RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
// 연결 시작(멱등) — 최초 1회 목록 로드 + 스트림 오픈. 로그인 화면 진입마다 호출돼도 안전
|
||||
async function connect(): Promise<void> {
|
||||
if (wantConnected) return
|
||||
wantConnected = true
|
||||
await fetchInitial()
|
||||
try {
|
||||
await fetchInitial()
|
||||
} catch {
|
||||
// 초기 로드 실패해도 스트림은 열어 둔다 — 실패 시 onerror→재연결이 복구
|
||||
}
|
||||
openStream()
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
activities: api.activities.map(toTaskActivityView),
|
||||
assignees: api.assignees.map(toUserView),
|
||||
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
|
||||
coIssuers: (api.coIssuers ?? []).map(toUserView),
|
||||
dueLabel: due.dateLabel,
|
||||
dday: due.dday,
|
||||
dueAt: api.dueDate,
|
||||
@@ -206,6 +207,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
}
|
||||
: undefined,
|
||||
changesNote: api.changesNote ?? undefined,
|
||||
reviewer: api.reviewer ? toUserView(api.reviewer) : null,
|
||||
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
|
||||
checkpoints: api.checkpoints.map((c) => ({
|
||||
id: c.id,
|
||||
|
||||
@@ -19,6 +19,9 @@ export type ApiActivityType =
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.file_removed'
|
||||
| 'task.removed'
|
||||
| 'drive.file_uploaded'
|
||||
| 'drive.file_deleted'
|
||||
| 'drive.folder_deleted'
|
||||
|
||||
// 활동(API 원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립한다
|
||||
export interface ApiActivity {
|
||||
|
||||
@@ -73,3 +73,30 @@ export interface PresignUploadPayload {
|
||||
size: number
|
||||
folderId?: string
|
||||
}
|
||||
|
||||
// --- 감사 로그(전사 공유 드라이브 — 누가 무엇을 했는지) ---
|
||||
// (SSOT: backend/src/modules/drive/drive-audit.service.ts 의 DriveActivityResponse)
|
||||
export type DriveActivityType =
|
||||
| 'file_uploaded'
|
||||
| 'file_renamed'
|
||||
| 'file_deleted'
|
||||
| 'folder_created'
|
||||
| 'folder_renamed'
|
||||
| 'folder_deleted'
|
||||
|
||||
// 감사 로그 1건(표시 문구는 프론트가 type+payload 로 조립)
|
||||
export interface DriveActivity {
|
||||
id: string
|
||||
type: DriveActivityType
|
||||
actor: DriveOwner | null
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 페이지네이션 응답(backend common/pagination 미러)
|
||||
export interface DriveActivityPage {
|
||||
items: DriveActivity[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ export type NotificationType =
|
||||
| 'task.changes_requested'
|
||||
| 'task.started'
|
||||
| 'task.due_changed'
|
||||
| 'task.due_soon'
|
||||
| 'task.overdue'
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.removed'
|
||||
| 'task.commented'
|
||||
| 'task.file_attached'
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
@@ -46,6 +49,7 @@ export interface NotificationView {
|
||||
read: boolean
|
||||
tone: string // 좌측 점 색상(blue/amber/green/red/accent/'')
|
||||
html: string // 조립된 문구(사용자 입력은 escapeHtml 적용)
|
||||
text: string // 태그 없는 평문(OS 토스트 본문용)
|
||||
time: string // 상대시간
|
||||
to: string | null // 클릭 시 이동 경로
|
||||
}
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface ApiTaskDetail {
|
||||
checklist: ApiChecklistItem[]
|
||||
assignees: ApiMember[]
|
||||
issuer: ApiMember | null
|
||||
// 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다
|
||||
coIssuers: ApiMember[]
|
||||
dueDate: string | null
|
||||
createdAt: string
|
||||
comments: ApiComment[]
|
||||
@@ -117,6 +119,8 @@ export interface ApiTaskDetail {
|
||||
files: ApiAttachment[]
|
||||
approval: ApiApproval | null
|
||||
changesNote: string | null
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 완료/수정요청 카드의 검토자 표기용
|
||||
reviewer: ApiMember | null
|
||||
checkpoints: ApiCheckpoint[]
|
||||
checkpointReports: ApiCheckpointReport[]
|
||||
}
|
||||
@@ -153,6 +157,8 @@ export interface CreateTaskPayload {
|
||||
title: string
|
||||
content?: string[]
|
||||
assigneeIds: string[]
|
||||
// 공동 지시자 사용자 ID(선택)
|
||||
coIssuerIds?: string[]
|
||||
dueDate?: string
|
||||
checklist?: { text: string; category?: ChecklistCategory }[]
|
||||
// 중간 점검일(ISO8601 목록)
|
||||
@@ -164,6 +170,8 @@ export interface UpdateTaskPayload {
|
||||
title?: string
|
||||
content?: string[]
|
||||
assigneeIds?: string[]
|
||||
// 공동 지시자 사용자 ID(제공 시 전체 교체)
|
||||
coIssuerIds?: string[]
|
||||
dueDate?: string | null
|
||||
// 체크리스트 전체 동기화(id 있으면 기존 유지, 없으면 신규, 빠지면 삭제)
|
||||
checklist?: { id?: string; text: string; category?: ChecklistCategory }[]
|
||||
|
||||
Reference in New Issue
Block a user