← back to Norma
components/email-analyzer/useReferences.ts
313 lines
'use client';
import { useState, useCallback } from 'react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export interface Reference {
id: string;
session_id: string;
ref_type: 'article' | 'link' | 'notes';
title: string | null;
source_url: string | null;
source_domain: string | null;
content: string;
key_points: string[];
include_in_rewrite: boolean;
sort_order: number;
created_at: string;
}
export interface FetchedUrlData {
title: string;
description: string | null;
content: string;
source: string;
author: string | null;
publishedDate: string | null;
keyPoints: string[];
}
/* ─── Hook ───────────────────────────────────────────────────────────────── */
/** Dispatched when a reference is added from outside the useReferences hook
* (e.g. from SuggestionsPanel). Sessions listen for this and reload. */
export const REFS_EXTERNAL_CHANGE_EVENT = 'norma-analyzer-refs-changed';
export function dispatchRefsChanged(sessionId: string): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(REFS_EXTERNAL_CHANGE_EVENT, { detail: { sessionId } }),
);
}
export function useReferences(sessionId: string) {
const [references, setReferences] = useState<Reference[]>([]);
const [loading, setLoading] = useState(false);
const [fetchingUrl, setFetchingUrl] = useState(false);
const [error, setError] = useState<string | null>(null);
/* ── Load references from API ──────────────────────────────────────────── */
const loadReferences = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/email-analyzer/references?session_id=${sessionId}`);
if (!res.ok) throw new Error('Failed to load references');
const data = await res.json();
const refs = (data.references || []).map((r: Record<string, unknown>) => ({
...r,
key_points: typeof r.key_points === 'string' ? JSON.parse(r.key_points as string) : (r.key_points || []),
}));
setReferences(refs);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [sessionId]);
/* ── Add a pasted text reference ───────────────────────────────────────── */
const addTextReference = useCallback(
async (text: string, title?: string): Promise<Reference | null> => {
if (!sessionId || !text.trim()) return null;
setError(null);
try {
const res = await fetch('/api/email-analyzer/references', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
ref_type: 'article',
title: title || text.split('\n')[0]?.substring(0, 80) || 'Pasted Article',
content: text,
key_points: [],
include_in_rewrite: true,
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to save reference');
}
const data = await res.json();
const ref = {
...data.reference,
key_points: typeof data.reference.key_points === 'string'
? JSON.parse(data.reference.key_points)
: (data.reference.key_points || []),
};
setReferences((prev) => [...prev, ref]);
return ref;
} catch (err) {
setError((err as Error).message);
return null;
}
},
[sessionId],
);
/* ── Add quick notes reference ─────────────────────────────────────────── */
const addNotesReference = useCallback(
async (text: string): Promise<Reference | null> => {
if (!sessionId || !text.trim()) return null;
setError(null);
try {
const res = await fetch('/api/email-analyzer/references', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
ref_type: 'notes',
title: 'Notes',
content: text,
key_points: [],
include_in_rewrite: true,
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to save notes');
}
const data = await res.json();
const ref = {
...data.reference,
key_points: typeof data.reference.key_points === 'string'
? JSON.parse(data.reference.key_points)
: (data.reference.key_points || []),
};
setReferences((prev) => [...prev, ref]);
return ref;
} catch (err) {
setError((err as Error).message);
return null;
}
},
[sessionId],
);
/* ── Add a URL reference (fetch + save) ────────────────────────────────── */
const addUrlReference = useCallback(
async (url: string): Promise<Reference | null> => {
if (!sessionId || !url.trim()) return null;
setFetchingUrl(true);
setError(null);
try {
// Step 1: Fetch and extract article content
const fetchRes = await fetch('/api/email-analyzer/fetch-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!fetchRes.ok) {
const errData = await fetchRes.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to fetch URL');
}
const fetchedData: FetchedUrlData = await fetchRes.json();
// Step 2: Save as reference
const saveRes = await fetch('/api/email-analyzer/references', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
ref_type: 'link',
title: fetchedData.title,
source_url: url,
source_domain: fetchedData.source,
content: fetchedData.content,
key_points: fetchedData.keyPoints,
include_in_rewrite: true,
}),
});
if (!saveRes.ok) {
const errData = await saveRes.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to save reference');
}
const data = await saveRes.json();
const ref = {
...data.reference,
key_points: typeof data.reference.key_points === 'string'
? JSON.parse(data.reference.key_points)
: (data.reference.key_points || []),
};
setReferences((prev) => [...prev, ref]);
return ref;
} catch (err) {
setError((err as Error).message);
return null;
} finally {
setFetchingUrl(false);
}
},
[sessionId],
);
/* ── Remove a reference ────────────────────────────────────────────────── */
const removeReference = useCallback(async (id: string): Promise<boolean> => {
setError(null);
try {
const res = await fetch(`/api/email-analyzer/references?id=${id}`, {
method: 'DELETE',
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to delete reference');
}
setReferences((prev) => prev.filter((r) => r.id !== id));
return true;
} catch (err) {
setError((err as Error).message);
return false;
}
}, []);
/* ── Toggle include_in_rewrite ─────────────────────────────────────────── */
const toggleInclude = useCallback(async (id: string): Promise<boolean> => {
const ref = references.find((r) => r.id === id);
if (!ref) return false;
const newValue = !ref.include_in_rewrite;
// Optimistic update
setReferences((prev) =>
prev.map((r) => (r.id === id ? { ...r, include_in_rewrite: newValue } : r)),
);
try {
const res = await fetch('/api/email-analyzer/references', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, include_in_rewrite: newValue }),
});
if (!res.ok) {
// Revert on failure
setReferences((prev) =>
prev.map((r) =>
r.id === id ? { ...r, include_in_rewrite: !newValue } : r,
),
);
return false;
}
return true;
} catch {
// Revert on failure
setReferences((prev) =>
prev.map((r) =>
r.id === id ? { ...r, include_in_rewrite: !newValue } : r,
),
);
return false;
}
}, [references]);
/* ── Build context string for AI rewrite ───────────────────────────────── */
const getContextString = useCallback((): string => {
const active = references.filter((r) => r.include_in_rewrite);
if (active.length === 0) return '';
const sections: string[] = ['## Reference Materials'];
for (const ref of active) {
const keyPoints = Array.isArray(ref.key_points) ? ref.key_points : [];
if (ref.ref_type === 'article') {
sections.push(`### Article: ${ref.title || 'Untitled'}`);
sections.push(ref.content.substring(0, 1500));
if (keyPoints.length > 0) {
sections.push('Key points:');
keyPoints.forEach((p) => sections.push(`- ${p}`));
}
} else if (ref.ref_type === 'link') {
const domain = ref.source_domain ? ` (${ref.source_domain})` : '';
sections.push(`### Link: ${ref.title || 'Untitled'}${domain}`);
sections.push(ref.content.substring(0, 1500));
if (keyPoints.length > 0) {
sections.push('Key points:');
keyPoints.forEach((p) => sections.push(`- ${p}`));
}
} else if (ref.ref_type === 'notes') {
sections.push('### Notes:');
sections.push(ref.content.substring(0, 1500));
}
sections.push(''); // blank line between references
}
return sections.join('\n');
}, [references]);
return {
references,
loading,
fetchingUrl,
error,
loadReferences,
addTextReference,
addNotesReference,
addUrlReference,
removeReference,
toggleInclude,
getContextString,
};
}