Initial import
This commit is contained in:
813
src/app/main/notificationApi.ts
Executable file
813
src/app/main/notificationApi.ts
Executable file
@@ -0,0 +1,813 @@
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
function resolveNotificationApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
const NOTIFICATION_API_BASE_URL = resolveNotificationApiBaseUrl();
|
||||
const NOTIFICATION_API_REQUEST_TIMEOUT_MS = 8000;
|
||||
const NOTIFICATION_MESSAGES_CACHE_WINDOW_MS = 1500;
|
||||
type NotificationMessagesResponse = {
|
||||
ok: boolean;
|
||||
items: NotificationMessageItem[];
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
const notificationMessagesCache = new Map<
|
||||
string,
|
||||
{
|
||||
fetchedAt: number;
|
||||
value: NotificationMessagesResponse | null;
|
||||
promise: Promise<NotificationMessagesResponse> | null;
|
||||
}
|
||||
>();
|
||||
|
||||
function resolveNotificationApiFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const NOTIFICATION_API_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && NOTIFICATION_API_BASE_URL === '/api'
|
||||
? resolveNotificationApiFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
class NotificationApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'NotificationApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type WebPushSubscriptionPayload = {
|
||||
endpoint: string;
|
||||
expirationTime?: number | null;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientNotificationPayload = {
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
export type ClientNotificationSendResult = {
|
||||
ok: boolean;
|
||||
ios: {
|
||||
ok: boolean;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
web: {
|
||||
ok: boolean;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PwaNotificationTokenPayload = {
|
||||
token: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
|
||||
export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type NotificationMessageListStatus = 'all' | 'unread';
|
||||
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
|
||||
|
||||
export type NotificationMessageItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
preview: string;
|
||||
category: string;
|
||||
source: string;
|
||||
priority: NotificationMessagePriority;
|
||||
read: boolean;
|
||||
readAt: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationMessagePayload = {
|
||||
title: string;
|
||||
body: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
priority?: NotificationMessagePriority;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type NotificationMessageRow = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
body: string;
|
||||
category: string;
|
||||
source: string;
|
||||
priority: NotificationMessagePriority | string;
|
||||
is_read: boolean;
|
||||
read_at: string | null;
|
||||
metadata_json: Record<string, unknown> | string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const NOTIFICATION_MESSAGE_TABLE = 'notification_messages';
|
||||
|
||||
let notificationMessageTableSetupPromise: Promise<void> | null = null;
|
||||
|
||||
function emitNotificationMessagesUpdated() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(NOTIFICATION_MESSAGES_UPDATED_EVENT));
|
||||
}
|
||||
|
||||
function createNotificationPreview(body: string) {
|
||||
const normalized = body
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/[#>*_`~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function mapNotificationMessageRow(row: NotificationMessageRow): NotificationMessageItem {
|
||||
const body = String(row.body ?? '');
|
||||
let metadata: Record<string, unknown> = {};
|
||||
|
||||
if (typeof row.metadata_json === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(row.metadata_json) as Record<string, unknown>;
|
||||
metadata = parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
metadata = {};
|
||||
}
|
||||
} else if (row.metadata_json && typeof row.metadata_json === 'object') {
|
||||
metadata = row.metadata_json;
|
||||
}
|
||||
|
||||
const metadataPreview =
|
||||
typeof metadata.previewText === 'string'
|
||||
? metadata.previewText
|
||||
: typeof metadata.listPreviewText === 'string'
|
||||
? metadata.listPreviewText
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
title: String(row.title ?? ''),
|
||||
body,
|
||||
preview: createNotificationPreview(metadataPreview || body),
|
||||
category: String(row.category ?? 'general'),
|
||||
source: String(row.source ?? 'system'),
|
||||
priority:
|
||||
row.priority === 'low' || row.priority === 'high' || row.priority === 'urgent' ? row.priority : 'normal',
|
||||
read: Boolean(row.is_read),
|
||||
readAt: row.read_at ?? null,
|
||||
metadata,
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureNotificationMessageTableViaCrud() {
|
||||
if (!notificationMessageTableSetupPromise) {
|
||||
notificationMessageTableSetupPromise = (async () => {
|
||||
const tables = await request<{ items: Array<{ table_name: string }> }>('/schema/tables');
|
||||
const hasTable = tables.items.some((item) => item.table_name === NOTIFICATION_MESSAGE_TABLE);
|
||||
|
||||
if (hasTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
await request('/ddl/raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sql: `
|
||||
create table if not exists ${NOTIFICATION_MESSAGE_TABLE} (
|
||||
id integer generated by default as identity primary key,
|
||||
title varchar(200) not null,
|
||||
body text not null,
|
||||
category varchar(60) not null default 'general',
|
||||
source varchar(80) not null default 'system',
|
||||
priority varchar(20) not null default 'normal',
|
||||
is_read boolean not null default false,
|
||||
read_at timestamptz null,
|
||||
metadata_json jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
`,
|
||||
}),
|
||||
});
|
||||
})().catch((error) => {
|
||||
notificationMessageTableSetupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return notificationMessageTableSetupPromise;
|
||||
}
|
||||
|
||||
async function fetchNotificationMessagesViaCrud(params?: {
|
||||
status?: NotificationMessageListStatus;
|
||||
limit?: number;
|
||||
}) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
|
||||
const limit = typeof params?.limit === 'number' ? Math.min(100, Math.max(1, Math.round(params.limit))) : 20;
|
||||
const where =
|
||||
params?.status === 'unread'
|
||||
? [
|
||||
{
|
||||
field: 'is_read',
|
||||
operator: 'eq' as const,
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const [itemsResponse, unreadCountResponse] = await Promise.all([
|
||||
request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
where,
|
||||
orderBy: [
|
||||
{ field: 'is_read', direction: 'asc' },
|
||||
{ field: 'created_at', direction: 'desc' },
|
||||
{ field: 'id', direction: 'desc' },
|
||||
],
|
||||
limit,
|
||||
}),
|
||||
}),
|
||||
request<{
|
||||
result?: {
|
||||
rows?: Array<{ count?: string | number }>;
|
||||
};
|
||||
}>('/ddl/raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sql: `select count(*)::int as count from ${NOTIFICATION_MESSAGE_TABLE} where is_read = false;`,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: itemsResponse.rows.map((row) => mapNotificationMessageRow(row)),
|
||||
unreadCount: Number(unreadCountResponse.result?.rows?.[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNotificationMessageViaCrud(id: number) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
limit: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const row = response.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new NotificationApiError('알림 메시지를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
return mapNotificationMessageRow(row);
|
||||
}
|
||||
|
||||
async function createNotificationMessageViaCrud(payload: CreateNotificationMessagePayload) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/insert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
rows: [
|
||||
{
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
category: payload.category ?? 'general',
|
||||
source: payload.source ?? 'system',
|
||||
priority: payload.priority ?? 'normal',
|
||||
metadata_json: payload.metadata ?? {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const row = response.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new NotificationApiError('알림 메시지를 저장하지 못했습니다.', 500);
|
||||
}
|
||||
|
||||
const item = mapNotificationMessageRow(row);
|
||||
emitNotificationMessagesUpdated();
|
||||
return item;
|
||||
}
|
||||
|
||||
async function updateNotificationMessageReadStateViaCrud(id: number, read: boolean) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/update`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
is_read: read,
|
||||
read_at: read ? new Date().toISOString() : null,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
}),
|
||||
});
|
||||
|
||||
const row = response.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new NotificationApiError('상태를 변경할 알림 메시지를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
const item = mapNotificationMessageRow(row);
|
||||
emitNotificationMessagesUpdated();
|
||||
return item;
|
||||
}
|
||||
|
||||
async function deleteNotificationMessageViaCrud(id: number) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows?: Array<{ id?: number | string }>;
|
||||
deleted?: number;
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/delete`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
}),
|
||||
});
|
||||
|
||||
const deletedCount =
|
||||
typeof response.deleted === 'number'
|
||||
? response.deleted
|
||||
: Array.isArray(response.rows)
|
||||
? response.rows.length
|
||||
: 0;
|
||||
|
||||
if (!deletedCount) {
|
||||
throw new NotificationApiError('삭제할 알림 메시지를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
emitNotificationMessagesUpdated();
|
||||
}
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), NOTIFICATION_API_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new NotificationApiError('알림 서버 응답이 지연됩니다.', 408);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new NotificationApiError(payload.message || '알림 요청 처리에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new NotificationApiError(text || '알림 요청 처리에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(NOTIFICATION_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
NOTIFICATION_API_FALLBACK_BASE_URL &&
|
||||
NOTIFICATION_API_FALLBACK_BASE_URL !== NOTIFICATION_API_BASE_URL &&
|
||||
(error instanceof NotificationApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|not found|Failed to fetch|Load failed|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(NOTIFICATION_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebPushConfig() {
|
||||
try {
|
||||
return await request<{ enabled: boolean; publicKey: string }>('/notifications/webpush/config');
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return {
|
||||
enabled: false,
|
||||
publicKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNotificationMessages(params?: {
|
||||
status?: NotificationMessageListStatus;
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.status) {
|
||||
searchParams.set('status', params.status);
|
||||
}
|
||||
|
||||
if (typeof params?.limit === 'number') {
|
||||
searchParams.set('limit', String(params.limit));
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
const cacheKey = query || '__default__';
|
||||
const cachedEntry = notificationMessagesCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
|
||||
if (cachedEntry?.value && now - cachedEntry.fetchedAt < NOTIFICATION_MESSAGES_CACHE_WINDOW_MS) {
|
||||
return cachedEntry.value;
|
||||
}
|
||||
|
||||
if (cachedEntry?.promise) {
|
||||
return cachedEntry.promise;
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const response = await request<NotificationMessagesResponse>(`/notifications/messages${query ? `?${query}` : ''}`);
|
||||
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: Date.now(),
|
||||
value: response,
|
||||
promise: null,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
const response = await fetchNotificationMessagesViaCrud(params);
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: Date.now(),
|
||||
value: response,
|
||||
promise: null,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
const currentEntry = notificationMessagesCache.get(cacheKey);
|
||||
|
||||
if (currentEntry?.promise) {
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: currentEntry.fetchedAt,
|
||||
value: currentEntry.value,
|
||||
promise: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: cachedEntry?.fetchedAt ?? 0,
|
||||
value: cachedEntry?.value ?? null,
|
||||
promise: requestPromise,
|
||||
});
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
export async function fetchNotificationMessage(id: number) {
|
||||
try {
|
||||
const response = await request<{ ok: boolean; item: NotificationMessageItem }>(`/notifications/messages/${id}`);
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return fetchNotificationMessageViaCrud(id);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNotificationMessage(payload: CreateNotificationMessagePayload) {
|
||||
try {
|
||||
const response = await request<{ ok: boolean; item: NotificationMessageItem }>('/notifications/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
category: payload.category ?? 'general',
|
||||
source: payload.source ?? 'system',
|
||||
priority: payload.priority ?? 'normal',
|
||||
metadata: payload.metadata ?? {},
|
||||
}),
|
||||
});
|
||||
|
||||
emitNotificationMessagesUpdated();
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return createNotificationMessageViaCrud(payload);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNotificationMessageReadState(id: number, read: boolean) {
|
||||
try {
|
||||
const response = await request<{ ok: boolean; item: NotificationMessageItem }>(`/notifications/messages/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
read,
|
||||
}),
|
||||
});
|
||||
|
||||
emitNotificationMessagesUpdated();
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return updateNotificationMessageReadStateViaCrud(id, read);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNotificationMessage(id: number) {
|
||||
try {
|
||||
await request<{ ok: boolean; deleted: boolean }>(`/notifications/messages/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
emitNotificationMessagesUpdated();
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return deleteNotificationMessageViaCrud(id);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationMetadataText(metadata: Record<string, unknown> | null | undefined, key: string) {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = metadata[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export async function markChatNotificationMessagesAsRead(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const response = await fetchNotificationMessages({
|
||||
status: 'unread',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const targetIds = response.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.read !== true &&
|
||||
item.category === 'chat' &&
|
||||
getNotificationMetadataText(item.metadata, 'sessionId') === normalizedSessionId,
|
||||
)
|
||||
.map((item) => item.id);
|
||||
|
||||
if (targetIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await Promise.allSettled(targetIds.map((id) => updateNotificationMessageReadState(id, true)));
|
||||
return targetIds.length;
|
||||
}
|
||||
|
||||
export async function dismissChatWebPushNotifications(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.ready);
|
||||
const notifications = await registration?.getNotifications();
|
||||
|
||||
if (!notifications || notifications.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let dismissedCount = 0;
|
||||
|
||||
notifications.forEach((notification) => {
|
||||
const notificationData =
|
||||
notification.data && typeof notification.data === 'object'
|
||||
? (notification.data as Record<string, unknown>)
|
||||
: null;
|
||||
const notificationCategory = getNotificationMetadataText(notificationData, 'category');
|
||||
const notificationType = getNotificationMetadataText(notificationData, 'type');
|
||||
const notificationThreadId = getNotificationMetadataText(notificationData, 'threadId');
|
||||
|
||||
if (
|
||||
(
|
||||
notificationCategory === 'chat' ||
|
||||
notificationType.startsWith('chat') ||
|
||||
notificationThreadId.startsWith('chat:')
|
||||
) &&
|
||||
getNotificationMetadataText(notificationData, 'sessionId') === normalizedSessionId
|
||||
) {
|
||||
notification.close();
|
||||
dismissedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return dismissedCount;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerWebPushSubscription(
|
||||
subscription: WebPushSubscriptionPayload,
|
||||
deviceId?: string,
|
||||
) {
|
||||
return request<{ ok: boolean; endpoint: string }>('/notifications/subscriptions/web', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
subscription,
|
||||
deviceId,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterWebPushSubscription(endpoint: string) {
|
||||
return request<{ ok: boolean; endpoint: string; removed: boolean }>('/notifications/subscriptions/web', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
endpoint,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerPwaNotificationToken(payload: PwaNotificationTokenPayload) {
|
||||
return request<{ ok: boolean; token: string }>('/notifications/tokens/ios', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
token: payload.token,
|
||||
deviceId: payload.deviceId,
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterPwaNotificationToken(token: string) {
|
||||
return request<{ ok: boolean; token: string; removed: boolean }>('/notifications/tokens/ios', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldFallbackToLocalNotification(result: ClientNotificationSendResult) {
|
||||
return (
|
||||
result.web.skipped === true ||
|
||||
result.web.ok !== true ||
|
||||
result.web.sentCount < 1 ||
|
||||
result.web.failedCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function showLocalClientNotification(payload: ClientNotificationPayload) {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof Notification === 'undefined' ||
|
||||
Notification.permission !== 'granted'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notificationOptions = {
|
||||
body: payload.body,
|
||||
data: payload.data ?? {},
|
||||
tag: payload.threadId ?? payload.data?.notificationKey ?? undefined,
|
||||
badge: '/pwa-192x192.svg',
|
||||
icon: '/pwa-192x192.svg',
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
const registration = (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.ready);
|
||||
|
||||
if (registration?.showNotification) {
|
||||
await registration.showNotification(payload.title, notificationOptions);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the Notification constructor below.
|
||||
}
|
||||
|
||||
try {
|
||||
new Notification(payload.title, notificationOptions);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendClientNotification(payload: ClientNotificationPayload) {
|
||||
return request<ClientNotificationSendResult>('/notifications/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user