Phase 1 UI: index actions + duplicate-review queue; bump to 0.1.0:45
- frontend: System Status page extended with one-click index actions (update/rebuild/find-duplicates, with live job status) and a human-in-the-loop duplicate-review queue (approve=merge / reject=keep-separate per candidate). - StartOS version 0.1.0:45 (image-only; schema via the in-app migration runner). Backend + new routes verified end-to-end via the running HTTP server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+351
-8
@@ -1280,6 +1280,152 @@
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.index-action-status {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-job-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c7d3e0;
|
||||||
|
background-color: #1b2837;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-job-pill.idle {
|
||||||
|
color: #8ea2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-job-pill.running {
|
||||||
|
color: #fcd34d;
|
||||||
|
background-color: #f59e0b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-job-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fcd34d;
|
||||||
|
animation: index-job-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes index-job-pulse {
|
||||||
|
0%, 100% { opacity: 0.35; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-job-tail {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: #0d1622;
|
||||||
|
border: 1px solid #263548;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8ea2b7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-action-hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8ea2b7;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-card {
|
||||||
|
background-color: #0d1622;
|
||||||
|
border: 1px solid #263548;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-people {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-person {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5edf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-email {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8ea2b7;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-vs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8ea2b7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-context {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c7d3e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-suggestion {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-confidence {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8ea2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-reason {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #c7d3e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-candidate-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.confirmation-dialog {
|
.confirmation-dialog {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -9118,26 +9264,108 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SystemStatusPage = ({ token }) => {
|
const SystemStatusPage = ({ token, user, onShowToast }) => {
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [candidates, setCandidates] = useState([]);
|
||||||
|
const [candidatesLoading, setCandidatesLoading] = useState(false);
|
||||||
|
const [decidingId, setDecidingId] = useState(null);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
const result = await api('/api/system/status', {}, token);
|
||||||
|
setData(result.data);
|
||||||
|
return result.data;
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result = await api('/api/system/status', {}, token);
|
await loadStatus();
|
||||||
setData(result.data);
|
if (!cancelled) setError('');
|
||||||
setError('');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err, 'Failed to load system status'));
|
if (!cancelled) setError(getErrorMessage(err, 'Failed to load system status'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}, [token]);
|
return () => { cancelled = true; };
|
||||||
|
}, [loadStatus]);
|
||||||
|
|
||||||
|
const indexJob = data?.index_job || null;
|
||||||
|
const jobRunning = !!(indexJob && indexJob.running);
|
||||||
|
|
||||||
|
// Poll the status endpoint while an index job is running so the
|
||||||
|
// indicator + last result update without a manual refresh.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobRunning) return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
if (!cancelled) await loadStatus();
|
||||||
|
} catch (_) { /* transient; next tick retries */ }
|
||||||
|
}, 3000);
|
||||||
|
return () => { cancelled = true; clearInterval(interval); };
|
||||||
|
}, [jobRunning, loadStatus]);
|
||||||
|
|
||||||
|
const loadCandidates = useCallback(async () => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
try {
|
||||||
|
setCandidatesLoading(true);
|
||||||
|
const result = await api('/api/entities/merge-candidates', {}, token);
|
||||||
|
setCandidates(Array.isArray(result.candidates) ? result.candidates : []);
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Failed to load duplicate review queue'), 'error');
|
||||||
|
} finally {
|
||||||
|
setCandidatesLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAdmin, token, onShowToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCandidates();
|
||||||
|
}, [loadCandidates]);
|
||||||
|
|
||||||
|
const runIndexAction = async (endpoint, label, confirmMessage) => {
|
||||||
|
if (jobRunning || actionBusy) return;
|
||||||
|
if (confirmMessage && !window.confirm(confirmMessage)) return;
|
||||||
|
try {
|
||||||
|
setActionBusy(true);
|
||||||
|
const result = await api(endpoint, { method: 'POST' }, token);
|
||||||
|
const kind = result?.data?.kind || label;
|
||||||
|
onShowToast(`Started: ${kind}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status === 409 || err?.payload?.error === 'job_running') {
|
||||||
|
onShowToast('An index job is already running', 'error');
|
||||||
|
} else {
|
||||||
|
onShowToast(getErrorMessage(err, `Failed to start ${label}`), 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
try { await loadStatus(); } catch (_) { /* ignore refresh failure */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decideCandidate = async (candidate, decision) => {
|
||||||
|
if (decidingId) return;
|
||||||
|
try {
|
||||||
|
setDecidingId(candidate.id);
|
||||||
|
await api(`/api/entities/merge-candidates/${candidate.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ decision })
|
||||||
|
}, token);
|
||||||
|
setCandidates((prev) => prev.filter((c) => c.id !== candidate.id));
|
||||||
|
onShowToast(decision === 'approve' ? 'Contacts merged' : 'Kept separate', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Failed to record decision'), 'error');
|
||||||
|
} finally {
|
||||||
|
setDecidingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
|
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
|
||||||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||||||
@@ -9202,6 +9430,121 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">Index Actions</div>
|
||||||
|
<div className="index-action-status">
|
||||||
|
{jobRunning ? (
|
||||||
|
<span className="index-job-pill running">
|
||||||
|
<span className="index-job-dot" /> running: {indexJob.kind || 'job'}
|
||||||
|
{indexJob.started_at ? ` · started ${formatDate(indexJob.started_at)}` : ''}
|
||||||
|
</span>
|
||||||
|
) : indexJob && indexJob.result ? (
|
||||||
|
<span className="index-job-pill">
|
||||||
|
last: {indexJob.kind ? `${indexJob.kind} — ` : ''}{indexJob.result}
|
||||||
|
{indexJob.finished_at ? ` · ${formatDate(indexJob.finished_at)}` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="index-job-pill idle">No index job has run yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{jobRunning && indexJob.tail ? (
|
||||||
|
<pre className="index-job-tail">{indexJob.tail}</pre>
|
||||||
|
) : null}
|
||||||
|
<div className="index-action-buttons">
|
||||||
|
<button
|
||||||
|
onClick={() => runIndexAction('/api/index/update', 'update search index')}
|
||||||
|
disabled={jobRunning || actionBusy}
|
||||||
|
>
|
||||||
|
Update search index
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => runIndexAction('/api/index/rebuild', 'rebuild search index', 'Rebuild the entire search index? This re-embeds every record and can take several minutes.')}
|
||||||
|
disabled={jobRunning || actionBusy}
|
||||||
|
>
|
||||||
|
Rebuild search index
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => runIndexAction('/api/entities/find-duplicates', 'find duplicate contacts')}
|
||||||
|
disabled={jobRunning || actionBusy}
|
||||||
|
>
|
||||||
|
Find duplicate contacts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="index-action-hint">
|
||||||
|
Update is a fast incremental pass. Rebuild re-embeds everything. Find duplicates runs the local model and fills the review queue below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
|
||||||
|
Duplicate Review
|
||||||
|
<span className="approval-pill">{candidates.length} pending</span>
|
||||||
|
</div>
|
||||||
|
{candidatesLoading ? (
|
||||||
|
<SkeletonBlock lines={4} />
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ padding: '20px 0' }}>No duplicates to review.</div>
|
||||||
|
) : (
|
||||||
|
<div className="merge-candidate-list">
|
||||||
|
{candidates.map((c) => {
|
||||||
|
const verdict = (c.verdict || '').toLowerCase();
|
||||||
|
const confidencePct = c.confidence != null
|
||||||
|
? `${Math.round((c.confidence <= 1 ? c.confidence * 100 : c.confidence))}%`
|
||||||
|
: null;
|
||||||
|
const busy = decidingId === c.id;
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="merge-candidate-card">
|
||||||
|
<div className="merge-candidate-people">
|
||||||
|
<div className="merge-candidate-person">
|
||||||
|
<div className="merge-candidate-name">{c.name_a || '(no name)'}</div>
|
||||||
|
<div className="merge-candidate-email">{c.email_a || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="merge-candidate-vs">vs</div>
|
||||||
|
<div className="merge-candidate-person">
|
||||||
|
<div className="merge-candidate-name">{c.name_b || '(no name)'}</div>
|
||||||
|
<div className="merge-candidate-email">{c.email_b || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c.context ? (
|
||||||
|
<div className="merge-candidate-context">{c.context}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="merge-candidate-suggestion">
|
||||||
|
<span className={`badge ${verdict === 'same' ? 'badge-funded' : 'badge-other'}`}>
|
||||||
|
Suggestion: {c.verdict || 'unknown'}
|
||||||
|
</span>
|
||||||
|
{confidencePct ? (
|
||||||
|
<span className="merge-candidate-confidence">{confidencePct} confidence</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{c.reason ? (
|
||||||
|
<div className="merge-candidate-reason">{c.reason}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="merge-candidate-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => decideCandidate(c, 'approve')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Same person — merge
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button-secondary"
|
||||||
|
onClick={() => decideCandidate(c, 'reject')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Different — keep separate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div className="section-title">Thesis</div>
|
<div className="section-title">Thesis</div>
|
||||||
<div className="kpi-grid" style={{ marginBottom: 0 }}>
|
<div className="kpi-grid" style={{ marginBottom: 0 }}>
|
||||||
@@ -9486,7 +9829,7 @@
|
|||||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||||
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
|
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
|
||||||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'system-status' && <SystemStatusPage token={token} />}
|
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
||||||
{page === 'instructions' && <InstructionsPage />}
|
{page === 'instructions' && <InstructionsPage />}
|
||||||
{page === 'settings' && (
|
{page === 'settings' && (
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * Cleanup: 0.1.0:40 (seed removed + multi-threaded server + abuser auto-ban)
|
// * Cleanup: 0.1.0:40 (seed removed + multi-threaded server + abuser auto-ban)
|
||||||
// * 0.1.0:41 (frontend persists auth across refreshes)
|
// * 0.1.0:41 (frontend persists auth across refreshes)
|
||||||
// * 0.1.0:42 (Gmail integration) / 0.1.0:43 (Gmail POST-body hotfix)
|
// * 0.1.0:42 (Gmail integration) / 0.1.0:43 (Gmail POST-body hotfix)
|
||||||
// * Current: 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action)
|
// * 0.1.0:44 (Phase-0 ingest + MCP server in image; build-index action)
|
||||||
export const PACKAGE_VERSION = '0.1.0:44'
|
// * Current: 0.1.0:45 (Phase-1 thesis system; dual approval; merge review; in-app index)
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:45'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { v_0_1_0_41 } from './v0.1.0.41'
|
|||||||
import { v_0_1_0_42 } from './v0.1.0.42'
|
import { v_0_1_0_42 } from './v0.1.0.42'
|
||||||
import { v_0_1_0_43 } from './v0.1.0.43'
|
import { v_0_1_0_43 } from './v0.1.0.43'
|
||||||
import { v_0_1_0_44 } from './v0.1.0.44'
|
import { v_0_1_0_44 } from './v0.1.0.44'
|
||||||
|
import { v_0_1_0_45 } from './v0.1.0.45'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_44,
|
current: v_0_1_0_45,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// Phase-1 release: the Architect's thesis system + in-app data/index controls.
|
||||||
|
//
|
||||||
|
// Adds (web server + image; all schema via the CRM's own migration runner —
|
||||||
|
// NO StartOS data migration, the live /data volume is preserved):
|
||||||
|
// * Thesis substrate (migration 0002): versioned per-segment thesis lines, a
|
||||||
|
// node tree + revisions, dual-approval review (thesis_reviews), segments.
|
||||||
|
// * Entity-merge review (migration 0003): the fuzzy/Qwen tier writes merge
|
||||||
|
// CANDIDATES for human approve/reject instead of auto-merging.
|
||||||
|
// * Web UI: a "Thesis" dual-approval review view and a "System Status" view
|
||||||
|
// (entity/index health, one-click index actions, duplicate-review queue).
|
||||||
|
// * Server routes: thesis review/approval, /api/system/status, UI-triggered
|
||||||
|
// index jobs (rebuild/update/find-duplicates), merge-candidate decisions.
|
||||||
|
export const v_0_1_0_45 = VersionInfo.of({
|
||||||
|
version: '0.1.0:45',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'Phase 1: the Architect thesis system. Adds versioned, per-segment thesis',
|
||||||
|
'lines with a dual partner sign-off review in the web app; a human-in-the-loop',
|
||||||
|
'duplicate-contact review queue (the local model suggests, you approve/reject);',
|
||||||
|
'one-click index actions and a system-status dashboard in the CRM; and the',
|
||||||
|
'supporting schema, created additively by the CRM migration runner — your',
|
||||||
|
'data is preserved.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: async () => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user