# Phase 1: OAuth 連線 & 整合設定頁 — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task.

**Spec:** [`design.md`](./design.md) (§4.1, §6, §13.1, §13.2, §15)
**Master Index:** [`implementation-plan.md`](./implementation-plan.md)

**Goal:** 讓 tenant admin 可以在「雲端空間整合設定」頁完成 Google Drive OAuth 連線與中斷，後端安全儲存加密 token，UI 顯示連線狀態。**不含**任何 Drive 資料夾建立或檔案同步。

**Architecture:** 新增 `cloud_integration` 模組（DDD 完整一套：api/app/domain/infra），單張 `tenant_drive_integrations` 表，Fernet 加密 token，OAuth state 存 Redis（CSRF 防護），標準 OAuth 2.0 server-side flow。前端新增 `/settings/cloud-integrations` 路由與 settings page。

**Tech Stack:** google-auth-oauthlib / google-api-python-client (僅 userinfo) / cryptography.fernet / Vue3 + PrimeVue

---

## File Structure (Phase 1)

### 後端新增 / 修改

```
common/code/
└── grc_error_code.py                         # 修改：新增 9 個 error codes (見 Task 2)

scripts/sql/
└── 2026-MM-DD-google-drive-oauth-integration.sql   # 新增：建表 + GRANT + RLS

api/cloud_integration/
├── __init__.py                               # create_module() Blueprint
├── routes/
│   └── google_drive_integration_route.py     # GET / POST auth-url / GET callback / DELETE / GET status
└── serializers/
    └── google_drive_integration.py           # marshmallow response schema

app/cloud_integration/
├── service/
│   └── google_drive_integration_service.py   # OAuth orchestration (auth_url / handle_callback / disconnect / status)
└── dto/
    └── google_drive_integration_dto.py       # DTO

domain/cloud_integration/
├── entities/
│   └── tenant_drive_integration_entity.py
├── repository/
│   └── tenant_drive_integration_repository.py   # interface
└── service/
    ├── tenant_drive_integration_domain_service.py   # CRUD
    └── token_crypto_service.py               # interface

infra/cloud_integration/
├── models/
│   └── tenant_drive_integration.py           # SQLAlchemy model
├── mapper/
│   └── tenant_drive_integration_mapper.py
├── repository/
│   └── tenant_drive_integration_repo_impl.py
├── crypto/
│   └── fernet_crypto.py                      # TokenCryptoService 實作
└── google_drive/
    └── google_oauth_client.py                # OAuth code exchange / userinfo / revoke

di_containers/cloud_integration/
├── __init__.py
└── cloud_integration_containers.py           # CloudIntegrationContainer

di_containers/
└── containers.py                             # 修改：register CloudIntegrationContainer

config/
└── app_modules.py                            # 修改：append "cloud_integration"

config/
└── config.py                                 # 修改：新增 GOOGLE_DRIVE_OAUTH_* / DRIVE_TOKEN_ENCRYPTION_KEY

pyproject.toml                                # 修改：add google-* + cryptography
```

### 前端新增 / 修改

```
src/config/api/
└── api.js                                    # 修改：append INTEGRATION_GDRIVE_* 常數

src/service/
└── CloudIntegrationService.js                # 新增

src/views/integrations/
└── CloudIntegrationsView.vue                 # 新增主頁

src/components/integrations/
└── GoogleDriveIntegrationCard.vue            # 新增 Drive 卡片

src/router/
└── index.js                                  # 修改：append route

src/lang/
├── zh-TW.js                                  # 修改：i18n 字串
└── en.js                                     # 修改：i18n 字串

src/components/layout/
└── AppMenu.vue (or wherever sidebar)         # 修改：選單加「雲端空間整合設定」(admin only)
```

### Tests

```
test/cloud_integration/
├── conftest.py                               # fixtures
├── test_token_crypto_service.py              # Fernet 加解密
├── test_tenant_drive_integration_repo.py     # repo CRUD
├── test_google_oauth_client.py               # OAuth client (mock httpx/google-auth)
├── test_google_drive_integration_service.py  # app service (mock client + repo)
└── test_google_drive_integration_route.py    # E2E with Flask test client
```

---

## Task List

### Task 1: 加套件依賴 & 註冊模組

**Files:**
- Modify: `pyproject.toml`
- Modify: `config/app_modules.py`
- Modify: `config/config.py`
- Create: `api/cloud_integration/__init__.py`

- [ ] **Step 1**：編輯 `pyproject.toml` 在 `[tool.poetry.dependencies]` 加：
  ```toml
  google-api-python-client = "^2.130.0"
  google-auth-oauthlib = "^1.2.0"
  google-auth-httplib2 = "^0.2.0"
  cryptography = "^42.0.0"  # 已有則跳過
  ```

- [ ] **Step 2**：執行 `poetry lock --no-update && poetry install`，確認無衝突。

- [ ] **Step 3**：編輯 `config/app_modules.py`，在 `REGISTERED_APPS` list 末尾加 `"cloud_integration"`。

- [ ] **Step 4**：編輯 `config/config.py` 在 `BaseConfig` 加：
  ```python
  GOOGLE_DRIVE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_DRIVE_OAUTH_CLIENT_ID", "")
  GOOGLE_DRIVE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_DRIVE_OAUTH_CLIENT_SECRET", "")
  GOOGLE_DRIVE_OAUTH_REDIRECT_URI = os.getenv("GOOGLE_DRIVE_OAUTH_REDIRECT_URI", "")
  DRIVE_TOKEN_ENCRYPTION_KEY = os.getenv("DRIVE_TOKEN_ENCRYPTION_KEY", "")
  ```

- [ ] **Step 5**：建立 `api/cloud_integration/__init__.py`，內容空 `create_module()`（先佔位，回傳空 Blueprint，避免 startup 報錯）：
  ```python
  """Cloud Integration Module — Flask Blueprint"""
  import logging
  from flask import Blueprint
  from flask_restful import Api

  logger = logging.getLogger("api")

  def create_module():
      bp = Blueprint("cloud_integration", __name__, url_prefix="/api/integrations")
      api = Api(bp)
      logger.info("Cloud Integration module registered (empty placeholder)")
      return bp
  ```

- [ ] **Step 6**：跑 `python main_app.py` 啟動後端，確認啟動不報錯（CTRL+C 結束）。

- [ ] **Step 7**：commit
  ```bash
  git add pyproject.toml poetry.lock config/app_modules.py config/config.py api/cloud_integration/
  git commit -m "feat(cloud_integration): scaffold module + add google-api-python-client deps"
  ```

---

### Task 2: 新增 Error Codes

**Files:**
- Modify: `common/code/grc_error_code.py`

- [ ] **Step 1**：在 `common/code/grc_error_code.py` 末尾加一個新 section（包含全部 phase 1-3 會用到的 codes，避免後續 phase 還要回頭加；標 `# Phase 2/3` 的代碼此 phase 暫不引用）：
  ```python
      # ── Auth / Admin ─────────────────────────────────────────────────────
      GRC_NOT_ADMIN                            = ("需要 tenant admin 權限",                  "GRC_403021")

      # ── Cloud Integration / Google Drive ─────────────────────────────────
      GRC_DRIVE_OAUTH_STATE_INVALID            = ("OAuth 狀態驗證失敗，請重新嘗試授權",        "GRC_400030")
      GRC_DRIVE_OAUTH_NO_REFRESH_TOKEN         = ("Google 未回傳 refresh token，請至帳號授權頁移除舊授權後重試",  "GRC_400032")
      GRC_DRIVE_FILE_OVERSIZED                 = ("Drive 檔案超過大小限制",                  "GRC_400031")  # Phase 3
      GRC_DRIVE_TOKEN_REVOKED                  = ("Drive 連線已失效，請重新授權",             "GRC_401010")
      GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE      = ("Drive 同步的證據請至 Google Drive 刪除",   "GRC_403020")  # Phase 3
      GRC_DRIVE_INTEGRATION_NOT_FOUND          = ("此 tenant 尚未連線 Google Drive",         "GRC_404030")
      GRC_DRIVE_FOLDER_MAPPING_NOT_FOUND       = ("Drive 資料夾對應不存在",                   "GRC_404031")  # Phase 2
      GRC_DRIVE_INTEGRATION_ALREADY_EXISTS     = ("Google Drive 已連線，請先中斷",            "GRC_409020")
      GRC_DRIVE_WEBHOOK_TOKEN_INVALID          = ("Webhook 驗證失敗",                       "GRC_412020")  # Phase 3
      GRC_DRIVE_API_ERROR                      = ("Google Drive API 錯誤，請稍後再試",        "GRC_500030")
  ```

- [ ] **Step 2**：跑 `python -c "from common.code.grc_error_code import GrcErrorCode; print(GrcErrorCode.GRC_DRIVE_TOKEN_REVOKED)"` 確認 import 不報錯。

- [ ] **Step 3**：commit
  ```bash
  git add common/code/grc_error_code.py
  git commit -m "feat(cloud_integration): add Google Drive integration error codes"
  ```

---

### Task 3: SQL Migration — `tenant_drive_integrations` 表

**Files:**
- Create: `scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql`

- [ ] **Step 1**：建檔，內容：
  ```sql
  -- Date: <YYYY-MM-DD>
  -- Purpose: Google Drive OAuth 連線資訊 per tenant

  -- 1. 建立 tenant_drive_integrations 表 (<YYYY-MM-DD>)
  CREATE TABLE IF NOT EXISTS compliance.tenant_drive_integrations (
      id                              SERIAL PRIMARY KEY,
      uid                             UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
      tenant_id                       INTEGER NOT NULL UNIQUE,
      provider                        VARCHAR(20) NOT NULL DEFAULT 'google_drive',
      google_account_email            VARCHAR(255),
      refresh_token_encrypted         TEXT,
      access_token_cache              TEXT,
      access_token_expires_at         TIMESTAMPTZ,
      root_folder_id                  VARCHAR(100),
      drive_change_cursor             VARCHAR(255),
      webhook_channel_id              VARCHAR(100),
      webhook_resource_id             VARCHAR(100),
      webhook_token                   VARCHAR(100),
      webhook_expires_at              TIMESTAMPTZ,
      status                          VARCHAR(20) NOT NULL DEFAULT 'DISCONNECTED',
      connected_by_user_id            INTEGER,
      connected_at                    TIMESTAMPTZ,
      last_sync_at                    TIMESTAMPTZ,
      last_sync_error                 TEXT,
      created_user                    VARCHAR(100),
      updated_user                    VARCHAR(100),
      created_at                      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      updated_at                      TIMESTAMPTZ NOT NULL DEFAULT NOW()
  );

  -- 2. 建立 status check constraint (<YYYY-MM-DD>)
  ALTER TABLE compliance.tenant_drive_integrations
      ADD CONSTRAINT chk_tenant_drive_integrations_status
      CHECK (status IN ('CONNECTED', 'EXPIRED', 'REVOKED', 'DISCONNECTED'));

  -- 3. 索引 (<YYYY-MM-DD>)
  CREATE INDEX IF NOT EXISTS idx_tenant_drive_integrations_tenant_id
      ON compliance.tenant_drive_integrations(tenant_id);
  CREATE INDEX IF NOT EXISTS idx_tenant_drive_integrations_status
      ON compliance.tenant_drive_integrations(status);

  -- 4. 授權給 cm_app (<YYYY-MM-DD>)
  GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.tenant_drive_integrations TO cm_app;
  GRANT USAGE, SELECT ON SEQUENCE compliance.tenant_drive_integrations_id_seq TO cm_app;

  -- 5. RLS — tenant 隔離 (<YYYY-MM-DD>)
  ALTER TABLE compliance.tenant_drive_integrations ENABLE ROW LEVEL SECURITY;
  CREATE POLICY tenant_drive_integrations_tenant_isolation
      ON compliance.tenant_drive_integrations
      USING (tenant_id = current_setting('app.tenant_id', true)::INTEGER);
  ```

- [ ] **Step 2**：手動跑 migration（local dev）：
  ```bash
  psql -h $DB_HOST -U postgres -d compliance_manager -f scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql
  ```
  確認無錯誤。

- [ ] **Step 3**：跑 `psql ... -c "\d compliance.tenant_drive_integrations"` 驗證欄位齊全。

- [ ] **Step 4**：commit
  ```bash
  git add scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql
  git commit -m "feat(cloud_integration): add tenant_drive_integrations table migration"
  ```

---

### Task 4: SQLAlchemy Model — `TenantDriveIntegration`

**Files:**
- Create: `infra/cloud_integration/__init__.py`
- Create: `infra/cloud_integration/models/__init__.py`
- Create: `infra/cloud_integration/models/tenant_drive_integration.py`

- [ ] **Step 1**：建空的 `__init__.py` 兩個。

- [ ] **Step 2**：建 `infra/cloud_integration/models/tenant_drive_integration.py`：
  ```python
  import uuid
  from datetime import datetime

  from sqlalchemy import Column, Integer, String, Text, DateTime, CheckConstraint
  from sqlalchemy.dialects.postgresql import UUID
  from jedi_common.session.database.db import Base


  class TenantDriveIntegration(Base):
      __tablename__ = "tenant_drive_integrations"
      __table_args__ = (
          CheckConstraint(
              "status IN ('CONNECTED', 'EXPIRED', 'REVOKED', 'DISCONNECTED')",
              name="chk_tenant_drive_integrations_status",
          ),
          {"schema": "compliance"},
      )

      id = Column(Integer, primary_key=True)
      uid = Column(UUID(as_uuid=True), nullable=False, unique=True, default=uuid.uuid4)
      tenant_id = Column(Integer, nullable=False, unique=True)
      provider = Column(String(20), nullable=False, default="google_drive")
      google_account_email = Column(String(255))
      refresh_token_encrypted = Column(Text)
      access_token_cache = Column(Text)
      access_token_expires_at = Column(DateTime(timezone=True))
      root_folder_id = Column(String(100))
      drive_change_cursor = Column(String(255))
      webhook_channel_id = Column(String(100))
      webhook_resource_id = Column(String(100))
      webhook_token = Column(String(100))
      webhook_expires_at = Column(DateTime(timezone=True))
      status = Column(String(20), nullable=False, default="DISCONNECTED")
      connected_by_user_id = Column(Integer)
      connected_at = Column(DateTime(timezone=True))
      last_sync_at = Column(DateTime(timezone=True))
      last_sync_error = Column(Text)
      created_user = Column(String(100))
      updated_user = Column(String(100))
      created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
      updated_at = Column(
          DateTime(timezone=True), nullable=False,
          default=lambda: datetime.now(timezone.utc),
          onupdate=lambda: datetime.now(timezone.utc),
      )
  ```

  > 注意：用 `datetime.now(timezone.utc)`（Python 3.12+ 慣用），不用 deprecated 的 `datetime.utcnow`。記得 `from datetime import datetime, timezone`。

- [ ] **Step 3**：寫 sanity test `test/cloud_integration/test_tenant_drive_integration_model.py`：
  ```python
  from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration

  def test_tenant_drive_integration_table_name():
      assert TenantDriveIntegration.__tablename__ == "tenant_drive_integrations"
      assert TenantDriveIntegration.__table_args__[-1] == {"schema": "compliance"}
  ```

- [ ] **Step 4**：執行 `pytest test/cloud_integration/test_tenant_drive_integration_model.py -v` → PASS。

- [ ] **Step 5**：commit
  ```bash
  git add infra/cloud_integration/ test/cloud_integration/test_tenant_drive_integration_model.py
  git commit -m "feat(cloud_integration): add TenantDriveIntegration ORM model"
  ```

---

### Task 5: Domain Entity & Repository Interface

**Files:**
- Create: `domain/cloud_integration/__init__.py` + subdirs
- Create: `domain/cloud_integration/entities/tenant_drive_integration_entity.py`
- Create: `domain/cloud_integration/repository/tenant_drive_integration_repository.py`

- [ ] **Step 1**：建 `__init__.py` (3 個：base + entities + repository)。

- [ ] **Step 2**：建 entity：
  ```python
  from dataclasses import dataclass, field
  from datetime import datetime
  from typing import Optional
  from uuid import UUID


  @dataclass
  class TenantDriveIntegrationEntity:
      tenant_id: int
      provider: str = "google_drive"
      id: Optional[int] = None
      uid: Optional[UUID] = None
      google_account_email: Optional[str] = None
      refresh_token_encrypted: Optional[str] = None
      access_token_cache: Optional[str] = None
      access_token_expires_at: Optional[datetime] = None
      root_folder_id: Optional[str] = None
      drive_change_cursor: Optional[str] = None
      webhook_channel_id: Optional[str] = None
      webhook_resource_id: Optional[str] = None
      webhook_token: Optional[str] = None
      webhook_expires_at: Optional[datetime] = None
      status: str = "DISCONNECTED"
      connected_by_user_id: Optional[int] = None
      connected_at: Optional[datetime] = None
      last_sync_at: Optional[datetime] = None
      last_sync_error: Optional[str] = None
      created_user: Optional[str] = None
      updated_user: Optional[str] = None
      created_at: Optional[datetime] = None
      updated_at: Optional[datetime] = None
  ```

- [ ] **Step 3**：建 repository interface：
  ```python
  from abc import ABC, abstractmethod
  from typing import Optional
  from uuid import UUID

  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )


  class ITenantDriveIntegrationRepo(ABC):
      @abstractmethod
      def get_by_tenant_id(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]: ...

      @abstractmethod
      def get_by_uid(self, uid: UUID) -> Optional[TenantDriveIntegrationEntity]: ...

      @abstractmethod
      def add(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity: ...

      @abstractmethod
      def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity: ...

      @abstractmethod
      def delete_by_tenant_id(self, tenant_id: int) -> bool: ...
  ```

- [ ] **Step 4**：commit
  ```bash
  git add domain/cloud_integration/
  git commit -m "feat(cloud_integration): add TenantDriveIntegration entity & repo interface"
  ```

---

### Task 6: Mapper

**Files:**
- Create: `infra/cloud_integration/mapper/__init__.py`
- Create: `infra/cloud_integration/mapper/tenant_drive_integration_mapper.py`

- [ ] **Step 1**：建 mapper：
  ```python
  from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )


  class TenantDriveIntegrationMapper:
      @staticmethod
      def to_entity(model: TenantDriveIntegration) -> TenantDriveIntegrationEntity:
          return TenantDriveIntegrationEntity(
              id=model.id,
              uid=model.uid,
              tenant_id=model.tenant_id,
              provider=model.provider,
              google_account_email=model.google_account_email,
              refresh_token_encrypted=model.refresh_token_encrypted,
              access_token_cache=model.access_token_cache,
              access_token_expires_at=model.access_token_expires_at,
              root_folder_id=model.root_folder_id,
              drive_change_cursor=model.drive_change_cursor,
              webhook_channel_id=model.webhook_channel_id,
              webhook_resource_id=model.webhook_resource_id,
              webhook_token=model.webhook_token,
              webhook_expires_at=model.webhook_expires_at,
              status=model.status,
              connected_by_user_id=model.connected_by_user_id,
              connected_at=model.connected_at,
              last_sync_at=model.last_sync_at,
              last_sync_error=model.last_sync_error,
              created_user=model.created_user,
              updated_user=model.updated_user,
              created_at=model.created_at,
              updated_at=model.updated_at,
          )

      @staticmethod
      def apply_to_model(entity: TenantDriveIntegrationEntity, model: TenantDriveIntegration) -> None:
          """Update model fields from entity (id/uid/created_at protected)."""
          model.tenant_id = entity.tenant_id
          model.provider = entity.provider
          model.google_account_email = entity.google_account_email
          model.refresh_token_encrypted = entity.refresh_token_encrypted
          model.access_token_cache = entity.access_token_cache
          model.access_token_expires_at = entity.access_token_expires_at
          model.root_folder_id = entity.root_folder_id
          model.drive_change_cursor = entity.drive_change_cursor
          model.webhook_channel_id = entity.webhook_channel_id
          model.webhook_resource_id = entity.webhook_resource_id
          model.webhook_token = entity.webhook_token
          model.webhook_expires_at = entity.webhook_expires_at
          model.status = entity.status
          model.connected_by_user_id = entity.connected_by_user_id
          model.connected_at = entity.connected_at
          model.last_sync_at = entity.last_sync_at
          model.last_sync_error = entity.last_sync_error
          model.updated_user = entity.updated_user
  ```

- [ ] **Step 2**：commit
  ```bash
  git add infra/cloud_integration/mapper/
  git commit -m "feat(cloud_integration): add TenantDriveIntegration mapper"
  ```

---

### Task 7: Repository Implementation

**Files:**
- Create: `infra/cloud_integration/repository/__init__.py`
- Create: `infra/cloud_integration/repository/tenant_drive_integration_repo_impl.py`
- Create: `test/cloud_integration/test_tenant_drive_integration_repo.py`

- [ ] **Step 1**：寫測試（僅 happy path，CRUD）：
  ```python
  import pytest
  from uuid import uuid4
  from infra.cloud_integration.repository.tenant_drive_integration_repo_impl import (
      TenantDriveIntegrationRepoImpl,
  )
  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )

  @pytest.mark.usefixtures("db_session")
  def test_add_and_get_by_tenant_id(db_session):
      repo = TenantDriveIntegrationRepoImpl(session=db_session)
      entity = TenantDriveIntegrationEntity(
          tenant_id=99001,  # 使用測試用大 ID 避免衝突
          status="DISCONNECTED",
          created_user="test",
          updated_user="test",
      )
      saved = repo.add(entity)
      assert saved.id is not None
      assert saved.uid is not None

      fetched = repo.get_by_tenant_id(99001)
      assert fetched is not None
      assert fetched.tenant_id == 99001

  @pytest.mark.usefixtures("db_session")
  def test_update_status(db_session):
      repo = TenantDriveIntegrationRepoImpl(session=db_session)
      entity = TenantDriveIntegrationEntity(tenant_id=99002, created_user="t", updated_user="t")
      saved = repo.add(entity)

      saved.status = "CONNECTED"
      saved.google_account_email = "x@y.com"
      updated = repo.update(saved)
      assert updated.status == "CONNECTED"
      assert updated.google_account_email == "x@y.com"

  @pytest.mark.usefixtures("db_session")
  def test_delete_by_tenant_id(db_session):
      repo = TenantDriveIntegrationRepoImpl(session=db_session)
      entity = TenantDriveIntegrationEntity(tenant_id=99003, created_user="t", updated_user="t")
      repo.add(entity)
      assert repo.delete_by_tenant_id(99003) is True
      assert repo.get_by_tenant_id(99003) is None
  ```

- [ ] **Step 2**：跑測試 → FAIL（impl 不存在）。

- [ ] **Step 3**：實作 `TenantDriveIntegrationRepoImpl`：
  ```python
  from typing import Optional
  from uuid import UUID

  from infra.cloud_integration.mapper.tenant_drive_integration_mapper import (
      TenantDriveIntegrationMapper,
  )
  from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )
  from domain.cloud_integration.repository.tenant_drive_integration_repository import (
      ITenantDriveIntegrationRepo,
  )


  class TenantDriveIntegrationRepoImpl(ITenantDriveIntegrationRepo):
      def __init__(self, session):
          self.session = session

      def get_by_tenant_id(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]:
          model = (
              self.session.query(TenantDriveIntegration)
              .filter(TenantDriveIntegration.tenant_id == tenant_id)
              .first()
          )
          return TenantDriveIntegrationMapper.to_entity(model) if model else None

      def get_by_uid(self, uid: UUID) -> Optional[TenantDriveIntegrationEntity]:
          model = (
              self.session.query(TenantDriveIntegration)
              .filter(TenantDriveIntegration.uid == uid)
              .first()
          )
          return TenantDriveIntegrationMapper.to_entity(model) if model else None

      def add(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
          model = TenantDriveIntegration()
          TenantDriveIntegrationMapper.apply_to_model(entity, model)
          model.created_user = entity.created_user
          self.session.add(model)
          self.session.flush()
          return TenantDriveIntegrationMapper.to_entity(model)

      def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
          from jedi_common.handler.exception import NotFound
          from common.code.grc_error_code import GrcErrorCode

          model = (
              self.session.query(TenantDriveIntegration)
              .filter(TenantDriveIntegration.id == entity.id)
              .first()
          )
          if not model:
              raise NotFound(GrcErrorCode.GRC_DRIVE_INTEGRATION_NOT_FOUND)
          TenantDriveIntegrationMapper.apply_to_model(entity, model)
          self.session.flush()
          return TenantDriveIntegrationMapper.to_entity(model)

      def delete_by_tenant_id(self, tenant_id: int) -> bool:
          deleted = (
              self.session.query(TenantDriveIntegration)
              .filter(TenantDriveIntegration.tenant_id == tenant_id)
              .delete()
          )
          self.session.flush()
          return deleted > 0
  ```

- [ ] **Step 4**：跑測試 → PASS。

- [ ] **Step 5**：commit
  ```bash
  git add infra/cloud_integration/repository/ test/cloud_integration/
  git commit -m "feat(cloud_integration): add TenantDriveIntegrationRepo impl + tests"
  ```

---

### Task 8: Domain Service — TenantDriveIntegrationDomainService

**Files:**
- Create: `domain/cloud_integration/service/__init__.py`
- Create: `domain/cloud_integration/service/tenant_drive_integration_domain_service.py`

- [ ] **Step 1**：實作 domain service（純委派 repo + 業務規則「一個 tenant 一筆」）：
  ```python
  from typing import Optional
  from uuid import UUID

  from jedi_common.handler.exception import ConflictError, NotFound
  from common.code.grc_error_code import GrcErrorCode

  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )
  from domain.cloud_integration.repository.tenant_drive_integration_repository import (
      ITenantDriveIntegrationRepo,
  )


  class TenantDriveIntegrationDomainService:
      def __init__(self, tenant_drive_integration_repo: ITenantDriveIntegrationRepo):
          self._repo = tenant_drive_integration_repo

      def get_or_none(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]:
          return self._repo.get_by_tenant_id(tenant_id)

      def get_required(self, tenant_id: int) -> TenantDriveIntegrationEntity:
          entity = self._repo.get_by_tenant_id(tenant_id)
          if entity is None:
              raise NotFound(GrcErrorCode.GRC_DRIVE_INTEGRATION_NOT_FOUND)
          return entity

      def create(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
          existing = self._repo.get_by_tenant_id(entity.tenant_id)
          if existing is not None and existing.status == "CONNECTED":
              raise ConflictError(GrcErrorCode.GRC_DRIVE_INTEGRATION_ALREADY_EXISTS)
          if existing is not None:
              # 之前 disconnected，覆寫 update
              entity.id = existing.id
              entity.uid = existing.uid
              return self._repo.update(entity)
          return self._repo.add(entity)

      def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
          return self._repo.update(entity)

      def disconnect(self, tenant_id: int) -> bool:
          entity = self._repo.get_by_tenant_id(tenant_id)
          if entity is None:
              return False
          entity.status = "DISCONNECTED"
          entity.refresh_token_encrypted = None
          entity.access_token_cache = None
          entity.access_token_expires_at = None
          entity.webhook_channel_id = None
          entity.webhook_resource_id = None
          entity.webhook_token = None
          entity.webhook_expires_at = None
          self._repo.update(entity)
          return True
  ```

- [ ] **Step 2**：寫單元測試 `test/cloud_integration/test_tenant_drive_integration_domain_service.py`（mock repo）：
  ```python
  from unittest.mock import MagicMock
  import pytest
  from jedi_common.handler.exception import NotFound, ConflictError

  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )
  from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
      TenantDriveIntegrationDomainService,
  )


  def test_get_required_raises_not_found_when_missing():
      repo = MagicMock()
      repo.get_by_tenant_id.return_value = None
      svc = TenantDriveIntegrationDomainService(repo)
      with pytest.raises(NotFound):
          svc.get_required(1)


  def test_create_raises_conflict_when_already_connected():
      repo = MagicMock()
      repo.get_by_tenant_id.return_value = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
      svc = TenantDriveIntegrationDomainService(repo)
      with pytest.raises(ConflictError):
          svc.create(TenantDriveIntegrationEntity(tenant_id=1))


  def test_create_overwrites_disconnected_record():
      existing = TenantDriveIntegrationEntity(tenant_id=1, status="DISCONNECTED", id=10)
      repo = MagicMock()
      repo.get_by_tenant_id.return_value = existing
      svc = TenantDriveIntegrationDomainService(repo)
      new_entity = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
      svc.create(new_entity)
      repo.update.assert_called_once()
      assert new_entity.id == 10  # 覆寫 id


  def test_create_can_reconnect_after_revoked():
      """Re-connect 後 status 從 REVOKED → CONNECTED 必須允許覆寫"""
      existing = TenantDriveIntegrationEntity(tenant_id=1, status="REVOKED", id=20)
      repo = MagicMock()
      repo.get_by_tenant_id.return_value = existing
      svc = TenantDriveIntegrationDomainService(repo)
      new_entity = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
      svc.create(new_entity)
      repo.update.assert_called_once()
      assert new_entity.id == 20
  ```

  > 注意 domain service `create()` 邏輯需把「DISCONNECTED / REVOKED / EXPIRED」三者都視為「可覆寫」，只有 `CONNECTED` 才拋 ConflictError。請對照 `if existing is not None and existing.status == "CONNECTED"` 確保 status 比對是對 CONNECTED，其他都走 update path。

- [ ] **Step 3**：跑測試 → PASS。

- [ ] **Step 4**：commit
  ```bash
  git add domain/cloud_integration/service/ test/cloud_integration/test_tenant_drive_integration_domain_service.py
  git commit -m "feat(cloud_integration): add TenantDriveIntegrationDomainService + tests"
  ```

---

### Task 9: Token Crypto — Interface & Fernet 實作

**Files:**
- Create: `domain/cloud_integration/service/token_crypto_service.py` (interface)
- Create: `infra/cloud_integration/crypto/__init__.py`
- Create: `infra/cloud_integration/crypto/fernet_crypto.py`
- Create: `test/cloud_integration/test_fernet_crypto.py`

- [ ] **Step 1**：建 interface：
  ```python
  # domain/cloud_integration/service/token_crypto_service.py
  from abc import ABC, abstractmethod


  class TokenCryptoService(ABC):
      @abstractmethod
      def encrypt(self, plaintext: str) -> str: ...

      @abstractmethod
      def decrypt(self, ciphertext: str) -> str: ...
  ```

- [ ] **Step 2**：寫測試：
  ```python
  # test/cloud_integration/test_fernet_crypto.py
  from cryptography.fernet import Fernet
  from infra.cloud_integration.crypto.fernet_crypto import FernetCrypto


  def test_encrypt_decrypt_roundtrip():
      key = Fernet.generate_key().decode()
      crypto = FernetCrypto(key=key)
      plaintext = "my-very-secret-refresh-token"
      ciphertext = crypto.encrypt(plaintext)
      assert ciphertext != plaintext
      assert crypto.decrypt(ciphertext) == plaintext


  def test_different_keys_cannot_decrypt():
      key1 = Fernet.generate_key().decode()
      key2 = Fernet.generate_key().decode()
      crypto1 = FernetCrypto(key=key1)
      crypto2 = FernetCrypto(key=key2)
      ciphertext = crypto1.encrypt("hello")
      try:
          crypto2.decrypt(ciphertext)
          assert False, "expected exception"
      except Exception:
          pass


  def test_decrypt_returns_str_not_bytes():
      key = Fernet.generate_key().decode()
      crypto = FernetCrypto(key=key)
      result = crypto.decrypt(crypto.encrypt("foo"))
      assert isinstance(result, str)
  ```

- [ ] **Step 3**：跑 → FAIL。

- [ ] **Step 4**：實作：
  ```python
  # infra/cloud_integration/crypto/fernet_crypto.py
  from cryptography.fernet import Fernet
  from domain.cloud_integration.service.token_crypto_service import TokenCryptoService


  class FernetCrypto(TokenCryptoService):
      def __init__(self, key: str):
          if not key:
              raise ValueError("Fernet key is required (set DRIVE_TOKEN_ENCRYPTION_KEY env)")
          self._fernet = Fernet(key.encode() if isinstance(key, str) else key)

      def encrypt(self, plaintext: str) -> str:
          return self._fernet.encrypt(plaintext.encode()).decode()

      def decrypt(self, ciphertext: str) -> str:
          return self._fernet.decrypt(ciphertext.encode()).decode()
  ```

- [ ] **Step 5**：跑 → PASS。

- [ ] **Step 6**：commit
  ```bash
  git add domain/cloud_integration/service/token_crypto_service.py infra/cloud_integration/crypto/ test/cloud_integration/test_fernet_crypto.py
  git commit -m "feat(cloud_integration): add TokenCryptoService interface + FernetCrypto impl"
  ```

---

### Task 10: Google OAuth Client（Infra 層）

**Files:**
- Create: `infra/cloud_integration/google_drive/__init__.py`
- Create: `infra/cloud_integration/google_drive/google_oauth_client.py`
- Create: `test/cloud_integration/test_google_oauth_client.py`

- [ ] **Step 1**：寫測試（用 `responses` 套件 mock HTTP；如沒裝可用 `unittest.mock`）：
  ```python
  from unittest.mock import patch, MagicMock
  from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient


  def test_build_auth_url_contains_required_params():
      client = GoogleOAuthClient(
          client_id="my-client-id",
          client_secret="my-secret",
          redirect_uri="https://app/callback",
      )
      url = client.build_auth_url(state="csrf123", scopes=["scope1"])
      assert "client_id=my-client-id" in url
      assert "state=csrf123" in url
      assert "access_type=offline" in url
      assert "prompt=consent" in url
      assert "scope=scope1" in url


  @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.post")
  def test_exchange_code_returns_tokens(mock_post):
      mock_post.return_value.ok = True
      mock_post.return_value.json.return_value = {
          "access_token": "at-xxx",
          "refresh_token": "rt-yyy",
          "expires_in": 3600,
          "token_type": "Bearer",
      }
      client = GoogleOAuthClient("cid", "csec", "https://app/callback")
      result = client.exchange_code("the-code")
      assert result["access_token"] == "at-xxx"
      assert result["refresh_token"] == "rt-yyy"


  @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.get")
  def test_get_userinfo_returns_email(mock_get):
      mock_get.return_value.ok = True
      mock_get.return_value.json.return_value = {"email": "x@y.com", "verified_email": True}
      client = GoogleOAuthClient("cid", "csec", "https://app/callback")
      info = client.get_userinfo("access-token")
      assert info["email"] == "x@y.com"
  ```

- [ ] **Step 2**：跑 → FAIL。

- [ ] **Step 3**：實作：
  ```python
  # infra/cloud_integration/google_drive/google_oauth_client.py
  from typing import Iterable
  from urllib.parse import urlencode

  import requests


  GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
  GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
  GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
  GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"


  class GoogleOAuthClient:
      def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
          self.client_id = client_id
          self.client_secret = client_secret
          self.redirect_uri = redirect_uri

      def build_auth_url(self, state: str, scopes: Iterable[str]) -> str:
          params = {
              "client_id": self.client_id,
              "redirect_uri": self.redirect_uri,
              "response_type": "code",
              "scope": " ".join(scopes),
              "access_type": "offline",
              "prompt": "consent",
              "include_granted_scopes": "true",
              "state": state,
          }
          return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"

      def exchange_code(self, code: str) -> dict:
          resp = requests.post(
              GOOGLE_TOKEN_URL,
              data={
                  "code": code,
                  "client_id": self.client_id,
                  "client_secret": self.client_secret,
                  "redirect_uri": self.redirect_uri,
                  "grant_type": "authorization_code",
              },
              timeout=10,
          )
          if not resp.ok:
              raise RuntimeError(f"OAuth exchange failed: {resp.status_code} {resp.text}")
          return resp.json()

      def refresh_access_token(self, refresh_token: str) -> dict:
          resp = requests.post(
              GOOGLE_TOKEN_URL,
              data={
                  "refresh_token": refresh_token,
                  "client_id": self.client_id,
                  "client_secret": self.client_secret,
                  "grant_type": "refresh_token",
              },
              timeout=10,
          )
          if not resp.ok:
              raise RuntimeError(f"Token refresh failed: {resp.status_code} {resp.text}")
          return resp.json()

      def get_userinfo(self, access_token: str) -> dict:
          resp = requests.get(
              GOOGLE_USERINFO_URL,
              headers={"Authorization": f"Bearer {access_token}"},
              timeout=10,
          )
          if not resp.ok:
              raise RuntimeError(f"userinfo failed: {resp.status_code} {resp.text}")
          return resp.json()

      def revoke(self, token: str) -> bool:
          resp = requests.post(
              GOOGLE_REVOKE_URL,
              params={"token": token},
              headers={"Content-Type": "application/x-www-form-urlencoded"},
              timeout=10,
          )
          return resp.ok
  ```

- [ ] **Step 4**：跑 → PASS。

- [ ] **Step 5**：commit
  ```bash
  git add infra/cloud_integration/google_drive/ test/cloud_integration/test_google_oauth_client.py
  git commit -m "feat(cloud_integration): add GoogleOAuthClient (auth-url / exchange / refresh / userinfo / revoke)"
  ```

---

### Task 11: App Service — GoogleDriveIntegrationService

**Files:**
- Create: `app/cloud_integration/__init__.py`
- Create: `app/cloud_integration/dto/__init__.py`
- Create: `app/cloud_integration/dto/google_drive_integration_dto.py`
- Create: `app/cloud_integration/service/__init__.py`
- Create: `app/cloud_integration/service/google_drive_integration_service.py`
- Create: `test/cloud_integration/test_google_drive_integration_service.py`

- [ ] **Step 1**：建 DTO：
  ```python
  # app/cloud_integration/dto/google_drive_integration_dto.py
  from dataclasses import dataclass
  from datetime import datetime
  from typing import Optional
  from uuid import UUID

  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )


  @dataclass
  class GoogleDriveIntegrationStatusDTO:
      uid: Optional[UUID]
      status: str  # CONNECTED / EXPIRED / REVOKED / DISCONNECTED
      google_account_email: Optional[str]
      root_folder_id: Optional[str]
      connected_by_user_id: Optional[int]
      connected_by_user_name: Optional[str]
      connected_at: Optional[datetime]
      last_sync_at: Optional[datetime]
      webhook_expires_at: Optional[datetime]
      last_sync_error: Optional[str]

      @classmethod
      def from_entity(
          cls,
          entity: Optional[TenantDriveIntegrationEntity],
          connected_by_user_name: Optional[str] = None,
      ) -> "GoogleDriveIntegrationStatusDTO":
          if entity is None:
              return cls(
                  uid=None,
                  status="DISCONNECTED",
                  google_account_email=None,
                  root_folder_id=None,
                  connected_by_user_id=None,
                  connected_by_user_name=None,
                  connected_at=None,
                  last_sync_at=None,
                  webhook_expires_at=None,
                  last_sync_error=None,
              )
          return cls(
              uid=entity.uid,
              status=entity.status,
              google_account_email=entity.google_account_email,
              root_folder_id=entity.root_folder_id,
              connected_by_user_id=entity.connected_by_user_id,
              connected_by_user_name=connected_by_user_name,
              connected_at=entity.connected_at,
              last_sync_at=entity.last_sync_at,
              webhook_expires_at=entity.webhook_expires_at,
              last_sync_error=entity.last_sync_error,
          )
  ```

- [ ] **Step 2**：建 app service。包含：
  - `build_auth_url(tenant_id, user_id) -> dict{auth_url, state}` — 產 state token 存 Redis (TTL 10min)
  - `handle_callback(code, state) -> entity` — 驗 state、call OAuth client 換 token、加密存 DB
  - `get_status(tenant_id) -> DTO` — 查現況
  - `disconnect(tenant_id) -> bool` — 撤銷 + 清 token
  ```python
  # app/cloud_integration/service/google_drive_integration_service.py
  import secrets
  from datetime import datetime, timedelta, timezone
  from typing import Optional

  from flask import current_app
  from jedi_common.handler.exception import BadRequestError
  from jedi_common.session.database.db import transaction
  from jedi_auth.domain.service.user_domain_service import UserDomainService

  from common.code.grc_error_code import GrcErrorCode
  from core.extensions import redis_client

  from app.cloud_integration.dto.google_drive_integration_dto import (
      GoogleDriveIntegrationStatusDTO,
  )
  from domain.cloud_integration.entities.tenant_drive_integration_entity import (
      TenantDriveIntegrationEntity,
  )
  from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
      TenantDriveIntegrationDomainService,
  )
  from domain.cloud_integration.service.token_crypto_service import TokenCryptoService
  from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient


  STATE_REDIS_PREFIX = "drive_oauth_state:"
  STATE_TTL_SECONDS = 600  # 10 min
  REQUIRED_SCOPES = [
      "https://www.googleapis.com/auth/drive",
      "https://www.googleapis.com/auth/userinfo.email",
  ]


  class GoogleDriveIntegrationService:
      def __init__(
          self,
          tenant_drive_integration_domain_service: TenantDriveIntegrationDomainService,
          token_crypto_service: TokenCryptoService,
          google_oauth_client: GoogleOAuthClient,
          user_domain_service: UserDomainService,
      ):
          self._domain = tenant_drive_integration_domain_service
          self._crypto = token_crypto_service
          self._oauth = google_oauth_client
          self._user_domain = user_domain_service

      # ─────────────────────────────────────────────────
      def build_auth_url(self, tenant_id: int, user_id: int) -> dict:
          state = secrets.token_urlsafe(32)
          payload = f"{tenant_id}:{user_id}"
          redis_client.setex(STATE_REDIS_PREFIX + state, STATE_TTL_SECONDS, payload)
          auth_url = self._oauth.build_auth_url(state=state, scopes=REQUIRED_SCOPES)
          return {"auth_url": auth_url, "state": state}

      # ─────────────────────────────────────────────────
      @transaction
      def handle_callback(self, code: str, state: str) -> TenantDriveIntegrationEntity:
          payload = redis_client.get(STATE_REDIS_PREFIX + state)
          if payload is None:
              raise BadRequestError(GrcErrorCode.GRC_DRIVE_OAUTH_STATE_INVALID)
          redis_client.delete(STATE_REDIS_PREFIX + state)

          tenant_id_str, user_id_str = payload.decode().split(":")
          tenant_id = int(tenant_id_str)
          user_id = int(user_id_str)

          tokens = self._oauth.exchange_code(code)
          refresh_token = tokens.get("refresh_token")
          access_token = tokens["access_token"]
          expires_in = tokens.get("expires_in", 3600)

          if not refresh_token:
              # 常見原因：user 之前已對該 OAuth client 同意過授權，
              # Google 不會再次回傳 refresh_token；要求 user 至 myaccount.google.com/permissions 移除舊授權再試。
              raise BadRequestError(GrcErrorCode.GRC_DRIVE_OAUTH_NO_REFRESH_TOKEN)

          userinfo = self._oauth.get_userinfo(access_token)
          user = self._user_domain.get_by_id(user_id)
          login_name = user.login_name if user else "system"

          entity = TenantDriveIntegrationEntity(
              tenant_id=tenant_id,
              provider="google_drive",
              google_account_email=userinfo.get("email"),
              refresh_token_encrypted=self._crypto.encrypt(refresh_token),
              access_token_cache=self._crypto.encrypt(access_token),
              access_token_expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in - 60),
              status="CONNECTED",
              connected_by_user_id=user_id,
              connected_at=datetime.now(timezone.utc),
              created_user=login_name,
              updated_user=login_name,
          )
          return self._domain.create(entity)

      # ─────────────────────────────────────────────────
      def get_status(self, tenant_id: int) -> GoogleDriveIntegrationStatusDTO:
          entity = self._domain.get_or_none(tenant_id)
          connected_by_name = None
          if entity and entity.connected_by_user_id:
              user = self._user_domain.get_by_id(entity.connected_by_user_id)
              if user:
                  connected_by_name = user.nickname
          return GoogleDriveIntegrationStatusDTO.from_entity(entity, connected_by_name)

      # ─────────────────────────────────────────────────
      def disconnect(self, tenant_id: int) -> bool:
          """Disconnect = (1) 先嘗試 best-effort revoke OAuth token (HTTP, 不在 transaction 內)
                         (2) 再 commit 把 token 從 DB 清除。
          revoke 失敗不影響 (2)。"""
          # Step 1: revoke (no DB transaction — HTTP call only)
          entity = self._domain.get_or_none(tenant_id)
          if entity is None:
              return False
          if entity.refresh_token_encrypted:
              try:
                  rt = self._crypto.decrypt(entity.refresh_token_encrypted)
                  self._oauth.revoke(rt)
              except Exception:
                  pass  # ignore revoke errors — DB 仍會清

          # Step 2: clear DB (own transaction)
          return self._clear_db(tenant_id)

      @transaction
      def _clear_db(self, tenant_id: int) -> bool:
          return self._domain.disconnect(tenant_id)
  ```

  > **重要**：OAuth revoke 是 HTTP call 給 Google，可能 timeout (~10s)。如果包在 `@transaction` 內會把 DB session/連線卡住整個 timeout 時間。所以拆成兩段：先做 HTTP 不開 transaction，再單獨開 transaction 清 DB。

- [ ] **Step 3**：寫單元測試（mock 所有依賴）：覆蓋
  - `build_auth_url` 寫 redis、回 url
  - `handle_callback` state 不存在 → BadRequest
  - `handle_callback` 成功 → 寫 entity
  - `get_status` 帶 nickname
  - `disconnect` 撤銷 + 標 status

  （測試完整 listing 略，依 Task 8 同 pattern；確保用 MagicMock 注入 4 個依賴）

- [ ] **Step 4**：跑測試 → PASS。

- [ ] **Step 5**：commit
  ```bash
  git add app/cloud_integration/ test/cloud_integration/test_google_drive_integration_service.py
  git commit -m "feat(cloud_integration): add GoogleDriveIntegrationService (auth-url/callback/status/disconnect)"
  ```

---

### Task 12: Marshmallow Serializers

**Files:**
- Create: `api/cloud_integration/serializers/__init__.py`
- Create: `api/cloud_integration/serializers/google_drive_integration.py`

- [ ] **Step 1**：實作 schema：
  ```python
  # api/cloud_integration/serializers/google_drive_integration.py
  from marshmallow import Schema, fields


  class GoogleDriveAuthUrlResponse(Schema):
      auth_url = fields.String(required=True)
      state = fields.String(required=True)


  class GoogleDriveStatusResponse(Schema):
      uid = fields.UUID(allow_none=True)
      status = fields.String(required=True)
      google_account_email = fields.String(allow_none=True)
      root_folder_id = fields.String(allow_none=True)
      connected_by_user_id = fields.Integer(allow_none=True)
      connected_by_user_name = fields.String(allow_none=True)
      connected_at = fields.DateTime(allow_none=True)
      last_sync_at = fields.DateTime(allow_none=True)
      webhook_expires_at = fields.DateTime(allow_none=True)
      last_sync_error = fields.String(allow_none=True)


  class GoogleDriveCallbackQuerySchema(Schema):
      code = fields.String(required=True)
      state = fields.String(required=True)
  ```

- [ ] **Step 2**：commit
  ```bash
  git add api/cloud_integration/serializers/
  git commit -m "feat(cloud_integration): add Drive integration serializers"
  ```

---

### Task 13: API Routes

**Files:**
- Create: `api/cloud_integration/routes/__init__.py`
- Create: `api/cloud_integration/routes/google_drive_integration_route.py`

- [ ] **Step 0**：先在 `app/cloud_integration/service/google_drive_integration_service.py` **把 admin 權限檢查移到 service 層**（DDD：權限檢查在 service，不在 route）。把 service 的 `build_auth_url` 與 `disconnect` 兩個方法的 signature 改為要求帶 `is_admin`：
  ```python
  from jedi_common.handler.exception import ForbiddenError
  from common.code.grc_error_code import GrcErrorCode

  def build_auth_url(self, tenant_id: int, user_id: int, *, is_admin: bool) -> dict:
      if not is_admin:
          raise ForbiddenError(GrcErrorCode.GRC_NOT_ADMIN)
      ... (原邏輯)

  def disconnect(self, tenant_id: int, *, is_admin: bool) -> bool:
      if not is_admin:
          raise ForbiddenError(GrcErrorCode.GRC_NOT_ADMIN)
      ... (原邏輯)
  ```
  > 同時更新 Task 11 的單元測試補：「非 admin 呼叫 build_auth_url / disconnect 拋 ForbiddenError + GRC_NOT_ADMIN」。

- [ ] **Step 1**：實作 routes — **GET 與 DELETE 合併到同一個 Resource class 共用 `/google-drive` path**（避免 Flask-RESTful 路徑衝突）：
  ```python
  # api/cloud_integration/routes/google_drive_integration_route.py
  from dataclasses import asdict

  from dependency_injector.wiring import inject, Provide
  from flask import request, Response, current_app
  from flask_apispec import MethodResource, doc, marshal_with
  from flask_jwt_extended import jwt_required
  from jedi_common.session.auth.auth_context import get_user_context

  from api.cloud_integration.serializers.google_drive_integration import (
      GoogleDriveAuthUrlResponse,
      GoogleDriveStatusResponse,
  )
  from app.cloud_integration.service.google_drive_integration_service import (
      GoogleDriveIntegrationService,
  )
  from common.util.response_util import return_response
  from di_containers.containers import Containers


  class GoogleDriveIntegrationRoute(MethodResource):
      """`/google-drive` — 同時負責 GET (status, 任何登入者) 與 DELETE (disconnect, admin only)."""

      @doc(description="取得當前 tenant 的 Google Drive 連線狀態", tags=["Cloud Integration"])
      @marshal_with(GoogleDriveStatusResponse, apply=False)
      @jwt_required()
      @inject
      def get(
          self,
          service: GoogleDriveIntegrationService = Provide[
              Containers.cloud_integration_container.google_drive_integration_service
          ],
      ):
          user = get_user_context()
          dto = service.get_status(user.tenant_id)
          return return_response(True, GoogleDriveStatusResponse().dump(asdict(dto)))

      @doc(description="中斷 Google Drive 連線", tags=["Cloud Integration"])
      @jwt_required()
      @inject
      def delete(
          self,
          service: GoogleDriveIntegrationService = Provide[
              Containers.cloud_integration_container.google_drive_integration_service
          ],
      ):
          user = get_user_context()
          ok = service.disconnect(user.tenant_id, is_admin=user.is_admin)
          return return_response(True, {"disconnected": ok})


  class GoogleDriveAuthUrlRoute(MethodResource):

      @doc(description="產生 OAuth 授權 URL", tags=["Cloud Integration"])
      @marshal_with(GoogleDriveAuthUrlResponse, apply=False)
      @jwt_required()
      @inject
      def post(
          self,
          service: GoogleDriveIntegrationService = Provide[
              Containers.cloud_integration_container.google_drive_integration_service
          ],
      ):
          user = get_user_context()
          result = service.build_auth_url(user.tenant_id, user.user_id, is_admin=user.is_admin)
          return return_response(True, result)


  class GoogleDriveCallbackRoute(MethodResource):

      @doc(description="OAuth callback (Google redirect 進來)", tags=["Cloud Integration"])
      @inject
      def get(
          self,
          service: GoogleDriveIntegrationService = Provide[
              Containers.cloud_integration_container.google_drive_integration_service
          ],
      ):
          code = request.args.get("code")
          state = request.args.get("state")
          error = request.args.get("error")

          if error or not code or not state:
              return Response(_callback_html("error", error or "missing_params"), mimetype="text/html")

          try:
              service.handle_callback(code, state)
          except Exception as e:
              return Response(_callback_html("error", str(e)), mimetype="text/html")

          return Response(_callback_html("success", None), mimetype="text/html")


  def _callback_html(status: str, error: str | None) -> str:
      """回 HTML，用 postMessage 通知 opener 後 self.close().
      targetOrigin 從 app config 取，避免用 '*' 造成 token leak 風險。"""
      err_repr = f'"{error}"' if error else "null"
      target_origin = current_app.config.get("FRONTEND_BASE_URL", "*")
      return f"""
  <!doctype html>
  <html>
  <body>
  <p>Google Drive 連線完成（{status}），視窗即將關閉...</p>
  <script>
    try {{
      if (window.opener) {{
        window.opener.postMessage(
          {{ source: 'guidant-drive-oauth', status: '{status}', error: {err_repr} }},
          '{target_origin}'
        );
      }}
    }} catch (e) {{ console.error(e); }}
    setTimeout(function() {{ window.close(); }}, 800);
  </script>
  </body>
  </html>
  """
  ```

  > 並在 `config/config.py` 新增 `FRONTEND_BASE_URL = os.getenv("FRONTEND_BASE_URL", "*")`，production 必設、dev 留 `*`。

- [ ] **Step 2**：更新 `api/cloud_integration/__init__.py`：
  ```python
  def create_module():
      from api.cloud_integration.routes.google_drive_integration_route import (
          GoogleDriveIntegrationRoute,
          GoogleDriveAuthUrlRoute,
          GoogleDriveCallbackRoute,
      )

      # 注意：url_prefix 用 /api（不是 /api/integrations），
      # 為了讓 Phase 3 的 webhook receiver (/api/webhooks/google-drive/<tenant_id>)
      # 也能掛在同一個 Blueprint 上。
      bp = Blueprint("cloud_integration", __name__, url_prefix="/api")
      api = Api(bp)

      api.add_resource(GoogleDriveIntegrationRoute, "/integrations/google-drive")  # GET + DELETE
      api.add_resource(GoogleDriveAuthUrlRoute, "/integrations/google-drive/auth-url")
      api.add_resource(GoogleDriveCallbackRoute, "/integrations/google-drive/callback")

      logger.info("Cloud Integration module registered")
      return bp
  ```

  > **設計注意**：
  > 1. `GoogleDriveIntegrationRoute` 是單一 Resource class 同時實作 `get()` (status) 與 `delete()` (disconnect)，避免 Flask-RESTful 不允許多 class 同 path 的限制。
  > 2. Blueprint `url_prefix='/api'` 是為了 Phase 3 webhook 共用此 Blueprint（不需多 Blueprint return list — `main_app.py` 只接受單一 Blueprint）。

- [ ] **Step 3**：commit
  ```bash
  git add api/cloud_integration/routes/ api/cloud_integration/__init__.py
  git commit -m "feat(cloud_integration): add Google Drive integration routes"
  ```

---

### Task 14: DI Container

**Files:**
- Create: `di_containers/cloud_integration/__init__.py`
- Create: `di_containers/cloud_integration/cloud_integration_containers.py`
- Modify: `di_containers/containers.py`

- [ ] **Step 1**：建 container：
  ```python
  # di_containers/cloud_integration/cloud_integration_containers.py
  from dependency_injector import containers, providers
  from jedi_common.session.database.db import get_session

  from infra.cloud_integration.repository.tenant_drive_integration_repo_impl import (
      TenantDriveIntegrationRepoImpl,
  )
  from infra.cloud_integration.crypto.fernet_crypto import FernetCrypto
  from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient

  from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
      TenantDriveIntegrationDomainService,
  )

  from app.cloud_integration.service.google_drive_integration_service import (
      GoogleDriveIntegrationService,
  )


  class CloudIntegrationContainer(containers.DeclarativeContainer):
      config = providers.Configuration()
      auth_container = providers.DependenciesContainer()

      # repos
      tenant_drive_integration_repo = providers.Factory(
          TenantDriveIntegrationRepoImpl,
          session=providers.Resource(get_session),
      )

      # domain services
      tenant_drive_integration_domain_service = providers.Factory(
          TenantDriveIntegrationDomainService,
          tenant_drive_integration_repo=tenant_drive_integration_repo,
      )

      # crypto
      # （app.config 已透過 core/app_factory.py 的 container.config.from_dict(app.config) 注入，
      # 所以這裡直接用大寫 key — 與 config/config.py 屬性名一致）
      token_crypto_service = providers.Singleton(
          FernetCrypto,
          key=config.DRIVE_TOKEN_ENCRYPTION_KEY,
      )

      # oauth client
      google_oauth_client = providers.Singleton(
          GoogleOAuthClient,
          client_id=config.GOOGLE_DRIVE_OAUTH_CLIENT_ID,
          client_secret=config.GOOGLE_DRIVE_OAUTH_CLIENT_SECRET,
          redirect_uri=config.GOOGLE_DRIVE_OAUTH_REDIRECT_URI,
      )

      # app service
      google_drive_integration_service = providers.Factory(
          GoogleDriveIntegrationService,
          tenant_drive_integration_domain_service=tenant_drive_integration_domain_service,
          token_crypto_service=token_crypto_service,
          google_oauth_client=google_oauth_client,
          user_domain_service=auth_container.user_domain_service,
      )
  ```

- [ ] **Step 2**：在 `di_containers/containers.py` import 並 wire：
  ```python
  from di_containers.cloud_integration.cloud_integration_containers import CloudIntegrationContainer
  # ...
  cloud_integration_container: CloudIntegrationContainer = providers.Container(
      CloudIntegrationContainer,
      config=config,                  # 全 app.config（已自動 from_dict 注入），不限 sub-key
      auth_container=auth_container,
  )
  ```

- [ ] **Step 3**：**不需要手動 `from_dict({...})`**。`core/app_factory.py:141` 已有 `container.config.from_dict(app.config)` 自動把全部 `app.config` 灌進 DI。所以 container 內直接用 key 名（與 `config/config.py` 屬性名一致，全大寫）：
  ```python
  # 在 cloud_integration_containers.py 內參考 config 時：
  token_crypto_service = providers.Singleton(
      FernetCrypto,
      key=config.DRIVE_TOKEN_ENCRYPTION_KEY,
  )
  google_oauth_client = providers.Singleton(
      GoogleOAuthClient,
      client_id=config.GOOGLE_DRIVE_OAUTH_CLIENT_ID,
      client_secret=config.GOOGLE_DRIVE_OAUTH_CLIENT_SECRET,
      redirect_uri=config.GOOGLE_DRIVE_OAUTH_REDIRECT_URI,
  )
  ```
  > 改 Step 1 的 container 定義對應更新（key 名從 `config.token_encryption_key` 改成 `config.DRIVE_TOKEN_ENCRYPTION_KEY`，依此類推）。
  > 用 `grep -rn "config\." di_containers/` 看其他既有 container 怎麼存取 app config 為參考（例如 `di_containers/system_config/`、`di_containers/notification/`）。

- [ ] **Step 4**：跑 `python main_app.py` 啟動，跑 `curl http://localhost:8000/api/integrations/google-drive` (帶 JWT) → 應該回 `{"code":1,"data":{"status":"DISCONNECTED",...}}`。

- [ ] **Step 5**：commit
  ```bash
  git add di_containers/cloud_integration/ di_containers/containers.py main_app.py
  git commit -m "feat(cloud_integration): wire DI container + register in main"
  ```

---

### Task 15: 後端 E2E 測試（route + service + DB）

**Files:**
- Create: `test/cloud_integration/test_google_drive_integration_route.py`

- [ ] **Step 1**：寫 E2E test（使用 Flask test client + mock OAuth client，不真打 Google）：
  ```python
  import json
  from unittest.mock import patch
  import pytest


  @pytest.mark.usefixtures("db_session")
  def test_get_status_disconnected_initially(client, headers):
      resp = client.get("/api/integrations/google-drive", headers=headers)
      assert resp.status_code == 200
      body = resp.get_json()
      assert body["data"]["status"] == "DISCONNECTED"


  @pytest.mark.usefixtures("db_session")
  def test_post_auth_url_returns_url_for_admin(admin_client, admin_headers):
      resp = admin_client.post("/api/integrations/google-drive/auth-url", headers=admin_headers)
      assert resp.status_code == 200
      assert "auth_url" in resp.get_json()["data"]


  @pytest.mark.usefixtures("db_session")
  def test_post_auth_url_forbidden_for_non_admin(client, headers):
      resp = client.post("/api/integrations/google-drive/auth-url", headers=headers)
      assert resp.status_code == 403


  @pytest.mark.usefixtures("db_session")
  @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.post")
  @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.get")
  def test_callback_creates_integration(mock_get, mock_post, admin_client, admin_headers, redis_client):
      # 1. 先取 auth-url 拿到 state
      resp = admin_client.post("/api/integrations/google-drive/auth-url", headers=admin_headers)
      state = resp.get_json()["data"]["state"]

      # 2. mock token exchange + userinfo
      mock_post.return_value.ok = True
      mock_post.return_value.json.return_value = {
          "access_token": "at-x", "refresh_token": "rt-y", "expires_in": 3600,
      }
      mock_get.return_value.ok = True
      mock_get.return_value.json.return_value = {"email": "shared@company.com"}

      # 3. 呼叫 callback
      resp = admin_client.get(f"/api/integrations/google-drive/callback?code=the-code&state={state}")
      assert resp.status_code == 200
      assert b"success" in resp.data

      # 4. 驗 status 變成 CONNECTED
      resp = admin_client.get("/api/integrations/google-drive", headers=admin_headers)
      assert resp.get_json()["data"]["status"] == "CONNECTED"
      assert resp.get_json()["data"]["google_account_email"] == "shared@company.com"


  @pytest.mark.usefixtures("db_session")
  def test_disconnect(admin_client, admin_headers):
      # 假設已連線（前一個 test 的延伸或 fixture 預先建好）
      resp = admin_client.delete("/api/integrations/google-drive", headers=admin_headers)
      assert resp.status_code == 200
      resp = admin_client.get("/api/integrations/google-drive", headers=admin_headers)
      assert resp.get_json()["data"]["status"] == "DISCONNECTED"
  ```

- [ ] **Step 2**：跑 `pytest test/cloud_integration/test_google_drive_integration_route.py -v` → PASS。

  > 若沒有 `admin_client` / `admin_headers` fixture，先在 `test/cloud_integration/conftest.py` 補上（參考 `tests/conftest.py` 既有 pattern）。

- [ ] **Step 3**：commit
  ```bash
  git add test/cloud_integration/test_google_drive_integration_route.py test/cloud_integration/conftest.py
  git commit -m "test(cloud_integration): add E2E tests for Google Drive integration routes"
  ```

---

### Task 16: 前端 — API 常數 & Service

**Files:**
- Modify: `src/config/api/api.js`
- Create: `src/service/CloudIntegrationService.js`

切換工作目錄到前端：`cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe`

- [ ] **Step 1**：在 `src/config/api/api.js` 末尾加：
  ```js
  // ── Cloud Integration ─────────────────────────────────────
  INTEGRATION_GDRIVE: '/integrations/google-drive',
  INTEGRATION_GDRIVE_AUTH_URL: '/integrations/google-drive/auth-url',
  ```

- [ ] **Step 2**：建 `src/service/CloudIntegrationService.js`：
  ```javascript
  import { BaseService } from '@/service/BaseService'
  import API from '@/config/api/api'

  export class CloudIntegrationService extends BaseService {
      async getGoogleDriveStatus() {
          return this.get(API.INTEGRATION_GDRIVE)
      }

      async getGoogleDriveAuthUrl() {
          return this.post(API.INTEGRATION_GDRIVE_AUTH_URL)
      }

      async disconnectGoogleDrive() {
          return this.delete(API.INTEGRATION_GDRIVE)
      }
  }
  ```

- [ ] **Step 3**：commit (前端 repo)
  ```bash
  git add src/config/api/api.js src/service/CloudIntegrationService.js
  git commit -m "feat(cloud-integration): add CloudIntegrationService + API constants"
  ```

---

### Task 17: 前端 — i18n 字串

**Files:**
- Modify: `src/lang/zh-TW.js` (or wherever zh strings live)
- Modify: `src/lang/en.js`

- [ ] **Step 1**：在中文檔加：
  ```js
  cloud_integration: {
      page_title: '雲端空間整合設定',
      gdrive: {
          title: 'Google Drive',
          description: '透過 Drive 上傳 evidence，自動同步進系統。',
          warning: '⚠ 將授權系統存取連線帳號的整個 Drive，建議使用 dedicated 共用公司帳號。',
          connect_btn: '連線 Google Drive',
          disconnect_btn: '中斷連線',
          reauth_btn: '重新授權',
          sync_now_btn: '立即同步',
          status_connected: '已連線',
          status_disconnected: '未連線',
          status_revoked: '連線已失效',
          status_expired: '授權已過期',
          field_account: '連線帳號',
          field_root_folder: '根目錄',
          field_connected_at: '連線時間',
          field_last_sync: '上次同步',
          field_webhook_expires: 'Webhook 到期',
          connecting: '連線中…',
          connect_success: 'Google Drive 連線成功！',
          connect_error: '連線失敗：{error}',
          disconnect_confirm: '確定要中斷 Google Drive 連線嗎？已快取的證據檔案仍可下載，但 Drive 上新的變更將不再同步。',
          disconnect_success: '已中斷 Google Drive 連線',
      }
  }
  ```

- [ ] **Step 2**：英文檔對應翻譯（語意一致即可）。

- [ ] **Step 3**：commit
  ```bash
  git add src/lang/
  git commit -m "feat(cloud-integration): add i18n strings for Google Drive integration"
  ```

---

### Task 18: 前端 — GoogleDriveIntegrationCard.vue 元件

**Files:**
- Create: `src/components/integrations/GoogleDriveIntegrationCard.vue`

- [ ] **Step 1**：建元件（PrimeVue Card / Button / Tag / Toast 風格與 `UserProfileForm.vue` 一致）：
  ```vue
  <script setup>
  import { ref, onMounted, onUnmounted, computed } from 'vue'
  import { useI18n } from 'vue-i18n'
  import { useToast } from 'primevue/usetoast'
  import { useConfirm } from 'primevue/useconfirm'
  import { CloudIntegrationService } from '@/service/CloudIntegrationService'

  const { t, d } = useI18n()
  const toast = useToast()
  const confirm = useConfirm()
  const service = new CloudIntegrationService()

  const status = ref(null)   // GoogleDriveStatusResponse
  const loading = ref(false)
  const connecting = ref(false)
  let messageHandler = null

  async function refresh() {
      loading.value = true
      try {
          status.value = await service.getGoogleDriveStatus()
      } finally {
          loading.value = false
      }
  }

  async function connect() {
      connecting.value = true
      try {
          const { auth_url } = await service.getGoogleDriveAuthUrl()
          const popup = window.open(auth_url, 'gdrive-oauth', 'width=500,height=700')

          messageHandler = (event) => {
              const msg = event.data
              if (!msg || msg.source !== 'guidant-drive-oauth') return
              if (msg.status === 'success') {
                  toast.add({ severity: 'success', summary: t('lang.cloud_integration.gdrive.connect_success'), life: 3000 })
                  refresh()
              } else {
                  toast.add({ severity: 'error', summary: t('lang.cloud_integration.gdrive.connect_error', { error: msg.error || '' }), life: 5000 })
              }
              connecting.value = false
              cleanup()
          }
          window.addEventListener('message', messageHandler)
      } catch (e) {
          connecting.value = false
          toast.add({ severity: 'error', summary: e?.message || 'Error', life: 4000 })
      }
  }

  function disconnect() {
      confirm.require({
          message: t('lang.cloud_integration.gdrive.disconnect_confirm'),
          header: t('lang.cloud_integration.gdrive.disconnect_btn'),
          accept: async () => {
              await service.disconnectGoogleDrive()
              toast.add({ severity: 'info', summary: t('lang.cloud_integration.gdrive.disconnect_success'), life: 3000 })
              refresh()
          }
      })
  }

  function cleanup() {
      if (messageHandler) {
          window.removeEventListener('message', messageHandler)
          messageHandler = null
      }
  }

  const isConnected = computed(() => status.value?.status === 'CONNECTED')
  const isError = computed(() => ['REVOKED', 'EXPIRED'].includes(status.value?.status))
  const driveRootUrl = computed(() => {
      if (!status.value?.root_folder_id) return null
      return `https://drive.google.com/drive/folders/${status.value.root_folder_id}`
  })

  onMounted(refresh)
  onUnmounted(cleanup)
  </script>

  <template>
      <div class="surface-card border-round-xl shadow-2 p-5 flex flex-column gap-4"
           style="border: 1px solid var(--surface-border)">
          <div class="flex align-items-center gap-3">
              <i class="pi pi-google text-3xl" style="color: #4285f4"></i>
              <div>
                  <div class="text-xl font-semibold">{{ t('lang.cloud_integration.gdrive.title') }}</div>
                  <div class="text-sm text-color-secondary">{{ t('lang.cloud_integration.gdrive.description') }}</div>
              </div>
          </div>

          <ProgressBar v-if="loading" mode="indeterminate" style="height: 4px" />

          <!-- 未連線 -->
          <div v-if="status && status.status === 'DISCONNECTED' && !loading"
               class="flex flex-column gap-3">
              <div class="text-sm text-color-secondary">
                  {{ t('lang.cloud_integration.gdrive.warning') }}
              </div>
              <Button :label="t('lang.cloud_integration.gdrive.connect_btn')"
                      icon="pi pi-link"
                      :loading="connecting"
                      @click="connect" />
          </div>

          <!-- 連線異常 -->
          <Message v-else-if="isError" severity="error" :closable="false">
              {{ status.status === 'REVOKED' ? t('lang.cloud_integration.gdrive.status_revoked')
                                              : t('lang.cloud_integration.gdrive.status_expired') }}
              <Button :label="t('lang.cloud_integration.gdrive.reauth_btn')"
                      severity="warn" size="small" class="ml-3" @click="connect" />
          </Message>

          <!-- 已連線 -->
          <div v-else-if="isConnected" class="flex flex-column gap-2">
              <Tag severity="success" :value="t('lang.cloud_integration.gdrive.status_connected')" class="align-self-start" />
              <div class="grid">
                  <div class="col-12 sm:col-6">
                      <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_account') }}</div>
                      <div class="font-medium">{{ status.google_account_email }}</div>
                  </div>
                  <div class="col-12 sm:col-6">
                      <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_root_folder') }}</div>
                      <div>
                          <a v-if="driveRootUrl" :href="driveRootUrl" target="_blank" rel="noopener"
                             class="text-primary">
                              GuidantAI <i class="pi pi-external-link text-xs ml-1"></i>
                          </a>
                          <span v-else class="text-color-secondary">—</span>
                      </div>
                  </div>
                  <div class="col-12 sm:col-6">
                      <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_connected_at') }}</div>
                      <div>{{ status.connected_at ? d(status.connected_at, 'long') : '—' }}
                          <span v-if="status.connected_by_user_name" class="text-color-secondary text-xs">
                              by {{ status.connected_by_user_name }}
                          </span>
                      </div>
                  </div>
                  <div class="col-12 sm:col-6">
                      <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_last_sync') }}</div>
                      <div>{{ status.last_sync_at ? d(status.last_sync_at, 'long') : '—' }}</div>
                  </div>
              </div>

              <div class="flex gap-2 mt-2">
                  <Button :label="t('lang.cloud_integration.gdrive.disconnect_btn')"
                          icon="pi pi-times" severity="danger" outlined
                          @click="disconnect" />
                  <Button :label="t('lang.cloud_integration.gdrive.reauth_btn')"
                          icon="pi pi-refresh" outlined
                          @click="connect" />
              </div>
          </div>
      </div>
  </template>
  ```

- [ ] **Step 2**：commit
  ```bash
  git add src/components/integrations/
  git commit -m "feat(cloud-integration): add GoogleDriveIntegrationCard component"
  ```

---

### Task 19: 前端 — CloudIntegrationsView 主頁

**Files:**
- Create: `src/views/integrations/CloudIntegrationsView.vue`
- Modify: `src/router/index.js`
- Modify: 側邊選單元件 (路徑視專案而定，可能在 `AppMenu.vue` 或 `Sidebar.vue`)

- [ ] **Step 1**：建主頁：
  ```vue
  <script setup>
  import { useI18n } from 'vue-i18n'
  import GoogleDriveIntegrationCard from '@/components/integrations/GoogleDriveIntegrationCard.vue'

  const { t } = useI18n()
  </script>

  <template>
      <div class="p-5 flex flex-column gap-4">
          <h2 class="m-0 font-semibold text-2xl">{{ t('lang.cloud_integration.page_title') }}</h2>
          <div class="grid">
              <div class="col-12 lg:col-8 xl:col-6">
                  <GoogleDriveIntegrationCard />
              </div>
          </div>
          <Toast />
          <ConfirmDialog />
      </div>
  </template>
  ```

- [ ] **Step 2**：在 `src/router/index.js` 加路由：
  ```js
  {
      path: '/settings/cloud-integrations',
      name: 'CloudIntegrations',
      component: () => import('@/views/integrations/CloudIntegrationsView.vue'),
      meta: { requiresAuth: true, requiresAdmin: true }
  }
  ```

- [ ] **Step 3**：在側邊選單加項目（admin only），label 用 `t('lang.cloud_integration.page_title')`，icon 用 `pi pi-cloud`，導向 `/settings/cloud-integrations`。具體檔案請查專案實際 menu component 路徑。

- [ ] **Step 4**：commit
  ```bash
  git add src/views/integrations/ src/router/index.js <menu file>
  git commit -m "feat(cloud-integration): add CloudIntegrationsView page + route + menu entry"
  ```

---

### Task 20: Playwright 驗收（前後端整合）

**Files:** N/A（用 Playwright MCP 操作 dev server）

- [ ] **Step 1**：在後端啟動 dev server（一個 terminal）：
  ```bash
  cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
  python main_app.py
  ```

- [ ] **Step 2**：在前端啟動 dev server（另一個 terminal）：
  ```bash
  cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe
  npm run dev
  ```

- [ ] **Step 3**：用 Playwright 開瀏覽器：
  - 登入（用 admin 帳號）
  - 進 `/settings/cloud-integrations`
  - 截圖確認頁面有 Google Drive 卡片，狀態為「未連線」
  - 點「連線 Google Drive」按鈕，截圖確認彈出 OAuth popup（會跳到 Google）

- [ ] **Step 4**：手動完成 OAuth（用 dev test user），等 popup 自動關閉。Playwright 截圖確認頁面變成「已連線」狀態，顯示 email、連線時間。

- [ ] **Step 5**：點「中斷連線」，確認 dialog，截圖確認回到「未連線」狀態。

- [ ] **Step 6**：把截圖貼到 changelog 或 PR description（demo 用）。

---

### Task 21: Changelog

**Files:**
- Create: `docs/changelog/<YYYY-MM-DD>-google-drive-oauth-integration.md`

- [ ] **Step 1**：寫 changelog：
  ```markdown
  # Google Drive OAuth 整合（Phase 1）

  日期：<YYYY-MM-DD>
  作者：raymond / Claude

  ## 需求說明

  Tenant admin 可在「雲端空間整合設定」頁完成 Google Drive OAuth 連線、查看狀態、中斷連線。為後續資料夾自動建立與檔案同步打基礎。

  ## 變更範圍

  ### 後端
  - 新增模組 `cloud_integration`
  - 新增 table `compliance.tenant_drive_integrations` (見 `scripts/sql/<...>.sql`)
  - 新增 9 個 error codes (GRC_400030-500030)
  - 新增依賴：google-api-python-client, google-auth-oauthlib, cryptography
  - Token 加密：Fernet（key 由 env `DRIVE_TOKEN_ENCRYPTION_KEY` 提供）

  ### 前端
  - 新增頁面 `/settings/cloud-integrations`（admin only）
  - 新增元件 `GoogleDriveIntegrationCard.vue`
  - 新增 service `CloudIntegrationService.js`

  ## API 變更

  | Method | Path | 說明 |
  |--------|------|------|
  | GET | /api/integrations/google-drive | 取得當前 tenant 連線狀態 |
  | POST | /api/integrations/google-drive/auth-url | 產生 OAuth URL（admin） |
  | GET | /api/integrations/google-drive/callback | OAuth callback（HTML） |
  | DELETE | /api/integrations/google-drive | 中斷連線（admin） |

  ## 環境變數

  - `GOOGLE_DRIVE_OAUTH_CLIENT_ID`
  - `GOOGLE_DRIVE_OAUTH_CLIENT_SECRET`
  - `GOOGLE_DRIVE_OAUTH_REDIRECT_URI`
  - `DRIVE_TOKEN_ENCRYPTION_KEY`（用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 產）

  ## 參考

  - Spec: docs/features/FR-016-2604-google-drive-sync/design.md §6
  - Implementation Plan: docs/features/FR-016-2604-google-drive-sync/implementation-plan-phase-1-oauth.md
  ```

- [ ] **Step 2**：commit
  ```bash
  git add docs/changelog/<YYYY-MM-DD>-google-drive-oauth-integration.md
  git commit -m "docs: add changelog for Google Drive OAuth integration phase 1"
  ```

---

## Phase 1 驗收 Checklist

- [ ] Migration 跑過 dev DB，table 存在
- [ ] 後端啟動無 error
- [ ] `GET /api/integrations/google-drive` 回 DISCONNECTED
- [ ] Admin 可走完 OAuth flow（用 dev test user）
- [ ] DB 中 refresh_token 是加密的（不可讀）
- [ ] 一般 user 呼叫 `POST /auth-url` 回 403
- [ ] DISCONNECT 後 token 欄位清空
- [ ] 所有 unit test PASS
- [ ] E2E test PASS
- [ ] Playwright 截圖驗收完成
- [ ] Changelog 寫好

完成 → 進 Phase 2。
