bedee3ba8d
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
174 lines
5.2 KiB
TypeScript
174 lines
5.2 KiB
TypeScript
import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
|
|
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
|
import { isRecord, isServerRequestError, serverRequest, writeStoredSession } from "./serverConnection";
|
|
|
|
interface CreateNotificationInput {
|
|
type: WebNotificationType;
|
|
title: string;
|
|
description?: string;
|
|
targetType?: string;
|
|
targetId?: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
const NOTIFICATION_VIEW_BY_TARGET: Record<string, WebViewKey> = {
|
|
task: "workbench",
|
|
generation_task: "workbench",
|
|
community_case: "login",
|
|
asset: "assets",
|
|
project: "canvas",
|
|
draft: "workbench",
|
|
};
|
|
|
|
let notificationsRouteMissing = false;
|
|
let notificationsUnauthorized = false;
|
|
|
|
function toStringValue(value: unknown, fallback = ""): string {
|
|
if (typeof value === "string") return value.trim() || fallback;
|
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeType(value: unknown): WebNotificationType {
|
|
const type = toStringValue(value);
|
|
if (
|
|
type === "task_completed" ||
|
|
type === "task_failed" ||
|
|
type === "review_pending" ||
|
|
type === "review_passed" ||
|
|
type === "review_rejected" ||
|
|
type === "credits_low" ||
|
|
type === "session_expired"
|
|
) {
|
|
return type;
|
|
}
|
|
return "info";
|
|
}
|
|
|
|
function normalizeNotification(raw: unknown): WebNotification {
|
|
const item = isRecord(raw) ? raw : {};
|
|
const targetType = toStringValue(item.targetType ?? item.target_type) || null;
|
|
const targetId = toStringValue(item.targetId ?? item.target_id) || undefined;
|
|
const readAt = toStringValue(item.readAt ?? item.read_at) || null;
|
|
return {
|
|
id: toStringValue(item.id, `notice-${Date.now()}`),
|
|
type: normalizeType(item.type),
|
|
title: toStringValue(item.title, "通知"),
|
|
description: toStringValue(item.description),
|
|
createdAt: toStringValue(item.createdAt ?? item.created_at, new Date().toISOString()),
|
|
isRead: Boolean(item.isRead ?? item.is_read ?? readAt),
|
|
targetType,
|
|
targetId,
|
|
targetView: targetType ? NOTIFICATION_VIEW_BY_TARGET[targetType] : undefined,
|
|
readAt,
|
|
metadata: isRecord(item.metadata) ? item.metadata : {},
|
|
};
|
|
}
|
|
|
|
function extractNotifications(payload: unknown): WebNotification[] {
|
|
if (Array.isArray(payload)) return payload.map(normalizeNotification);
|
|
if (!isRecord(payload)) return [];
|
|
const rows = payload.notifications ?? payload.items;
|
|
return Array.isArray(rows) ? rows.map(normalizeNotification) : [];
|
|
}
|
|
|
|
function isUnauthorized(error: unknown): boolean {
|
|
return isServerRequestError(error) && (error.status === 401 || error.status === 403);
|
|
}
|
|
|
|
function handleUnauthorizedNotifications(): void {
|
|
notificationsUnauthorized = true;
|
|
writeStoredSession(null);
|
|
}
|
|
|
|
export const notificationClient = {
|
|
async list(): Promise<WebNotification[]> {
|
|
if (notificationsRouteMissing || notificationsUnauthorized) return [];
|
|
try {
|
|
return extractNotifications(await serverRequest<unknown>("notifications"));
|
|
} catch (error) {
|
|
if (isOptionalApiRouteMissing(error)) {
|
|
notificationsRouteMissing = true;
|
|
return [];
|
|
}
|
|
if (isUnauthorized(error)) {
|
|
handleUnauthorizedNotifications();
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async create(input: CreateNotificationInput): Promise<WebNotification> {
|
|
if (notificationsRouteMissing || notificationsUnauthorized) {
|
|
return normalizeNotification({
|
|
...input,
|
|
id: `local-notice-${Date.now()}`,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
try {
|
|
const payload = await serverRequest<{ notification: unknown }>("notifications", {
|
|
method: "POST",
|
|
body: input,
|
|
});
|
|
return normalizeNotification(payload.notification ?? payload);
|
|
} catch (error) {
|
|
if (isOptionalApiRouteMissing(error)) {
|
|
notificationsRouteMissing = true;
|
|
return normalizeNotification({
|
|
...input,
|
|
id: `local-notice-${Date.now()}`,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
if (isUnauthorized(error)) {
|
|
handleUnauthorizedNotifications();
|
|
return normalizeNotification({
|
|
...input,
|
|
id: `local-notice-${Date.now()}`,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async markRead(id: string, isRead = true): Promise<void> {
|
|
if (notificationsRouteMissing || notificationsUnauthorized) return;
|
|
try {
|
|
await serverRequest(`notifications/${id}/read`, {
|
|
method: "PATCH",
|
|
body: { isRead },
|
|
});
|
|
} catch (error) {
|
|
if (isOptionalApiRouteMissing(error)) {
|
|
notificationsRouteMissing = true;
|
|
return;
|
|
}
|
|
if (isUnauthorized(error)) {
|
|
handleUnauthorizedNotifications();
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async markAllRead(): Promise<void> {
|
|
if (notificationsRouteMissing || notificationsUnauthorized) return;
|
|
try {
|
|
await serverRequest("notifications/read-all", { method: "POST" });
|
|
} catch (error) {
|
|
if (isOptionalApiRouteMissing(error)) {
|
|
notificationsRouteMissing = true;
|
|
return;
|
|
}
|
|
if (isUnauthorized(error)) {
|
|
handleUnauthorizedNotifications();
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
};
|