吸收数字员工通知跨端同步
This commit is contained in:
@@ -28,6 +28,8 @@ const DEFAULT_RISK_APPROVAL_POLICY_PATH = "/api/digital-employee/policies/risk-a
|
||||
const DEFAULT_ROUTE_DECISION_POLICY_PATH = "/api/digital-employee/policies/route-decision";
|
||||
const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
|
||||
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
|
||||
const DEFAULT_NOTIFICATION_UPDATES_PATH = "/api/digital-employee/notifications/updates";
|
||||
const DEFAULT_NOTIFICATION_ACTIONS_PATH = "/api/digital-employee/notifications/actions";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
|
||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||
"/system/content/content-digital-employee-sync";
|
||||
@@ -54,6 +56,8 @@ let routeDecisionPolicyPulling = false;
|
||||
let lastRouteDecisionPolicyPullAttemptMs = 0;
|
||||
let approvalUpdatesPulling = false;
|
||||
let lastApprovalUpdatesPullAttemptMs = 0;
|
||||
let notificationUpdatesPulling = false;
|
||||
let lastNotificationUpdatesPullAttemptMs = 0;
|
||||
|
||||
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
if (!ensureDigitalEmployeeSchema()) return null;
|
||||
@@ -69,6 +73,8 @@ interface DigitalEmployeeSyncConfig {
|
||||
routeDecisionPolicyEndpoint: string | null;
|
||||
approvalUpdatesEndpoint: string | null;
|
||||
approvalDecisionsEndpoint: string | null;
|
||||
notificationUpdatesEndpoint: string | null;
|
||||
notificationActionsEndpoint: string | null;
|
||||
leaseEndpoint: string | null;
|
||||
clientKey: string | null;
|
||||
authToken: string | null;
|
||||
@@ -128,6 +134,23 @@ export interface DigitalEmployeeApprovalDecisionSubmitResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeNotificationUpdatesPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint: string | null;
|
||||
pulled_at: string | null;
|
||||
applied: number;
|
||||
ignored: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeNotificationActionSubmitResult {
|
||||
ok: boolean;
|
||||
endpoint: string | null;
|
||||
submitted_at: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanStepRemoteLease {
|
||||
lease_id: string;
|
||||
state: "active" | "released" | "expired";
|
||||
@@ -652,6 +675,154 @@ export async function submitDigitalEmployeeApprovalDecision(
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullDigitalEmployeeNotificationUpdates(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeeNotificationUpdatesPullResult> {
|
||||
if (notificationUpdatesPulling) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().notificationUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (!options.force && nowMs - lastNotificationUpdatesPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().notificationUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
||||
}
|
||||
lastNotificationUpdatesPullAttemptMs = nowMs;
|
||||
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
if (!config.notificationUpdatesEndpoint || missingCredentials.length > 0) {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const error = !config.notificationUpdatesEndpoint ? "management notification updates endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
recordNotificationSyncEvent("notification_updates_pull_failed", pulledAt, {
|
||||
action: "pull_notification_updates",
|
||||
endpoint: config.notificationUpdatesEndpoint,
|
||||
ok: false,
|
||||
applied: 0,
|
||||
ignored: 0,
|
||||
error,
|
||||
source: "management-api",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error };
|
||||
}
|
||||
|
||||
notificationUpdatesPulling = true;
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.notificationUpdatesEndpoint);
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
const updates = readRemoteNotificationUpdates(response);
|
||||
const pulledAt = new Date().toISOString();
|
||||
const currentState = readDigitalEmployeeUiState();
|
||||
const notificationStateById: Record<string, unknown> = { ...(currentState.notificationStateById ?? {}) };
|
||||
let applied = 0;
|
||||
let ignored = 0;
|
||||
for (const update of updates) {
|
||||
if (!update.notificationId) {
|
||||
ignored += 1;
|
||||
continue;
|
||||
}
|
||||
const currentOverlay = objectRecord(notificationStateById[update.notificationId]) ?? {};
|
||||
notificationStateById[update.notificationId] = {
|
||||
...currentOverlay,
|
||||
...update.overlay,
|
||||
};
|
||||
applied += 1;
|
||||
}
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "notification_updates_pulled",
|
||||
metadata: {
|
||||
action: "pull_notification_updates",
|
||||
endpoint: config.notificationUpdatesEndpoint,
|
||||
ok: true,
|
||||
applied,
|
||||
ignored,
|
||||
source: "management-api",
|
||||
},
|
||||
state: {
|
||||
...currentState,
|
||||
notificationStateById,
|
||||
},
|
||||
});
|
||||
return { ok: true, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
|
||||
} catch (error) {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const message = normalizeError(error);
|
||||
recordNotificationSyncEvent("notification_updates_pull_failed", pulledAt, {
|
||||
action: "pull_notification_updates",
|
||||
endpoint: config.notificationUpdatesEndpoint,
|
||||
ok: false,
|
||||
applied: 0,
|
||||
ignored: 0,
|
||||
error: message,
|
||||
source: "management-api",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error: message };
|
||||
} finally {
|
||||
notificationUpdatesPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitDigitalEmployeeNotificationAction(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<DigitalEmployeeNotificationActionSubmitResult> {
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
const submittedAt = new Date().toISOString();
|
||||
const notificationId = stringField(input.notification_id ?? input.notificationId) || null;
|
||||
const notificationAction = stringField(input.action) || null;
|
||||
const status = stringField(input.status) || null;
|
||||
const reply = stringField(input.reply ?? input.content ?? input.message);
|
||||
if (!config.notificationActionsEndpoint || missingCredentials.length > 0) {
|
||||
const error = !config.notificationActionsEndpoint ? "management notification actions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
recordNotificationSyncEvent("notification_action_submit_failed", submittedAt, {
|
||||
action: "submit_notification_action",
|
||||
ok: false,
|
||||
endpoint: config.notificationActionsEndpoint,
|
||||
error,
|
||||
notification_id: notificationId,
|
||||
notification_action: notificationAction,
|
||||
status,
|
||||
reply_length: reply ? reply.length : null,
|
||||
source: "qimingclaw-notification-sync",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error };
|
||||
}
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.notificationActionsEndpoint, {
|
||||
...input,
|
||||
acted_at: stringField(input.acted_at ?? input.actedAt) || submittedAt,
|
||||
submitted_at: submittedAt,
|
||||
device_id: getDeviceId(),
|
||||
});
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
recordNotificationSyncEvent("notification_action_submitted", submittedAt, {
|
||||
action: "submit_notification_action",
|
||||
ok: true,
|
||||
endpoint: config.notificationActionsEndpoint,
|
||||
notification_id: notificationId,
|
||||
notification_action: notificationAction,
|
||||
status,
|
||||
reply_length: reply ? reply.length : null,
|
||||
source: "qimingclaw-notification-sync",
|
||||
});
|
||||
return { ok: true, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error: null };
|
||||
} catch (error) {
|
||||
const message = normalizeError(error);
|
||||
recordNotificationSyncEvent("notification_action_submit_failed", submittedAt, {
|
||||
action: "submit_notification_action",
|
||||
ok: false,
|
||||
endpoint: config.notificationActionsEndpoint,
|
||||
error: message,
|
||||
notification_id: notificationId,
|
||||
notification_action: notificationAction,
|
||||
status,
|
||||
reply_length: reply ? reply.length : null,
|
||||
source: "qimingclaw-notification-sync",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
||||
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
||||
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
||||
@@ -881,6 +1052,16 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
? config.approvalDecisionsEndpoint.trim()
|
||||
: null;
|
||||
const approvalDecisionsEndpoint = approvalDecisionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_DECISIONS_PATH);
|
||||
const notificationUpdatesEndpointOverride =
|
||||
typeof config.notificationUpdatesEndpoint === "string" && config.notificationUpdatesEndpoint.trim()
|
||||
? config.notificationUpdatesEndpoint.trim()
|
||||
: null;
|
||||
const notificationUpdatesEndpoint = notificationUpdatesEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_UPDATES_PATH);
|
||||
const notificationActionsEndpointOverride =
|
||||
typeof config.notificationActionsEndpoint === "string" && config.notificationActionsEndpoint.trim()
|
||||
? config.notificationActionsEndpoint.trim()
|
||||
: null;
|
||||
const notificationActionsEndpoint = notificationActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_ACTIONS_PATH);
|
||||
const leaseEndpointOverride =
|
||||
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
||||
? config.planStepDispatchLeaseEndpoint.trim()
|
||||
@@ -901,6 +1082,8 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
routeDecisionPolicyEndpoint,
|
||||
approvalUpdatesEndpoint,
|
||||
approvalDecisionsEndpoint,
|
||||
notificationUpdatesEndpoint,
|
||||
notificationActionsEndpoint,
|
||||
leaseEndpoint,
|
||||
clientKey: readClientKey(serverHost),
|
||||
authToken: readAuthToken(serverHost),
|
||||
@@ -1098,6 +1281,41 @@ function readRemoteApprovalUpdates(response: Record<string, unknown>): DigitalEm
|
||||
.filter((item) => Boolean(item.approvalId || item.remoteId));
|
||||
}
|
||||
|
||||
function readRemoteNotificationUpdates(response: Record<string, unknown>): Array<{ notificationId: string | null; overlay: Record<string, unknown> }> {
|
||||
const data = objectRecord(response.data) ?? response;
|
||||
const raw = Array.isArray(data.notifications)
|
||||
? data.notifications
|
||||
: Array.isArray(data.updates)
|
||||
? data.updates
|
||||
: Array.isArray(data.items)
|
||||
? data.items
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
return raw
|
||||
.map((item) => objectRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||
.map((item) => {
|
||||
const notificationId = stringField(item.notification_id ?? item.notificationId ?? item.id) || null;
|
||||
const overlay: Record<string, unknown> = {};
|
||||
const status = stringField(item.status);
|
||||
const readAt = stringField(item.read_at ?? item.readAt);
|
||||
const dismissedAt = stringField(item.dismissed_at ?? item.dismissedAt);
|
||||
const reply = stringField(item.reply ?? item.content ?? item.message);
|
||||
const repliedAt = stringField(item.replied_at ?? item.repliedAt);
|
||||
const remoteUpdatedAt = stringField(item.remote_updated_at ?? item.remoteUpdatedAt ?? item.updated_at ?? item.updatedAt);
|
||||
const source = stringField(item.source) || "management-api";
|
||||
if (status) overlay.status = status;
|
||||
if (readAt) overlay.read_at = readAt;
|
||||
if (dismissedAt) overlay.dismissed_at = dismissedAt;
|
||||
if (reply) overlay.reply = reply;
|
||||
if (repliedAt) overlay.replied_at = repliedAt;
|
||||
if (remoteUpdatedAt) overlay.remote_updated_at = remoteUpdatedAt;
|
||||
overlay.source = source;
|
||||
return { notificationId, overlay };
|
||||
});
|
||||
}
|
||||
|
||||
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
|
||||
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
|
||||
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
|
||||
@@ -1304,6 +1522,15 @@ function recordApprovalSyncEvent(action: string, occurredAt: string, metadata: R
|
||||
});
|
||||
}
|
||||
|
||||
function recordNotificationSyncEvent(action: string, occurredAt: string, metadata: Record<string, unknown>): void {
|
||||
const state = readDigitalEmployeeUiState();
|
||||
saveDigitalEmployeeUiState({
|
||||
action,
|
||||
metadata: { ...metadata, occurred_at: occurredAt },
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||
const payload = parsePayload(row.payload);
|
||||
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
||||
@@ -1783,11 +2010,18 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
|
||||
return {
|
||||
notification_id: notificationId,
|
||||
action: stringField(metadata.action ?? payload.action) || null,
|
||||
notification_action: stringField(metadata.notification_action ?? payload.notification_action) || null,
|
||||
status: stringField(overlay.status ?? payload.status) || null,
|
||||
read_at: stringField(overlay.read_at ?? payload.read_at ?? payload.readAt) || null,
|
||||
dismissed_at: stringField(overlay.dismissed_at ?? payload.dismissed_at ?? payload.dismissedAt) || null,
|
||||
replied_at: stringField(overlay.replied_at ?? payload.replied_at ?? payload.repliedAt) || null,
|
||||
reply_length: reply ? reply.length : null,
|
||||
source: stringField(metadata.source) || null,
|
||||
endpoint: stringField(metadata.endpoint) || null,
|
||||
ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
|
||||
applied: numberField(metadata.applied),
|
||||
ignored: numberField(metadata.ignored),
|
||||
error: stringField(metadata.error) || null,
|
||||
reply_length: numberField(metadata.reply_length) ?? (reply ? reply.length : null),
|
||||
preferences_enabled: typeof preferences.enabled === "boolean" ? preferences.enabled : null,
|
||||
muted_kinds: stringArrayField(preferences.muted_kinds ?? preferences.mutedKinds),
|
||||
muted_levels: stringArrayField(preferences.muted_levels ?? preferences.mutedLevels),
|
||||
@@ -1839,7 +2073,11 @@ function isNotificationEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_update_notification_preferences"
|
||||
|| kind === "digital_workday_notification_read"
|
||||
|| kind === "digital_workday_notification_dismiss"
|
||||
|| kind === "digital_workday_notification_reply";
|
||||
|| kind === "digital_workday_notification_reply"
|
||||
|| kind === "digital_workday_notification_updates_pulled"
|
||||
|| kind === "digital_workday_notification_updates_pull_failed"
|
||||
|| kind === "digital_workday_notification_action_submitted"
|
||||
|| kind === "digital_workday_notification_action_submit_failed";
|
||||
}
|
||||
|
||||
function routeDecisionAttentionLevel(policyResult: string, reasonCodes: string[], createdCommandId: string | null): "info" | "action" | "warn" | "error" {
|
||||
|
||||
Reference in New Issue
Block a user