Files
PROJECT-CONTORL/openspec/changes/fix-realtime-notifications/design.md
beabigegg 10db2c9d1f feat: implement audit trail alignment (soft delete & permission audit)
- Task Soft Delete:
  - Add is_deleted, deleted_at, deleted_by fields to Task model
  - Convert DELETE to soft delete with cascade to subtasks
  - Add include_deleted query param (admin only)
  - Add POST /api/tasks/{id}/restore endpoint
  - Exclude deleted tasks from subtask_count

- Permission Change Audit:
  - Add user.role_change event (high sensitivity)
  - Add user.admin_change event (critical, triggers alert)
  - Add PATCH /api/users/{id}/admin endpoint
  - Add role.permission_change event type

- Append-Only Enforcement:
  - Add DB triggers for audit_logs immutability (manual for production)
  - Migration 008 with graceful trigger failure handling

- Tests: 11 new soft delete tests (153 total passing)
- OpenSpec: fix-audit-trail archived, fix-realtime-notifications & fix-weekly-report proposals added

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:58:30 +08:00

3.6 KiB
Raw Blame History

Context

collaboration spec 要求即時通知透過 WebSocket 推播,但現行 NotificationService 僅寫入資料庫,未實作即時推送。

Goals / Non-Goals

Goals:

  • 建立 WebSocket 連線管理基礎設施
  • 通知建立時透過 Redis Pub/Sub 廣播
  • 使用者連線時補送未讀通知
  • 前端即時接收並更新通知

Non-Goals:

  • 不實作通知偏好設定(靜音/訂閱)
  • 不實作 Push Notification (PWA/Mobile)
  • 不實作通知分組或摺疊

Decisions

1. WebSocket 連線管理

Decision: 使用 in-memory dict + Redis Pub/Sub

Rationale:

  • 單 process 內使用 dict 維護 WebSocket 連線
  • 跨 process 透過 Redis Pub/Sub 廣播
  • 簡單且符合現有架構
class ConnectionManager:
    def __init__(self):
        self.active_connections: Dict[str, List[WebSocket]] = {}

    async def connect(self, user_id: str, websocket: WebSocket):
        await websocket.accept()
        if user_id not in self.active_connections:
            self.active_connections[user_id] = []
        self.active_connections[user_id].append(websocket)

    async def disconnect(self, user_id: str, websocket: WebSocket):
        self.active_connections[user_id].remove(websocket)
        if not self.active_connections[user_id]:
            del self.active_connections[user_id]

    async def send_to_user(self, user_id: str, message: dict):
        if user_id in self.active_connections:
            for connection in self.active_connections[user_id]:
                await connection.send_json(message)

2. Redis Pub/Sub 架構

Decision: 使用 user-specific channel

Rationale:

  • Channel 命名: notifications:{user_id}
  • 避免 broadcast 給不相關的 worker
  • 減少訊息處理量
async def publish_notification(user_id: str, notification: dict):
    channel = f"notifications:{user_id}"
    await redis.publish(channel, json.dumps(notification))

async def subscribe_notifications(user_id: str):
    pubsub = redis.pubsub()
    await pubsub.subscribe(f"notifications:{user_id}")
    return pubsub

3. 連線時補送未讀

Decision: 連線建立後立即查詢並推送

Rationale:

  • 確保使用者不漏接通知
  • 簡化前端狀態同步邏輯
@router.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket, token: str = Query(...)):
    user = await verify_ws_token(token)
    await manager.connect(user.id, websocket)

    # 連線時補送未讀
    unread = get_unread_notifications(db, user.id)
    await websocket.send_json({
        "type": "unread_sync",
        "notifications": [n.dict() for n in unread]
    })

    # 開始監聽
    await listen_for_notifications(user.id, websocket)

4. 訊息格式

Decision: 統一 JSON 格式

{
  "type": "notification",
  "data": {
    "id": "uuid",
    "type": "mention|assignment|blocker|...",
    "title": "...",
    "message": "...",
    "reference_type": "task|comment",
    "reference_id": "uuid",
    "created_at": "ISO8601"
  }
}

API Changes

# 新增 WebSocket endpoint
WS /ws/notifications?token={jwt_token}

# 訊息類型
-> {"type": "unread_sync", "notifications": [...]}  # 連線時
-> {"type": "notification", "data": {...}}          # 新通知
-> {"type": "mark_read", "notification_id": "..."}  # 已讀確認
<- {"type": "ping"}                                  # 心跳

Risks / Trade-offs

Risk Mitigation
WebSocket 連線數量大 使用心跳偵測清理斷線
Redis Pub/Sub 可靠性 Pub/Sub 為 fire-and-forget已有 DB 紀錄作為 fallback
Token 驗證 in query WebSocket 標準限制token 有過期機制