diff --git a/backend/alembic/versions/046_add_person_id_to_connection_requests.py b/backend/alembic/versions/046_add_person_id_to_connection_requests.py new file mode 100644 index 0000000..5dcca47 --- /dev/null +++ b/backend/alembic/versions/046_add_person_id_to_connection_requests.py @@ -0,0 +1,34 @@ +"""Add person_id to connection_requests + +Revision ID: 046 +Revises: 045 +""" +from alembic import op +import sqlalchemy as sa + +revision = "046" +down_revision = "045" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "connection_requests", + sa.Column( + "person_id", + sa.Integer(), + sa.ForeignKey("people.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index( + "ix_connection_requests_person_id", + "connection_requests", + ["person_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_connection_requests_person_id", table_name="connection_requests") + op.drop_column("connection_requests", "person_id") diff --git a/backend/app/models/connection_request.py b/backend/app/models/connection_request.py index 6a851f1..78ae150 100644 --- a/backend/app/models/connection_request.py +++ b/backend/app/models/connection_request.py @@ -27,6 +27,9 @@ class ConnectionRequest(Base): status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending") created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None) + person_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True + ) # Relationships with explicit foreign_keys to disambiguate sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin") diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index a8fe237..f63107e 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -195,10 +195,24 @@ async def send_connection_request( if pending_count >= 5: raise HTTPException(status_code=429, detail="Too many pending requests for this user") + # Validate person_id if provided (link existing standard contact) + link_person_id = None + if body.person_id is not None: + person_result = await db.execute( + select(Person).where(Person.id == body.person_id, Person.user_id == current_user.id) + ) + link_person = person_result.scalar_one_or_none() + if not link_person: + raise HTTPException(status_code=400, detail="Person not found or not owned by you") + if link_person.is_umbral_contact: + raise HTTPException(status_code=400, detail="Person is already an umbral contact") + link_person_id = body.person_id + # Create the request (IntegrityError guard for TOCTOU race on partial unique index) conn_request = ConnectionRequest( sender_id=current_user.id, receiver_id=target.id, + person_id=link_person_id, ) db.add(conn_request) try: @@ -352,13 +366,19 @@ async def respond_to_request( ConnectionRequest.status == "pending", ) .values(status=body.action + "ed", resolved_at=now) - .returning(ConnectionRequest.id, ConnectionRequest.sender_id, ConnectionRequest.receiver_id) + .returning( + ConnectionRequest.id, + ConnectionRequest.sender_id, + ConnectionRequest.receiver_id, + ConnectionRequest.person_id, + ) ) row = result.first() if not row: raise HTTPException(status_code=409, detail="Request not found or already resolved") sender_id = row.sender_id + request_person_id = row.person_id if body.action == "accept": # Verify sender is still active @@ -386,11 +406,44 @@ async def respond_to_request( person_for_receiver = create_person_from_connection( current_user.id, sender, sender_settings, sender_shared ) - person_for_sender = create_person_from_connection( - sender_id, current_user, receiver_settings, receiver_shared - ) db.add(person_for_receiver) - db.add(person_for_sender) + + # Sender side: reuse existing Person if person_id was provided on the request + person_for_sender = None + if request_person_id: + existing_result = await db.execute( + select(Person).where(Person.id == request_person_id) + ) + existing_person = existing_result.scalar_one_or_none() + # Re-validate at accept time (C-01, W-01): ownership must match sender, + # and must not already be umbral (prevents double-conversion races) + if existing_person and existing_person.user_id == sender_id and not existing_person.is_umbral_contact: + # Convert existing standard contact to umbral + existing_person.linked_user_id = current_user.id + existing_person.is_umbral_contact = True + existing_person.category = "Umbral" + # Update from shared profile + first_name = receiver_shared.get("first_name") or receiver_shared.get("preferred_name") or current_user.umbral_name + last_name = receiver_shared.get("last_name") + existing_person.first_name = first_name + existing_person.last_name = last_name + existing_person.email = receiver_shared.get("email") or existing_person.email + existing_person.phone = receiver_shared.get("phone") or existing_person.phone + existing_person.mobile = receiver_shared.get("mobile") or existing_person.mobile + existing_person.address = receiver_shared.get("address") or existing_person.address + existing_person.company = receiver_shared.get("company") or existing_person.company + existing_person.job_title = receiver_shared.get("job_title") or existing_person.job_title + # Recompute display name + full = ((first_name or '') + ' ' + (last_name or '')).strip() + existing_person.name = full or current_user.umbral_name + person_for_sender = existing_person + + if person_for_sender is None: + person_for_sender = create_person_from_connection( + sender_id, current_user, receiver_settings, receiver_shared + ) + db.add(person_for_sender) + await db.flush() # populate person IDs # Create bidirectional connections diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 622bf93..5724f9c 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -100,6 +100,8 @@ async def get_people( shared_profiles[uid] = resolve_shared_profile( user, user_settings, overrides_by_user.get(uid) ) + # umbral_name is always visible (public identity), not a shareable field + shared_profiles[uid]["umbral_name"] = user.umbral_name # Attach to response responses = [] @@ -176,6 +178,7 @@ async def get_person( resp.shared_fields = resolve_shared_profile( linked_user, linked_settings, conn.sharing_overrides if conn else None ) + resp.shared_fields["umbral_name"] = linked_user.umbral_name return resp diff --git a/backend/app/schemas/connection.py b/backend/app/schemas/connection.py index d293290..7489baf 100644 --- a/backend/app/schemas/connection.py +++ b/backend/app/schemas/connection.py @@ -30,6 +30,7 @@ class UmbralSearchResponse(BaseModel): class SendConnectionRequest(BaseModel): model_config = ConfigDict(extra="forbid") umbral_name: str = Field(..., max_length=50) + person_id: Optional[int] = Field(default=None, ge=1, le=2147483647) @field_validator('umbral_name') @classmethod diff --git a/frontend/src/components/connections/ConnectionSearch.tsx b/frontend/src/components/connections/ConnectionSearch.tsx index e7e2ecb..9dcf54f 100644 --- a/frontend/src/components/connections/ConnectionSearch.tsx +++ b/frontend/src/components/connections/ConnectionSearch.tsx @@ -19,9 +19,10 @@ import { getErrorMessage } from '@/lib/api'; interface ConnectionSearchProps { open: boolean; onOpenChange: (open: boolean) => void; + personId?: number; } -export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) { +export default function ConnectionSearch({ open, onOpenChange, personId }: ConnectionSearchProps) { const { search, isSearching, sendRequest, isSending } = useConnections(); const { settings } = useSettings(); const navigate = useNavigate(); @@ -45,7 +46,7 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc const handleSend = async () => { try { - await sendRequest(umbralName.trim()); + await sendRequest({ umbralName: umbralName.trim(), personId }); setSent(true); toast.success('Connection request sent'); } catch (err) { @@ -69,7 +70,9 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc Find Umbra User - Search for a user by their umbral name to send a connection request. + {personId + ? 'Search for an umbral user to link this contact to.' + : 'Search for a user by their umbral name to send a connection request.'}
diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 3c89ca0..302d8d5 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, Unlink } from 'lucide-react'; +import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format, parseISO, differenceInYears } from 'date-fns'; @@ -192,6 +192,7 @@ const columns: ColumnDef[] = [ // Panel field config // --------------------------------------------------------------------------- const panelFields: PanelField[] = [ + { label: 'Preferred Name', key: 'preferred_name', icon: User2 }, { label: 'Mobile', key: 'mobile', copyable: true, icon: Phone }, { label: 'Phone', key: 'phone', copyable: true, icon: Phone }, { label: 'Email', key: 'email', copyable: true, icon: Mail }, @@ -220,6 +221,7 @@ export default function PeoplePage() { const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [showConnectionSearch, setShowConnectionSearch] = useState(false); + const [linkPersonId, setLinkPersonId] = useState(null); const [showAddDropdown, setShowAddDropdown] = useState(false); const addDropdownRef = useRef(null); @@ -431,9 +433,16 @@ export default function PeoplePage() { )}
- {p.category && ( - {p.category} - )} +
+ {p.is_umbral_contact && p.shared_fields?.umbral_name ? ( + + @{String(p.shared_fields.umbral_name)} + + ) : null} + {p.category && ( + {p.category} + )} +
); @@ -441,6 +450,7 @@ export default function PeoplePage() { // Shared field key mapping (panel key -> shared_fields key) const sharedKeyMap: Record = { + preferred_name: 'preferred_name', email: 'email', phone: 'phone', mobile: 'mobile', @@ -519,7 +529,17 @@ export default function PeoplePage() { Unlink - ) : null + ) : ( + + ) } /> ); @@ -760,6 +780,12 @@ export default function PeoplePage() { open={showConnectionSearch} onOpenChange={setShowConnectionSearch} /> + + { if (!open) setLinkPersonId(null); }} + personId={linkPersonId ?? undefined} + /> ); } diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index d92eec4..61ea79e 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -39,9 +39,10 @@ export function useConnections() { }); const sendRequestMutation = useMutation({ - mutationFn: async (umbralName: string) => { + mutationFn: async (params: { umbralName: string; personId?: number }) => { const { data } = await api.post('/connections/request', { - umbral_name: umbralName, + umbral_name: params.umbralName, + ...(params.personId != null && { person_id: params.personId }), }); return data; },