916 lines
25 KiB
TypeScript
916 lines
25 KiB
TypeScript
import { appendClientIdHeader, getOrCreateClientId } 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 WebPushSubscriptionItem = {
|
|
id: number;
|
|
endpoint: string;
|
|
deviceId: string;
|
|
clientId: string;
|
|
userAgent: string;
|
|
appOrigin: string;
|
|
appDomain: string;
|
|
enabled: boolean;
|
|
lastRegisteredAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type ClientNotificationPayload = {
|
|
title: string;
|
|
body: string;
|
|
data?: Record<string, string>;
|
|
threadId?: string;
|
|
targetDeviceIds?: string[];
|
|
targetClientIds?: string[];
|
|
targetAppOrigins?: string[];
|
|
targetAppDomains?: 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;
|
|
matchedCount?: number;
|
|
matchedSubscriptions?: Array<{
|
|
endpoint: string;
|
|
deviceId: string;
|
|
clientId: string;
|
|
appOrigin: string;
|
|
appDomain: string;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
function normalizeNotificationOriginValue(value: unknown) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function getCurrentAppOrigin() {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
|
|
return window.location.origin;
|
|
}
|
|
|
|
function getCurrentAppDomain() {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
|
|
return window.location.hostname;
|
|
}
|
|
|
|
function appendNotificationOriginToBody(body: string, data?: Record<string, string>) {
|
|
const normalizedBody = String(body ?? '').trim();
|
|
const appOrigin = normalizeNotificationOriginValue(data?.appOrigin);
|
|
const appDomain = normalizeNotificationOriginValue(data?.appDomain);
|
|
const originLabel = appOrigin || appDomain;
|
|
|
|
if (!originLabel) {
|
|
return normalizedBody;
|
|
}
|
|
|
|
const originLine = `origin: ${originLabel}`;
|
|
|
|
if (normalizedBody.includes(originLine)) {
|
|
return normalizedBody;
|
|
}
|
|
|
|
return normalizedBody ? `${normalizedBody}\n${originLine}` : originLine;
|
|
}
|
|
|
|
function withCurrentAppOriginMetadata(data?: Record<string, string>) {
|
|
const metadata = { ...(data ?? {}) };
|
|
const appOrigin = getCurrentAppOrigin().trim();
|
|
const appDomain = getCurrentAppDomain().trim();
|
|
|
|
if (appOrigin && !normalizeNotificationOriginValue(metadata.appOrigin)) {
|
|
metadata.appOrigin = appOrigin;
|
|
}
|
|
|
|
if (appDomain && !normalizeNotificationOriginValue(metadata.appDomain)) {
|
|
metadata.appDomain = appDomain;
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
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;
|
|
|
|
export function notifyNotificationMessagesUpdated() {
|
|
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);
|
|
notifyNotificationMessagesUpdated();
|
|
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);
|
|
notifyNotificationMessagesUpdated();
|
|
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);
|
|
}
|
|
|
|
notifyNotificationMessagesUpdated();
|
|
}
|
|
|
|
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 fetchWebPushSubscriptions() {
|
|
const response = await request<{ items: WebPushSubscriptionItem[] }>('/notifications/subscriptions/web');
|
|
return Array.isArray(response.items) ? response.items : [];
|
|
}
|
|
|
|
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 ?? {},
|
|
}),
|
|
});
|
|
|
|
notifyNotificationMessagesUpdated();
|
|
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,
|
|
}),
|
|
});
|
|
|
|
notifyNotificationMessagesUpdated();
|
|
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',
|
|
});
|
|
notifyNotificationMessagesUpdated();
|
|
} 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 dismissChatNotificationMessages(sessionId: string) {
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!normalizedSessionId) {
|
|
return 0;
|
|
}
|
|
|
|
const response = await fetchNotificationMessages({
|
|
status: 'all',
|
|
limit: 100,
|
|
});
|
|
|
|
const targetIds = response.items
|
|
.filter((item) => item.category === 'chat' && getNotificationMetadataText(item.metadata, 'sessionId') === normalizedSessionId)
|
|
.map((item) => item.id);
|
|
|
|
if (targetIds.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
await Promise.allSettled(targetIds.map((id) => deleteNotificationMessage(id)));
|
|
return targetIds.length;
|
|
}
|
|
|
|
export async function dismissChatWebPushNotifications(sessionId?: string) {
|
|
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
|
|
if (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');
|
|
|
|
const isChatNotification =
|
|
notificationCategory === 'chat' ||
|
|
notificationType.startsWith('chat') ||
|
|
notificationThreadId.startsWith('chat:');
|
|
const notificationSessionId = getNotificationMetadataText(notificationData, 'sessionId');
|
|
|
|
if (!isChatNotification) {
|
|
return;
|
|
}
|
|
|
|
if (normalizedSessionId && notificationSessionId !== normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
notification.close();
|
|
dismissedCount += 1;
|
|
});
|
|
|
|
return dismissedCount;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
export async function registerWebPushSubscription(
|
|
subscription: WebPushSubscriptionPayload,
|
|
deviceId?: string,
|
|
) {
|
|
const clientId = getOrCreateClientId().trim();
|
|
|
|
return request<{ ok: boolean; endpoint: string }>('/notifications/subscriptions/web', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
subscription,
|
|
deviceId,
|
|
clientId: clientId || undefined,
|
|
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
appOrigin: getCurrentAppOrigin(),
|
|
appDomain: getCurrentAppDomain(),
|
|
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 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 notificationData = withCurrentAppOriginMetadata(payload.data);
|
|
const notificationOptions = {
|
|
body: appendNotificationOriginToBody(payload.body, notificationData),
|
|
data: notificationData,
|
|
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) {
|
|
const notificationData = withCurrentAppOriginMetadata(payload.data);
|
|
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
|
|
return request<ClientNotificationSendResult>('/notifications/send', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
...payload,
|
|
targetDeviceIds,
|
|
data: notificationData,
|
|
}),
|
|
});
|
|
}
|