diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 6dfe41c..622bf93 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -216,13 +216,79 @@ async def update_person( return person +async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None: + """Remove bidirectional UserConnection rows and detach the counterpart's Person.""" + if not person.linked_user_id: + return + + counterpart_id = person.linked_user_id + + # Find our connection + conn_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == current_user.id, + UserConnection.connected_user_id == counterpart_id, + ) + ) + our_conn = conn_result.scalar_one_or_none() + + # Find reverse connection + reverse_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == counterpart_id, + UserConnection.connected_user_id == current_user.id, + ) + ) + reverse_conn = reverse_result.scalar_one_or_none() + + # Detach the counterpart's Person record (if it exists) + if reverse_conn and reverse_conn.person_id: + cp_result = await db.execute( + select(Person).where(Person.id == reverse_conn.person_id) + ) + cp_person = cp_result.scalar_one_or_none() + if cp_person: + await detach_umbral_contact(cp_person) + + # Delete both connection rows + if our_conn: + await db.delete(our_conn) + if reverse_conn: + await db.delete(reverse_conn) + + +@router.put("/{person_id}/unlink", response_model=PersonResponse) +async def unlink_person( + person_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Unlink an umbral contact — convert to standard contact and sever the connection.""" + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) + person = result.scalar_one_or_none() + + if not person: + raise HTTPException(status_code=404, detail="Person not found") + if not person.is_umbral_contact: + raise HTTPException(status_code=400, detail="Person is not an umbral contact") + + await _sever_connection(db, current_user, person) + await detach_umbral_contact(person) + + await db.commit() + await db.refresh(person) + return person + + @router.delete("/{person_id}", status_code=204) async def delete_person( person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Delete a person.""" + """Delete a person. If umbral contact, also severs the bidirectional connection.""" result = await db.execute( select(Person).where(Person.id == person_id, Person.user_id == current_user.id) ) @@ -231,19 +297,8 @@ async def delete_person( if not person: raise HTTPException(status_code=404, detail="Person not found") - # Auto-detach umbral contact before delete if person.is_umbral_contact: - await detach_umbral_contact(person) - # Null out the current user's connection person_id so the connection survives - conn_result = await db.execute( - select(UserConnection).where( - UserConnection.user_id == current_user.id, - UserConnection.person_id == person.id, - ) - ) - conn = conn_result.scalar_one_or_none() - if conn: - conn.person_id = None + await _sever_connection(db, current_user, person) await db.delete(person) await db.commit() diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index aa1cd7a..6b8ec70 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from 'react'; -import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown } from 'lucide-react'; +import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format, parseISO, differenceInYears } from 'date-fns'; @@ -331,6 +331,7 @@ export default function PeoplePage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Person deleted'); @@ -341,6 +342,22 @@ export default function PeoplePage() { }, }); + // Unlink umbral contact mutation + const unlinkMutation = useMutation({ + mutationFn: async (personId: number) => { + const { data } = await api.put(`/people/${personId}/unlink`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['connections'] }); + toast.success('Contact unlinked — converted to standard contact'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to unlink contact')); + }, + }); + // Toggle favourite mutation const toggleFavouriteMutation = useMutation({ mutationFn: async (person: Person) => { @@ -474,6 +491,20 @@ export default function PeoplePage() { isFavourite={selectedPerson?.is_favourite} onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)} favouriteLabel="favourite" + extraActions={(p) => + p.is_umbral_contact ? ( + + ) : null + } /> ); diff --git a/frontend/src/components/shared/EntityDetailPanel.tsx b/frontend/src/components/shared/EntityDetailPanel.tsx index 2a2781d..d0ac938 100644 --- a/frontend/src/components/shared/EntityDetailPanel.tsx +++ b/frontend/src/components/shared/EntityDetailPanel.tsx @@ -27,6 +27,7 @@ interface EntityDetailPanelProps { isFavourite?: boolean; onToggleFavourite?: () => void; favouriteLabel?: string; + extraActions?: (item: T) => React.ReactNode; } export function EntityDetailPanel({ @@ -42,6 +43,7 @@ export function EntityDetailPanel({ isFavourite, onToggleFavourite, favouriteLabel = 'favourite', + extraActions, }: EntityDetailPanelProps) { const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); @@ -134,7 +136,10 @@ export function EntityDetailPanel({ {/* Footer */}
- {formatUpdatedAt(getUpdatedAt(item))} +
+ {formatUpdatedAt(getUpdatedAt(item))} + {extraActions?.(item)} +