From b078640c44ab7f943e1fd46089e7e0bfee4d0745 Mon Sep 17 00:00:00 2001 From: Peter Milanese Date: Sat, 14 Feb 2026 14:37:44 -0500 Subject: [PATCH 1/7] fix: Await params Promise and fix GraphQL client endpoint resolution --- app/family-notes/[family]/page.tsx | 5 +- lib/graphql/client.ts | 6 +- scripts/migrate-family-notes-api.ts | 103 ++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 scripts/migrate-family-notes-api.ts diff --git a/app/family-notes/[family]/page.tsx b/app/family-notes/[family]/page.tsx index 92710f9..957da6a 100644 --- a/app/family-notes/[family]/page.tsx +++ b/app/family-notes/[family]/page.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { title: 'Family Research Notes', }; -export default function FamilyNotePage({ params }: { params: { family: string } }) { - return ; +export default async function FamilyNotePage({ params }: { params: Promise<{ family: string }> }) { + const { family } = await params; + return ; } diff --git a/lib/graphql/client.ts b/lib/graphql/client.ts index 972edef..85786e4 100644 --- a/lib/graphql/client.ts +++ b/lib/graphql/client.ts @@ -61,9 +61,9 @@ export function getGraphQLClient( if (typeof window === 'undefined') { // Server-side: use full URL - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || - process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : - 'http://localhost:3000'; + const baseUrl = + process.env.NEXT_PUBLIC_BASE_URL || + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'); endpoint = `${baseUrl}/api/graphql`; } else { // Client-side: use relative URL diff --git a/scripts/migrate-family-notes-api.ts b/scripts/migrate-family-notes-api.ts new file mode 100644 index 0000000..e57090c --- /dev/null +++ b/scripts/migrate-family-notes-api.ts @@ -0,0 +1,103 @@ +/** + * API-based migration script for family research notes + * Uses GraphQL API instead of direct database connection + * + * Usage: GRAPHQL_ENDPOINT=https://family.milanese.life/api/graphql API_KEY=your_key npx tsx scripts/migrate-family-notes-api.ts + */ + +import { promises as fs } from 'fs'; +import path from 'path'; + +const NOTES_DIR = '/home/petermilanese/workspace/projects/genealogy/research/families'; +const GRAPHQL_ENDPOINT = process.env.GRAPHQL_ENDPOINT || 'https://family.milanese.life/api/graphql'; +const API_KEY = process.env.API_KEY; + +if (!API_KEY) { + console.error('ERROR: API_KEY environment variable not set'); + console.error('Usage: API_KEY=your_key npx tsx scripts/migrate-family-notes-api.ts'); + process.exit(1); +} + +const UPSERT_MUTATION = ` + mutation UpsertFamilyResearchNote($input: FamilyResearchNoteInput!) { + upsertFamilyResearchNote(input: $input) { + id + familyName + } + } +`; + +async function uploadNote(familyName: string, content: string): Promise { + try { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': API_KEY!, + }, + body: JSON.stringify({ + query: UPSERT_MUTATION, + variables: { + input: { + familyName, + content, + }, + }, + }), + }); + + const result = await response.json(); + + if (result.errors) { + console.error(`โŒ ${familyName} - GraphQL errors:`, result.errors[0].message); + return false; + } + + console.log(`โœ… ${familyName} - uploaded (${content.length} bytes)`); + return true; + } catch (error) { + console.error(`โŒ ${familyName} - error:`, error instanceof Error ? error.message : error); + return false; + } +} + +async function main() { + console.log('Starting family research notes migration via API...\n'); + console.log(`GraphQL Endpoint: ${GRAPHQL_ENDPOINT}\n`); + + // Read all markdown files + const files = await fs.readdir(NOTES_DIR); + const mdFiles = files.filter((f) => f.endsWith('.md')); + + console.log(`Found ${mdFiles.length} markdown files to migrate\n`); + + let successCount = 0; + let errorCount = 0; + + for (const filename of mdFiles) { + const familyName = filename.replace('.md', ''); + const filepath = path.join(NOTES_DIR, filename); + const content = await fs.readFile(filepath, 'utf-8'); + + const success = await uploadNote(familyName, content); + if (success) { + successCount++; + } else { + errorCount++; + } + } + + console.log(`\nโœจ Migration complete!`); + console.log(` Success: ${successCount}`); + console.log(` Errors: ${errorCount}`); + console.log(` Total: ${mdFiles.length}`); + + if (errorCount > 0) { + process.exit(1); + } +} + +main().catch((error) => { + console.error('Migration failed:', error); + process.exit(1); +}); From 2adad58a83a643c7ae0dce174f3e3a753bd70154 Mon Sep 17 00:00:00 2001 From: Peter Milanese Date: Sat, 14 Feb 2026 14:43:43 -0500 Subject: [PATCH 2/7] fix: Resolve lint errors --- app/api/family-notes/[family]/route.ts | 52 ++- app/api/family-notes/route.ts | 9 +- app/family-notes/[family]/page.tsx | 8 +- app/family-notes/page.tsx | 2 +- components/FamilyNoteViewerClient.tsx | 33 +- components/FamilyNotesClient.tsx | 12 +- components/person/PersonFactsCard.tsx | 64 ++- components/person/PersonHeroCard.tsx | 106 +++-- components/person/UnifiedLifeEventsCard.tsx | 384 ++++++++++++++---- lib/graphql/client.ts | 4 +- .../resolvers/family-research-notes.ts | 26 +- lib/graphql/resolvers/index.ts | 2 +- .../resolvers/person/field-resolvers.ts | 1 - lib/graphql/schema/index.ts | 2 +- lib/graphql/schema/types/index.ts | 2 +- 15 files changed, 538 insertions(+), 169 deletions(-) diff --git a/app/api/family-notes/[family]/route.ts b/app/api/family-notes/[family]/route.ts index eea89ac..3b4f49c 100644 --- a/app/api/family-notes/[family]/route.ts +++ b/app/api/family-notes/[family]/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { getGraphQLClient } from '@/lib/graphql/client'; @@ -40,8 +40,8 @@ const UPSERT_FAMILY_NOTE_MUTATION = ` `; export async function GET( - request: NextRequest, - { params }: { params: Promise<{ family: string }> } + _request: NextRequest, + { params }: { params: Promise<{ family: string }> }, ) { try { const session = await auth(); @@ -51,22 +51,25 @@ export async function GET( const { family } = await params; const client = getGraphQLClient(); - const { data, errors } = await client.request(FAMILY_NOTE_QUERY, { - familyName: family.toUpperCase(), - }); + const { data, errors } = await client.request( + FAMILY_NOTE_QUERY, + { + familyName: family.toUpperCase(), + }, + ); if (errors) { console.error('GraphQL errors:', errors); return NextResponse.json( { error: 'Failed to fetch family note' }, - { status: 500 } + { status: 500 }, ); } if (!data?.familyResearchNote) { return NextResponse.json( { error: 'Family notes not found' }, - { status: 404 } + { status: 404 }, ); } @@ -75,18 +78,18 @@ export async function GET( content: data.familyResearchNote.content, lastModified: data.familyResearchNote.updatedAt, }); - } catch (error: any) { + } catch (error) { console.error('Error reading family notes:', error); return NextResponse.json( { error: 'Failed to read family notes' }, - { status: 500 } + { status: 500 }, ); } } export async function PUT( request: NextRequest, - { params }: { params: Promise<{ family: string }> } + { params }: { params: Promise<{ family: string }> }, ) { try { const session = await auth(); @@ -100,25 +103,30 @@ export async function PUT( if (typeof content !== 'string') { return NextResponse.json( { error: 'Invalid content format' }, - { status: 400 } + { status: 400 }, ); } const client = getGraphQLClient(); - const { data, errors } = await client.request(UPSERT_FAMILY_NOTE_MUTATION, { - input: { - familyName: family.toUpperCase(), - content, - }, - }, { - 'x-user-id': session.user.id!, - }); + const { data, errors } = + await client.request( + UPSERT_FAMILY_NOTE_MUTATION, + { + input: { + familyName: family.toUpperCase(), + content, + }, + }, + { + 'x-user-id': session.user.id ?? '', + }, + ); if (errors) { console.error('GraphQL errors:', errors); return NextResponse.json( { error: 'Failed to update family notes' }, - { status: 500 } + { status: 500 }, ); } @@ -130,7 +138,7 @@ export async function PUT( console.error('Error updating family notes:', error); return NextResponse.json( { error: 'Failed to update family notes' }, - { status: 500 } + { status: 500 }, ); } } diff --git a/app/api/family-notes/route.ts b/app/api/family-notes/route.ts index a925e07..54cb48c 100644 --- a/app/api/family-notes/route.ts +++ b/app/api/family-notes/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { getGraphQLClient } from '@/lib/graphql/client'; @@ -34,13 +34,14 @@ export async function GET(request: NextRequest) { } const client = getGraphQLClient(); - const { data, errors } = await client.request(FAMILY_NOTES_QUERY); + const { data, errors } = + await client.request(FAMILY_NOTES_QUERY); if (errors) { console.error('GraphQL errors:', errors); return NextResponse.json( { error: 'Failed to fetch family notes' }, - { status: 500 } + { status: 500 }, ); } @@ -59,7 +60,7 @@ export async function GET(request: NextRequest) { console.error('Error listing family notes:', error); return NextResponse.json( { error: 'Failed to list family notes' }, - { status: 500 } + { status: 500 }, ); } } diff --git a/app/family-notes/[family]/page.tsx b/app/family-notes/[family]/page.tsx index 957da6a..3d7366a 100644 --- a/app/family-notes/[family]/page.tsx +++ b/app/family-notes/[family]/page.tsx @@ -1,11 +1,15 @@ -import { Metadata } from 'next'; +import type { Metadata } from 'next'; import { FamilyNoteViewerClient } from '@/components/FamilyNoteViewerClient'; export const metadata: Metadata = { title: 'Family Research Notes', }; -export default async function FamilyNotePage({ params }: { params: Promise<{ family: string }> }) { +export default async function FamilyNotePage({ + params, +}: { + params: Promise<{ family: string }>; +}) { const { family } = await params; return ; } diff --git a/app/family-notes/page.tsx b/app/family-notes/page.tsx index f33f402..009e0dc 100644 --- a/app/family-notes/page.tsx +++ b/app/family-notes/page.tsx @@ -1,4 +1,4 @@ -import { Metadata } from 'next'; +import type { Metadata } from 'next'; import { FamilyNotesClient } from '@/components/FamilyNotesClient'; import { PageHeader } from '@/components/ui'; diff --git a/components/FamilyNoteViewerClient.tsx b/components/FamilyNoteViewerClient.tsx index 51b69f2..67a9757 100644 --- a/components/FamilyNoteViewerClient.tsx +++ b/components/FamilyNoteViewerClient.tsx @@ -81,11 +81,7 @@ export function FamilyNoteViewerClient({ family }: Props) { if (loading) { return ( <> - +
@@ -112,7 +108,10 @@ export function FamilyNoteViewerClient({ family }: Props) {

{error}

- @@ -152,7 +151,11 @@ export function FamilyNoteViewerClient({ family }: Props) { {/* Action buttons */}
- @@ -207,16 +210,24 @@ export function FamilyNoteViewerClient({ family }: Props) { ( -

{children}

+

+ {children} +

), h2: ({ children }) => ( -

{children}

+

+ {children} +

), h3: ({ children }) => ( -

{children}

+

+ {children} +

), h4: ({ children }) => ( -

{children}

+

+ {children} +

), p: ({ children }) =>

{children}

, ul: ({ children }) => ( diff --git a/components/FamilyNotesClient.tsx b/components/FamilyNotesClient.tsx index b44a82c..f470736 100644 --- a/components/FamilyNotesClient.tsx +++ b/components/FamilyNotesClient.tsx @@ -3,7 +3,13 @@ import { BookOpen, ChevronRight } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useState } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, Skeleton } from '@/components/ui'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from '@/components/ui'; interface FamilyNote { family: string; @@ -80,7 +86,9 @@ export function FamilyNotesClient() { return ( -

No family research notes found.

+

+ No family research notes found. +

); diff --git a/components/person/PersonFactsCard.tsx b/components/person/PersonFactsCard.tsx index 5e45ad6..b68f0f2 100644 --- a/components/person/PersonFactsCard.tsx +++ b/components/person/PersonFactsCard.tsx @@ -24,7 +24,12 @@ import { TableCell, TableRow, } from '@/components/ui'; -import { ADD_FACT, DELETE_FACT, GET_PERSON, UPDATE_FACT } from '@/lib/graphql/queries'; +import { + ADD_FACT, + DELETE_FACT, + GET_PERSON, + UPDATE_FACT, +} from '@/lib/graphql/queries'; import type { Fact } from '@/lib/types'; const FACT_TYPES = [ @@ -46,7 +51,9 @@ const FACT_TYPES = [ ]; /** Badge color by fact type */ -function getFactBadgeVariant(type: string | null): 'default' | 'secondary' | 'outline' { +function getFactBadgeVariant( + type: string | null, +): 'default' | 'secondary' | 'outline' { switch (type) { case 'Biography': return 'default'; @@ -72,18 +79,27 @@ export default function PersonFactsCard({ const t = useTranslations('Person'); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); - const [formData, setFormData] = useState({ fact_type: 'Note', fact_value: '' }); + const [formData, setFormData] = useState({ + fact_type: 'Note', + fact_value: '', + }); const refetchQueries = [{ query: GET_PERSON, variables: { id: personId } }]; const [addFact, { loading: adding }] = useMutation(ADD_FACT, { refetchQueries, - onCompleted: () => { toast.success('Fact added'); resetForm(); }, + onCompleted: () => { + toast.success('Fact added'); + resetForm(); + }, onError: (err) => toast.error('Error', { description: err.message }), }); const [updateFact, { loading: updating }] = useMutation(UPDATE_FACT, { refetchQueries, - onCompleted: () => { toast.success('Fact updated'); resetForm(); }, + onCompleted: () => { + toast.success('Fact updated'); + resetForm(); + }, onError: (err) => toast.error('Error', { description: err.message }), }); const [deleteFact] = useMutation(DELETE_FACT, { @@ -137,7 +153,9 @@ export default function PersonFactsCard({
- {t('facts') || 'Facts & Details'} + + {t('facts') || 'Facts & Details'} + {canEdit && !showForm && ( -
@@ -209,7 +241,11 @@ export default function PersonFactsCard({ {canEdit && (
- - {isMe ? t('unlinkMe') : t('markAsMe')} + + {isMe ? t('unlinkMe') : t('markAsMe')} + )} {canEdit && ( @@ -177,16 +200,30 @@ export default function PersonHeroCard({ variant="ghost" size="icon-sm" onClick={onToggleNotable} - className={person.is_notable ? 'text-amber-500' : 'text-muted-foreground'} + className={ + person.is_notable + ? 'text-amber-500' + : 'text-muted-foreground' + } > - + - {person.is_notable ? t('removeNotableStatus') : t('markAsNotableTitle')} + + {person.is_notable + ? t('removeNotableStatus') + : t('markAsNotableTitle')} + - @@ -238,7 +275,11 @@ export default function PersonHeroCard({ @@ -246,7 +287,11 @@ export default function PersonHeroCard({ @@ -264,7 +309,10 @@ export default function PersonHeroCard({ @@ -287,7 +335,9 @@ export default function PersonHeroCard({ const type = n.name_type ? ` (${n.name_type})` : ''; return ( - {name}{type}{i < alternateNames.length - 1 ? ', ' : ''} + {name} + {type} + {i < alternateNames.length - 1 ? ', ' : ''} ); })} @@ -341,7 +391,9 @@ function VitalStat({
{icon} - {label} + + {label} +

Unknown

@@ -349,16 +401,20 @@ function VitalStat({ } return ( -
+
{icon} - {label} + + {label} +
- {date && ( -

{date}

- )} + {date &&

{date}

} {place && ( -

{place}

+

+ {place} +

)}
); diff --git a/components/person/UnifiedLifeEventsCard.tsx b/components/person/UnifiedLifeEventsCard.tsx index 5523ef8..921bd18 100644 --- a/components/person/UnifiedLifeEventsCard.tsx +++ b/components/person/UnifiedLifeEventsCard.tsx @@ -9,10 +9,10 @@ import { Badge, Button, Card, + CardAction, CardContent, CardHeader, CardTitle, - CardAction, Input, Label, Select, @@ -37,31 +37,74 @@ const HERO_EVENTS = ['Birth', 'Death', 'Burial']; /** All available event types */ const ALL_EVENT_TYPES = [ - 'Birth', 'Death', 'Burial', 'Christening', 'Baptism', - 'Immigration', 'Naturalization', 'Residence', 'Occupation', - 'Education', 'Military', 'Census', 'Marriage', 'Divorce', - 'Emigration', 'Confirmation', 'Funeral', 'Cremation', - 'Obituary', 'Will', 'Draft Registration', 'Other', + 'Birth', + 'Death', + 'Burial', + 'Christening', + 'Baptism', + 'Immigration', + 'Naturalization', + 'Residence', + 'Occupation', + 'Education', + 'Military', + 'Census', + 'Marriage', + 'Divorce', + 'Emigration', + 'Confirmation', + 'Funeral', + 'Cremation', + 'Obituary', + 'Will', + 'Draft Registration', + 'Other', ]; /** Icons for each event type */ const EVENT_ICONS: Record = { - Birth: '๐ŸŽ‚', Death: 'โœ๏ธ', Burial: '๐Ÿชฆ', Christening: 'โ›ช', - Baptism: 'โ›ช', Immigration: '๐Ÿ›ณ๏ธ', Emigration: 'โœˆ๏ธ', - Naturalization: '๐Ÿ“œ', Residence: '๐Ÿ ', Occupation: '๐Ÿ’ผ', - Education: '๐ŸŽ“', Military: '๐ŸŽ–๏ธ', Census: '๐Ÿ“Š', Marriage: '๐Ÿ’’', - Divorce: '๐Ÿ’”', Confirmation: 'โœž', Funeral: 'โšฑ๏ธ', Cremation: '๐Ÿ”ฅ', - Obituary: '๐Ÿ“ฐ', Will: '๐Ÿ“‹', 'Draft Registration': '๐Ÿ“Œ', Other: '๐Ÿ“Œ', + Birth: '๐ŸŽ‚', + Death: 'โœ๏ธ', + Burial: '๐Ÿชฆ', + Christening: 'โ›ช', + Baptism: 'โ›ช', + Immigration: '๐Ÿ›ณ๏ธ', + Emigration: 'โœˆ๏ธ', + Naturalization: '๐Ÿ“œ', + Residence: '๐Ÿ ', + Occupation: '๐Ÿ’ผ', + Education: '๐ŸŽ“', + Military: '๐ŸŽ–๏ธ', + Census: '๐Ÿ“Š', + Marriage: '๐Ÿ’’', + Divorce: '๐Ÿ’”', + Confirmation: 'โœž', + Funeral: 'โšฑ๏ธ', + Cremation: '๐Ÿ”ฅ', + Obituary: '๐Ÿ“ฐ', + Will: '๐Ÿ“‹', + 'Draft Registration': '๐Ÿ“Œ', + Other: '๐Ÿ“Œ', }; /** Event category for grouping */ function getEventCategory(type: string): string { - if (['Immigration', 'Emigration', 'Naturalization'].includes(type)) return 'Migration'; - if (['Military', 'Military Enlistment', 'Military Discharge', 'Draft Registration'].includes(type)) return 'Military'; + if (['Immigration', 'Emigration', 'Naturalization'].includes(type)) + return 'Migration'; + if ( + [ + 'Military', + 'Military Enlistment', + 'Military Discharge', + 'Draft Registration', + ].includes(type) + ) + return 'Military'; if (['Residence', 'Census'].includes(type)) return 'Residence'; if (['Marriage', 'Divorce'].includes(type)) return 'Family'; if (['Occupation', 'Education'].includes(type)) return 'Career'; - if (['Christening', 'Baptism', 'Confirmation'].includes(type)) return 'Religious'; + if (['Christening', 'Baptism', 'Confirmation'].includes(type)) + return 'Religious'; return 'Other'; } @@ -78,7 +121,14 @@ const CATEGORY_COLORS: Record = { /** Name types for alternate names */ const NAME_TYPES = [ - 'maiden', 'married', 'nickname', 'birth', 'adopted', 'alias', 'religious', 'other', + 'maiden', + 'married', + 'nickname', + 'birth', + 'adopted', + 'alias', + 'religious', + 'other', ]; interface Props { @@ -93,8 +143,22 @@ interface Props { function sortEvents(events: LifeEvent[]): LifeEvent[] { return [...events].sort((a, b) => { - const aVal = a.event_type === 'Birth' ? -Infinity : a.event_type === 'Death' ? Infinity - 1 : a.event_type === 'Burial' ? Infinity : (a.event_year || 0); - const bVal = b.event_type === 'Birth' ? -Infinity : b.event_type === 'Death' ? Infinity - 1 : b.event_type === 'Burial' ? Infinity : (b.event_year || 0); + const aVal = + a.event_type === 'Birth' + ? -Infinity + : a.event_type === 'Death' + ? Infinity - 1 + : a.event_type === 'Burial' + ? Infinity + : a.event_year || 0; + const bVal = + b.event_type === 'Birth' + ? -Infinity + : b.event_type === 'Death' + ? Infinity - 1 + : b.event_type === 'Burial' + ? Infinity + : b.event_year || 0; return aVal - bVal; }); } @@ -111,23 +175,37 @@ export default function UnifiedLifeEventsCard({ const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [formData, setFormData] = useState({ - event_type: 'Residence', event_date: '', event_year: '', event_place: '', event_value: '', + event_type: 'Residence', + event_date: '', + event_year: '', + event_place: '', + event_value: '', }); // Alternate name state const [showAliasForm, setShowAliasForm] = useState(false); const [editingAliasId, setEditingAliasId] = useState(null); - const [aliasData, setAliasData] = useState({ name_type: 'nickname', name_given: '', name_surname: '' }); + const [aliasData, setAliasData] = useState({ + name_type: 'nickname', + name_given: '', + name_surname: '', + }); const refetchQueries = [{ query: GET_PERSON, variables: { id: personId } }]; const [addEvent, { loading: adding }] = useMutation(ADD_LIFE_EVENT, { refetchQueries, - onCompleted: () => { toast.success(t('eventAdded')); resetForm(); }, + onCompleted: () => { + toast.success(t('eventAdded')); + resetForm(); + }, onError: (err) => toast.error(t('error'), { description: err.message }), }); const [updateEvent, { loading: updating }] = useMutation(UPDATE_LIFE_EVENT, { refetchQueries, - onCompleted: () => { toast.success(t('eventUpdated')); resetForm(); }, + onCompleted: () => { + toast.success(t('eventUpdated')); + resetForm(); + }, onError: (err) => toast.error(t('error'), { description: err.message }), }); const [deleteEvent] = useMutation(DELETE_LIFE_EVENT, { @@ -138,13 +216,24 @@ export default function UnifiedLifeEventsCard({ // Alias mutations const [addAlias, { loading: addingAlias }] = useMutation(ADD_ALTERNATE_NAME, { - refetchQueries, onCompleted: () => { toast.success('Alias added'); resetAliasForm(); }, - onError: (err) => toast.error('Error', { description: err.message }), - }); - const [updateAlias, { loading: updatingAlias }] = useMutation(UPDATE_ALTERNATE_NAME, { - refetchQueries, onCompleted: () => { toast.success('Alias updated'); resetAliasForm(); }, + refetchQueries, + onCompleted: () => { + toast.success('Alias added'); + resetAliasForm(); + }, onError: (err) => toast.error('Error', { description: err.message }), }); + const [updateAlias, { loading: updatingAlias }] = useMutation( + UPDATE_ALTERNATE_NAME, + { + refetchQueries, + onCompleted: () => { + toast.success('Alias updated'); + resetAliasForm(); + }, + onError: (err) => toast.error('Error', { description: err.message }), + }, + ); const [deleteAlias] = useMutation(DELETE_ALTERNATE_NAME, { refetchQueries, onCompleted: () => toast.success('Alias deleted'), @@ -153,13 +242,21 @@ export default function UnifiedLifeEventsCard({ // Filter out hero events (Birth, Death, Burial) and Marriage (shown in hero stats) const timelineEvents = sortEvents( - lifeEvents.filter((e) => !HERO_EVENTS.includes(e.event_type) && e.event_type !== 'Marriage'), + lifeEvents.filter( + (e) => !HERO_EVENTS.includes(e.event_type) && e.event_type !== 'Marriage', + ), ).filter((e) => !(e.event_type === 'Death' && living)); const getIcon = (type: string) => EVENT_ICONS[type] || EVENT_ICONS.Other; const resetForm = () => { - setFormData({ event_type: 'Residence', event_date: '', event_year: '', event_place: '', event_value: '' }); + setFormData({ + event_type: 'Residence', + event_date: '', + event_year: '', + event_place: '', + event_value: '', + }); setEditingId(null); setShowForm(false); }; @@ -172,8 +269,10 @@ export default function UnifiedLifeEventsCard({ const handleEdit = (evt: LifeEvent) => { setFormData({ - event_type: evt.event_type, event_date: evt.event_date || '', - event_year: evt.event_year?.toString() || '', event_place: evt.event_place || '', + event_type: evt.event_type, + event_date: evt.event_date || '', + event_year: evt.event_year?.toString() || '', + event_place: evt.event_place || '', event_value: evt.event_value || '', }); setEditingId(evt.id.toString()); @@ -183,9 +282,13 @@ export default function UnifiedLifeEventsCard({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const input = { - event_type: formData.event_type, event_date: formData.event_date || null, - event_year: formData.event_year ? parseInt(formData.event_year, 10) : null, - event_place: formData.event_place || null, event_value: formData.event_value || null, + event_type: formData.event_type, + event_date: formData.event_date || null, + event_year: formData.event_year + ? parseInt(formData.event_year, 10) + : null, + event_place: formData.event_place || null, + event_value: formData.event_value || null, }; if (editingId) { await updateEvent({ variables: { id: editingId, input } }); @@ -201,14 +304,22 @@ export default function UnifiedLifeEventsCard({ }; const handleEditAlias = (alias: AlternateName) => { - setAliasData({ name_type: alias.name_type || 'nickname', name_given: alias.name_given || '', name_surname: alias.name_surname || '' }); + setAliasData({ + name_type: alias.name_type || 'nickname', + name_given: alias.name_given || '', + name_surname: alias.name_surname || '', + }); setEditingAliasId(alias.id.toString()); setShowAliasForm(true); }; const handleAliasSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const input = { name_type: aliasData.name_type, name_given: aliasData.name_given || null, name_surname: aliasData.name_surname || null }; + const input = { + name_type: aliasData.name_type, + name_given: aliasData.name_given || null, + name_surname: aliasData.name_surname || null, + }; if (editingAliasId) { await updateAlias({ variables: { id: editingAliasId, input } }); } else { @@ -223,7 +334,8 @@ export default function UnifiedLifeEventsCard({ }; // Don't render at all if no events and no aliases and not editable - if (timelineEvents.length === 0 && alternateNames.length === 0 && !canEdit) return null; + if (timelineEvents.length === 0 && alternateNames.length === 0 && !canEdit) + return null; return ( @@ -245,39 +357,83 @@ export default function UnifiedLifeEventsCard({ {/* Add/Edit Event Form */} {showForm && ( - +
- + setFormData((p) => ({ ...p, event_type: v })) + } + > + + + {ALL_EVENT_TYPES.map((type) => ( - {getIcon(type)} {type} + + {getIcon(type)} {type} + ))}
- setFormData((p) => ({ ...p, event_year: e.target.value }))} placeholder="1920" /> + + setFormData((p) => ({ ...p, event_year: e.target.value })) + } + placeholder="1920" + />
- setFormData((p) => ({ ...p, event_date: e.target.value }))} placeholder={t('datePlaceholder')} /> + + setFormData((p) => ({ ...p, event_date: e.target.value })) + } + placeholder={t('datePlaceholder')} + />
- setFormData((p) => ({ ...p, event_place: e.target.value }))} placeholder={t('placePlaceholder')} /> + + setFormData((p) => ({ ...p, event_place: e.target.value })) + } + placeholder={t('placePlaceholder')} + />
- setFormData((p) => ({ ...p, event_value: e.target.value }))} placeholder={t('detailsPlaceholder')} /> + + setFormData((p) => ({ ...p, event_value: e.target.value })) + } + placeholder={t('detailsPlaceholder')} + />
- - + +
)} @@ -287,18 +443,24 @@ export default function UnifiedLifeEventsCard({
{timelineEvents.map((evt) => { const category = getEventCategory(evt.event_type); - const colorClass = CATEGORY_COLORS[category] || CATEGORY_COLORS.Other; + const colorClass = + CATEGORY_COLORS[category] || CATEGORY_COLORS.Other; return (
{/* Timeline dot */} -
+
{/* Event type + date on one line */}
- + {getIcon(evt.event_type)} {evt.event_type} {(evt.event_date || evt.event_year) && ( @@ -311,8 +473,12 @@ export default function UnifiedLifeEventsCard({ {(evt.event_place || evt.event_value) && (
{evt.event_place && {evt.event_place}} - {evt.event_place && evt.event_value && ยท } - {evt.event_value && {evt.event_value}} + {evt.event_place && evt.event_value && ( + ยท + )} + {evt.event_value && ( + {evt.event_value} + )}
)}
@@ -320,10 +486,19 @@ export default function UnifiedLifeEventsCard({ {/* Edit/delete buttons */} {canEdit && (
- -
@@ -334,7 +509,9 @@ export default function UnifiedLifeEventsCard({ })}
) : ( - !canEdit &&

{t('noEvents')}

+ !canEdit && ( +

{t('noEvents')}

+ ) )} {/* Also Known As section */} @@ -343,15 +520,32 @@ export default function UnifiedLifeEventsCard({ Also known as:{' '} {alternateNames.length > 0 ? ( alternateNames.map((n, i) => { - const name = n.name_full || [n.name_given, n.name_surname].filter(Boolean).join(' '); + const name = + n.name_full || + [n.name_given, n.name_surname].filter(Boolean).join(' '); const type = n.name_type ? ` (${n.name_type})` : ''; return ( - {name}{type} + + {name} + {type} + {canEdit && ( - - + + )} {i < alternateNames.length - 1 && ', '} @@ -362,34 +556,86 @@ export default function UnifiedLifeEventsCard({ None )} {canEdit && !showAliasForm && ( - )} - {/* Add/Edit Alias Form */} {showAliasForm && ( -
+
- + setAliasData((p) => ({ ...p, name_type: v })) + } + > + + + + + {NAME_TYPES.map((type) => ( + + {type} + + ))} +
- setAliasData((p) => ({ ...p, name_given: e.target.value }))} placeholder="First" /> + + setAliasData((p) => ({ + ...p, + name_given: e.target.value, + })) + } + placeholder="First" + />
- setAliasData((p) => ({ ...p, name_surname: e.target.value }))} placeholder="Last" /> + + setAliasData((p) => ({ + ...p, + name_surname: e.target.value, + })) + } + placeholder="Last" + />
- - + +
)} diff --git a/lib/graphql/client.ts b/lib/graphql/client.ts index 85786e4..27ea3dc 100644 --- a/lib/graphql/client.ts +++ b/lib/graphql/client.ts @@ -63,7 +63,9 @@ export function getGraphQLClient( // Server-side: use full URL const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || - (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'); + (process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'); endpoint = `${baseUrl}/api/graphql`; } else { // Client-side: use relative URL diff --git a/lib/graphql/resolvers/family-research-notes.ts b/lib/graphql/resolvers/family-research-notes.ts index f57a959..ddf40e2 100644 --- a/lib/graphql/resolvers/family-research-notes.ts +++ b/lib/graphql/resolvers/family-research-notes.ts @@ -36,7 +36,7 @@ export const familyResearchNotesResolvers = { */ familyResearchNotes: async (_: unknown, __: unknown, { pool }: Context) => { const { rows } = await pool.query( - `SELECT * FROM family_research_notes ORDER BY family_name` + `SELECT * FROM family_research_notes ORDER BY family_name`, ); return rows.map((row) => ({ id: row.id, @@ -54,11 +54,11 @@ export const familyResearchNotesResolvers = { familyResearchNote: async ( _: unknown, { familyName }: { familyName: string }, - { pool }: Context + { pool }: Context, ) => { const { rows } = await pool.query( `SELECT * FROM family_research_notes WHERE family_name = $1`, - [familyName] + [familyName], ); if (rows.length === 0) { @@ -84,14 +84,14 @@ export const familyResearchNotesResolvers = { upsertFamilyResearchNote: async ( _: unknown, { input }: { input: FamilyResearchNoteInput }, - { pool, userId }: Context + { pool, userId }: Context, ) => { const { familyName, content } = input; // Check if exists const { rows: existing } = await pool.query( `SELECT id FROM family_research_notes WHERE family_name = $1`, - [familyName] + [familyName], ); let row: FamilyResearchNote; @@ -103,7 +103,7 @@ export const familyResearchNotesResolvers = { SET content = $1, updated_by = $2 WHERE family_name = $3 RETURNING *`, - [content, userId || null, familyName] + [content, userId || null, familyName], ); row = rows[0]; } else { @@ -112,7 +112,7 @@ export const familyResearchNotesResolvers = { `INSERT INTO family_research_notes (id, family_name, content, updated_by, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING *`, - [generateId(), familyName, content, userId || null] + [generateId(), familyName, content, userId || null], ); row = rows[0]; } @@ -133,11 +133,11 @@ export const familyResearchNotesResolvers = { deleteFamilyResearchNote: async ( _: unknown, { familyName }: { familyName: string }, - { pool }: Context + { pool }: Context, ) => { const { rowCount } = await pool.query( `DELETE FROM family_research_notes WHERE family_name = $1`, - [familyName] + [familyName], ); return (rowCount ?? 0) > 0; }, @@ -147,16 +147,12 @@ export const familyResearchNotesResolvers = { /** * Resolve updatedBy user reference */ - updatedBy: async ( - parent: any, - _: unknown, - { pool }: Context - ) => { + updatedBy: async (parent: any, _: unknown, { pool }: Context) => { if (!parent.updatedBy?.id) return null; const { rows } = await pool.query( `SELECT id, name, email, role FROM users WHERE id = $1`, - [parent.updatedBy.id] + [parent.updatedBy.id], ); return rows[0] || null; diff --git a/lib/graphql/resolvers/index.ts b/lib/graphql/resolvers/index.ts index 7893b4b..ca983e9 100644 --- a/lib/graphql/resolvers/index.ts +++ b/lib/graphql/resolvers/index.ts @@ -14,8 +14,8 @@ import { cyclesResolvers } from './cycles'; import { dashboardResolvers } from './dashboard'; import { duplicatesResolvers } from './duplicates'; import { factResolvers } from './fact'; -import { familyResearchNotesResolvers } from './family-research-notes'; import { familyResolvers } from './family'; +import { familyResearchNotesResolvers } from './family-research-notes'; import { gedcomResolvers } from './gedcom'; import { life_eventResolvers } from './life_event'; import { mapResolvers } from './map'; diff --git a/lib/graphql/resolvers/person/field-resolvers.ts b/lib/graphql/resolvers/person/field-resolvers.ts index 6ca541b..a05df7d 100644 --- a/lib/graphql/resolvers/person/field-resolvers.ts +++ b/lib/graphql/resolvers/person/field-resolvers.ts @@ -25,7 +25,6 @@ export const personFieldResolvers = { : []; }, - father: async (person: { id: string }, _: unknown, ctx: Context) => { const families = await ctx.loaders.familiesAsChildLoader.load(person.id); if (families.length === 0) return null; diff --git a/lib/graphql/schema/index.ts b/lib/graphql/schema/index.ts index 9aee667..79d951f 100644 --- a/lib/graphql/schema/index.ts +++ b/lib/graphql/schema/index.ts @@ -9,8 +9,8 @@ import { adminTypes } from './types/admin'; import { commentTypes } from './types/comment'; import { commonTypes } from './types/common'; import { crestTypes } from './types/crest'; -import { familyResearchNotesTypeDefs } from './types/family-research-notes'; import { familyTypes } from './types/family'; +import { familyResearchNotesTypeDefs } from './types/family-research-notes'; import { locationTypes } from './types/location'; import { mediaTypes } from './types/media'; import { personTypes } from './types/person'; diff --git a/lib/graphql/schema/types/index.ts b/lib/graphql/schema/types/index.ts index 74a9351..bdab1d0 100644 --- a/lib/graphql/schema/types/index.ts +++ b/lib/graphql/schema/types/index.ts @@ -10,8 +10,8 @@ export { adminTypes } from './admin'; export { commentTypes } from './comment'; export { commonTypes } from './common'; export { crestTypes } from './crest'; -export { familyResearchNotesTypeDefs } from './family-research-notes'; export { familyTypes } from './family'; +export { familyResearchNotesTypeDefs } from './family-research-notes'; export { locationTypes } from './location'; export { mediaTypes } from './media'; export { personTypes } from './person'; From 8611f60af1466d0fa89d039f322502a35a6b50a0 Mon Sep 17 00:00:00 2001 From: Peter Milanese Date: Sat, 14 Feb 2026 14:48:38 -0500 Subject: [PATCH 3/7] refactor: Convert family notes to API-first GraphQL pattern - Delete REST API routes (/api/family-notes) - Delete server-side GraphQL client (lib/graphql/client.ts) - Convert components to use Apollo Client directly - Add family-notes GraphQL queries and mutations - All data flows through /api/graphql endpoint This aligns with the API-first architecture where client code uses Apollo Client to query GraphQL directly, not REST wrappers. --- app/api/family-notes/[family]/route.ts | 144 ------------------------- app/api/family-notes/route.ts | 66 ------------ components/FamilyNoteViewerClient.tsx | 96 ++++++++--------- components/FamilyNotesClient.tsx | 52 ++++----- lib/graphql/client.ts | 76 ------------- lib/graphql/queries/family-notes.ts | 36 +++++++ lib/graphql/queries/index.ts | 6 ++ 7 files changed, 110 insertions(+), 366 deletions(-) delete mode 100644 app/api/family-notes/[family]/route.ts delete mode 100644 app/api/family-notes/route.ts delete mode 100644 lib/graphql/client.ts create mode 100644 lib/graphql/queries/family-notes.ts diff --git a/app/api/family-notes/[family]/route.ts b/app/api/family-notes/[family]/route.ts deleted file mode 100644 index 3b4f49c..0000000 --- a/app/api/family-notes/[family]/route.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import { auth } from '@/lib/auth'; -import { getGraphQLClient } from '@/lib/graphql/client'; - -interface FamilyResearchNote { - id: string; - familyName: string; - content: string; - updatedAt: string; -} - -interface FamilyNoteQueryResponse { - familyResearchNote: FamilyResearchNote | null; -} - -interface UpsertFamilyNoteMutationResponse { - upsertFamilyResearchNote: FamilyResearchNote; -} - -const FAMILY_NOTE_QUERY = ` - query FamilyResearchNote($familyName: String!) { - familyResearchNote(familyName: $familyName) { - id - familyName - content - updatedAt - } - } -`; - -const UPSERT_FAMILY_NOTE_MUTATION = ` - mutation UpsertFamilyResearchNote($input: FamilyResearchNoteInput!) { - upsertFamilyResearchNote(input: $input) { - id - familyName - content - updatedAt - } - } -`; - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ family: string }> }, -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { family } = await params; - const client = getGraphQLClient(); - const { data, errors } = await client.request( - FAMILY_NOTE_QUERY, - { - familyName: family.toUpperCase(), - }, - ); - - if (errors) { - console.error('GraphQL errors:', errors); - return NextResponse.json( - { error: 'Failed to fetch family note' }, - { status: 500 }, - ); - } - - if (!data?.familyResearchNote) { - return NextResponse.json( - { error: 'Family notes not found' }, - { status: 404 }, - ); - } - - return NextResponse.json({ - family: data.familyResearchNote.familyName, - content: data.familyResearchNote.content, - lastModified: data.familyResearchNote.updatedAt, - }); - } catch (error) { - console.error('Error reading family notes:', error); - return NextResponse.json( - { error: 'Failed to read family notes' }, - { status: 500 }, - ); - } -} - -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ family: string }> }, -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { family } = await params; - const { content } = await request.json(); - - if (typeof content !== 'string') { - return NextResponse.json( - { error: 'Invalid content format' }, - { status: 400 }, - ); - } - - const client = getGraphQLClient(); - const { data, errors } = - await client.request( - UPSERT_FAMILY_NOTE_MUTATION, - { - input: { - familyName: family.toUpperCase(), - content, - }, - }, - { - 'x-user-id': session.user.id ?? '', - }, - ); - - if (errors) { - console.error('GraphQL errors:', errors); - return NextResponse.json( - { error: 'Failed to update family notes' }, - { status: 500 }, - ); - } - - return NextResponse.json({ - success: true, - lastModified: data?.upsertFamilyResearchNote.updatedAt, - }); - } catch (error) { - console.error('Error updating family notes:', error); - return NextResponse.json( - { error: 'Failed to update family notes' }, - { status: 500 }, - ); - } -} diff --git a/app/api/family-notes/route.ts b/app/api/family-notes/route.ts deleted file mode 100644 index 54cb48c..0000000 --- a/app/api/family-notes/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import { auth } from '@/lib/auth'; -import { getGraphQLClient } from '@/lib/graphql/client'; - -interface FamilyResearchNote { - id: string; - familyName: string; - content: string; - createdAt: string; - updatedAt: string; -} - -interface FamilyNotesQueryResponse { - familyResearchNotes: FamilyResearchNote[]; -} - -const FAMILY_NOTES_QUERY = ` - query FamilyResearchNotes { - familyResearchNotes { - id - familyName - content - createdAt - updatedAt - } - } -`; - -export async function GET(request: NextRequest) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const client = getGraphQLClient(); - const { data, errors } = - await client.request(FAMILY_NOTES_QUERY); - - if (errors) { - console.error('GraphQL errors:', errors); - return NextResponse.json( - { error: 'Failed to fetch family notes' }, - { status: 500 }, - ); - } - - // Transform to match expected format - const families = (data?.familyResearchNotes || []).map((note) => ({ - family: note.familyName.toLowerCase(), - title: note.familyName.replace(/_/g, ' '), - size: note.content.length, - lastModified: note.updatedAt, - })); - - families.sort((a, b) => a.family.localeCompare(b.family)); - - return NextResponse.json({ families }); - } catch (error) { - console.error('Error listing family notes:', error); - return NextResponse.json( - { error: 'Failed to list family notes' }, - { status: 500 }, - ); - } -} diff --git a/components/FamilyNoteViewerClient.tsx b/components/FamilyNoteViewerClient.tsx index 67a9757..72106a8 100644 --- a/components/FamilyNoteViewerClient.tsx +++ b/components/FamilyNoteViewerClient.tsx @@ -1,9 +1,9 @@ 'use client'; +import { useMutation, useQuery } from '@apollo/client'; import { ArrowLeft, Edit, Save, X } from 'lucide-react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import { toast } from 'sonner'; import { @@ -14,68 +14,66 @@ import { Skeleton, Textarea, } from '@/components/ui'; +import { + GET_FAMILY_RESEARCH_NOTE, + UPSERT_FAMILY_RESEARCH_NOTE, +} from '@/lib/graphql/queries/family-notes'; interface Props { family: string; } export function FamilyNoteViewerClient({ family }: Props) { - const router = useRouter(); - const [content, setContent] = useState(''); - const [originalContent, setOriginalContent] = useState(''); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const [isEditing, setIsEditing] = useState(false); - const [error, setError] = useState(null); + const [content, setContent] = useState(''); - useEffect(() => { - async function loadNote() { - try { - const res = await fetch(`/api/family-notes/${family}`); - if (!res.ok) { - if (res.status === 404) { - throw new Error('Family notes not found'); - } - throw new Error('Failed to load family notes'); - } - const data = await res.json(); - setContent(data.content); - setOriginalContent(data.content); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); + const { data, loading, error } = useQuery(GET_FAMILY_RESEARCH_NOTE, { + variables: { familyName: family.toUpperCase() }, + onCompleted: (data) => { + if (data?.familyResearchNote) { + setContent(data.familyResearchNote.content); } + }, + }); + + const [upsertNote, { loading: saving }] = useMutation( + UPSERT_FAMILY_RESEARCH_NOTE, + { + onCompleted: () => { + setIsEditing(false); + toast.success('Notes saved successfully'); + }, + onError: (err) => { + toast.error('Failed to save notes', { description: err.message }); + }, + refetchQueries: [ + { + query: GET_FAMILY_RESEARCH_NOTE, + variables: { familyName: family.toUpperCase() }, + }, + ], } - loadNote(); - }, [family]); + ); const handleSave = async () => { - setSaving(true); - try { - const res = await fetch(`/api/family-notes/${family}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content }), - }); - - if (!res.ok) throw new Error('Failed to save notes'); - - setOriginalContent(content); - setIsEditing(false); - toast.success('Notes saved successfully'); - } catch (err: any) { - toast.error('Failed to save notes', { description: err.message }); - } finally { - setSaving(false); - } + await upsertNote({ + variables: { + input: { + familyName: family.toUpperCase(), + content, + }, + }, + }); }; const handleCancel = () => { - setContent(originalContent); + if (data?.familyResearchNote) { + setContent(data.familyResearchNote.content); + } setIsEditing(false); }; + const originalContent = data?.familyResearchNote?.content || ''; const hasChanges = content !== originalContent; if (loading) { @@ -95,7 +93,7 @@ export function FamilyNoteViewerClient({ family }: Props) { ); } - if (error) { + if (error || !data?.familyResearchNote) { return ( <> -

{error}

+

+ {error?.message || 'Family notes not found'} +