Add delete-with-sever and unlink actions for umbral contacts

Delete person now severs the bidirectional connection when the person
is an umbral contact — removes both UserConnection rows and detaches
the counterpart's Person record. Fixes "Already connected" error
when trying to reconnect after deleting an umbral contact.

New PUT /people/{id}/unlink endpoint converts an umbral contact to a
standard contact (detaches linked fields) while also severing the
bidirectional connection, keeping the Person in the contact list.

Frontend: EntityDetailPanel gains extraActions prop. PeoplePage renders
an "Unlink" button in the panel footer for umbral contacts. Delete
mutation now also invalidates connections query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-04 07:50:31 +08:00
parent 75fc3e3485
commit 33aac72639
3 changed files with 106 additions and 15 deletions

View File

@ -216,13 +216,79 @@ async def update_person(
return 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) @router.delete("/{person_id}", status_code=204)
async def delete_person( async def delete_person(
person_id: int = Path(ge=1, le=2147483647), person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) 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( result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id) select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
) )
@ -231,19 +297,8 @@ async def delete_person(
if not person: if not person:
raise HTTPException(status_code=404, detail="Person not found") raise HTTPException(status_code=404, detail="Person not found")
# Auto-detach umbral contact before delete
if person.is_umbral_contact: if person.is_umbral_contact:
await detach_umbral_contact(person) await _sever_connection(db, current_user, 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 db.delete(person) await db.delete(person)
await db.commit() await db.commit()

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react'; 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 type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns'; import { format, parseISO, differenceInYears } from 'date-fns';
@ -331,6 +331,7 @@ export default function PeoplePage() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Person deleted'); 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 // Toggle favourite mutation
const toggleFavouriteMutation = useMutation({ const toggleFavouriteMutation = useMutation({
mutationFn: async (person: Person) => { mutationFn: async (person: Person) => {
@ -474,6 +491,20 @@ export default function PeoplePage() {
isFavourite={selectedPerson?.is_favourite} isFavourite={selectedPerson?.is_favourite}
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)} onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
favouriteLabel="favourite" favouriteLabel="favourite"
extraActions={(p) =>
p.is_umbral_contact ? (
<Button
variant="ghost"
size="sm"
onClick={() => unlinkMutation.mutate(p.id)}
disabled={unlinkMutation.isPending}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Unlink className="h-3 w-3" />
Unlink
</Button>
) : null
}
/> />
); );

View File

@ -27,6 +27,7 @@ interface EntityDetailPanelProps<T> {
isFavourite?: boolean; isFavourite?: boolean;
onToggleFavourite?: () => void; onToggleFavourite?: () => void;
favouriteLabel?: string; favouriteLabel?: string;
extraActions?: (item: T) => React.ReactNode;
} }
export function EntityDetailPanel<T>({ export function EntityDetailPanel<T>({
@ -42,6 +43,7 @@ export function EntityDetailPanel<T>({
isFavourite, isFavourite,
onToggleFavourite, onToggleFavourite,
favouriteLabel = 'favourite', favouriteLabel = 'favourite',
extraActions,
}: EntityDetailPanelProps<T>) { }: EntityDetailPanelProps<T>) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
@ -134,7 +136,10 @@ export function EntityDetailPanel<T>({
{/* Footer */} {/* Footer */}
<div className="px-5 py-4 border-t border-border flex items-center justify-between"> <div className="px-5 py-4 border-t border-border flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span> <div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
{extraActions?.(item)}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"