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:
parent
75fc3e3485
commit
33aac72639
@ -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()
|
||||
|
||||
@ -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 ? (
|
||||
<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
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ interface EntityDetailPanelProps<T> {
|
||||
isFavourite?: boolean;
|
||||
onToggleFavourite?: () => void;
|
||||
favouriteLabel?: string;
|
||||
extraActions?: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function EntityDetailPanel<T>({
|
||||
@ -42,6 +43,7 @@ export function EntityDetailPanel<T>({
|
||||
isFavourite,
|
||||
onToggleFavourite,
|
||||
favouriteLabel = 'favourite',
|
||||
extraActions,
|
||||
}: EntityDetailPanelProps<T>) {
|
||||
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
|
||||
|
||||
@ -134,7 +136,10 @@ export function EntityDetailPanel<T>({
|
||||
|
||||
{/* Footer */}
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user