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
|
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()
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user