import { dbAll, dbGet, dbRun } from "./db.mjs"; const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; function parseJson(value, fallback) { try { return JSON.parse(value); } catch { return fallback; } } function privateHost(hostname) { const host = String(hostname || "").toLowerCase(); if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true; const octets = host.split(".").map(Number); if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168); } async function fetchWithTimeout(url, options, timeoutMs = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } } function eventMatches(channel, eventKey) { const events = parseJson(channel.events_json, []); return events.includes("*") || events.includes(eventKey); } function notificationError(status, code, message, details = {}) { const error = new Error(message); error.status = status; error.code = code; error.details = details; return error; } const NOTIFICATION_PREFERENCE_CATALOG = [ { category: "job", label: "生成任务", description: "本地任务完成、失败和重试状态" }, { category: "review", label: "审片流程", description: "质检通过、驳回、修改和新评论" }, { category: "delivery", label: "交付运营", description: "交付版本创建、批准和回滚" }, { category: "task", label: "协作任务", description: "任务分派、状态变更和截止提醒" }, { category: "access", label: "组织访问", description: "邀请、成员和权限相关事件" }, { category: "billing", label: "用量与配额", description: "配额预警和成本边界" }, { category: "system", label: "系统通知", description: "平台策略和系统范围变更" } ]; function notificationDefinition(eventKey, payload = {}) { const kind = String(payload.kind || "生成任务"); const shot = payload.shotTitle || payload.shotId || "当前镜头"; const error = String(payload.error || "模型连接器返回了失败结果").slice(0, 180); const lane = payload.lane || "连续性检查"; const version = payload.version || "交付版本"; const definitions = { "job.completed": { category: "job", severity: "success", title: `${kind}已完成`, body: `${shot} 已完成本地执行,产出 ${Number(payload.artifactCount || 0)} 个可检查文件。`, targetTab: "jobs" }, "job.failed": { category: "job", severity: "error", title: `${kind}生成失败`, body: `${shot} 的本地任务失败:${error}`, targetTab: "jobs" }, "review.approved": { category: "review", severity: "success", title: `${lane}已通过`, body: `${shot} 的审片门已通过,可以继续下一步生产。`, targetTab: "qa" }, "review.changes_requested": { category: "review", severity: "warning", title: `${lane}需要修改`, body: `${shot} 的审片门要求补充证据或修改后再提交。`, targetTab: "qa" }, "review.rejected": { category: "review", severity: "error", title: `${lane}已驳回`, body: `${shot} 未通过审片,请查看阻断项并重新提交。`, targetTab: "qa" }, "review.comment": { category: "review", severity: "info", title: "审片中心有新评论", body: `${shot} 收到新的审片意见,请回到审片中心查看。`, targetTab: "qa" }, "delivery.created": { category: "delivery", severity: "info", title: `${version} 已创建`, body: "新的内部交付版本已建立,等待批次证据和审片结果。", targetTab: "export" }, "delivery.approved": { category: "delivery", severity: "success", title: `${version} 已批准交付`, body: "交付版本已通过所有阻断门,可以进入本地发布或导出流程。", targetTab: "export" }, "task.assigned": { category: "task", severity: "info", title: "你收到一个协作任务", body: `任务“${payload.taskTitle || "未命名任务"}”已分配给你,请在任务中心确认负责人和截止时间。`, targetTab: "tasks" }, "task.updated": { category: "task", severity: payload.status === "blocked" ? "warning" : payload.status === "done" ? "success" : "info", title: "协作任务状态已更新", body: `任务“${payload.taskTitle || "未命名任务"}”当前状态:${payload.status || "已更新"}。`, targetTab: "tasks" }, "task.commented": { category: "task", severity: "info", title: "协作任务有新讨论", body: `任务“${payload.taskTitle || "未命名任务"}”收到新的评论或 @提醒,请打开任务详情查看。`, targetTab: "tasks" }, "invitation.created": { category: "access", severity: "info", title: "你收到新的组织邀请", body: `${payload.organizationName || "一个生产组织"} 邀请你以 ${payload.roleName || payload.roleKey || "成员"} 身份加入。`, targetTab: "account" }, "system.changed": { category: "system", severity: "info", title: "系统配置已更新", body: payload.detail || "系统范围配置发生了变化,请确认当前生产策略。", targetTab: payload.targetTab || "system-overview" }, "quota.warning": { category: "billing", severity: "warning", title: "组织配额即将达到上限", body: payload.detail || "请联系组织管理员调整配额或清理不再需要的产物。", targetTab: "admin-usage" } }; return definitions[eventKey] || { category: String(payload.category || "system"), severity: String(payload.severity || "info"), title: String(payload.title || eventKey || "平台通知"), body: String(payload.body || "生产平台收到一条新的事件通知。"), targetTab: String(payload.targetTab || "creator-home") }; } function scopedRecipientIds(context, eventKey, payload = {}) { const requested = [ ...(Array.isArray(payload.recipientUserIds) ? payload.recipientUserIds : []), ...(payload.recipientUserId ? [payload.recipientUserId] : []) ].map((value) => String(value || "").trim()).filter(Boolean); if (requested.length) { const placeholders = requested.map(() => "?").join(","); return dbAll( `SELECT DISTINCT u.id FROM users u JOIN organization_members om ON om.user_id = u.id WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active' AND u.id IN (${placeholders})`, [context.organization.id, ...requested] ).map((row) => row.id); } if (!context?.organization?.id || !context?.user?.id) return []; if (eventKey.startsWith("system.")) return [context.user.id]; if (eventKey === "invitation.created") return payload.recipientUserId ? [String(payload.recipientUserId)] : [context.user.id]; return dbAll( `SELECT DISTINCT u.id FROM users u JOIN organization_members om ON om.user_id = u.id LEFT JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active' LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active' WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active' AND (om.role_key IN ('org_owner', 'org_admin') OR wm.user_id IS NOT NULL OR pm.user_id IS NOT NULL)`, [context.workspace?.id || "", context.project?.id || "", context.organization.id] ).map((row) => row.id); } function userNotificationPayload(row) { return { id: row.id, category: row.category, eventKey: row.event_key, severity: row.severity, title: row.title, body: row.body, targetTab: row.target_tab, targetId: row.target_id || "", metadata: parseJson(row.metadata_json, {}), createdAt: row.created_at, readAt: row.read_at, read: Boolean(row.read_at), organizationId: row.organization_id, workspaceId: row.workspace_id, projectId: row.project_id }; } function preferenceCatalogItem(row) { const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === row.category) || { category: row.category, label: row.category, description: "" }; return { ...definition, enabled: row.in_app_enabled === undefined ? true : Boolean(row.in_app_enabled), customized: row.in_app_enabled !== undefined }; } function inAppScope(context) { if (!context?.user?.id || !context?.organization?.id) throw notificationError(401, "auth_required", "请先登录"); return { where: "user_id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", params: [context.user.id, context.organization.id, context.workspace?.id || ""] }; } function createUserNotifications({ context, eventKey, payload = {}, definition }) { const recipientIds = scopedRecipientIds(context, eventKey, payload); if (!recipientIds.length) return []; const timestamp = new Date().toISOString(); const metadata = { ...payload }; delete metadata.recipientUserId; delete metadata.recipientUserIds; const created = []; for (const userId of recipientIds) { const preference = dbGet("SELECT in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ? AND category = ?", [userId, context.organization.id, definition.category]); if (preference && !Boolean(preference.in_app_enabled)) continue; const id = makeId("user-notification"); dbRun( "INSERT INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ id, userId, context.organization.id, context.workspace?.id || null, context.project?.id || null, definition.category, eventKey, definition.severity, definition.title, definition.body, definition.targetTab, String(payload.targetId || payload.reviewId || payload.jobId || payload.deliveryId || ""), JSON.stringify(metadata), timestamp ] ); created.push(dbGet("SELECT * FROM user_notifications WHERE id = ?", [id])); } return created; } export function listUserNotificationPreferences(context) { const scope = inAppScope(context); const rows = dbAll("SELECT category, in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ?", [scope.params[0], scope.params[1]]); const byCategory = new Map(rows.map((row) => [row.category, row])); return { preferences: NOTIFICATION_PREFERENCE_CATALOG.map((definition) => preferenceCatalogItem(byCategory.get(definition.category) || { category: definition.category })), scope: { organizationId: context.organization.id }, generatedAt: new Date().toISOString() }; } export function updateUserNotificationPreference(context, category, enabled) { inAppScope(context); const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === String(category || "").trim()); if (!definition) throw notificationError(400, "notification_category_invalid", "通知类别不存在", { category }); dbRun( "INSERT INTO user_notification_preferences(user_id, organization_id, category, in_app_enabled, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, organization_id, category) DO UPDATE SET in_app_enabled = excluded.in_app_enabled, updated_at = excluded.updated_at", [context.user.id, context.organization.id, definition.category, enabled ? 1 : 0, new Date().toISOString()] ); return listUserNotificationPreferences(context); } export function listUserNotifications(context, { limit = 60, unreadOnly = false } = {}) { const scope = inAppScope(context); const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 60))); const unreadClause = unreadOnly ? " AND read_at IS NULL" : ""; const rows = dbAll( `SELECT * FROM user_notifications WHERE ${scope.where}${unreadClause} ORDER BY CASE WHEN read_at IS NULL THEN 0 ELSE 1 END, created_at DESC LIMIT ?`, [...scope.params, normalizedLimit] ); const unreadCount = Number(dbGet(`SELECT COUNT(*) AS count FROM user_notifications WHERE ${scope.where} AND read_at IS NULL`, scope.params)?.count || 0); return { notifications: rows.map(userNotificationPayload), unreadCount, total: rows.length, scope: { organizationId: context.organization.id, workspaceId: context.workspace?.id || "" }, generatedAt: new Date().toISOString() }; } export function markUserNotificationRead(context, notificationId, read = true) { const scope = inAppScope(context); const current = dbGet(`SELECT * FROM user_notifications WHERE id = ? AND ${scope.where}`, [notificationId, ...scope.params]); if (!current) throw notificationError(404, "notification_not_found", "通知不存在或不属于当前工作区"); dbRun(`UPDATE user_notifications SET read_at = ? WHERE id = ? AND ${scope.where}`, [read ? new Date().toISOString() : null, notificationId, ...scope.params]); return listUserNotifications(context, { limit: 60 }); } export function markAllUserNotificationsRead(context) { const scope = inAppScope(context); dbRun(`UPDATE user_notifications SET read_at = COALESCE(read_at, ?) WHERE ${scope.where} AND read_at IS NULL`, [new Date().toISOString(), ...scope.params]); return listUserNotifications(context, { limit: 60 }); } async function deliver(channel, eventKey, requestBody) { const id = makeId("delivery"); const timestamp = new Date().toISOString(); dbRun("INSERT INTO notification_deliveries(id, channel_id, event_key, organization_id, workspace_id, project_id, status, attempt_count, request_json, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)", [id, channel.id, eventKey, requestBody.organizationId || null, requestBody.workspaceId || null, requestBody.projectId || null, JSON.stringify(requestBody), timestamp]); let status = "delivered"; let response = { kind: channel.kind }; let errorMessage = ""; let httpStatus = null; try { if (channel.kind === "local-log") { response = { logged: true, event: eventKey }; } else if (channel.kind === "webhook") { const endpoint = new URL(String(channel.endpoint || "")); if (endpoint.protocol !== "http:" || !privateHost(endpoint.hostname)) { status = "blocked"; errorMessage = "本地策略只允许投递到 HTTP 私有地址"; } else { const result = await fetchWithTimeout(endpoint.toString(), { method: "POST", headers: { "content-type": "application/json", "x-ai-drama-event": eventKey }, body: JSON.stringify(requestBody) }); httpStatus = result.status; const raw = await result.text(); response = { httpStatus, body: raw.slice(0, 1000) }; if (!result.ok) { status = "failed"; errorMessage = `Webhook 返回 HTTP ${result.status}`; } } } else { status = "blocked"; errorMessage = `通知渠道类型 ${channel.kind} 暂不支持`; } } catch (error) { status = "failed"; errorMessage = error.name === "AbortError" ? "通知投递超时" : String(error.message || error).slice(0, 500); } const deliveredAt = status === "delivered" ? new Date().toISOString() : null; dbRun("UPDATE notification_deliveries SET status = ?, attempt_count = 1, response_json = ?, error_message = ?, delivered_at = ? WHERE id = ?", [status, JSON.stringify({ ...response, httpStatus }), errorMessage, deliveredAt, id]); return dbGet("SELECT * FROM notification_deliveries WHERE id = ?", [id]); } export async function dispatchNotificationEvent({ context, eventKey, payload = {} }) { const definition = notificationDefinition(eventKey, payload); const userNotifications = createUserNotifications({ context, eventKey, payload, definition }); const channels = dbAll("SELECT * FROM notification_channels WHERE enabled = 1 ORDER BY created_at").filter((channel) => eventMatches(channel, eventKey)); const requestBody = { schema: "ai-drama.notification.v1", event: eventKey, occurredAt: new Date().toISOString(), actor: context?.user ? { id: context.user.id, name: context.user.display_name } : null, organizationId: context?.organization?.id || null, workspaceId: context?.workspace?.id || null, projectId: context?.project?.id || null, payload }; const deliveries = []; for (const channel of channels) deliveries.push(await deliver(channel, eventKey, requestBody)); return { deliveries, userNotifications: userNotifications.map(userNotificationPayload) }; } export function notificationDeliveries(context, limit = 80) { return dbAll( `SELECT d.*, c.name AS channel_name, c.kind AS channel_kind FROM notification_deliveries d JOIN notification_channels c ON c.id = d.channel_id WHERE d.organization_id = ? AND (d.workspace_id IS NULL OR d.workspace_id = ?) AND (d.project_id IS NULL OR d.project_id = ?) ORDER BY d.created_at DESC LIMIT ?`, [context.organization.id, context.workspace.id, context.project?.id || "", Math.max(1, Math.min(200, Number(limit || 80)))] ).map((row) => ({ ...row, request: parseJson(row.request_json, {}), response: parseJson(row.response_json, {}) })); }