Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

813
src/app/main/notificationApi.ts Executable file
View 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),
});
}