검증게시판 기능 추가

This commit is contained in:
Macbook
2026-05-27 15:41:19 +09:00
parent 7a0af36b9b
commit 63693d47c0
57 changed files with 4480 additions and 14 deletions
+148
View File
@@ -0,0 +1,148 @@
/**
* GoldenChart 화면·기능별 재현 경로 카탈로그 (검증 이슈 재현경로 자동완성)
*
* path: 사용자에게 표시·저장되는 경로 (메뉴-화면-… 형식)
* keywords: 입력 검색용 별칭 (메뉴명, 기능명, 영문 등)
*/
export interface AppNavigationPathEntry {
path: string;
keywords: string[];
}
const P = (path: string, ...keywords: string[]): AppNavigationPathEntry => ({
path,
keywords: [path, ...keywords],
});
/** 프로젝트에서 제공하는 주요 화면·기능 경로 */
export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
// ── 상단 메뉴 ──
P('메뉴-대시보드', 'dashboard', '홈'),
P('메뉴-실시간 차트', 'chart', '실시간차트', '캔들', '차트'),
P('메뉴-가상매매', 'virtual', '가상', '모의'),
P('메뉴-추세검색', 'trend', '추세', '검색'),
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'),
P('메뉴-설정', 'settings', '환경설정'),
// ── 실시간 차트 · 툴바 ──
P('메뉴-실시간 차트-종목 변경', '심볼', '검색', '마켓'),
P('메뉴-실시간 차트-워치리스트', 'watchlist', '관심'),
P('메뉴-실시간 차트-시간봉 변경', 'timeframe', '분봉', '일봉'),
P('메뉴-실시간 차트-차트 유형', '캔들', '봉', 'line', 'heikin'),
P('메뉴-실시간 차트-지표 추가 버튼', '지표', 'indicator', '추가', 'Ctrl+I', '보조지표'),
P('메뉴-실시간 차트-지표 추가 팝업-지표 검색', '지표검색', 'RSI', 'MACD', '이동평균'),
P('메뉴-실시간 차트-지표 추가 팝업-보조지표 설정', '지표설정', '활성지표'),
P('메뉴-실시간 차트-보조지표 설정 버튼', '일괄설정', '보조지표설정'),
P('메뉴-실시간 차트-실행 취소', 'undo', 'Ctrl+Z'),
P('메뉴-실시간 차트-다시 실행', 'redo', 'Ctrl+Y'),
P('메뉴-실시간 차트-자석모드', 'magnet', '스냅'),
P('메뉴-실시간 차트-전체 드로잉 삭제', '드로잉', 'drawing'),
P('메뉴-실시간 차트-통계 패널', 'stats', '통계'),
P('메뉴-실시간 차트-자동 피보나치', 'fib', '피보'),
P('메뉴-실시간 차트-그리드 표시', 'grid', '격자'),
P('메뉴-실시간 차트-트레이딩/차트 모드 전환', 'mode', '트레이딩'),
P('메뉴-실시간 차트-백테스팅 전략 선택', '백테', '전략선택'),
P('메뉴-실시간 차트-백테스팅 설정', 'bt설정'),
P('메뉴-실시간 차트-백테스팅 결과 보기', 'bt결과'),
P('메뉴-실시간 차트-실시간 전략 체크', 'live', '라이브전략'),
P('메뉴-실시간 차트-마켓 패널', '마켓', '좌측패널'),
P('메뉴-실시간 차트-확대경', 'magnifier', '돋보기'),
P('메뉴-실시간 차트-레이아웃 설정', 'layout', '분할'),
P('메뉴-실시간 차트-화면 맞춤', 'fit', 'F키'),
P('메뉴-실시간 차트-현재 시각으로 이동', 'realtime', 'now'),
P('메뉴-실시간 차트-매매 시그널 알림', 'trade', '시그널', '알림'),
P('메뉴-실시간 차트-가격 알림', 'alert', '가격'),
P('메뉴-실시간 차트-스크린샷', 'screenshot', '캡처'),
P('메뉴-실시간 차트-전체화면', 'fullscreen'),
// ── 설정 카테고리 ──
P('메뉴-설정-일반 설정', 'general', '언어', '테마'),
P('메뉴-설정-차트 설정', 'chart설정', '캔들색', '범례'),
P('메뉴-설정-보조지표 설정', 'indicators', '지표기본값', 'Main탭'),
P('메뉴-설정-백테스팅', 'bt옵션', '자본'),
P('메뉴-설정-전략 설정', 'strategy', '전략기본'),
P('메뉴-설정-가상매매', 'paper', '가상자본'),
P('메뉴-설정-추세검색', 'trend설정', '배점'),
P('메뉴-설정-알림 설정', 'alert설정', '소리', '팝업'),
P('메뉴-설정-네트워크', 'network', 'API', 'WebSocket'),
P('메뉴-설정-관리자 설정', 'admin', '권한', '역할'),
// ── 가상매매 ──
P('메뉴-가상매매-투자대상 그리드', '타겟', '종목'),
P('메뉴-가상매매-우측 매매 탭', '주문', 'trade'),
P('메뉴-가상매매-우측 호가 탭', '호가', 'orderbook'),
P('메뉴-가상매매-우측 체결 탭', '체결', 'history'),
// ── 추세검색 ──
P('메뉴-추세검색-검색 실행', 'run', '스캔'),
P('메뉴-추세검색-결과 카드', '결과', '차트미리보기'),
// ── 검증게시판 ──
P('메뉴-검증게시판-이슈 등록', '등록', '추가'),
P('메뉴-검증게시판-이슈 수정', '수정', '편집'),
P('메뉴-검증게시판-이슈 삭제', '삭제'),
P('메뉴-검증게시판-첨부 이미지', '이미지', '스크린샷', '첨부'),
P('메뉴-검증게시판-댓글', 'comment', '코멘트'),
// ── 전략편집기 ──
P('메뉴-전략편집기-전략 목록', '목록', '리스트'),
P('메뉴-전략편집기-노드 캔버스', 'flow', '노드', '조건'),
P('메뉴-전략편집기-지표 팔레트', 'palette', '팔레트'),
P('메뉴-전략편집기-전략 저장', 'save'),
P('메뉴-전략편집기-전략 JSON보내기', 'export', '보내기'),
P('메뉴-전략편집기-전략 JSON 가져오기', 'import', '가져오기'),
// ── 백테스팅 ──
P('메뉴-백테스팅-결과 목록', '히스토리', 'history'),
P('메뉴-백테스팅-분석 차트', 'analysis'),
// ── 상단 메뉴바 기타 ──
P('메뉴-알림 목록', 'notifications', '시그널목록'),
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
P('메뉴-로그인', 'login', 'auth'),
];
function normalizeForSearch(s: string): string {
return s.toLowerCase().replace(/\s+/g, '');
}
/**
* 입력 문자열과 관련된 경로 후보 (최대 limit건, 관련도 순)
*/
export function searchAppNavigationPaths(query: string, limit = 12): AppNavigationPathEntry[] {
const q = query.trim();
if (!q) return APP_NAVIGATION_PATHS.slice(0, limit);
const nq = normalizeForSearch(q);
const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
type Scored = { entry: AppNavigationPathEntry; score: number };
const scored: Scored[] = [];
for (const entry of APP_NAVIGATION_PATHS) {
let score = 0;
const pathNorm = normalizeForSearch(entry.path);
if (entry.path.includes(q)) score += 80;
if (pathNorm.includes(nq)) score += 60;
if (pathNorm.startsWith(nq)) score += 40;
for (const kw of entry.keywords) {
const kn = normalizeForSearch(kw);
if (kw.includes(q)) score += 50;
if (kn.includes(nq)) score += 35;
if (kn.startsWith(nq)) score += 25;
for (const t of tokens) {
if (kn.includes(t) || kw.toLowerCase().includes(t)) score += 15;
}
}
if (score > 0) scored.push({ entry, score });
}
scored.sort((a, b) => b.score - a.score || a.entry.path.localeCompare(b.entry.path, 'ko'));
return scored.slice(0, limit).map(s => s.entry);
}
+2
View File
@@ -404,6 +404,8 @@ export interface AppSettingsDto {
tradeAlertPopupLayout?: string;
/** 그리드 배치 열 개수 (2~4) */
tradeAlertPopupGridCols?: number;
/** 검증 이슈 등록·단계 변경 알림 팝업 (기본 true) */
verificationIssueNotify?: boolean;
/** 실시간 전략 체크 마스터 ON — ON이면 DB 관심종목 전체가 체크 대상 */
liveStrategyCheck?: boolean;
/** 관심종목 공통 전략 ID */
+183
View File
@@ -0,0 +1,183 @@
/**
* 클립보드·붙여넣기 이미지 → 업로드용 File 정규화
*/
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
]);
/** 클립보드 항목에서 우선 선택할 MIME (macOS TIFF 등 제외) */
const CLIPBOARD_PREFERRED = [
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
'image/bmp',
];
function normalizeMime(raw: string | undefined): string | null {
if (!raw) return null;
const base = raw.split(';')[0]?.trim().toLowerCase() ?? '';
if (base === 'image/jpg' || base === 'image/pjpeg') {
return 'image/jpeg';
}
if (base === 'image/x-png') {
return 'image/png';
}
return ALLOWED_MIME.has(base) ? base : null;
}
function extForMime(mime: string): string {
switch (mime) {
case 'image/jpeg': return 'jpg';
case 'image/png': return 'png';
case 'image/gif': return 'gif';
case 'image/webp': return 'webp';
case 'image/bmp': return 'bmp';
default: return 'png';
}
}
function guessMimeFromName(name: string): string | undefined {
const lower = name.toLowerCase();
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.bmp')) return 'image/bmp';
return undefined;
}
/** File MIME·이름 정규화 (클립보드·붙여넣기 대응) */
export function normalizeImageFile(file: File): File | null {
const mime = normalizeMime(file.type)
?? normalizeMime(guessMimeFromName(file.name))
?? 'image/png';
if (!ALLOWED_MIME.has(mime)) return null;
if (file.size === 0) return null;
const ext = extForMime(mime);
const name = file.name && !file.name.startsWith('blob')
? file.name
: `clipboard-${Date.now()}.${ext}`;
if (file.type === mime && file.name === name) return file;
return new File([file], name, { type: mime, lastModified: file.lastModified });
}
/** FileList / File[] → 허용 이미지만 */
export function pickImageFiles(list: FileList | File[]): File[] {
return Array.from(list)
.map(normalizeImageFile)
.filter((f): f is File => f != null);
}
/** 백엔드 제한(5MB) 초과 시 JPEG로 축소 */
export async function compressImageIfNeeded(
file: File,
maxBytes = 5 * 1024 * 1024,
): Promise<File> {
if (file.size <= maxBytes) return file;
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
throw new Error('IMAGE_READ_FAILED');
}
let w = bitmap.width;
let h = bitmap.height;
const maxDim = 2560;
if (w > maxDim || h > maxDim) {
const scale = Math.min(maxDim / w, maxDim / h);
w = Math.max(1, Math.round(w * scale));
h = Math.max(1, Math.round(h * scale));
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
throw new Error('IMAGE_READ_FAILED');
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
let quality = 0.9;
let blob: Blob | null = null;
for (let i = 0; i < 8; i++) {
blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve, 'image/jpeg', quality);
});
if (blob && blob.size <= maxBytes) break;
quality -= 0.1;
}
if (!blob || blob.size > maxBytes) {
throw new Error('IMAGE_TOO_LARGE');
}
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${base}.jpg`, { type: 'image/jpeg', lastModified: Date.now() });
}
/** 업로드 전 이미지 정규화·용량 조정 */
export async function prepareImagesForUpload(files: File[]): Promise<File[]> {
const normalized = pickImageFiles(files);
const out: File[] = [];
for (const f of normalized) {
out.push(await compressImageIfNeeded(f));
}
return out;
}
/** ClipboardEvent / DataTransfer 에서 이미지 File 추출 */
export function filesFromDataTransfer(data: DataTransfer): File[] {
const out: File[] = [];
for (const item of Array.from(data.items)) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (!file) continue;
const normalized = normalizeImageFile(file);
if (normalized) out.push(normalized);
}
if (out.length === 0) {
return pickImageFiles(data.files);
}
return out;
}
/** navigator.clipboard.read() 결과 → File[] */
export async function filesFromClipboardRead(): Promise<File[]> {
if (!navigator.clipboard?.read) {
throw new Error('CLIPBOARD_UNSUPPORTED');
}
const items = await navigator.clipboard.read();
const files: File[] = [];
for (const item of items) {
const type = CLIPBOARD_PREFERRED.find(t => item.types.includes(t));
if (!type) continue;
const blob = await item.getType(type);
if (!blob || blob.size === 0) continue;
const mime = normalizeMime(blob.type) ?? type;
const ext = extForMime(mime);
files.push(new File(
[blob],
`clipboard-${Date.now()}.${ext}`,
{ type: mime },
));
}
return files;
}
+3 -2
View File
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings', 'verification-board',
];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
];
export const ALL_MENU_IDS = [
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
] as const;
@@ -32,6 +32,7 @@ export const MENU_LABELS: Record<string, string> = {
paper: '모의투자',
virtual: '가상매매',
'trend-search': '추세검색',
'verification-board': '검증게시판',
strategy: '투자전략',
'strategy-editor': '전략편집기',
backtest: '백테스팅',
+213
View File
@@ -0,0 +1,213 @@
import type { VerificationIssueStage } from './verificationBoardStages';
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
export interface VerificationIssueDto {
id?: number;
title: string;
content: string;
reproductionPath?: string;
stage: VerificationIssueStage;
createdAt?: string;
updatedAt?: string;
}
function getDeviceId(): string {
let id = localStorage.getItem('gc_device_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('gc_device_id', id);
}
return id;
}
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-device-id': getDeviceId(),
};
const userId = localStorage.getItem('gc_user_id');
if (userId) headers['x-user-id'] = userId;
return headers;
}
function authHeadersMultipart(): Record<string, string> {
const headers: Record<string, string> = {
'x-device-id': getDeviceId(),
};
const userId = localStorage.getItem('gc_user_id');
if (userId) headers['x-user-id'] = userId;
return headers;
}
export function verificationIssueImageUrl(issueId: number, imageId: number): string {
return `${API_BASE}/verification-issues/${issueId}/images/${imageId}/file`;
}
async function apiGet<T>(path: string): Promise<T | null> {
try {
const res = await fetch(`${API_BASE}${path}`, { headers: authHeaders() });
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
async function mutateOrThrow<T>(path: string, init: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { ...authHeaders(), ...(init.headers ?? {}) },
});
const text = await res.text().catch(() => '');
if (!res.ok) {
throw new Error(text ? text.slice(0, 200) : `요청 실패 (${res.status})`);
}
if (res.status === 204 || !text) return undefined as T;
return JSON.parse(text) as T;
}
export async function loadVerificationIssues(
stage?: VerificationIssueStage | '',
): Promise<VerificationIssueDto[]> {
const qs = stage ? `?stage=${encodeURIComponent(stage)}` : '';
return (await apiGet<VerificationIssueDto[]>(`/verification-issues${qs}`)) ?? [];
}
export async function loadVerificationIssue(id: number): Promise<VerificationIssueDto | null> {
return apiGet<VerificationIssueDto>(`/verification-issues/${id}`);
}
export async function saveVerificationIssue(dto: VerificationIssueDto): Promise<VerificationIssueDto> {
return mutateOrThrow<VerificationIssueDto>('/verification-issues', {
method: 'POST',
body: JSON.stringify(dto),
});
}
export async function deleteVerificationIssue(id: number): Promise<void> {
await mutateOrThrow<void>(`/verification-issues/${id}`, { method: 'DELETE' });
}
// ── 댓글 ─────────────────────────────────────────────────────────────────────
export interface VerificationIssueCommentDto {
id?: number;
issueId?: number;
content: string;
authorName?: string;
createdAt?: string;
updatedAt?: string;
}
export async function loadVerificationIssueComments(
issueId: number,
): Promise<VerificationIssueCommentDto[]> {
return (await apiGet<VerificationIssueCommentDto[]>(
`/verification-issues/${issueId}/comments`,
)) ?? [];
}
export async function createVerificationIssueComment(
issueId: number,
dto: Pick<VerificationIssueCommentDto, 'content' | 'authorName'>,
): Promise<VerificationIssueCommentDto> {
return mutateOrThrow<VerificationIssueCommentDto>(
`/verification-issues/${issueId}/comments`,
{ method: 'POST', body: JSON.stringify(dto) },
);
}
export async function updateVerificationIssueComment(
issueId: number,
commentId: number,
dto: Pick<VerificationIssueCommentDto, 'content'>,
): Promise<VerificationIssueCommentDto> {
return mutateOrThrow<VerificationIssueCommentDto>(
`/verification-issues/${issueId}/comments/${commentId}`,
{ method: 'PUT', body: JSON.stringify(dto) },
);
}
export async function deleteVerificationIssueComment(
issueId: number,
commentId: number,
): Promise<void> {
await mutateOrThrow<void>(
`/verification-issues/${issueId}/comments/${commentId}`,
{ method: 'DELETE' },
);
}
// ── 첨부 이미지 ─────────────────────────────────────────────────────────────
export interface VerificationIssueImageDto {
id?: number;
issueId?: number;
fileName?: string;
contentType?: string;
fileSize?: number;
sortOrder?: number;
url?: string;
createdAt?: string;
}
export async function loadVerificationIssueImages(
issueId: number,
): Promise<VerificationIssueImageDto[]> {
return (await apiGet<VerificationIssueImageDto[]>(
`/verification-issues/${issueId}/images`,
)) ?? [];
}
export async function uploadVerificationIssueImages(
issueId: number,
files: File[],
): Promise<VerificationIssueImageDto[]> {
const form = new FormData();
files.forEach(f => form.append('files', f));
const res = await fetch(`${API_BASE}/verification-issues/${issueId}/images`, {
method: 'POST',
headers: authHeadersMultipart(),
body: form,
});
const text = await res.text().catch(() => '');
if (!res.ok) {
throw new Error(parseVerificationUploadError(res.status, text));
}
return JSON.parse(text) as VerificationIssueImageDto[];
}
function parseVerificationUploadError(status: number, text: string): string {
if (text) {
try {
const json = JSON.parse(text) as { message?: string };
if (json.message) return json.message;
} catch {
if (text.includes('413') || text.toLowerCase().includes('too large')) {
return '업로드 용량이 너무 큽니다. 이미지를 줄이거나 5MB 이하 파일을 사용하세요.';
}
if (!text.trimStart().startsWith('<')) {
return text.slice(0, 200);
}
}
}
if (status === 413) {
return '업로드 용량이 너무 큽니다. 이미지를 줄이거나 5MB 이하 파일을 사용하세요.';
}
if (status === 400) return '이미지 형식 또는 크기를 확인하세요. (jpeg, png, gif, webp, bmp · 파일당 5MB)';
return `이미지 업로드에 실패했습니다. (${status})`;
}
export async function deleteVerificationIssueImage(
issueId: number,
imageId: number,
): Promise<void> {
const res = await fetch(
`${API_BASE}/verification-issues/${issueId}/images/${imageId}`,
{ method: 'DELETE', headers: authHeadersMultipart() },
);
if (!res.ok && res.status !== 204) {
throw new Error(`삭제 실패 (${res.status})`);
}
}
@@ -0,0 +1,33 @@
/** 검증 이슈 처리 단계 */
export type VerificationIssueStage =
| 'REGISTERED'
| 'IN_FIX'
| 'FIX_COMPLETE'
| 'RE_REGISTERED'
| 'COMPLETE';
export interface VerificationIssueStageMeta {
value: VerificationIssueStage;
label: string;
color: string;
}
export const VERIFICATION_ISSUE_STAGES: VerificationIssueStageMeta[] = [
{ value: 'REGISTERED', label: '등록', color: '#42a5f5' },
{ value: 'IN_FIX', label: '수정중', color: '#ffa726' },
{ value: 'FIX_COMPLETE', label: '수정완료', color: '#66bb6a' },
{ value: 'RE_REGISTERED', label: '재등록', color: '#ab47bc' },
{ value: 'COMPLETE', label: '완료', color: '#78909c' },
];
export const STAGE_BY_VALUE = Object.fromEntries(
VERIFICATION_ISSUE_STAGES.map(s => [s.value, s]),
) as Record<VerificationIssueStage, VerificationIssueStageMeta>;
export function stageLabel(stage: VerificationIssueStage): string {
return STAGE_BY_VALUE[stage]?.label ?? stage;
}
export function stageColor(stage: VerificationIssueStage): string {
return STAGE_BY_VALUE[stage]?.color ?? '#78909c';
}
@@ -0,0 +1,63 @@
import type { VerificationIssueStage } from './verificationBoardStages';
export const VERIFICATION_ISSUE_EVENT_TOPIC = '/sub/verification-issues/events';
export type VerificationIssueEventType = 'CREATED' | 'STAGE_CHANGED';
export interface VerificationIssueEventDto {
eventType: VerificationIssueEventType;
issueId: number;
title: string;
stage: VerificationIssueStage;
previousStage?: VerificationIssueStage | null;
updatedAt?: string;
sourceDeviceId?: string | null;
}
export interface VerificationIssueToastItem {
id: string;
issueId: number;
title: string;
stage: VerificationIssueStage;
previousStage?: VerificationIssueStage | null;
eventType: VerificationIssueEventType;
}
export function getVerificationDeviceId(): string {
let id = localStorage.getItem('gc_device_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('gc_device_id', id);
}
return id;
}
export function parseVerificationIssueEvent(raw: string): VerificationIssueEventDto | null {
try {
const data = JSON.parse(raw) as VerificationIssueEventDto;
if (data.issueId == null || !data.eventType || !data.title) return null;
if (data.eventType !== 'CREATED' && data.eventType !== 'STAGE_CHANGED') return null;
return data;
} catch {
return null;
}
}
export function shouldShowVerificationIssueEvent(event: VerificationIssueEventDto): boolean {
const localDevice = getVerificationDeviceId();
if (event.sourceDeviceId && event.sourceDeviceId === localDevice) {
return false;
}
return true;
}
export function eventToToast(event: VerificationIssueEventDto): VerificationIssueToastItem {
return {
id: `${event.eventType}-${event.issueId}-${event.updatedAt ?? Date.now()}`,
issueId: event.issueId,
title: event.title,
stage: event.stage,
previousStage: event.previousStage,
eventType: event.eventType,
};
}